feat: integrate yandex metrika ecommerce

This commit is contained in:
2025-10-26 18:22:19 +03:00
parent 4e59c4e788
commit 2f74aba35f
18 changed files with 240 additions and 31 deletions

View File

@@ -7,6 +7,7 @@ use Cart\Currency;
use Config;
use Language;
use Loader;
use ModelCatalogCategory;
use ModelCatalogProduct;
use ModelDesignBanner;
use ModelSettingSetting;
@@ -27,6 +28,7 @@ use Url;
* @property ModelCatalogProduct $model_catalog_product
* @property ModelSettingSetting $model_setting_setting
* @property ModelDesignBanner $model_design_banner
* @property ModelCatalogCategory $model_catalog_category
*/
class OcRegistryDecorator
{

View File

@@ -55,7 +55,7 @@ class ProductsHandler
], Response::HTTP_NOT_FOUND);
} catch (Exception $exception) {
$this->logger->logException($exception);
throw new RuntimeException('Error get product with id ' . $productId, 500, $exception);
throw new RuntimeException('Error get product with id ' . $productId, 500);
}
return new JsonResponse([

View File

@@ -56,6 +56,7 @@ class SettingsHandler
'store_enabled' => $this->settings->get('store_enabled'),
'feature_coupons' => $this->settings->get('feature_coupons') ?? false,
'feature_vouchers' => $this->settings->get('feature_vouchers') ?? false,
'currency_code' => $this->settings->get('oc_default_currency', 'RUB'),
]);
}

View File

@@ -195,6 +195,7 @@ class OrderCreateService
'total' => $orderData['total'],
'final_total_numeric' => $orderData['total_numeric'],
'currency' => $currencyCode,
'products' => $products,
];
}

View File

@@ -15,6 +15,7 @@ use Openguru\OpenCartFramework\OpenCart\Decorators\OcRegistryDecorator;
use Openguru\OpenCartFramework\QueryBuilder\Builder;
use Openguru\OpenCartFramework\QueryBuilder\JoinClause;
use Openguru\OpenCartFramework\QueryBuilder\RawExpression;
use Openguru\OpenCartFramework\QueryBuilder\Table;
use Openguru\OpenCartFramework\Support\Arr;
use Openguru\OpenCartFramework\Support\PaginationHelper;
@@ -61,6 +62,8 @@ class ProductsService
$filters = $params['filters'] ?? [];
$customerGroupId = (int) $this->settings->get('oc_customer_group_id');
$currency = $this->settings->get('oc_default_currency');
$specialPriceSql = "(SELECT price
FROM oc_product_special ps
WHERE ps.product_id = products.product_id
@@ -78,6 +81,8 @@ class ProductsService
'products.price' => 'price',
'products.image' => 'product_image',
'products.tax_class_id' => 'tax_class_id',
'manufacturer.name' => 'manufacturer_name',
'category_description.name' => 'category_name',
new RawExpression($specialPriceSql),
])
->from(db_table('product'), 'products')
@@ -88,6 +93,20 @@ class ProductsService
->where('product_description.language_id', '=', $languageId);
}
)
->leftJoin(new Table(db_table('manufacturer'), 'manufacturer'), function (JoinClause $join) {
$join->on('products.manufacturer_id', '=', 'manufacturer.manufacturer_id');
})
->leftJoin(new Table(db_table('product_to_category'), 'product_to_category'), function (JoinClause $join) {
$join->on('products.product_id', '=', 'product_to_category.product_id')
->where('product_to_category.main_category', '=', 1);
})
->leftJoin(
new Table(db_table('category_description'), 'category_description'),
function (JoinClause $join) use ($languageId) {
$join->on('product_to_category.category_id', '=', 'category_description.category_id')
->where('category_description.language_id', '=', $languageId);
}
)
->where('products.status', '=', 1)
->whereRaw('products.date_available < NOW()')
->when($search, function (Builder $query) use ($search) {
@@ -136,7 +155,7 @@ class ProductsService
}
return [
'data' => array_map(function ($product) use ($productsImagesMap, $imageWidth, $imageHeight) {
'data' => array_map(function ($product) use ($productsImagesMap, $imageWidth, $imageHeight, $currency) {
$allImages = [];
$image = $this->ocImageTool->resize(
@@ -151,24 +170,24 @@ class ProductsService
'alt' => Utils::htmlEntityEncode($product['product_name']),
];
$price = $this->currency->format(
$this->tax->calculate(
$product['price'],
$product['tax_class_id'],
$this->settings->get('oc_config_tax'),
),
$this->settings->get('oc_default_currency'),
$priceNumeric = $this->tax->calculate(
$product['price'],
$product['tax_class_id'],
$this->settings->get('oc_config_tax'),
);
$price = $this->currency->format($priceNumeric, $currency);
$special = false;
$specialPriceNumeric = null;
if ($product['special'] && (float) $product['special'] >= 0) {
$specialPriceNumeric = $this->tax->calculate(
$product['special'],
$product['tax_class_id'],
$this->settings->get('oc_config_tax'),
);
$special = $this->currency->format(
$this->tax->calculate(
$product['special'],
$product['tax_class_id'],
$this->settings->get('oc_config_tax'),
),
$this->settings->get('oc_default_currency'),
$specialPriceNumeric,
$currency,
);
}
@@ -183,6 +202,11 @@ class ProductsService
'price' => $price,
'special' => $special,
'images' => $allImages,
'special_numeric' => $specialPriceNumeric,
'price_numeric' => $priceNumeric,
'final_price_numeric' => $specialPriceNumeric ?: $priceNumeric,
'manufacturer_name' => $product['manufacturer_name'],
'category_name' => $product['category_name'],
];
}, $products),
@@ -400,8 +424,27 @@ class ProductsService
$data['recurrings'] = $this->oc->model_catalog_product->getProfiles($productId);
$data['category'] = $this->getProductMainCategory($productId);
$data['id'] = $productId;
$this->oc->model_catalog_product->updateViewed($productId);
return $data;
}
private function getProductMainCategory(int $productId): ?array
{
return $this->queryBuilder->newQuery()
->select([
'category_description.category_id' => 'id',
'category_description.name' => 'name',
])
->from(db_table('category_description'), 'category_description')
->join(new Table(db_table('product_to_category'), 'product_to_category'), function (JoinClause $join) {
$join->on('product_to_category.category_id', '=', 'category_description.category_id')
->where('product_to_category.main_category', '=', 1);
})
->where('product_to_category.product_id', '=', $productId)
->firstOrNull();
}
}

View File

View File

@@ -7,11 +7,11 @@
class="products-grid grid grid-cols-2 gap-x-5 gap-y-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 xl:gap-x-8"
>
<RouterLink
v-for="product in products"
v-for="(product, index) in products"
:key="product.id"
class="product-grid-card group"
:to="`/product/${product.id}`"
@click="haptic"
@click="productClick(product, index)"
>
<ProductImageSwiper :images="product.images"/>
<h3 class="product-title mt-4 text-sm">{{ product.name }}</h3>
@@ -54,7 +54,9 @@ import ProductImageSwiper from "@/components/ProductImageSwiper.vue";
import {useSettingsStore} from "@/stores/SettingsStore.js";
import {ref} from "vue";
import {useIntersectionObserver} from '@vueuse/core';
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
const yaMetrika = useYaMetrikaStore();
const settings = useSettingsStore();
const bottom = ref(null);
@@ -87,8 +89,26 @@ const props = defineProps({
}
});
function haptic() {
function productClick(product, index) {
window.Telegram.WebApp.HapticFeedback.selectionChanged();
yaMetrika.dataLayerPush({
"ecommerce": {
"currencyCode": settings.currency_code,
"click": {
"products": [
{
"id": product.id,
"name": product.name,
"price": product.final_price_numeric,
"brand": product.manufacturer_name,
"category": product.category_name,
"list": "Главная страница",
"position": index,
}
]
}
}
});
}
useIntersectionObserver(

View File

@@ -1,5 +1,6 @@
export const YA_METRIKA_GOAL = {
ADD_TO_CART: 'add_to_cart',
PRODUCT_OPEN_EXTERNAL: 'product_open_external',
CREATE_ORDER: 'create_order',
ORDER_CREATED_SUCCESS: 'order_created_success',
VIEW_PRODUCT: 'view_product',

View File

@@ -1,6 +1,8 @@
import {defineStore} from "pinia";
import {isNotEmpty} from "@/helpers.js";
import {addToCart, cartEditItem, cartRemoveItem, getCart, setCoupon, setVoucher} from "@/utils/ftch.js";
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
import {useSettingsStore} from "@/stores/SettingsStore.js";
export const useCartStore = defineStore('cart', {
state: () => ({
@@ -79,12 +81,27 @@ export const useCartStore = defineStore('cart', {
}
},
async removeItem(rowId) {
async removeItem(cartItem, rowId, index = 0) {
try {
this.isLoading = true;
const formData = new FormData();
formData.append('key', rowId);
await cartRemoveItem(formData);
useYaMetrikaStore().dataLayerPush({
"ecommerce": {
"currencyCode": useSettingsStore().currency_code,
"remove": {
"products": [
{
"id": cartItem.product_id,
"name": cartItem.name,
"quantity": cartItem.quantity,
"position": index
}
]
}
}
});
await this.getProducts();
} catch (error) {
console.error(error);

View File

@@ -2,6 +2,9 @@ import {defineStore} from "pinia";
import {isNotEmpty} from "@/helpers.js";
import {storeOrder} from "@/utils/ftch.js";
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";
export const useCheckoutStore = defineStore('checkout', {
state: () => ({
@@ -56,6 +59,37 @@ export const useCheckoutStore = defineStore('checkout', {
const response = await storeOrder(this.customer);
this.order = response.data;
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.quantif,
};
}) : [],
}
}
});
await window.Telegram.WebApp.HapticFeedback.notificationOccurred('success');
await useCartStore().getProducts();
} catch (error) {

View File

@@ -17,6 +17,7 @@ export const useSettingsStore = defineStore('settings', {
ya_metrika_enabled: false,
feature_coupons: false,
feature_vouchers: false,
currency_code: null,
theme: {
light: 'light', dark: 'dark', variables: {
'--product_list_title_max_lines': 2,
@@ -44,6 +45,7 @@ export const useSettingsStore = defineStore('settings', {
this.store_enabled = settings.store_enabled;
this.feature_coupons = settings.feature_coupons;
this.feature_vouchers = settings.feature_vouchers;
this.currency_code = settings.currency_code;
}
}
});

View File

@@ -11,8 +11,7 @@ export const useYaMetrikaStore = defineStore('ya_metrika', {
actions: {
pushHit(url, params = {}) {
const settings = useSettingsStore();
if (!settings.ya_metrika_enabled) {
if (!useSettingsStore().ya_metrika_enabled) {
console.debug('[ym] Yandex Metrika disabled in settings.');
return;
}
@@ -39,8 +38,7 @@ export const useYaMetrikaStore = defineStore('ya_metrika', {
},
reachGoal(target, params = {}) {
const settings = useSettingsStore();
if (!settings.ya_metrika_enabled) {
if (!useSettingsStore().ya_metrika_enabled) {
console.debug('[ym] Yandex Metrika disabled in settings.');
return;
}
@@ -61,6 +59,11 @@ export const useYaMetrikaStore = defineStore('ya_metrika', {
},
initUserParams() {
if (!useSettingsStore().ya_metrika_enabled) {
console.debug('[ym] Yandex Metrika disabled in settings.');
return;
}
if (typeof window.ym === 'function' && window.YA_METRIKA_ID !== undefined) {
let tgID = null;
@@ -95,6 +98,9 @@ export const useYaMetrikaStore = defineStore('ya_metrika', {
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 if (item.event === 'dataLayer') {
console.debug('[ym] queue dataLayer push: ', item.payload);
window.dataLayer.push(item.payload);
} else {
console.error('[ym] Unsupported queue event: ', item.event);
}
@@ -102,5 +108,23 @@ export const useYaMetrikaStore = defineStore('ya_metrika', {
console.debug('[ym] Queue processing complete. Size: ', this.queue.length);
},
dataLayerPush(object) {
if (!useSettingsStore().ya_metrika_enabled) {
console.debug('[ym] Yandex Metrika disabled in settings.');
return;
}
if (Array.isArray(window.dataLayer)) {
console.debug('[ym] dataLayer push: ', object);
window.dataLayer.push(object);
} else {
console.debug('[ym] dataLayer inaccessible. Put to queue');
this.queue.push({
event: 'dataLayer',
payload: object,
});
}
}
},
});

View File

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

View File

@@ -21,7 +21,7 @@
<div v-if="cart.items.length > 0">
<div
v-for="item in cart.items"
v-for="(item, index) in cart.items"
:key="item.cart_id"
class="card card-border bg-base-100 card-sm mb-3"
:class="item.stock === false ? 'border-error' : ''"
@@ -62,7 +62,7 @@
v-model="item.quantity"
@update:modelValue="cart.setQuantity(item.cart_id, $event)"
/>
<button class="btn btn-error" @click="removeItem(item.cart_id)" :disabled="cart.isLoading">
<button class="btn btn-error" @click="removeItem(item, item.cart_id, index)" :disabled="cart.isLoading">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
</svg>
@@ -162,8 +162,8 @@ const lastTotal = computed(() => {
return cart.totals.at(-1) ?? null;
});
function removeItem(cartId) {
cart.removeItem(cartId);
function removeItem(cartItem, cartId, index) {
cart.removeItem(cartItem, cartId, index);
window.Telegram.WebApp.HapticFeedback.notificationOccurred('error');
}

View File

@@ -74,6 +74,7 @@ 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";
import {useSettingsStore} from "@/stores/SettingsStore.js";
const checkout = useCheckoutStore();
const yaMetrika = useYaMetrikaStore();
@@ -92,11 +93,9 @@ async function onCreateBtnClick() {
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 = 'Невозможно создать заказ.';

View File

@@ -39,6 +39,7 @@ 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";
import {useSettingsStore} from "@/stores/SettingsStore.js";
defineOptions({
name: 'Home'
@@ -48,12 +49,14 @@ const router = useRouter();
const filtersStore = useProductFiltersStore();
const yaMetrika = useYaMetrikaStore();
const haptic = window.Telegram.WebApp.HapticFeedback;
const settings = useSettingsStore();
const products = ref([]);
const hasMore = ref(false);
const isLoading = ref(false);
const isLoadingMore = ref(false);
const page = ref(1);
const perPage = 20;
function showFilters() {
haptic.impactOccurred('soft');
@@ -67,11 +70,31 @@ async function fetchProducts() {
console.debug('Home: Fetch products from server using filters: ', toRaw(filtersStore.applied));
const response = await ftch('products', null, toRaw({
page: page.value,
perPage: perPage,
filters: filtersStore.applied,
}));
products.value = response.data;
hasMore.value = response.meta.hasMore;
console.debug('Home: Products for main page loaded.');
yaMetrika.dataLayerPush({
ecommerce: {
currencyCode: settings.currency_code,
impressions: products.value.map((product, index) => {
return {
id: product.id,
name: product.name,
price: product.final_price_numeric,
brand: product.manufacturer_name,
category: product.category_name,
list: 'Главная страница',
position: index,
discount: product.price_numeric - product.final_price_numeric,
quantity: product.product_quantity,
};
}),
},
});
} catch (error) {
console.error(error);
} finally {

View File

@@ -224,6 +224,25 @@ async function onCartBtnClick() {
price: product.value.final_price_numeric,
currency: product.value.currency,
});
yaMetrika.dataLayerPush({
"ecommerce": {
"currencyCode": settings.currency_code,
"add": {
"products": [
{
"id": product.value?.id,
"name": product.value?.name,
"price": product.value?.final_price_numeric,
"brand": product.value?.manufacturer,
"category": product.value?.category?.name,
"quantity": 1,
"list": "Выдача категории",
"position": 2
}
]
}
}
});
} else {
window.Telegram.WebApp.HapticFeedback.selectionChanged();
await router.push({'name': 'cart'});
@@ -240,6 +259,11 @@ function openProductInMarketplace() {
return;
}
yaMetrika.reachGoal(YA_METRIKA_GOAL.PRODUCT_OPEN_EXTERNAL, {
price: product.value?.final_price_numeric,
currency: product.value?.currency,
});
window.Telegram.WebApp.openLink(product.value.share, {try_instant_view: false});
}
@@ -285,6 +309,23 @@ onMounted(async () => {
price: data.final_price_numeric,
currency: data.currency,
});
yaMetrika.dataLayerPush({
"ecommerce": {
"currencyCode": settings.currency_code,
"detail": {
"products": [
{
"id": data.product_id,
"name": data.name,
"price": data.final_price_numeric,
"brand": data.manufacturer,
"category": data.category?.name,
}
]
}
}
});
} catch (error) {
console.error(error);
} finally {