diff --git a/README.md b/README.md index a5d606c..6c54e2f 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,8 @@ **Contributors:** octanist **Tags:** tracking, analytics, forms, leads, conversions **Requires at least:** 6.0 -**Tested up to:** 7.0 -**Stable tag:** 4.0.1 +**Tested up to:** 7.1 +**Stable tag:** 4.1.0 **License:** GPLv2 or later **License URI:** https://www.gnu.org/licenses/gpl-2.0.html @@ -20,6 +20,7 @@ The plugin acts as a first-party proxy for the Octanist pixel: - **Pixel proxy:** The tracking script is served from your own WordPress site at `/wp-json/oct/p` from a local cache, with refreshes handled in the background. - **Event proxy:** Events from the pixel POST to `/wp-json/oct/e`; the plugin forwards them immediately with a one-second upstream timeout so onboarding and live reporting stay responsive. Pixel failures are not persisted in WordPress, which prevents an upstream outage from filling the site database. +- **Call tracking proxy:** The pixel can request a dynamic number at `/wp-json/oct/call-tracking/assign`. The plugin forwards that request to Octanist so website numbers stay first-party. - **Server-side form capture:** Form submissions from supported plugins are captured via server-side action hooks and forwarded synchronously with a longer timeout. Confirmed failures are queued for retry. Supported form plugins: @@ -46,6 +47,13 @@ Listener mode and consent mode are optional settings. ## Changelog +### 4.1.0 + +- Added an optional Call tracking setting, off by default. +- Added a first-party proxy for call tracking assignment (`POST /wp-json/oct/call-tracking/assign`). +- Setup codes can include a call tracking flag (`OCTA1.OCT-XXXXXXXX.s.a.t`). +- Tested up to WordPress 7.1. + ### 4.0.1 - Pixel serving now uses the local cache immediately and refreshes upstream in the background. diff --git a/includes/class-octanist-api.php b/includes/class-octanist-api.php index b81abb8..a48c4e9 100644 --- a/includes/class-octanist-api.php +++ b/includes/class-octanist-api.php @@ -8,8 +8,10 @@ class Octanist_Api const COOKIE_PREFIX = 'octa_'; const PIXEL_PATH = '/p'; const EVENT_PATH = '/e'; + const ASSIGN_PATH = '/call-tracking/assign'; const PIXEL_TIMEOUT = 1; const COLLECT_TIMEOUT = 1; + const ASSIGN_TIMEOUT = 8; const FORM_TIMEOUT = 10; const RETRY_TIMEOUT = 3; const FORWARD_TIMEOUT = 1; @@ -145,6 +147,38 @@ public static function forward_event(array $payload, array $opts = []) return $response; } + /** + * Forward a DNI assignment request to the upstream /call-tracking/assign endpoint. + * Blocking: the pixel needs the leased number in the same request. + */ + public static function forward_call_tracking_assignment(array $payload, array $signals = []) + { + $headers = [ + 'Content-Type' => 'application/json', + ]; + + if (!empty($signals['ip'])) { + $headers['X-Forwarded-For'] = $signals['ip']; + $headers['X-Octanist-Client-IP'] = $signals['ip']; + } + if (!empty($signals['country'])) { + $headers['X-Octanist-Client-Country'] = $signals['country']; + } + if (!empty($signals['ua'])) { + $headers['X-Octanist-Client-UA'] = $signals['ua']; + } + + return wp_remote_post(OCTANIST_UPSTREAM . self::ASSIGN_PATH, [ + 'method' => 'POST', + 'timeout' => self::ASSIGN_TIMEOUT, + 'redirection' => 0, + 'blocking' => true, + 'headers' => $headers, + 'body' => wp_json_encode($payload), + 'limit_response_size' => 4096, + ]); + } + public static function pixel_delivery_is_paused(): bool { return (bool) get_transient(self::PIXEL_CIRCUIT_TRANSIENT); diff --git a/includes/class-octanist-rest.php b/includes/class-octanist-rest.php index f368d50..d5869aa 100644 --- a/includes/class-octanist-rest.php +++ b/includes/class-octanist-rest.php @@ -8,6 +8,7 @@ class Octanist_Rest const NAMESPACE = 'oct'; const PIXEL = 'p'; const COLLECT = 'e'; + const ASSIGN = 'call-tracking/assign'; const MAX_BODY = 65536; // 64 KB public static function register(): void @@ -28,6 +29,12 @@ public static function register_routes(): void 'callback' => [__CLASS__, 'collect'], 'permission_callback' => '__return_true', ]); + + register_rest_route(self::NAMESPACE, '/' . self::ASSIGN, [ + 'methods' => 'POST', + 'callback' => [__CLASS__, 'assign'], + 'permission_callback' => '__return_true', + ]); } public static function pixel_url(): string @@ -169,6 +176,50 @@ public static function collect(WP_REST_Request $request) return $rest_response; } + public static function assign(WP_REST_Request $request) + { + $raw = $request->get_body(); + if (strlen($raw) > self::MAX_BODY) { + return self::assignment_unavailable(); + } + + $payload = json_decode($raw, true); + if (!is_array($payload)) { + return new WP_REST_Response(null, 400); + } + + $settings = Octanist_Settings::get(); + if (!empty($settings['measurement_id']) && empty($payload['mid'])) { + $payload['mid'] = $settings['measurement_id']; + } + + $signals = Octanist_Api::collect_client_signals(); + $response = Octanist_Api::forward_call_tracking_assignment($payload, $signals); + + if (is_wp_error($response)) { + return self::assignment_unavailable(); + } + + $code = (int) wp_remote_retrieve_response_code($response); + $body = json_decode((string) wp_remote_retrieve_body($response), true); + if (!is_array($body)) { + return self::assignment_unavailable(); + } + + return new WP_REST_Response($body, $code >= 200 && $code < 600 ? $code : 200); + } + + private static function assignment_unavailable(): WP_REST_Response + { + return new WP_REST_Response([ + 'success' => false, + 'enabled' => false, + 'available' => false, + 'phoneNumber' => null, + 'reason' => 'assignment_unavailable', + ], 200); + } + private static function send_headers(array $headers): void { foreach ($headers as $name => $value) { diff --git a/includes/class-octanist-settings.php b/includes/class-octanist-settings.php index e9ba738..1560259 100644 --- a/includes/class-octanist-settings.php +++ b/includes/class-octanist-settings.php @@ -26,6 +26,7 @@ public static function get(): array 'measurement_id' => '', 'listener_mode' => 'server', 'consent_mode' => 'auto', + 'call_tracking' => false, ]; $settings = get_option(self::OPTION, []); if (!is_array($settings)) { @@ -52,6 +53,9 @@ public static function pixel_data_attrs(): array 'data-consent-mode' => $s['consent_mode'], 'data-cookie-mode' => 'server', ]; + if (!empty($s['call_tracking'])) { + $attrs['data-call-tracking'] = 'true'; + } if ($s['listener_mode'] === 'client') { // Value-less attribute, browser pixel binds to forms itself. $attrs['data-forms'] = ''; @@ -92,6 +96,7 @@ public static function register_settings(): void 'measurement_id' => '', 'listener_mode' => 'server', 'consent_mode' => 'auto', + 'call_tracking' => false, ], ]); } @@ -116,6 +121,9 @@ public static function sanitize($input): array $input['measurement_id'] = $decoded['measurement_id']; $input['listener_mode'] = $decoded['listener_mode']; $input['consent_mode'] = $decoded['consent_mode']; + if (array_key_exists('call_tracking', $decoded)) { + $input['call_tracking'] = $decoded['call_tracking']; + } add_settings_error( self::OPTION, @@ -149,6 +157,7 @@ public static function sanitize($input): array 'measurement_id' => $mid, 'listener_mode' => $listener, 'consent_mode' => $consent, + 'call_tracking' => !empty($input['call_tracking']), ]; } @@ -166,7 +175,7 @@ private static function decode_setup_code(string $code) } $parts = explode('.', $code); - if (count($parts) !== 4) { + if (count($parts) !== 4 && count($parts) !== 5) { return new WP_Error( 'octanist_setup_code_shape', __('Setup code should look like OCTA1.OCT-XXXXXXXX.s.a.', 'octanist') @@ -211,11 +220,24 @@ private static function decode_setup_code(string $code) ); } - return [ + $decoded = [ 'measurement_id' => $measurement_id, 'listener_mode' => $listener_mode, 'consent_mode' => $consent_mode, ]; + + if (isset($parts[4])) { + $call_code = sanitize_key((string) $parts[4]); + if (!in_array($call_code, ['t', 'n'], true)) { + return new WP_Error( + 'octanist_setup_code_call_tracking', + __('Setup code contains an invalid call tracking flag.', 'octanist') + ); + } + $decoded['call_tracking'] = $call_code === 't'; + } + + return $decoded; } public static function enqueue_assets($hook): void @@ -362,6 +384,11 @@ class="octanist-input octanist-input--wide"
+
+
+
+ +
@@ -413,6 +440,18 @@ class="octanist-input" + +
+ + + +
@@ -428,7 +467,7 @@ class="octanist-input octanist-input--wide" autocomplete="off" spellcheck="false" aria-label=""> -

+

@@ -530,6 +569,20 @@ private static function format_consent_mode_help(string $mode): string return $labels[$mode] ?? $labels['auto']; } + private static function format_call_tracking($enabled): string + { + return !empty($enabled) + ? __('On', 'octanist') + : __('Off', 'octanist'); + } + + private static function format_call_tracking_help($enabled): string + { + return !empty($enabled) + ? __('The pixel can replace website phone numbers.', 'octanist') + : __('Leave this off unless Octanist enabled call tracking for this site.', 'octanist'); + } + private static function format_time($ts): string { if (empty($ts)) { diff --git a/languages/octanist.pot b/languages/octanist.pot index e2f38d7..e194c4a 100644 --- a/languages/octanist.pot +++ b/languages/octanist.pot @@ -2,7 +2,7 @@ # This file is distributed under the same license as the Octanist plugin. msgid "" msgstr "" -"Project-Id-Version: Octanist 4.0.1\n" +"Project-Id-Version: Octanist 4.1.0\n" "Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/octanist\n" "POT-Creation-Date: 2026-06-08 08:31+0000\n" "MIME-Version: 1.0\n" diff --git a/octanist.php b/octanist.php index ca6aad1..fe13561 100644 --- a/octanist.php +++ b/octanist.php @@ -1,22 +1,22 @@