feat: track and push TeleCart Pulse events
This commit is contained in:
@@ -109,9 +109,11 @@
|
||||
</template>
|
||||
<template #body="{ data }">
|
||||
<template v-if="col.field === 'id'">{{ data.id }}</template>
|
||||
<template v-else-if="col.field === 'telegram_user_id'">{{
|
||||
data.telegram_user_id
|
||||
}}
|
||||
<template v-else-if="col.field === 'telegram_user_id'">
|
||||
{{ data.telegram_user_id }}
|
||||
</template>
|
||||
<template v-else-if="col.field === 'tracking_id'">
|
||||
<code>{{ data.tracking_id }}</code>
|
||||
</template>
|
||||
<template v-else-if="col.field === 'username'">
|
||||
<div class="tw:flex tw:items-center tw:gap-2">
|
||||
@@ -180,7 +182,8 @@
|
||||
<InputText v-model="filterModel.value" type="text" placeholder="Поиск по фамилии"
|
||||
class="p-column-filter"/>
|
||||
</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"/>
|
||||
</template>
|
||||
<template v-else-if="col.field === 'orders_count'">
|
||||
@@ -314,6 +317,14 @@ const columns = ref([
|
||||
filterable: true,
|
||||
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: 'first_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,
|
||||
constraints: [{value: null, matchMode: FilterMatchMode.DATE_IS}]
|
||||
},
|
||||
privacy_consented_at: {
|
||||
operator: FilterOperator.AND,
|
||||
constraints: [{value: null, matchMode: FilterMatchMode.DATE_IS}]
|
||||
},
|
||||
privacy_consented_at: {
|
||||
operator: FilterOperator.AND,
|
||||
constraints: [{value: null, matchMode: FilterMatchMode.DATE_IS}]
|
||||
},
|
||||
};
|
||||
lazyParams.value.page = 1;
|
||||
lazyParams.value.first = 0;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export const TC_PULSE_EVENTS = {
|
||||
WEBAPP_OPEN: 'WEBAPP_OPEN',
|
||||
ORDER_CREATED: 'ORDER_CREATED',
|
||||
};
|
||||
|
||||
@@ -87,3 +87,39 @@ export function getCssVarOklchRgb(cssVarName) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {useSettingsStore} from "@/stores/SettingsStore.js";
|
||||
import ApplicationError from "@/ApplicationError.vue";
|
||||
import AppMetaInitializer from "@/utils/AppMetaInitializer.ts";
|
||||
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 'swiper/element/bundle';
|
||||
@@ -20,6 +20,7 @@ import {getCssVarOklchRgb} from "@/helpers.js";
|
||||
import {defaultConfig, plugin} from '@formkit/vue';
|
||||
import config from './formkit.config.js';
|
||||
import {TC_PULSE_EVENTS} from "@/constants/tPulseEvents.js";
|
||||
import {usePulseStore} from "@/stores/Pulse.js";
|
||||
|
||||
register();
|
||||
|
||||
@@ -34,6 +35,7 @@ app
|
||||
|
||||
const settings = useSettingsStore();
|
||||
const blocks = useBlocksStore();
|
||||
const pulse = usePulseStore();
|
||||
|
||||
const appLoading = createApp(AppLoading);
|
||||
appLoading.mount('#app');
|
||||
@@ -51,24 +53,9 @@ settings.load()
|
||||
throw new Error('App disabled (maintenance mode)');
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
const webapp = window.Telegram.WebApp;
|
||||
ingest({
|
||||
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(() => pulse.initFromStartParams())
|
||||
.then(() => pulse.catchTelegramCustomerFromInitData())
|
||||
.then(() => pulse.ingest(TC_PULSE_EVENTS.WEBAPP_OPEN))
|
||||
.then(() => {
|
||||
(async () => {
|
||||
try {
|
||||
|
||||
@@ -5,6 +5,9 @@ import {useCartStore} from "@/stores/CartStore.js";
|
||||
import {YA_METRIKA_GOAL} from "@/constants/yaMetrikaGoals.js";
|
||||
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.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', {
|
||||
state: () => ({
|
||||
@@ -30,7 +33,7 @@ export const useCheckoutStore = defineStore('checkout', {
|
||||
|
||||
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");
|
||||
const granted = await new Promise(resolve => {
|
||||
window.Telegram.WebApp.requestWriteAccess((granted) => {
|
||||
@@ -52,35 +55,44 @@ export const useCheckoutStore = defineStore('checkout', {
|
||||
});
|
||||
this.order = response.data;
|
||||
|
||||
if (! this.order.id) {
|
||||
if (!this.order.id) {
|
||||
console.debug(response.data);
|
||||
throw new Error('Ошибка создания заказа.');
|
||||
}
|
||||
|
||||
const yaMetrika = useYaMetrikaStore();
|
||||
yaMetrika.reachGoal(YA_METRIKA_GOAL.ORDER_CREATED_SUCCESS, {
|
||||
price: this.order?.final_total_numeric,
|
||||
currency: this.order?.currency,
|
||||
});
|
||||
yaMetrika.dataLayerPush({
|
||||
"ecommerce": {
|
||||
"currencyCode": useSettingsStore().currency_code,
|
||||
"purchase": {
|
||||
"actionField": {
|
||||
"id": this.order.id,
|
||||
'revenue': this.order?.final_total_numeric,
|
||||
},
|
||||
"products": this.order.products ? this.order.products.map((product, index) => {
|
||||
return {
|
||||
id: product.product_id,
|
||||
name: product.name,
|
||||
price: product.total_numeric,
|
||||
position: index,
|
||||
quantity: product.quantity,
|
||||
};
|
||||
}) : [],
|
||||
const pulse = usePulseStore();
|
||||
|
||||
await nextTick(() => {
|
||||
yaMetrika.reachGoal(YA_METRIKA_GOAL.ORDER_CREATED_SUCCESS, {
|
||||
price: this.order?.final_total_numeric,
|
||||
currency: this.order?.currency,
|
||||
});
|
||||
yaMetrika.dataLayerPush({
|
||||
"ecommerce": {
|
||||
"currencyCode": useSettingsStore().currency_code,
|
||||
"purchase": {
|
||||
"actionField": {
|
||||
"id": this.order.id,
|
||||
'revenue': this.order?.final_total_numeric,
|
||||
},
|
||||
"products": this.order.products ? this.order.products.map((product, index) => {
|
||||
return {
|
||||
id: product.product_id,
|
||||
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');
|
||||
|
||||
50
frontend/spa/src/stores/Pulse.js
Normal file
50
frontend/spa/src/stores/Pulse.js
Normal 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));
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import {defineStore} from "pinia";
|
||||
import {useSettingsStore} from "@/stores/SettingsStore.js";
|
||||
import sha256 from 'crypto-js/sha256';
|
||||
import {toRaw} from "vue";
|
||||
import {usePulseStore} from "@/stores/Pulse.js";
|
||||
|
||||
export const useYaMetrikaStore = defineStore('ya_metrika', {
|
||||
state: () => ({
|
||||
@@ -20,6 +20,10 @@ export const useYaMetrikaStore = defineStore('ya_metrika', {
|
||||
|
||||
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) {
|
||||
console.debug('[ym] Hit ', fullUrl);
|
||||
console.debug('[ym] ID ', window.YA_METRIKA_ID);
|
||||
@@ -47,6 +51,10 @@ export const useYaMetrikaStore = defineStore('ya_metrika', {
|
||||
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) {
|
||||
console.debug('[ym] reachGoal ', target, ' params: ', 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) {
|
||||
let tgID = null;
|
||||
|
||||
if (window?.Telegram?.WebApp?.initDataUnsafe?.user?.id) {
|
||||
tgID = sha256(window.Telegram.WebApp.initDataUnsafe.user.id).toString();
|
||||
}
|
||||
|
||||
const userParams = {
|
||||
tg_id: tgID,
|
||||
tracking_id: usePulseStore().tracking_id,
|
||||
language: window.Telegram?.WebApp?.initDataUnsafe?.user?.language_code || 'unknown',
|
||||
platform: window.Telegram?.WebApp?.platform || 'unknown',
|
||||
};
|
||||
@@ -119,6 +121,19 @@ export const useYaMetrikaStore = defineStore('ya_metrika', {
|
||||
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)) {
|
||||
console.debug('[ym] dataLayer push: ', object);
|
||||
window.dataLayer.push(object);
|
||||
|
||||
Reference in New Issue
Block a user