feat: track and push TeleCart Pulse events

This commit is contained in:
2025-11-30 16:52:32 +03:00
parent fc8044484e
commit ef785654b9
19 changed files with 583 additions and 70 deletions

View File

@@ -109,9 +109,11 @@
</template> </template>
<template #body="{ data }"> <template #body="{ data }">
<template v-if="col.field === 'id'">{{ data.id }}</template> <template v-if="col.field === 'id'">{{ data.id }}</template>
<template v-else-if="col.field === 'telegram_user_id'">{{ <template v-else-if="col.field === 'telegram_user_id'">
data.telegram_user_id {{ data.telegram_user_id }}
}} </template>
<template v-else-if="col.field === 'tracking_id'">
<code>{{ data.tracking_id }}</code>
</template> </template>
<template v-else-if="col.field === 'username'"> <template v-else-if="col.field === 'username'">
<div class="tw:flex tw:items-center tw:gap-2"> <div class="tw:flex tw:items-center tw:gap-2">
@@ -180,7 +182,8 @@
<InputText v-model="filterModel.value" type="text" placeholder="Поиск по фамилии" <InputText v-model="filterModel.value" type="text" placeholder="Поиск по фамилии"
class="p-column-filter"/> class="p-column-filter"/>
</template> </template>
<template v-else-if="['last_seen_at', 'created_at', 'privacy_consented_at'].includes(col.field)"> <template
v-else-if="['last_seen_at', 'created_at', 'privacy_consented_at'].includes(col.field)">
<DatePicker v-model="filterModel.value" dateFormat="dd.mm.yy" placeholder="dd.mm.yyyy"/> <DatePicker v-model="filterModel.value" dateFormat="dd.mm.yy" placeholder="dd.mm.yyyy"/>
</template> </template>
<template v-else-if="col.field === 'orders_count'"> <template v-else-if="col.field === 'orders_count'">
@@ -314,6 +317,14 @@ const columns = ref([
filterable: true, filterable: true,
visible: false visible: false
}, },
{
field: 'tracking_id',
header: 'Tracking ID',
sortable: false,
filterable: true,
visible: false,
help: 'Tracking ID это публичный уникальный идентификатор покупателя, используется в рекламных кампаниях для отслеживания активности.',
},
{field: 'username', header: 'Имя пользователя', sortable: true, filterable: true, visible: true}, {field: 'username', header: 'Имя пользователя', sortable: true, filterable: true, visible: true},
{field: 'first_name', header: 'Имя', sortable: true, filterable: true, visible: true}, {field: 'first_name', header: 'Имя', sortable: true, filterable: true, visible: true},
{field: 'last_name', header: 'Фамилия', sortable: true, filterable: true, visible: true}, {field: 'last_name', header: 'Фамилия', sortable: true, filterable: true, visible: true},
@@ -597,10 +608,10 @@ function resetFilters() {
operator: FilterOperator.AND, operator: FilterOperator.AND,
constraints: [{value: null, matchMode: FilterMatchMode.DATE_IS}] constraints: [{value: null, matchMode: FilterMatchMode.DATE_IS}]
}, },
privacy_consented_at: { privacy_consented_at: {
operator: FilterOperator.AND, operator: FilterOperator.AND,
constraints: [{value: null, matchMode: FilterMatchMode.DATE_IS}] constraints: [{value: null, matchMode: FilterMatchMode.DATE_IS}]
}, },
}; };
lazyParams.value.page = 1; lazyParams.value.page = 1;
lazyParams.value.first = 0; lazyParams.value.first = 0;

View File

@@ -1,3 +1,4 @@
export const TC_PULSE_EVENTS = { export const TC_PULSE_EVENTS = {
WEBAPP_OPEN: 'WEBAPP_OPEN', WEBAPP_OPEN: 'WEBAPP_OPEN',
ORDER_CREATED: 'ORDER_CREATED',
}; };

View File

@@ -87,3 +87,39 @@ export function getCssVarOklchRgb(cssVarName) {
return `#${toHex(r)}${toHex(g)}${toHex(b_)}`; return `#${toHex(r)}${toHex(g)}${toHex(b_)}`;
} }
export function deserializeStartParams(serialized) {
if (!serialized) {
return {};
}
// Восстанавливаем стандартные base64 символы
let encoded = serialized.replace(/-/g, '+').replace(/_/g, '/');
// Добавляем padding, если нужно
const padding = encoded.length % 4;
if (padding !== 0) {
encoded += '='.repeat(4 - padding);
}
// Декодируем из base64
let json;
try {
json = atob(encoded); // btoa / atob стандартные в браузере
} catch (e) {
throw new Error('Failed to decode base64 string');
}
// Парсим JSON
let parameters;
try {
parameters = JSON.parse(json);
} catch (e) {
throw new Error('Failed to decode JSON: ' + e.message);
}
if (typeof parameters !== 'object' || parameters === null || Array.isArray(parameters) && !Array.isArray(parameters)) {
throw new Error('Decoded value is not an object');
}
return parameters;
}

View File

@@ -8,7 +8,7 @@ import {useSettingsStore} from "@/stores/SettingsStore.js";
import ApplicationError from "@/ApplicationError.vue"; import ApplicationError from "@/ApplicationError.vue";
import AppMetaInitializer from "@/utils/AppMetaInitializer.ts"; import AppMetaInitializer from "@/utils/AppMetaInitializer.ts";
import {injectYaMetrika} from "@/utils/yaMetrika.js"; import {injectYaMetrika} from "@/utils/yaMetrika.js";
import {checkIsUserPrivacyConsented, ingest, saveTelegramCustomer} from "@/utils/ftch.js"; import {checkIsUserPrivacyConsented} from "@/utils/ftch.js";
import {register} from 'swiper/element/bundle'; import {register} from 'swiper/element/bundle';
import 'swiper/element/bundle'; import 'swiper/element/bundle';
@@ -20,6 +20,7 @@ import {getCssVarOklchRgb} from "@/helpers.js";
import {defaultConfig, plugin} from '@formkit/vue'; import {defaultConfig, plugin} from '@formkit/vue';
import config from './formkit.config.js'; import config from './formkit.config.js';
import {TC_PULSE_EVENTS} from "@/constants/tPulseEvents.js"; import {TC_PULSE_EVENTS} from "@/constants/tPulseEvents.js";
import {usePulseStore} from "@/stores/Pulse.js";
register(); register();
@@ -34,6 +35,7 @@ app
const settings = useSettingsStore(); const settings = useSettingsStore();
const blocks = useBlocksStore(); const blocks = useBlocksStore();
const pulse = usePulseStore();
const appLoading = createApp(AppLoading); const appLoading = createApp(AppLoading);
appLoading.mount('#app'); appLoading.mount('#app');
@@ -51,24 +53,9 @@ settings.load()
throw new Error('App disabled (maintenance mode)'); throw new Error('App disabled (maintenance mode)');
} }
}) })
.then(() => { .then(() => pulse.initFromStartParams())
const webapp = window.Telegram.WebApp; .then(() => pulse.catchTelegramCustomerFromInitData())
ingest({ .then(() => pulse.ingest(TC_PULSE_EVENTS.WEBAPP_OPEN))
event: TC_PULSE_EVENTS.WEBAPP_OPEN,
webapp,
})
.catch(err => console.error('Ingest failed:', err));
})
.then(() => {
// Сохраняем данные Telegram-пользователя в базу данных
const userData = window.Telegram?.WebApp?.initDataUnsafe?.user;
if (userData) {
console.debug('[Init] Saving Telegram customer data');
saveTelegramCustomer(userData)
.then(() => console.debug('[Init] Telegram customer data saved successfully'))
.catch(() => console.warn('[Init] Failed to save Telegram customer data:', error));
}
})
.then(() => { .then(() => {
(async () => { (async () => {
try { try {

View File

@@ -5,6 +5,9 @@ import {useCartStore} from "@/stores/CartStore.js";
import {YA_METRIKA_GOAL} from "@/constants/yaMetrikaGoals.js"; import {YA_METRIKA_GOAL} from "@/constants/yaMetrikaGoals.js";
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js"; import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
import {useSettingsStore} from "@/stores/SettingsStore.js"; import {useSettingsStore} from "@/stores/SettingsStore.js";
import {usePulseStore} from "@/stores/Pulse.js";
import {TC_PULSE_EVENTS} from "@/constants/tPulseEvents.js";
import {nextTick} from "vue";
export const useCheckoutStore = defineStore('checkout', { export const useCheckoutStore = defineStore('checkout', {
state: () => ({ state: () => ({
@@ -30,7 +33,7 @@ export const useCheckoutStore = defineStore('checkout', {
console.log("Allows write to PM: ", data.user.allows_write_to_pm); console.log("Allows write to PM: ", data.user.allows_write_to_pm);
if (! data.user.allows_write_to_pm) { if (!data.user.allows_write_to_pm) {
console.log("Sending request"); console.log("Sending request");
const granted = await new Promise(resolve => { const granted = await new Promise(resolve => {
window.Telegram.WebApp.requestWriteAccess((granted) => { window.Telegram.WebApp.requestWriteAccess((granted) => {
@@ -52,35 +55,44 @@ export const useCheckoutStore = defineStore('checkout', {
}); });
this.order = response.data; this.order = response.data;
if (! this.order.id) { if (!this.order.id) {
console.debug(response.data); console.debug(response.data);
throw new Error('Ошибка создания заказа.'); throw new Error('Ошибка создания заказа.');
} }
const yaMetrika = useYaMetrikaStore(); const yaMetrika = useYaMetrikaStore();
yaMetrika.reachGoal(YA_METRIKA_GOAL.ORDER_CREATED_SUCCESS, { const pulse = usePulseStore();
price: this.order?.final_total_numeric,
currency: this.order?.currency, await nextTick(() => {
}); yaMetrika.reachGoal(YA_METRIKA_GOAL.ORDER_CREATED_SUCCESS, {
yaMetrika.dataLayerPush({ price: this.order?.final_total_numeric,
"ecommerce": { currency: this.order?.currency,
"currencyCode": useSettingsStore().currency_code, });
"purchase": { yaMetrika.dataLayerPush({
"actionField": { "ecommerce": {
"id": this.order.id, "currencyCode": useSettingsStore().currency_code,
'revenue': this.order?.final_total_numeric, "purchase": {
}, "actionField": {
"products": this.order.products ? this.order.products.map((product, index) => { "id": this.order.id,
return { 'revenue': this.order?.final_total_numeric,
id: product.product_id, },
name: product.name, "products": this.order.products ? this.order.products.map((product, index) => {
price: product.total_numeric, return {
position: index, id: product.product_id,
quantity: product.quantity, name: product.name,
}; price: product.total_numeric,
}) : [], position: index,
quantity: product.quantity,
};
}) : [],
}
} }
} });
pulse.ingest(TC_PULSE_EVENTS.ORDER_CREATED, {
order_id: this.order.id,
revenue: this.order?.final_total_numeric,
currency: this.order?.currency,
});
}); });
await window.Telegram.WebApp.HapticFeedback.notificationOccurred('success'); await window.Telegram.WebApp.HapticFeedback.notificationOccurred('success');

View File

@@ -0,0 +1,50 @@
import {defineStore} from "pinia";
import {ingest, saveTelegramCustomer} from "@/utils/ftch.js";
import {toRaw} from "vue";
import {deserializeStartParams} from "@/helpers.js";
export const usePulseStore = defineStore('pulse', {
state: () => ({
tracking_id: null,
campaign_id: null,
}),
actions: {
initFromStartParams() {
const webapp = window.Telegram.WebApp;
const startParam = webapp.initDataUnsafe.start_param;
const deserialized = deserializeStartParams(startParam);
this.tracking_id = deserialized?.tracking_id;
this.campaign_id = deserialized?.campaign_id;
console.debug('[Pulse] Init with start parameters: ', deserialized);
},
ingest(event, eventData = {}) {
ingest({
event: event,
payload: {
webapp: window.Telegram.WebApp,
eventData: eventData,
},
})
.then(() => console.debug('[Pulse] Event Ingested', event, eventData))
.catch(err => console.error('Ingest failed:', err));
},
catchTelegramCustomerFromInitData() {
const userData = window.Telegram?.WebApp?.initDataUnsafe?.user;
if (userData) {
console.debug('[Pulse] Saving Telegram customer data');
saveTelegramCustomer(userData)
.then((response) => {
this.tracking_id = this.tracking_id || response?.data?.tracking_id || null;
console.debug(
'[Pulse] Telegram customer data saved successfully. Tracking ID: ',
toRaw(this.tracking_id)
);
})
.catch(() => console.warn('[Pulse] Failed to save Telegram customer data:', error));
}
},
},
});

View File

@@ -1,7 +1,7 @@
import {defineStore} from "pinia"; import {defineStore} from "pinia";
import {useSettingsStore} from "@/stores/SettingsStore.js"; import {useSettingsStore} from "@/stores/SettingsStore.js";
import sha256 from 'crypto-js/sha256';
import {toRaw} from "vue"; import {toRaw} from "vue";
import {usePulseStore} from "@/stores/Pulse.js";
export const useYaMetrikaStore = defineStore('ya_metrika', { export const useYaMetrikaStore = defineStore('ya_metrika', {
state: () => ({ state: () => ({
@@ -20,6 +20,10 @@ export const useYaMetrikaStore = defineStore('ya_metrika', {
params.referer = params.referer ?? this.prevPath; params.referer = params.referer ?? this.prevPath;
const pulse = usePulseStore();
params.campaign_id = params.campaign_id || pulse.campaign_id || null;
params.tracking_id = params.tracking_id || pulse.tracking_id || null;
if (typeof window.ym === 'function' && window.YA_METRIKA_ID !== undefined) { if (typeof window.ym === 'function' && window.YA_METRIKA_ID !== undefined) {
console.debug('[ym] Hit ', fullUrl); console.debug('[ym] Hit ', fullUrl);
console.debug('[ym] ID ', window.YA_METRIKA_ID); console.debug('[ym] ID ', window.YA_METRIKA_ID);
@@ -47,6 +51,10 @@ export const useYaMetrikaStore = defineStore('ya_metrika', {
return; return;
} }
const pulse = usePulseStore();
params.campaign_id = params.campaign_id || pulse.campaign_id || null;
params.tracking_id = params.tracking_id || pulse.tracking_id || null;
if (typeof window.ym === 'function' && window.YA_METRIKA_ID !== undefined) { if (typeof window.ym === 'function' && window.YA_METRIKA_ID !== undefined) {
console.debug('[ym] reachGoal ', target, ' params: ', params); console.debug('[ym] reachGoal ', target, ' params: ', params);
window.ym(window.YA_METRIKA_ID, 'reachGoal', target, params); window.ym(window.YA_METRIKA_ID, 'reachGoal', target, params);
@@ -69,14 +77,8 @@ export const useYaMetrikaStore = defineStore('ya_metrika', {
} }
if (typeof window.ym === 'function' && window.YA_METRIKA_ID !== undefined) { if (typeof window.ym === 'function' && window.YA_METRIKA_ID !== undefined) {
let tgID = null;
if (window?.Telegram?.WebApp?.initDataUnsafe?.user?.id) {
tgID = sha256(window.Telegram.WebApp.initDataUnsafe.user.id).toString();
}
const userParams = { const userParams = {
tg_id: tgID, tracking_id: usePulseStore().tracking_id,
language: window.Telegram?.WebApp?.initDataUnsafe?.user?.language_code || 'unknown', language: window.Telegram?.WebApp?.initDataUnsafe?.user?.language_code || 'unknown',
platform: window.Telegram?.WebApp?.platform || 'unknown', platform: window.Telegram?.WebApp?.platform || 'unknown',
}; };
@@ -119,6 +121,19 @@ export const useYaMetrikaStore = defineStore('ya_metrika', {
return; return;
} }
const pulse = usePulseStore();
const campaignId = pulse.campaign_id || null;
object.ecommerce = object.ecommerce || {};
if (campaignId) {
object.ecommerce.promotions = object.ecommerce.promotions || [];
object.ecommerce.promotions.push({ id: campaignId });
}
// Всегда добавляем ключи на верхнем уровне
object.campaign_id = campaignId;
object.tracking_id = pulse.tracking_id || null;
if (Array.isArray(window.dataLayer)) { if (Array.isArray(window.dataLayer)) {
console.debug('[ym] dataLayer push: ', object); console.debug('[ym] dataLayer push: ', object);
window.dataLayer.push(object); window.dataLayer.push(object);

View File

@@ -79,6 +79,7 @@ class TelegramCustomersHandler
'id', 'id',
'telegram_user_id', 'telegram_user_id',
'oc_customer_id', 'oc_customer_id',
'tracking_id',
'username', 'username',
'first_name', 'first_name',
'last_name', 'last_name',
@@ -323,6 +324,7 @@ class TelegramCustomersHandler
'id' => (int) $customer['id'], 'id' => (int) $customer['id'],
'telegram_user_id' => (int) $customer['telegram_user_id'], 'telegram_user_id' => (int) $customer['telegram_user_id'],
'oc_customer_id' => (int) $customer['oc_customer_id'], 'oc_customer_id' => (int) $customer['oc_customer_id'],
'tracking_id' => $customer['tracking_id'],
'username' => $customer['username'], 'username' => $customer['username'],
'first_name' => $customer['first_name'], 'first_name' => $customer['first_name'],
'last_name' => $customer['last_name'], 'last_name' => $customer['last_name'],

View File

@@ -30,7 +30,8 @@
"psr/container": "^2.0", "psr/container": "^2.0",
"psr/log": "^1.1", "psr/log": "^1.1",
"symfony/cache": "^5.4", "symfony/cache": "^5.4",
"vlucas/phpdotenv": "^5.6" "vlucas/phpdotenv": "^5.6",
"ramsey/uuid": "^4.2"
}, },
"require-dev": { "require-dev": {
"doctrine/sql-formatter": "^1.3", "doctrine/sql-formatter": "^1.3",

View File

@@ -4,8 +4,68 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "0c1bcdf986f5b31fb943e21467785c64", "content-hash": "049ebb1f7c985aa2bbbe3578c203fb37",
"packages": [ "packages": [
{
"name": "brick/math",
"version": "0.9.3",
"source": {
"type": "git",
"url": "https://github.com/brick/math.git",
"reference": "ca57d18f028f84f777b2168cd1911b0dee2343ae"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/brick/math/zipball/ca57d18f028f84f777b2168cd1911b0dee2343ae",
"reference": "ca57d18f028f84f777b2168cd1911b0dee2343ae",
"shasum": ""
},
"require": {
"ext-json": "*",
"php": "^7.1 || ^8.0"
},
"require-dev": {
"php-coveralls/php-coveralls": "^2.2",
"phpunit/phpunit": "^7.5.15 || ^8.5 || ^9.0",
"vimeo/psalm": "4.9.2"
},
"type": "library",
"autoload": {
"psr-4": {
"Brick\\Math\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "Arbitrary-precision arithmetic library",
"keywords": [
"Arbitrary-precision",
"BigInteger",
"BigRational",
"arithmetic",
"bigdecimal",
"bignum",
"brick",
"math"
],
"support": {
"issues": "https://github.com/brick/math/issues",
"source": "https://github.com/brick/math/tree/0.9.3"
},
"funding": [
{
"url": "https://github.com/BenMorel",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/brick/math",
"type": "tidelift"
}
],
"time": "2021-08-15T20:50:18+00:00"
},
{ {
"name": "carbonphp/carbon-doctrine-types", "name": "carbonphp/carbon-doctrine-types",
"version": "2.1.0", "version": "2.1.0",
@@ -1488,6 +1548,194 @@
}, },
"time": "2019-03-08T08:55:37+00:00" "time": "2019-03-08T08:55:37+00:00"
}, },
{
"name": "ramsey/collection",
"version": "1.3.0",
"source": {
"type": "git",
"url": "https://github.com/ramsey/collection.git",
"reference": "ad7475d1c9e70b190ecffc58f2d989416af339b4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/ramsey/collection/zipball/ad7475d1c9e70b190ecffc58f2d989416af339b4",
"reference": "ad7475d1c9e70b190ecffc58f2d989416af339b4",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0",
"symfony/polyfill-php81": "^1.23"
},
"require-dev": {
"captainhook/plugin-composer": "^5.3",
"ergebnis/composer-normalize": "^2.28.3",
"fakerphp/faker": "^1.21",
"hamcrest/hamcrest-php": "^2.0",
"jangregor/phpstan-prophecy": "^1.0",
"mockery/mockery": "^1.5",
"php-parallel-lint/php-console-highlighter": "^1.0",
"php-parallel-lint/php-parallel-lint": "^1.3",
"phpcsstandards/phpcsutils": "^1.0.0-rc1",
"phpspec/prophecy-phpunit": "^2.0",
"phpstan/extension-installer": "^1.2",
"phpstan/phpstan": "^1.9",
"phpstan/phpstan-mockery": "^1.1",
"phpstan/phpstan-phpunit": "^1.3",
"phpunit/phpunit": "^9.5",
"psalm/plugin-mockery": "^1.1",
"psalm/plugin-phpunit": "^0.18.4",
"ramsey/coding-standard": "^2.0.3",
"ramsey/conventional-commits": "^1.3",
"vimeo/psalm": "^5.4"
},
"type": "library",
"extra": {
"captainhook": {
"force-install": true
},
"ramsey/conventional-commits": {
"configFile": "conventional-commits.json"
}
},
"autoload": {
"psr-4": {
"Ramsey\\Collection\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Ben Ramsey",
"email": "ben@benramsey.com",
"homepage": "https://benramsey.com"
}
],
"description": "A PHP library for representing and manipulating collections.",
"keywords": [
"array",
"collection",
"hash",
"map",
"queue",
"set"
],
"support": {
"issues": "https://github.com/ramsey/collection/issues",
"source": "https://github.com/ramsey/collection/tree/1.3.0"
},
"funding": [
{
"url": "https://github.com/ramsey",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/ramsey/collection",
"type": "tidelift"
}
],
"time": "2022-12-27T19:12:24+00:00"
},
{
"name": "ramsey/uuid",
"version": "4.2.3",
"source": {
"type": "git",
"url": "https://github.com/ramsey/uuid.git",
"reference": "fc9bb7fb5388691fd7373cd44dcb4d63bbcf24df"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/ramsey/uuid/zipball/fc9bb7fb5388691fd7373cd44dcb4d63bbcf24df",
"reference": "fc9bb7fb5388691fd7373cd44dcb4d63bbcf24df",
"shasum": ""
},
"require": {
"brick/math": "^0.8 || ^0.9",
"ext-json": "*",
"php": "^7.2 || ^8.0",
"ramsey/collection": "^1.0",
"symfony/polyfill-ctype": "^1.8",
"symfony/polyfill-php80": "^1.14"
},
"replace": {
"rhumsaa/uuid": "self.version"
},
"require-dev": {
"captainhook/captainhook": "^5.10",
"captainhook/plugin-composer": "^5.3",
"dealerdirect/phpcodesniffer-composer-installer": "^0.7.0",
"doctrine/annotations": "^1.8",
"ergebnis/composer-normalize": "^2.15",
"mockery/mockery": "^1.3",
"moontoast/math": "^1.1",
"paragonie/random-lib": "^2",
"php-mock/php-mock": "^2.2",
"php-mock/php-mock-mockery": "^1.3",
"php-parallel-lint/php-parallel-lint": "^1.1",
"phpbench/phpbench": "^1.0",
"phpstan/extension-installer": "^1.0",
"phpstan/phpstan": "^0.12",
"phpstan/phpstan-mockery": "^0.12",
"phpstan/phpstan-phpunit": "^0.12",
"phpunit/phpunit": "^8.5 || ^9",
"slevomat/coding-standard": "^7.0",
"squizlabs/php_codesniffer": "^3.5",
"vimeo/psalm": "^4.9"
},
"suggest": {
"ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.",
"ext-ctype": "Enables faster processing of character classification using ctype functions.",
"ext-gmp": "Enables faster math with arbitrary-precision integers using GMP.",
"ext-uuid": "Enables the use of PeclUuidTimeGenerator and PeclUuidRandomGenerator.",
"paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter",
"ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type."
},
"type": "library",
"extra": {
"captainhook": {
"force-install": true
},
"branch-alias": {
"dev-main": "4.x-dev"
}
},
"autoload": {
"files": [
"src/functions.php"
],
"psr-4": {
"Ramsey\\Uuid\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "A PHP library for generating and working with universally unique identifiers (UUIDs).",
"keywords": [
"guid",
"identifier",
"uuid"
],
"support": {
"issues": "https://github.com/ramsey/uuid/issues",
"source": "https://github.com/ramsey/uuid/tree/4.2.3"
},
"funding": [
{
"url": "https://github.com/ramsey",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/ramsey/uuid",
"type": "tidelift"
}
],
"time": "2021-09-25T23:10:38+00:00"
},
{ {
"name": "symfony/cache", "name": "symfony/cache",
"version": "v5.4.46", "version": "v5.4.46",
@@ -2063,6 +2311,86 @@
], ],
"time": "2025-01-02T08:10:11+00:00" "time": "2025-01-02T08:10:11+00:00"
}, },
{
"name": "symfony/polyfill-php81",
"version": "v1.33.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php81.git",
"reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c",
"reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c",
"shasum": ""
},
"require": {
"php": ">=7.2"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/polyfill",
"name": "symfony/polyfill"
}
},
"autoload": {
"files": [
"bootstrap.php"
],
"psr-4": {
"Symfony\\Polyfill\\Php81\\": ""
},
"classmap": [
"Resources/stubs"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"polyfill",
"portable",
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-php81/tree/v1.33.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2024-09-09T11:45:10+00:00"
},
{ {
"name": "symfony/service-contracts", "name": "symfony/service-contracts",
"version": "v1.1.2", "version": "v1.1.2",

View File

@@ -0,0 +1,16 @@
<?php
use Openguru\OpenCartFramework\Migrations\Migration;
return new class extends Migration {
public function up(): void
{
$sql = <<<SQL
ALTER TABLE `telecart_customers`
ADD COLUMN `tracking_id` VARCHAR(64) NOT NULL AFTER `oc_customer_id`;
SQL;
$this->database->statement($sql);
}
};

View File

@@ -3,6 +3,7 @@
use Openguru\OpenCartFramework\Application; use Openguru\OpenCartFramework\Application;
use Openguru\OpenCartFramework\Config\Settings; use Openguru\OpenCartFramework\Config\Settings;
use Openguru\OpenCartFramework\Support\Utils; use Openguru\OpenCartFramework\Support\Utils;
use Openguru\OpenCartFramework\TeleCartPulse\TeleCartPulseService;
if (! function_exists('table')) { if (! function_exists('table')) {
function db_table(string $name): string function db_table(string $name): string

View File

@@ -5,4 +5,5 @@ namespace Openguru\OpenCartFramework\TeleCartPulse;
final class PulseEvents final class PulseEvents
{ {
public const WEBAPP_OPEN = 'WEBAPP_OPEN'; public const WEBAPP_OPEN = 'WEBAPP_OPEN';
public const ORDER_CREATED = 'ORDER_CREATED';
} }

View File

@@ -33,7 +33,7 @@ class TeleCartPulseService
return; return;
} }
$initData = Arr::get($data, 'webapp.initData'); $initData = Arr::get($data, 'payload.webapp.initData');
if (! $initData) { if (! $initData) {
return; return;
} }
@@ -46,12 +46,16 @@ class TeleCartPulseService
try { try {
$decoded = $this->initDataDecoder->parseInitDataStringToArray($initData); $decoded = $this->initDataDecoder->parseInitDataStringToArray($initData);
$startParam = Arr::get($decoded, 'start_param'); $startParam = Arr::get($decoded, 'start_param', '');
$deserialized = StartParamSerializer::deserialize($startParam); $deserialized = StartParamSerializer::deserialize($startParam);
if ($event === PulseEvents::WEBAPP_OPEN) { if ($event === PulseEvents::WEBAPP_OPEN) {
$this->handleWebAppInit($data, $deserialized); $this->handleWebAppInit($data, $deserialized);
} }
if ($event === PulseEvents::ORDER_CREATED) {
$this->handleOrderCreated($data, $deserialized);
}
} catch (ClientException $exception) { } catch (ClientException $exception) {
$contents = (string)$exception->getResponse()->getBody(); $contents = (string)$exception->getResponse()->getBody();
$decoded = json_decode($contents, true); $decoded = json_decode($contents, true);
@@ -108,4 +112,30 @@ class TeleCartPulseService
$client->post('events', compact('json')); $client->post('events', compact('json'));
} }
private function handleOrderCreated(array $data, array $deserialized): void
{
if (isset($deserialized['campaign_id'], $deserialized['tracking_id'])) {
$payload = [
'event' => PulseEvents::ORDER_CREATED,
'campaign_id' => $deserialized['campaign_id'],
'tracking_id' => $deserialized['tracking_id'],
'meta' => [
'domain' => Utils::getCurrentDomain(),
'version' => Arr::get($data, 'webapp.version'),
'platform' => Arr::get($data, 'webapp.platform'),
'order_id' => Arr::get($data, 'eventData.order_id'),
'currency' => Arr::get($data, 'eventData.currency'),
],
'timestamp' => Carbon::now('UTC')->toJSON(),
];
$dataToSend = [
'payload' => $payload,
'signature' => $this->payloadSigner->sign($payload),
];
$this->pushEvent($dataToSend);
}
}
} }

View File

@@ -0,0 +1,13 @@
<?php
namespace Openguru\OpenCartFramework\TeleCartPulse;
use Ramsey\Uuid\Uuid;
class TrackingIdGenerator
{
public static function generate(): string
{
return Uuid::uuid4()->toString();
}
}

View File

@@ -96,7 +96,7 @@ class ETLHandler
$query $query
->select([ ->select([
new RawExpression('md5(telegram_user_id) AS tracking_id'), 'tracking_id',
'telegram_user_id' => 'tg_user_id', 'telegram_user_id' => 'tg_user_id',
'telecart_customers.oc_customer_id', 'telecart_customers.oc_customer_id',
'is_premium', 'is_premium',

View File

@@ -9,6 +9,7 @@ use Openguru\OpenCartFramework\Http\JsonResponse;
use Openguru\OpenCartFramework\Http\Request; use Openguru\OpenCartFramework\Http\Request;
use Openguru\OpenCartFramework\Http\Response; use Openguru\OpenCartFramework\Http\Response;
use Openguru\OpenCartFramework\Support\Arr; use Openguru\OpenCartFramework\Support\Arr;
use Openguru\OpenCartFramework\TeleCartPulse\TrackingIdGenerator;
use Openguru\OpenCartFramework\Telegram\Enums\TelegramHeader; use Openguru\OpenCartFramework\Telegram\Enums\TelegramHeader;
use Openguru\OpenCartFramework\Telegram\Exceptions\DecodeTelegramInitDataException; use Openguru\OpenCartFramework\Telegram\Exceptions\DecodeTelegramInitDataException;
use Openguru\OpenCartFramework\Telegram\TelegramInitDataDecoder; use Openguru\OpenCartFramework\Telegram\TelegramInitDataDecoder;
@@ -41,11 +42,15 @@ class TelegramCustomerHandler
public function saveOrUpdate(Request $request): JsonResponse public function saveOrUpdate(Request $request): JsonResponse
{ {
try { try {
$this->telegramCustomerService->saveOrUpdate( $customer = $this->telegramCustomerService->saveOrUpdate(
$this->extractTelegramUserData($request) $this->extractTelegramUserData($request)
); );
return new JsonResponse([], Response::HTTP_NO_CONTENT); return new JsonResponse([
'data' => [
'tracking_id' => Arr::get($customer, 'tracking_id'),
],
], Response::HTTP_OK);
} catch (Throwable $e) { } catch (Throwable $e) {
$this->logger->error('Could not save telegram customer data', ['exception' => $e]); $this->logger->error('Could not save telegram customer data', ['exception' => $e]);

View File

@@ -7,6 +7,7 @@ namespace App\Models;
use Carbon\Carbon; use Carbon\Carbon;
use Openguru\OpenCartFramework\QueryBuilder\Builder; use Openguru\OpenCartFramework\QueryBuilder\Builder;
use Openguru\OpenCartFramework\QueryBuilder\Connections\ConnectionInterface; use Openguru\OpenCartFramework\QueryBuilder\Connections\ConnectionInterface;
use Openguru\OpenCartFramework\TeleCartPulse\TrackingIdGenerator;
use RuntimeException; use RuntimeException;
class TelegramCustomer class TelegramCustomer
@@ -81,6 +82,7 @@ class TelegramCustomer
{ {
$data['created_at'] = Carbon::now()->toDateTimeString(); $data['created_at'] = Carbon::now()->toDateTimeString();
$data['updated_at'] = Carbon::now()->toDateTimeString(); $data['updated_at'] = Carbon::now()->toDateTimeString();
$data['tracking_id'] = TrackingIdGenerator::generate();
$success = $this->database->insert(self::TABLE_NAME, $data); $success = $this->database->insert(self::TABLE_NAME, $data);

View File

@@ -23,10 +23,10 @@ class TelegramCustomerService
* Сохранить или обновить Telegram-пользователя * Сохранить или обновить Telegram-пользователя
* *
* @param array $telegramUserData Данные пользователя из Telegram.WebApp.initDataUnsafe * @param array $telegramUserData Данные пользователя из Telegram.WebApp.initDataUnsafe
* @return void * @return array
* @throws RuntimeException Если данные невалидны или не удалось сохранить * @throws RuntimeException Если данные невалидны или не удалось сохранить
*/ */
public function saveOrUpdate(array $telegramUserData): void public function saveOrUpdate(array $telegramUserData): array
{ {
$telegramUserId = $this->extractTelegramUserId($telegramUserData); $telegramUserId = $this->extractTelegramUserId($telegramUserData);
$telegramCustomerData = $this->prepareCustomerData($telegramUserData, $telegramUserId); $telegramCustomerData = $this->prepareCustomerData($telegramUserData, $telegramUserId);
@@ -38,6 +38,8 @@ class TelegramCustomerService
} else { } else {
$this->telegramCustomer->create($telegramCustomerData); $this->telegramCustomer->create($telegramCustomerData);
} }
return $this->telegramCustomer->findByTelegramUserId($telegramUserId);
} }
/** /**
@@ -49,7 +51,7 @@ class TelegramCustomerService
*/ */
private function extractTelegramUserId(array $telegramUserData): int private function extractTelegramUserId(array $telegramUserData): int
{ {
$telegramUserId = (int) Arr::get($telegramUserData, 'id'); $telegramUserId = (int)Arr::get($telegramUserData, 'id');
if ($telegramUserId <= 0) { if ($telegramUserId <= 0) {
throw new RuntimeException('Telegram user ID is required and must be positive'); throw new RuntimeException('Telegram user ID is required and must be positive');
@@ -102,7 +104,7 @@ class TelegramCustomerService
]); ]);
} }
return (int) $customer['id']; return (int)$customer['id'];
} }
public function increaseOrdersCount(int $telecartCustomerId): void public function increaseOrdersCount(int $telecartCustomerId): void