| @@ -18,10 +18,18 @@ | ||
| 18 | 18 | 'utm_source', |
| 19 | 19 | 'utm_medium', |
| 20 | 20 | 'utm_id', |
| 21 | 21 | 'refer_url', |
| 22 | + // Ad network click identifiers. gbraid and wbraid carry iOS clicks where gclid | |
| 23 | + // is absent, and gad_campaignid names the campaign on the url itself, which | |
| 24 | + // survives cookie loss. | |
| 22 | 25 | 'fbclid', |
| 23 | - 'gclid' | |
| 26 | + 'gclid', | |
| 27 | + 'gbraid', | |
| 28 | + 'wbraid', | |
| 29 | + 'gad_campaignid', | |
| 30 | + 'gad_source', | |
| 31 | + 'msclkid' | |
| 24 | 32 | ]; |
| 25 | 33 | |
| 26 | 34 | return apply_filters('fluent_cart/utm/allowed_keys', $keys, []); |
| 27 | 35 | } |
| @@ -57,10 +65,38 @@ | ||
| 57 | 65 | |
| 58 | 66 | return array_values(array_unique($hosts)); |
| 59 | 67 | } |
| 60 | 68 | |
| 61 | - public static function addUtmToOrder($orderId, $data = []) | |
| 69 | + /** | |
| 70 | + * Record an order's traffic source on its operation row. | |
| 71 | + * | |
| 72 | + * The known UTM fields become columns; anything else on the allow list — click | |
| 73 | + * identifiers such as gclid — goes into meta, which is where reporting and any | |
| 74 | + * conversion upload will look for them. | |
| 75 | + * | |
| 76 | + * Safe to call more than once for the same order. Empty incoming values never | |
| 77 | + * overwrite something already recorded. | |
| 78 | + * | |
| 79 | + * An order arriving with no source at all still gets its row when a cart hash is | |
| 80 | + * given. Other code treats the operation row as the order's companion — ReceiptHandler | |
| 81 | + * reads sales_recorded off it to decide whether a receipt is being seen for the first | |
| 82 | + * time — so an untracked order must not be left without one. | |
| 83 | + * | |
| 84 | + * @param int $orderId | |
| 85 | + * @param array $data Raw source data, normally the cart's utm_data. | |
| 86 | + * @param string $cartHash Recorded alongside so the order can be traced back to its cart. | |
| 87 | + * @return OrderOperation|null The row, or null when there was nothing to record it against. | |
| 88 | + */ | |
| 89 | + public static function addUtmToOrder($orderId, $data = [], $cartHash = '') | |
| 62 | 90 | { |
| 91 | + if (!$orderId) { | |
| 92 | + return null; | |
| 93 | + } | |
| 94 | + | |
| 95 | + if (!is_array($data)) { | |
| 96 | + $data = []; | |
| 97 | + } | |
| 98 | + | |
| 63 | 99 | $directValueKeys = [ |
| 64 | 100 | 'utm_campaign', |
| 65 | 101 | 'utm_content', |
| 66 | 102 | 'utm_term', |
| @@ -69,45 +105,73 @@ | ||
| 69 | 105 | 'utm_id', |
| 70 | 106 | 'refer_url', |
| 71 | 107 | ]; |
| 72 | 108 | |
| 73 | - $directValues = Arr::only($data, $directValueKeys); | |
| 74 | - $metaValues = Arr::except($data, $directValueKeys); | |
| 109 | + $directValues = []; | |
| 110 | + foreach (Arr::only($data, $directValueKeys) as $key => $value) { | |
| 111 | + $value = $key == 'refer_url' | |
| 112 | + ? self::normalizeReferUrl($value) | |
| 113 | + : sanitize_text_field($value); | |
| 75 | 114 | |
| 76 | - foreach ($directValues as $key => $value) { | |
| 77 | - if ($key == 'refer_url') { | |
| 78 | - $directValues[$key] = self::normalizeReferUrl($value); | |
| 79 | - } else { | |
| 80 | - $directValues[$key] = sanitize_text_field($value); | |
| 115 | + if ($value !== '' && $value !== null) { | |
| 116 | + $directValues[$key] = $value; | |
| 81 | 117 | } |
| 82 | 118 | } |
| 83 | 119 | |
| 84 | 120 | $allowedKeys = self::allowedUtmParameterKey(); |
| 85 | - $allowedMetaValues = []; | |
| 86 | - foreach ($metaValues as $key => $value) { | |
| 87 | - if (!in_array($key, $allowedKeys)) { | |
| 121 | + $metaValues = []; | |
| 122 | + foreach (Arr::except($data, $directValueKeys) as $key => $value) { | |
| 123 | + if (!in_array($key, $allowedKeys, true)) { | |
| 88 | 124 | continue; |
| 89 | 125 | } |
| 90 | - $allowedMetaValues[$key] = sanitize_text_field($value); | |
| 126 | + | |
| 127 | + $value = sanitize_text_field($value); | |
| 128 | + if ($value !== '' && $value !== null) { | |
| 129 | + $metaValues[$key] = $value; | |
| 130 | + } | |
| 91 | 131 | } |
| 92 | 132 | |
| 93 | - $directValues['meta'] = $allowedMetaValues; | |
| 133 | + // A cart hash on its own is reason enough: the row is the order's companion and | |
| 134 | + // downstream code reads it whether or not the visit carried a source. | |
| 135 | + if (!$directValues && !$metaValues && !$cartHash) { | |
| 136 | + return null; | |
| 137 | + } | |
| 94 | 138 | |
| 95 | - $hasValues = array_filter($directValues) || array_filter($allowedMetaValues); | |
| 139 | + $operation = OrderOperation::query()->where('order_id', $orderId)->first(); | |
| 96 | 140 | |
| 141 | + if (!$operation) { | |
| 142 | + $attributes = array_merge($directValues, ['order_id' => $orderId]); | |
| 97 | 143 | |
| 98 | - $oldOperation = OrderOperation::query()->where('order_id', $orderId)->first(); | |
| 144 | + if ($metaValues) { | |
| 145 | + $attributes['meta'] = $metaValues; | |
| 146 | + } | |
| 99 | 147 | |
| 100 | - if (empty($oldOperation)) { | |
| 101 | - OrderOperation::query()->create( | |
| 102 | - array_merge($directValues, ['order_id' => $orderId]) | |
| 148 | + if ($cartHash) { | |
| 149 | + $attributes['cart_hash'] = $cartHash; | |
| 150 | + } | |
| 151 | + | |
| 152 | + return OrderOperation::query()->create($attributes); | |
| 153 | + } | |
| 154 | + | |
| 155 | + $attributes = $directValues; | |
| 156 | + | |
| 157 | + if ($metaValues) { | |
| 158 | + $existingMeta = $operation->meta; | |
| 159 | + $attributes['meta'] = Arr::mergeMissingValues( | |
| 160 | + $metaValues, | |
| 161 | + is_array($existingMeta) ? $existingMeta : [] | |
| 103 | 162 | ); |
| 104 | - } else { | |
| 105 | - $directValues['meta'] = Arr::mergeMissingValues($directValues['meta'], $oldOperation->meta); | |
| 106 | - Arr::mergeMissingValues($directValues, Arr::except($oldOperation->toArray(), 'meta')); | |
| 107 | - $directValues = array_merge($directValues); | |
| 108 | - $oldOperation->update($directValues); | |
| 109 | 163 | } |
| 164 | + | |
| 165 | + if ($cartHash && !$operation->cart_hash) { | |
| 166 | + $attributes['cart_hash'] = $cartHash; | |
| 167 | + } | |
| 168 | + | |
| 169 | + if ($attributes) { | |
| 170 | + $operation->update($attributes); | |
| 171 | + } | |
| 172 | + | |
| 173 | + return $operation; | |
| 110 | 174 | } |
| 111 | 175 | |
| 112 | 176 | /** |
| 113 | 177 | * Reduce a referrer (full URL or bare host) to its bare domain: |
| @@ -134,8 +198,36 @@ | ||
| 134 | 198 | $value = substr($value, 4); |
| 135 | 199 | } |
| 136 | 200 | |
| 137 | 201 | return sanitize_text_field($value); |
| 202 | + } | |
| 203 | + | |
| 204 | + /** | |
| 205 | + * Choose the attribution block to record against an order. | |
| 206 | + * | |
| 207 | + * The browser resolves attribution before posting — UTMManager replaces the | |
| 208 | + * attribution block on a fresh marketing touch and carries ad click identifiers | |
| 209 | + * forward on their own longer window — so the posted block is already the | |
| 210 | + * finished answer and is taken whole. Merging it with the cart column key by | |
| 211 | + * key would re-introduce fields from a touch the browser deliberately dropped, | |
| 212 | + * because a cart row is reused across visits and only refreshes when the | |
| 213 | + * customer edits a checkout field. | |
| 214 | + * | |
| 215 | + * The cart remains the fallback for order creation that never went through a | |
| 216 | + * browser, where it is the only source available. | |
| 217 | + * | |
| 218 | + * @param array $requestUtmData Attribution posted with the current request. | |
| 219 | + * @param mixed $cartUtmData The cart's stored block, or null. | |
| 220 | + * | |
| 221 | + * @return array | |
| 222 | + */ | |
| 223 | + public static function resolveUtmData(array $requestUtmData, $cartUtmData = []): array | |
| 224 | + { | |
| 225 | + if ($requestUtmData) { | |
| 226 | + return $requestUtmData; | |
| 227 | + } | |
| 228 | + | |
| 229 | + return is_array($cartUtmData) ? $cartUtmData : []; | |
| 138 | 230 | } |
| 139 | 231 | |
| 140 | 232 | public static function getUtmDataOfRequest(): array |
| 141 | 233 | { |