<?php /** * First-party user journey tracking (visitor token, events, admin, ERP summary). * * @package eLearnPOSH */ if ( ! defined( 'ABSPATH' ) ) { exit; } if ( ! class_exists( 'ElearnPOSH_Journey' ) ) { /** * Journey tracker. */ class ElearnPOSH_Journey { const DB_VERSION = '1.0.0'; const COOKIE_VID = 'ep_vid'; const COOKIE_SID = 'ep_sid'; const VID_DAYS = 365; const SID_MINUTES = 30; const RETENTION_DAYS = 90; const MAX_PATH_PAGES = 20; /** * Bootstrap hooks. */ public static function init() { add_action( 'init', array( __CLASS__, 'maybe_upgrade_db' ), 5 ); add_action( 'rest_api_init', array( __CLASS__, 'register_rest_routes' ) ); add_action( 'wp_enqueue_scripts', array( __CLASS__, 'enqueue_scripts' ), 100 ); add_action( 'admin_menu', array( __CLASS__, 'register_admin_menu' ) ); add_action( 'elearnposh_journey_purge', array( __CLASS__, 'purge_old_events' ) ); if ( ! wp_next_scheduled( 'elearnposh_journey_purge' ) ) { wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'elearnposh_journey_purge' ); } } /** * Table names. * * @return array{visitors:string,events:string} */ public static function tables() { global $wpdb; return array( 'visitors' => $wpdb->prefix . 'ep_visitors', 'events' => $wpdb->prefix . 'ep_journey_events', ); } /** * Create / upgrade tables. */ public static function maybe_upgrade_db() { $installed = get_option( 'elearnposh_journey_db_version', '' ); if ( self::DB_VERSION === $installed ) { return; } global $wpdb; $tables = self::tables(); $charset = $wpdb->get_charset_collate(); $sql_visitors = "CREATE TABLE {$tables['visitors']} ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, ep_vid varchar(64) NOT NULL, first_seen datetime NOT NULL, last_seen datetime NOT NULL, visit_count int(11) unsigned NOT NULL DEFAULT 1, first_landing text NULL, last_page text NULL, converted tinyint(1) NOT NULL DEFAULT 0, converted_email varchar(190) NULL, PRIMARY KEY (id), UNIQUE KEY ep_vid (ep_vid), KEY last_seen (last_seen), KEY converted_email (converted_email) ) $charset;"; $sql_events = "CREATE TABLE {$tables['events']} ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, ep_vid varchar(64) NOT NULL, ep_sid varchar(64) NOT NULL DEFAULT '', event_type varchar(32) NOT NULL, page_url text NULL, page_title varchar(255) NULL, duration_sec int(11) unsigned NOT NULL DEFAULT 0, meta_json longtext NULL, created_at datetime NOT NULL, PRIMARY KEY (id), KEY ep_vid_created (ep_vid, created_at), KEY event_type (event_type), KEY created_at (created_at) ) $charset;"; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; dbDelta( $sql_visitors ); dbDelta( $sql_events ); update_option( 'elearnposh_journey_db_version', self::DB_VERSION ); } /** * Generate a UUID v4-ish token. * * @return string */ public static function generate_token() { if ( function_exists( 'wp_generate_uuid4' ) ) { return wp_generate_uuid4(); } return sprintf( '%04x%04x-%04x-%04x-%04x-%04x%04x%04x', wp_rand( 0, 0xffff ), wp_rand( 0, 0xffff ), wp_rand( 0, 0xffff ), wp_rand( 0, 0x0fff ) | 0x4000, wp_rand( 0, 0x3fff ) | 0x8000, wp_rand( 0, 0xffff ), wp_rand( 0, 0xffff ), wp_rand( 0, 0xffff ) ); } /** * Sanitize visitor / session id. * * @param mixed $value Raw value. * @return string */ public static function sanitize_token( $value ) { $value = strtolower( trim( (string) $value ) ); $value = preg_replace( '/[^a-f0-9\-]/', '', $value ); if ( ! is_string( $value ) || strlen( $value ) < 8 || strlen( $value ) > 64 ) { return ''; } return $value; } /** * Read ep_vid from POST, cookie, or empty. * * @return string */ public static function get_request_vid() { if ( isset( $_POST['ep_vid'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing $vid = self::sanitize_token( wp_unslash( $_POST['ep_vid'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing if ( $vid ) { return $vid; } } if ( isset( $_COOKIE[ self::COOKIE_VID ] ) ) { return self::sanitize_token( wp_unslash( $_COOKIE[ self::COOKIE_VID ] ) ); } return ''; } /** * Register REST route for beacons. */ public static function register_rest_routes() { register_rest_route( 'elearnposh/v1', '/journey', array( 'methods' => 'POST', 'callback' => array( __CLASS__, 'rest_ingest' ), 'permission_callback' => '__return_true', ) ); } /** * Ingest journey event(s). * * @param WP_REST_Request $request Request. * @return WP_REST_Response */ public static function rest_ingest( $request ) { $params = $request->get_json_params(); if ( ! is_array( $params ) || empty( $params ) ) { $params = $request->get_body_params(); } if ( ! is_array( $params ) ) { $params = array(); } // sendBeacon may post raw JSON as body. if ( empty( $params ) ) { $raw = $request->get_body(); if ( is_string( $raw ) && '' !== $raw ) { $decoded = json_decode( $raw, true ); if ( is_array( $decoded ) ) { $params = $decoded; } } } $events = array(); if ( isset( $params['events'] ) && is_array( $params['events'] ) ) { $events = $params['events']; } else { $events[] = $params; } $accepted = 0; foreach ( $events as $event ) { if ( ! is_array( $event ) ) { continue; } if ( self::record_event( $event ) ) { $accepted++; } } return new WP_REST_Response( array( 'ok' => true, 'accepted' => $accepted, ), 200 ); } /** * Persist one event and upsert visitor. * * @param array<string,mixed> $event Event payload. * @return bool */ public static function record_event( $event ) { $vid = self::sanitize_token( isset( $event['ep_vid'] ) ? $event['ep_vid'] : '' ); if ( ! $vid && isset( $_COOKIE[ self::COOKIE_VID ] ) ) { $vid = self::sanitize_token( wp_unslash( $_COOKIE[ self::COOKIE_VID ] ) ); } if ( ! $vid ) { return false; } $sid = self::sanitize_token( isset( $event['ep_sid'] ) ? $event['ep_sid'] : '' ); $type = isset( $event['event_type'] ) ? sanitize_key( $event['event_type'] ) : ''; $allowed = array( 'page_view', 'page_exit', 'click', 'scroll_depth', 'form_start', 'form_submit' ); if ( ! in_array( $type, $allowed, true ) ) { return false; } $page_url = isset( $event['page_url'] ) ? esc_url_raw( (string) $event['page_url'] ) : ''; $page_title = isset( $event['page_title'] ) ? sanitize_text_field( (string) $event['page_title'] ) : ''; $duration = isset( $event['duration_sec'] ) ? max( 0, (int) $event['duration_sec'] ) : 0; $meta = array(); if ( isset( $event['meta'] ) && is_array( $event['meta'] ) ) { $meta = $event['meta']; } elseif ( isset( $event['meta_json'] ) && is_string( $event['meta_json'] ) ) { $decoded = json_decode( $event['meta_json'], true ); if ( is_array( $decoded ) ) { $meta = $decoded; } } // Bound meta size. $meta_json = wp_json_encode( $meta ); if ( is_string( $meta_json ) && strlen( $meta_json ) > 4000 ) { $meta_json = wp_json_encode( array( 'truncated' => true ) ); } global $wpdb; $tables = self::tables(); $now = current_time( 'mysql' ); $wpdb->insert( $tables['events'], array( 'ep_vid' => $vid, 'ep_sid' => $sid, 'event_type' => $type, 'page_url' => $page_url, 'page_title' => $page_title, 'duration_sec' => $duration, 'meta_json' => $meta_json ? $meta_json : null, 'created_at' => $now, ), array( '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%s' ) ); self::upsert_visitor( $vid, $page_url, $type === 'page_view', ! empty( $meta['new_session'] ) ); return true; } /** * Insert or update visitor row. * * @param string $vid Visitor id. * @param string $page_url Current page. * @param bool $is_view Whether this is a page_view. * @param bool $new_session Whether a new session cookie was issued. */ public static function upsert_visitor( $vid, $page_url = '', $is_view = false, $new_session = false ) { global $wpdb; $tables = self::tables(); $now = current_time( 'mysql' ); $row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$tables['visitors']} WHERE ep_vid = %s LIMIT 1", $vid ), ARRAY_A ); if ( ! $row ) { $wpdb->insert( $tables['visitors'], array( 'ep_vid' => $vid, 'first_seen' => $now, 'last_seen' => $now, 'visit_count' => 1, 'first_landing' => $page_url, 'last_page' => $page_url, 'converted' => 0, ), array( '%s', '%s', '%s', '%d', '%s', '%s', '%d' ) ); return; } $visit_count = (int) $row['visit_count']; if ( $new_session && $is_view ) { $visit_count++; } $update = array( 'last_seen' => $now, 'visit_count' => max( 1, $visit_count ), ); $formats = array( '%s', '%d' ); if ( $page_url ) { $update['last_page'] = $page_url; $formats[] = '%s'; if ( empty( $row['first_landing'] ) ) { $update['first_landing'] = $page_url; $formats[] = '%s'; } } $wpdb->update( $tables['visitors'], $update, array( 'ep_vid' => $vid ), $formats, array( '%s' ) ); } /** * Bump visit_count for a returning session (called from JS when new ep_sid). * * @param string $vid Visitor id. */ public static function bump_visit_count( $vid ) { $vid = self::sanitize_token( $vid ); if ( ! $vid ) { return; } global $wpdb; $tables = self::tables(); $wpdb->query( $wpdb->prepare( "UPDATE {$tables['visitors']} SET visit_count = visit_count + 1, last_seen = %s WHERE ep_vid = %s", current_time( 'mysql' ), $vid ) ); } /** * Mark visitor converted and attach email. * * @param string $vid Visitor id. * @param string $email Lead email. */ public static function mark_converted( $vid, $email = '' ) { $vid = self::sanitize_token( $vid ); if ( ! $vid ) { return; } global $wpdb; $tables = self::tables(); $wpdb->update( $tables['visitors'], array( 'converted' => 1, 'converted_email' => sanitize_email( $email ), 'last_seen' => current_time( 'mysql' ), ), array( 'ep_vid' => $vid ), array( '%d', '%s', '%s' ), array( '%s' ) ); } /** * Build human-readable journey summary for ERPNext. * * @param string $vid Visitor id. * @return string */ public static function build_summary( $vid ) { $vid = self::sanitize_token( $vid ); if ( ! $vid ) { return ''; } global $wpdb; $tables = self::tables(); $visitor = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$tables['visitors']} WHERE ep_vid = %s LIMIT 1", $vid ), ARRAY_A ); $views = $wpdb->get_results( $wpdb->prepare( "SELECT page_url, page_title, duration_sec, created_at FROM {$tables['events']} WHERE ep_vid = %s AND event_type IN ('page_view','page_exit') ORDER BY created_at DESC LIMIT 100", $vid ), ARRAY_A ); if ( empty( $views ) && empty( $visitor ) ) { return ''; } // Collapse to unique path in chronological order (prefer page_view order). $path = array(); $durations = array(); $seen_urls = array(); if ( is_array( $views ) ) { $chrono = array_reverse( $views ); foreach ( $chrono as $row ) { $url = isset( $row['page_url'] ) ? $row['page_url'] : ''; if ( ! $url ) { continue; } $path_key = self::path_key( $url ); $dur = isset( $row['duration_sec'] ) ? (int) $row['duration_sec'] : 0; if ( ! isset( $durations[ $path_key ] ) ) { $durations[ $path_key ] = 0; } $durations[ $path_key ] += $dur; if ( ! isset( $seen_urls[ $path_key ] ) ) { $seen_urls[ $path_key ] = true; $label = self::pretty_path( $url ); if ( ! empty( $row['page_title'] ) ) { $label = sanitize_text_field( $row['page_title'] ); } $path[] = array( 'label' => $label, 'url' => $url, 'key' => $path_key, ); } } } // Keep last N pages. if ( count( $path ) > self::MAX_PATH_PAGES ) { $path = array_slice( $path, -1 * self::MAX_PATH_PAGES ); } $total_sec = 0; foreach ( $durations as $sec ) { $total_sec += (int) $sec; } $landing = ''; if ( ! empty( $visitor['first_landing'] ) ) { $landing = self::pretty_path( $visitor['first_landing'] ); } elseif ( ! empty( $path[0]['url'] ) ) { $landing = self::pretty_path( $path[0]['url'] ); } $visits = isset( $visitor['visit_count'] ) ? (int) $visitor['visit_count'] : 1; $labels = array(); foreach ( $path as $p ) { $labels[] = $p['label']; } $lines = array(); $lines[] = 'Landing: ' . ( $landing ? $landing : '(unknown)' ); if ( ! empty( $labels ) ) { $lines[] = 'Pages: ' . implode( ' ’! ', $labels ) . ' (' . count( $labels ) . ' pages, ' . self::format_duration( $total_sec ) . ')'; } else { $lines[] = 'Pages: (none recorded yet)'; } $lines[] = 'Visits: ' . $visits . ' | Token: ' . $vid; return implode( "\n", $lines ); } /** * Path-only key for dedupe. * * @param string $url Full URL. * @return string */ public static function path_key( $url ) { $path = wp_parse_url( $url, PHP_URL_PATH ); return $path ? untrailingslashit( strtolower( $path ) ) : strtolower( $url ); } /** * Human path label. * * @param string $url URL. * @return string */ public static function pretty_path( $url ) { $path = wp_parse_url( $url, PHP_URL_PATH ); if ( ! $path || '/' === $path ) { return 'Home'; } return untrailingslashit( $path ); } /** * Format seconds as Xm Ys. * * @param int $seconds Seconds. * @return string */ public static function format_duration( $seconds ) { $seconds = max( 0, (int) $seconds ); if ( $seconds < 60 ) { return $seconds . 's'; } $m = (int) floor( $seconds / 60 ); $s = $seconds % 60; return $m . 'm ' . $s . 's'; } /** * Attach journey fields to an ERP lead payload. * * @param array<string,mixed> $lead_data Lead data. * @param string $ep_vid Visitor token. * @param string $email Optional email for conversion flag. * @return array<string,mixed> */ public static function apply_to_lead( $lead_data, $ep_vid = '', $email = '' ) { if ( ! is_array( $lead_data ) ) { $lead_data = array(); } $vid = self::sanitize_token( $ep_vid ); if ( ! $vid ) { $vid = self::get_request_vid(); } if ( ! $vid ) { return $lead_data; } $summary = self::build_summary( $vid ); if ( '' === $summary ) { $summary = 'Token: ' . $vid . ' (no page events yet)'; } $lead_data['custom_user_journey'] = $summary; $lead_data['custom_ep_vid'] = $vid; // Also append as a note row so data survives if custom fields are missing in ERPNext. $note_html = '<div><strong>User journey</strong><br>' . nl2br( esc_html( $summary ) ) . '</div>'; if ( empty( $lead_data['notes'] ) || ! is_array( $lead_data['notes'] ) ) { $lead_data['notes'] = array(); } $lead_data['notes'][] = array( 'note' => $note_html, 'added_by' => 'Administrator', 'added_on' => current_time( 'mysql' ), ); if ( $email ) { self::mark_converted( $vid, $email ); } return $lead_data; } /** * Strip fragile custom journey fields after ERP validation errors. * * @param array<string,mixed> $lead_data Lead payload. * @return array<string,mixed> */ public static function strip_custom_fields( $lead_data ) { unset( $lead_data['custom_user_journey'], $lead_data['custom_ep_vid'] ); return $lead_data; } /** * Enqueue frontend tracker. */ public static function enqueue_scripts() { if ( is_admin() ) { return; } if ( function_exists( 'is_amp_endpoint' ) && is_amp_endpoint() ) { return; } $handle = 'elearnposh-journey'; $root = dirname( __DIR__ ); $src = plugins_url( 'js/journey-tracker.js', $root . '/mailsend.php' ); $file = $root . '/js/journey-tracker.js'; $ver = file_exists( $file ) ? (string) filemtime( $file ) : self::DB_VERSION; wp_enqueue_script( $handle, $src, array(), $ver, true ); wp_localize_script( $handle, 'elearnposhJourney', array( 'endpoint' => esc_url_raw( rest_url( 'elearnposh/v1/journey' ) ), 'cookieVid' => self::COOKIE_VID, 'cookieSid' => self::COOKIE_SID, 'vidDays' => self::VID_DAYS, 'sidMinutes' => self::SID_MINUTES, 'ga4Id' => 'G-28GVK7WTMH', 'ctaSelector' => 'a.btn, a.button, button, input[type="submit"], .blog-submit, #blog-submit, .cta, [data-ep-track]', ) ); } /** * Admin menu. */ public static function register_admin_menu() { add_menu_page( 'User Journeys', 'User Journeys', 'manage_options', 'elearnposh-journeys', array( __CLASS__, 'render_admin_page' ), 'dashicons-chart-area', 58 ); } /** * Admin UI router. */ public static function render_admin_page() { if ( ! current_user_can( 'manage_options' ) ) { return; } $view = isset( $_GET['view'] ) ? sanitize_key( wp_unslash( $_GET['view'] ) ) : 'visitors'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $vid = isset( $_GET['ep_vid'] ) ? self::sanitize_token( wp_unslash( $_GET['ep_vid'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended echo '<div class="wrap">'; echo '<h1>User Journeys</h1>'; echo '<h2 class="nav-tab-wrapper">'; $tabs = array( 'visitors' => 'Visitors', 'pages' => 'Page stats', 'lookup' => 'Lead lookup', ); foreach ( $tabs as $key => $label ) { $url = admin_url( 'admin.php?page=elearnposh-journeys&view=' . $key ); $class = ( $view === $key || ( 'detail' === $view && 'visitors' === $key ) ) ? ' nav-tab-active' : ''; echo '<a class="nav-tab' . esc_attr( $class ) . '" href="' . esc_url( $url ) . '">' . esc_html( $label ) . '</a>'; } echo '</h2>'; if ( 'detail' === $view && $vid ) { self::render_journey_detail( $vid ); } elseif ( 'pages' === $view ) { self::render_page_stats(); } elseif ( 'lookup' === $view ) { self::render_lead_lookup(); } else { self::render_visitors_list(); } echo '</div>'; } /** * Visitors list table. */ public static function render_visitors_list() { global $wpdb; $tables = self::tables(); $rows = $wpdb->get_results( "SELECT ep_vid, first_seen, last_seen, visit_count, first_landing, last_page, converted, converted_email FROM {$tables['visitors']} ORDER BY last_seen DESC LIMIT 100", ARRAY_A ); echo '<table class="widefat striped"><thead><tr>'; echo '<th>Visitor</th><th>Visits</th><th>First seen</th><th>Last seen</th><th>Landing</th><th>Last page</th><th>Converted</th>'; echo '</tr></thead><tbody>'; if ( empty( $rows ) ) { echo '<tr><td colspan="7">No visitors tracked yet.</td></tr>'; } else { foreach ( $rows as $row ) { $link = admin_url( 'admin.php?page=elearnposh-journeys&view=detail&ep_vid=' . rawurlencode( $row['ep_vid'] ) ); echo '<tr>'; echo '<td><a href="' . esc_url( $link ) . '"><code>' . esc_html( substr( $row['ep_vid'], 0, 13 ) ) . '& </code></a></td>'; echo '<td>' . esc_html( (string) $row['visit_count'] ) . '</td>'; echo '<td>' . esc_html( $row['first_seen'] ) . '</td>'; echo '<td>' . esc_html( $row['last_seen'] ) . '</td>'; echo '<td>' . esc_html( self::pretty_path( (string) $row['first_landing'] ) ) . '</td>'; echo '<td>' . esc_html( self::pretty_path( (string) $row['last_page'] ) ) . '</td>'; $conv = ! empty( $row['converted'] ) ? 'Yes' : 'No'; if ( ! empty( $row['converted_email'] ) ) { $conv .= ' (' . esc_html( $row['converted_email'] ) . ')'; } echo '<td>' . $conv . '</td>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped echo '</tr>'; } } echo '</tbody></table>'; } /** * Single visitor timeline. * * @param string $vid Visitor id. */ public static function render_journey_detail( $vid ) { global $wpdb; $tables = self::tables(); echo '<p><a href="' . esc_url( admin_url( 'admin.php?page=elearnposh-journeys' ) ) . '">&larr; Back to visitors</a></p>'; echo '<h2>Journey: <code>' . esc_html( $vid ) . '</code></h2>'; $summary = self::build_summary( $vid ); if ( $summary ) { echo '<pre style="background:#fff;border:1px solid #ccd0d4;padding:12px;white-space:pre-wrap;">' . esc_html( $summary ) . '</pre>'; } $events = $wpdb->get_results( $wpdb->prepare( "SELECT event_type, page_url, page_title, duration_sec, meta_json, created_at FROM {$tables['events']} WHERE ep_vid = %s ORDER BY created_at ASC LIMIT 500", $vid ), ARRAY_A ); echo '<table class="widefat striped"><thead><tr>'; echo '<th>Time</th><th>Event</th><th>Page</th><th>Duration</th><th>Meta</th>'; echo '</tr></thead><tbody>'; if ( empty( $events ) ) { echo '<tr><td colspan="5">No events.</td></tr>'; } else { foreach ( $events as $ev ) { echo '<tr>'; echo '<td>' . esc_html( $ev['created_at'] ) . '</td>'; echo '<td>' . esc_html( $ev['event_type'] ) . '</td>'; $title = $ev['page_title'] ? $ev['page_title'] : self::pretty_path( (string) $ev['page_url'] ); echo '<td title="' . esc_attr( (string) $ev['page_url'] ) . '">' . esc_html( $title ) . '</td>'; echo '<td>' . esc_html( $ev['duration_sec'] ? self::format_duration( (int) $ev['duration_sec'] ) : ' ' ) . '</td>'; echo '<td><code style="font-size:11px;">' . esc_html( (string) $ev['meta_json'] ) . '</code></td>'; echo '</tr>'; } } echo '</tbody></table>'; } /** * Aggregate page stats. */ public static function render_page_stats() { global $wpdb; $tables = self::tables(); $rows = $wpdb->get_results( "SELECT page_url, COUNT(*) AS views, COUNT(DISTINCT ep_vid) AS unique_visitors, AVG(CASE WHEN duration_sec > 0 THEN duration_sec END) AS avg_duration FROM {$tables['events']} WHERE event_type IN ('page_view','page_exit') AND page_url IS NOT NULL AND page_url != '' GROUP BY page_url ORDER BY views DESC LIMIT 100", ARRAY_A ); echo '<table class="widefat striped"><thead><tr>'; echo '<th>Page</th><th>Views</th><th>Unique visitors</th><th>Avg time</th>'; echo '</tr></thead><tbody>'; if ( empty( $rows ) ) { echo '<tr><td colspan="4">No page data yet.</td></tr>'; } else { foreach ( $rows as $row ) { $avg = isset( $row['avg_duration'] ) && $row['avg_duration'] !== null ? self::format_duration( (int) round( (float) $row['avg_duration'] ) ) : ' '; echo '<tr>'; echo '<td>' . esc_html( self::pretty_path( (string) $row['page_url'] ) ) . '</td>'; echo '<td>' . esc_html( (string) $row['views'] ) . '</td>'; echo '<td>' . esc_html( (string) $row['unique_visitors'] ) . '</td>'; echo '<td>' . esc_html( $avg ) . '</td>'; echo '</tr>'; } } echo '</tbody></table>'; } /** * Lookup by lead email. */ public static function render_lead_lookup() { $email = ''; if ( isset( $_GET['email'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended $email = sanitize_email( wp_unslash( $_GET['email'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended } echo '<form method="get" style="margin:16px 0;">'; echo '<input type="hidden" name="page" value="elearnposh-journeys" />'; echo '<input type="hidden" name="view" value="lookup" />'; echo '<label>Lead email: <input type="email" name="email" value="' . esc_attr( $email ) . '" class="regular-text" /></label> '; submit_button( 'Find journey', 'secondary', '', false ); echo '</form>'; if ( ! $email ) { return; } global $wpdb; $tables = self::tables(); $row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$tables['visitors']} WHERE converted_email = %s ORDER BY last_seen DESC LIMIT 1", $email ), ARRAY_A ); if ( ! $row ) { echo '<p>No converted visitor found for <code>' . esc_html( $email ) . '</code>.</p>'; return; } echo '<p>Matched visitor <code>' . esc_html( $row['ep_vid'] ) . '</code>.</p>'; self::render_journey_detail( $row['ep_vid'] ); } /** * Delete events older than retention window. */ public static function purge_old_events() { global $wpdb; $tables = self::tables(); $days = (int) apply_filters( 'elearnposh_journey_retention_days', self::RETENTION_DAYS ); $cutoff = gmdate( 'Y-m-d H:i:s', time() - ( $days * DAY_IN_SECONDS ) ); $wpdb->query( $wpdb->prepare( "DELETE FROM {$tables['events']} WHERE created_at < %s", $cutoff ) ); } /** * Server-side page_view (AMP / no-JS fallback). * * @param string $page_url Page URL. * @param string $page_title Title. * @param string $vid Optional vid (cookie used if empty). * @param string $sid Optional sid. */ public static function log_server_page_view( $page_url = '', $page_title = '', $vid = '', $sid = '' ) { $vid = self::sanitize_token( $vid ); if ( ! $vid && isset( $_COOKIE[ self::COOKIE_VID ] ) ) { $vid = self::sanitize_token( wp_unslash( $_COOKIE[ self::COOKIE_VID ] ) ); } if ( ! $vid ) { $vid = self::generate_token(); self::set_cookie( self::COOKIE_VID, $vid, self::VID_DAYS * DAY_IN_SECONDS ); } $sid = self::sanitize_token( $sid ); if ( ! $sid && isset( $_COOKIE[ self::COOKIE_SID ] ) ) { $sid = self::sanitize_token( wp_unslash( $_COOKIE[ self::COOKIE_SID ] ) ); } if ( ! $sid ) { $sid = self::generate_token(); self::set_cookie( self::COOKIE_SID, $sid, self::SID_MINUTES * MINUTE_IN_SECONDS ); self::upsert_visitor( $vid, $page_url, true ); // New session Ò! bump visits if visitor already existed. global $wpdb; $tables = self::tables(); $exists = (int) $wpdb->get_var( $wpdb->prepare( "SELECT visit_count FROM {$tables['visitors']} WHERE ep_vid = %s", $vid ) ); if ( $exists > 0 ) { // upsert just created with visit_count=1; only bump if last_seen older handled via cookie miss. } } if ( ! $page_url ) { $page_url = home_url( isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '/' ); } self::record_event( array( 'ep_vid' => $vid, 'ep_sid' => $sid, 'event_type' => 'page_view', 'page_url' => $page_url, 'page_title' => $page_title, 'meta' => array( 'source' => 'server' ), ) ); } /** * Set a first-party cookie. * * @param string $name Cookie name. * @param string $value Value. * @param int $max_age Max-age seconds. */ public static function set_cookie( $name, $value, $max_age ) { if ( headers_sent() ) { return; } $options = array( 'expires' => time() + (int) $max_age, 'path' => '/', 'secure' => is_ssl(), 'httponly' => false, 'samesite' => 'Lax', ); setcookie( $name, $value, $options ); $_COOKIE[ $name ] = $value; } } ElearnPOSH_Journey::init(); } /** * Procedural helpers used by form / ERP plugins. * * @param array<string,mixed> $lead_data Lead payload. * @param string $ep_vid Optional token. * @param string $email Optional email. * @return array<string,mixed> */ function elearnposh_journey_apply_to_lead( $lead_data, $ep_vid = '', $email = '' ) { if ( class_exists( 'ElearnPOSH_Journey' ) ) { return ElearnPOSH_Journey::apply_to_lead( $lead_data, $ep_vid, $email ); } return $lead_data; } /** * @param string $vid Visitor id. * @return string */ function elearnposh_journey_build_summary( $vid ) { if ( class_exists( 'ElearnPOSH_Journey' ) ) { return ElearnPOSH_Journey::build_summary( $vid ); } return ''; } /** * @return string */ function elearnposh_journey_get_request_vid() { if ( class_exists( 'ElearnPOSH_Journey' ) ) { return ElearnPOSH_Journey::get_request_vid(); } return ''; } https://elearnposh.com/post-sitemap.xml 2026-07-14T08:06:17+00:00 https://elearnposh.com/page-sitemap.xml 2026-07-09T11:02:49+00:00 https://elearnposh.com/ae_global_templates-sitemap.xml 2025-10-09T04:35:27+00:00 https://elearnposh.com/product-sitemap.xml 2025-12-29T11:18:37+00:00 https://elearnposh.com/elementor-hf-sitemap.xml 2026-06-18T08:13:15+00:00 https://elearnposh.com/category-sitemap.xml 2026-07-14T08:06:17+00:00