feat: WIP add yandex metrika goals

This commit is contained in:
2025-10-26 14:35:39 +03:00
parent fbccd50675
commit 4e59c4e788
19 changed files with 219 additions and 38 deletions

View File

@@ -547,17 +547,18 @@ HTML,
],
'orders' => [
'module_tgshop_order_customer_group_id' => [
'type' => 'select',
'options' => $this->getCustomerGroups(),
'help' => 'Группа покупателей, которая будет назначена для заказов, оформленных через Telegram-магазин.',
],
'module_tgshop_order_default_status_id' => [
'type' => 'select',
'options' => $this->getOrderStatuses(),
'help' => 'Статус, с которым будут создаваться заказы через Telegram по умолчанию.',
],
'module_tgshop_order_customer_group_id' => [
'hidden' => true,
'type' => 'select',
'options' => $this->getCustomerGroups(),
'help' => 'Группа покупателей, которая будет назначена для заказов, оформленных через Telegram-магазин.',
],
],
];
}

View File

@@ -45,8 +45,7 @@ class ControllerExtensionTgshopHandle extends Controller
'app_debug' => $appDebug,
'oc_config_tax' => $this->config->get('config_tax'),
'oc_default_currency' => $this->config->get('config_currency'),
// ID группы покупателей, которая будет использоаваться в заказах через Телеграм.
'oc_customer_group_id' => $this->config->get('module_tgshop_order_customer_group_id'),
'oc_customer_group_id' => $this->config->get('config_customer_group_id'),
// ID магазина, для которого будут создаваться заказы из Телеграм
'oc_store_id' => 0,
// Название магазина, для которого будут создаваться заказы из Телеграм

View File

@@ -177,6 +177,7 @@ class OrderCreateService
$this->cartService->flush();
$orderData['order_id'] = $orderId;
$orderData['total_numeric'] = $orderData['total'] ?? 0;
$orderData['total'] = $cart['total_text'] ?? '';
$this->sendNotifications($orderData, $data['tgData']);
@@ -192,6 +193,8 @@ class OrderCreateService
'id' => $orderData['order_id'],
'created_at' => $dateTimeFormatted,
'total' => $orderData['total'],
'final_total_numeric' => $orderData['total_numeric'],
'currency' => $currencyCode,
];
}

View File

@@ -214,6 +214,7 @@ class ProductsService
$configTax = $this->oc->config->get('config_tax');
$product_info = $this->oc->model_catalog_product->getProduct($productId);
$currency = $this->oc->session->data['currency'];
if (! $product_info) {
throw new EntityNotFoundException('Product with id ' . $productId . ' not found');
@@ -280,24 +281,24 @@ class ProductsService
$data['images'] = $images;
$data['price'] = $this->currency->format(
$this->tax->calculate(
$productPrice = $this->tax->calculate(
$product_info['price'],
$product_info['tax_class_id'],
$configTax,
),
$this->oc->session->data['currency']
);
$data['price'] = $this->currency->format($productPrice, $currency);
$data['currency'] = $currency;
$data['final_price_numeric'] = $productPrice;
if (! is_null($product_info['special']) && (float) $product_info['special'] >= 0) {
$data['special'] = $this->currency->format(
$this->tax->calculate(
$productSpecialPrice = $this->tax->calculate(
$product_info['special'],
$product_info['tax_class_id'],
$configTax,
),
$this->oc->session->data['currency']
);
$data['special'] = $this->currency->format($productSpecialPrice, $currency);
$data['final_price_numeric'] = $productSpecialPrice;
$tax_price = (float) $product_info['special'];
} else {
$data['special'] = false;
@@ -305,7 +306,7 @@ class ProductsService
}
if ($configTax) {
$data['tax'] = $this->currency->format($tax_price, $this->oc->session->data['currency']);
$data['tax'] = $this->currency->format($tax_price, $currency);
} else {
$data['tax'] = false;
}
@@ -323,7 +324,7 @@ class ProductsService
$product_info['tax_class_id'],
$configTax,
),
$this->oc->session->data['currency']
$currency
)
);
}
@@ -341,7 +342,7 @@ class ProductsService
$product_info['tax_class_id'],
$configTax ? 'P' : false
),
$this->oc->session->data['currency']
$currency
);
$product_option_value_data[] = array(

View File

@@ -0,0 +1,14 @@
export const YA_METRIKA_GOAL = {
ADD_TO_CART: 'add_to_cart',
CREATE_ORDER: 'create_order',
ORDER_CREATED_SUCCESS: 'order_created_success',
VIEW_PRODUCT: 'view_product',
VIEW_CART: 'view_cart',
VIEW_CHECKOUT: 'view_checkout',
VIEW_HOME: 'view_home',
VIEW_FILTERS: 'view_filters',
FILTERS_APPLY: 'filters_apply',
FILTERS_RESET: 'filters_reset',
VIEW_SEARCH: 'view_search',
PERFORM_SEARCH: 'perform_search',
};

View File

@@ -35,3 +35,9 @@ export const router = createRouter({
history: createWebHashHistory('/image/catalog/tgshopspa/'),
routes,
});
router.beforeEach((to, from, next) => {
const ym = useYaMetrikaStore();
ym.prevPath = from.path;
next();
});

View File

@@ -38,5 +38,24 @@ export const useCategoriesStore = defineStore('categories', {
this.isLoading = false;
}
},
async findCategoryById(id, list = []) {
if (! id) return null;
if (list && list.length === 0) {
await this.fetchCategories();
list = this.categories;
}
for (const cat of list) {
if (parseInt(cat.id) === parseInt(id)) return cat;
if (cat.children?.length) {
const found = await this.findCategoryById(id, cat.children);
if (found) return found;
}
}
return null;
}
},
});

View File

@@ -1,5 +1,7 @@
import {defineStore} from "pinia";
import ftch from "@/utils/ftch.js";
import {YA_METRIKA_GOAL} from "@/constants/yaMetrikaGoals.js";
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
export const useSearchStore = defineStore('search', {
state: () => ({
@@ -31,6 +33,10 @@ export const useSearchStore = defineStore('search', {
return this.reset();
}
useYaMetrikaStore().reachGoal(YA_METRIKA_GOAL.PERFORM_SEARCH, {
keyword: this.search,
});
try {
this.isLoading = true;
this.products = await ftch('products', {

View File

@@ -1,9 +1,12 @@
import {defineStore} from "pinia";
import {useSettingsStore} from "@/stores/SettingsStore.js";
import sha256 from 'crypto-js/sha256';
import {toRaw} from "vue";
export const useYaMetrikaStore = defineStore('ya_metrika', {
state: () => ({
queue: [],
prevPath: null,
}),
actions: {
@@ -16,6 +19,8 @@ export const useYaMetrikaStore = defineStore('ya_metrika', {
const fullUrl = `/#${url}`;
params.referer = params.referer ?? this.prevPath;
if (typeof window.ym === 'function' && window.YA_METRIKA_ID !== undefined) {
console.debug('[ym] Hit ', fullUrl);
console.debug('[ym] ID ', window.YA_METRIKA_ID);
@@ -33,6 +38,49 @@ export const useYaMetrikaStore = defineStore('ya_metrika', {
}
},
reachGoal(target, params = {}) {
const settings = useSettingsStore();
if (!settings.ya_metrika_enabled) {
console.debug('[ym] Yandex Metrika disabled in settings.');
return;
}
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);
} else {
console.debug('[ym] Yandex Metrika is not initialized. Pushed to queue.');
this.queue.push({
event: 'reachGoal',
payload: {
target,
params
},
});
}
},
initUserParams() {
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,
language: window.Telegram?.WebApp?.initDataUnsafe?.user?.language_code || 'unknown',
platform: window.Telegram?.WebApp?.platform || 'unknown',
};
window.ym(window.YA_METRIKA_ID, 'userParams', userParams);
console.debug('[ym] User params initialized: ', userParams);
} else {
console.debug('[ym] Yandex Metrika is not initialized. Could not init user params.');
}
},
processQueue() {
if (this.queue.length === 0) {
return;
@@ -43,8 +91,10 @@ export const useYaMetrikaStore = defineStore('ya_metrika', {
while (this.queue.length > 0) {
const item = this.queue.shift();
if (item.event === 'hit') {
console.debug('[ym] Queue ', item);
console.debug('[ym] Queue ', toRaw(item));
window.ym(window.YA_METRIKA_ID, item.event, item.payload.url, item.payload.params);
} else if (item.event === 'reachGoal') {
window.ym(window.YA_METRIKA_ID, item.event, item.payload.target, item.payload.params);
} else {
console.error('[ym] Unsupported queue event: ', item.event);
}

View File

@@ -23,6 +23,7 @@ export function injectYaMetrika() {
window.YA_METRIKA_ID = getMetrikaId();
console.debug('[Init] Detected Yandex.Metrika ID:', window.YA_METRIKA_ID);
const yaMetrika = useYaMetrikaStore();
yaMetrika.initUserParams();
yaMetrika.processQueue();
}
}

View File

@@ -139,9 +139,13 @@ import OptionRadio from "@/components/ProductOptions/Cart/OptionRadio.vue";
import OptionCheckbox from "@/components/ProductOptions/Cart/OptionCheckbox.vue";
import OptionText from "@/components/ProductOptions/Cart/OptionText.vue";
import {computed, onMounted} from "vue";
import {useRouter} from "vue-router";
import {useRoute, useRouter} from "vue-router";
import {useSettingsStore} from "@/stores/SettingsStore.js";
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
import {YA_METRIKA_GOAL} from "@/constants/yaMetrikaGoals.js";
const route = useRoute();
const yaMetrika = useYaMetrikaStore();
const cart = useCartStore();
const router = useRouter();
const settings = useSettingsStore();
@@ -169,11 +173,15 @@ function goToCheckout() {
onMounted(async () => {
window.document.title = 'Корзина покупок';
yaMetrika.pushHit(route.path, {
title: 'Корзина покупок',
});
yaMetrika.reachGoal(YA_METRIKA_GOAL.VIEW_CART);
});
</script>
<style scoped>
.btn-checkout {
bottom: calc(var(--spacing, 0px) * 22px + var(--tg-safe-area-inset-bottom, 0px))
bottom: calc(var(--spacing, 0px) * 22 + var(--tg-safe-area-inset-bottom, 0px))
}
</style>

View File

@@ -58,9 +58,11 @@ import {router} from "@/router.js";
import {useCategoriesStore} from "@/stores/CategoriesStore.js";
import {useRoute} from "vue-router";
import CategoryItem from "@/components/CategoriesList/CategoryItem.vue";
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
const route = useRoute();
const categoriesStore = useCategoriesStore();
const yaMetrika = useYaMetrikaStore();
const parentId = computed(() => route.params.id ? Number(route.params.id) : null);
@@ -106,6 +108,9 @@ function showProductsInParentCategory() {
onMounted(async () => {
window.document.title = 'Каталог';
yaMetrika.pushHit(route.path, {
title: 'Каталог',
});
await categoriesStore.fetchCategories();
});
</script>

View File

@@ -69,11 +69,15 @@
import {useCheckoutStore} from "@/stores/CheckoutStore.js";
import TgInput from "@/components/Form/TgInput.vue";
import TgTextarea from "@/components/Form/TgTextarea.vue";
import {useRouter} from "vue-router";
import {useRoute, useRouter} from "vue-router";
import {computed, onMounted, ref} from "vue";
import {IMaskComponent} from "vue-imask";
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
import {YA_METRIKA_GOAL} from "@/constants/yaMetrikaGoals.js";
const checkout = useCheckoutStore();
const yaMetrika = useYaMetrikaStore();
const route = useRoute();
const router = useRouter();
const error = ref(null);
@@ -84,7 +88,15 @@ const btnText = computed(() => {
async function onCreateBtnClick() {
try {
error.value = null;
yaMetrika.reachGoal(YA_METRIKA_GOAL.CREATE_ORDER, {
price: checkout.order?.final_total_numeric,
currency: checkout.order?.currency,
});
await checkout.makeOrder();
yaMetrika.reachGoal(YA_METRIKA_GOAL.ORDER_CREATED_SUCCESS, {
price: checkout.order?.final_total_numeric,
currency: checkout.order?.currency,
});
router.push({name: 'order_created'});
} catch {
error.value = 'Невозможно создать заказ.';
@@ -93,5 +105,9 @@ async function onCreateBtnClick() {
onMounted(async () => {
window.document.title = 'Оформление заказа';
yaMetrika.pushHit(route.path, {
title: 'Оформление заказа',
});
yaMetrika.reachGoal(YA_METRIKA_GOAL.VIEW_CHECKOUT);
});
</script>

View File

@@ -49,8 +49,10 @@ import {nextTick, onMounted} from "vue";
import {useProductFiltersStore} from "@/stores/ProductFiltersStore.js";
import ProductPrice from "@/components/ProductFilters/Components/ProductPrice.vue";
import ForMainPage from "@/components/ProductFilters/Components/ForMainPage.vue";
import {useRouter} from "vue-router";
import {useRoute, useRouter} from "vue-router";
import ProductCategory from "@/components/ProductFilters/Components/ProductCategory/ProductCategory.vue";
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
import {YA_METRIKA_GOAL} from "@/constants/yaMetrikaGoals.js";
defineOptions({
name: 'Filters'
@@ -63,6 +65,8 @@ const componentMap = {
};
const router = useRouter();
const yaMetrika = useYaMetrikaStore();
const route = useRoute();
const emit = defineEmits(['close', 'apply']);
const filtersStore = useProductFiltersStore();
@@ -72,6 +76,7 @@ const applyFilters = async () => {
filtersStore.applied = JSON.parse(JSON.stringify(filtersStore.draft));
console.debug('Filters: apply filters. Hash for router: ', filtersStore.paramsHashForRouter);
haptic.impactOccurred('soft');
yaMetrika.reachGoal(YA_METRIKA_GOAL.FILTERS_APPLY);
await nextTick();
router.back();
}
@@ -80,6 +85,7 @@ const resetFilters = async () => {
filtersStore.applied = filtersStore.default;
console.debug('Filters: reset filters. Hash for router: ', filtersStore.paramsHashForRouter);
haptic.notificationOccurred('success');
yaMetrika.reachGoal(YA_METRIKA_GOAL.FILTERS_RESET);
await nextTick();
window.scrollTo(0, 0);
router.back();
@@ -89,6 +95,9 @@ onMounted(async () => {
console.debug('Filters: OnMounted');
window.document.title = 'Фильтры';
yaMetrika.pushHit(route.path, {title: 'Фильтры'});
yaMetrika.reachGoal(YA_METRIKA_GOAL.VIEW_FILTERS);
if (filtersStore.applied?.rules) {
console.debug('Filters: Found applied filters.');
filtersStore.draft = JSON.parse(JSON.stringify(filtersStore.applied));

View File

@@ -38,6 +38,7 @@ import ftch from "@/utils/ftch.js";
import {useProductFiltersStore} from "@/stores/ProductFiltersStore.js";
import Banner from "@/components/Banner.vue";
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
import {YA_METRIKA_GOAL} from "@/constants/yaMetrikaGoals.js";
defineOptions({
name: 'Home'
@@ -99,7 +100,10 @@ async function onLoadMore() {
}
onActivated(() => {
yaMetrika.pushHit('/');
yaMetrika.pushHit('/', {
title: 'Главная страница',
});
yaMetrika.reachGoal(YA_METRIKA_GOAL.VIEW_HOME);
});
onMounted(async () => {

View File

@@ -39,10 +39,17 @@
<script setup>
import {useCheckoutStore} from "@/stores/CheckoutStore.js";
import {onMounted} from "vue";
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
import {useRoute} from "vue-router";
const checkout = useCheckoutStore();
const yaMetrika = useYaMetrikaStore();
const route = useRoute();
onMounted(async () => {
onMounted(() => {
window.document.title = 'Заказ оформлен';
yaMetrika.pushHit(route.path, {
title: 'Заказ оформлен',
});
});
</script>

View File

@@ -106,7 +106,7 @@
class="btn btn-primary btn-lg w-full"
:class="isInCart ? 'btn-success' : 'btn-primary'"
:disabled="cart.isLoading || canAddToCart === false"
@click="actionBtnClick"
@click="onCartBtnClick"
>
<span v-if="cart.isLoading" class="loading loading-spinner loading-sm"></span>
{{ btnText }}
@@ -156,7 +156,7 @@
</template>
<script setup>
import {computed, nextTick, onMounted, onUnmounted, ref} from "vue";
import {computed, onMounted, onUnmounted, ref} from "vue";
import {useRoute, useRouter} from 'vue-router'
import ProductOptions from "../components/ProductOptions/ProductOptions.vue";
import {useCartStore} from "../stores/CartStore.js";
@@ -168,6 +168,7 @@ import LoadingFullScreen from "@/components/LoadingFullScreen.vue";
import ProductNotFound from "@/components/ProductNotFound.vue";
import {useSettingsStore} from "@/stores/SettingsStore.js";
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
import {YA_METRIKA_GOAL} from "@/constants/yaMetrikaGoals.js";
const route = useRoute();
const productId = computed(() => route.params.id);
@@ -211,7 +212,7 @@ function closeFullScreen() {
document.body.style.overflow = '';
}
async function actionBtnClick() {
async function onCartBtnClick() {
try {
error.value = '';
@@ -219,6 +220,10 @@ async function actionBtnClick() {
await cart.addProduct(productId.value, product.value.name, product.value.price, quantity.value, product.value.options);
isInCart.value = true;
window.Telegram.WebApp.HapticFeedback.notificationOccurred('success');
yaMetrika.reachGoal(YA_METRIKA_GOAL.ADD_TO_CART, {
price: product.value.final_price_numeric,
currency: product.value.currency,
});
} else {
window.Telegram.WebApp.HapticFeedback.selectionChanged();
await router.push({'name': 'cart'});
@@ -275,6 +280,11 @@ onMounted(async () => {
'Цена': data.price,
},
});
yaMetrika.reachGoal(YA_METRIKA_GOAL.VIEW_PRODUCT, {
price: data.final_price_numeric,
currency: data.currency,
});
} catch (error) {
console.error(error);
} finally {

View File

@@ -5,6 +5,7 @@
:hasMore="productsStore.products.meta.hasMore"
:isLoading="productsStore.isLoading"
:isLoadingMore="productsStore.isLoadingMore"
:categoryName="category?.name"
@loadMore="productsStore.loadMore"
/>
</div>
@@ -12,9 +13,11 @@
<script setup>
import ProductsList from "@/components/ProductsList.vue";
import {onMounted} from "vue";
import {onMounted, ref} from "vue";
import {useRoute} from "vue-router";
import {useProductsStore} from "@/stores/ProductsStore.js";
import {useCategoriesStore} from "@/stores/CategoriesStore.js";
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
defineOptions({
name: 'Products'
@@ -22,12 +25,22 @@ defineOptions({
const route = useRoute();
const productsStore = useProductsStore();
const categoriesStore = useCategoriesStore();
const yaMetrika = useYaMetrikaStore();
const categoryId = route.params.category_id ?? null;
const category = ref(null);
onMounted(async () => {
console.debug("Category Products Mounted");
console.debug("Load products for category: ", categoryId);
console.debug("[Category] Category Products Mounted");
console.debug("[Category] Load products for category: ", categoryId);
category.value = await categoriesStore.findCategoryById(categoryId);
console.debug("[Category] Category Name: ", category.value?.name);
window.document.title = `${category.value?.name ?? 'Неизвестная категория'}`;
yaMetrika.pushHit(route.path, {
title: `${category.value?.name ?? 'Неизвестная категория'}`,
});
if (productsStore.filtersFullUrl === route.fullPath) {
await productsStore.loadProducts(productsStore.filters ?? {

View File

@@ -74,12 +74,21 @@
import {useSearchStore} from "@/stores/SearchStore.js";
import {useDebounceFn} from "@vueuse/core";
import {onMounted, ref} from "vue";
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
import {useRoute} from "vue-router";
import {YA_METRIKA_GOAL} from "@/constants/yaMetrikaGoals.js";
const route = useRoute();
const yaMetrika = useYaMetrikaStore();
const searchStore = useSearchStore();
const searchInput = ref(null);
const debouncedSearch = useDebounceFn(() => searchStore.performSearch(), 500);
onMounted(async () => {
window.document.title = 'Поиск';
yaMetrika.pushHit(route.path, {
title: 'Поиск',
});
yaMetrika.reachGoal(YA_METRIKA_GOAL.VIEW_SEARCH);
});
</script>