feat: added new products_carousel bock type

This commit is contained in:
2025-11-13 13:41:35 +03:00
parent 6f9855995d
commit f0837e5c94
22 changed files with 747 additions and 108 deletions

View File

@@ -0,0 +1,52 @@
<template>
<span>
<slot name="default" :value="selectedValue">
{{ selectedValue?.label || 'Не выбрана' }}
</slot>
</span>
</template>
<script setup>
import {useAutocompleteStore} from "@/stores/autocomplete.js";
import {computed, onMounted, ref} from "vue";
const autocomplete = useAutocompleteStore();
const isLoading = ref(false);
const props = defineProps({
id: {
type: Number,
default: null,
},
});
function findNodeByKey(nodes, keyToFind) {
for (const node of nodes) {
if (node.key === keyToFind) {
return node;
}
if (node.children?.length) {
const found = findNodeByKey(node.children, keyToFind);
if (found) return found;
}
}
return null;
}
const selectedValue = computed(() => {
const id = props.id;
if (!id) return null;
const node = findNodeByKey(autocomplete.categories || [], id);
if (!node) return null;
return node;
});
onMounted(async () => {
isLoading.value = true;
await autocomplete.fetchCategories();
isLoading.value = false;
});
</script>

View File

@@ -0,0 +1,50 @@
<template>
<TreeSelect
:modelValue="selectedValue"
@update:modelValue="setNewValue"
filter
filterMode="lenient"
:options="autocomplete.categories"
:disabled="disabled"
:loading="isLoading"
:placeholder="placeholder"
class="md:w-80 w-full"
/>
</template>
<script setup>
import TreeSelect from "primevue/treeselect";
import {useAutocompleteStore} from "@/stores/autocomplete.js";
import {computed, onMounted, ref} from "vue";
const autocomplete = useAutocompleteStore();
const isLoading = ref(false);
const model = defineModel();
const props = defineProps({
placeholder: {
type: String,
default: null,
},
disabled: {
type: Boolean,
default: false,
},
});
const selectedValue = computed(() => {
const id = model.value;
return id ? {[String(id)]: true} : null;
});
function setNewValue(event) {
model.value = Number(Object.keys(event)[0]);
}
onMounted(async () => {
isLoading.value = true;
await autocomplete.fetchCategories();
isLoading.value = false;
});
</script>

View File

@@ -0,0 +1,31 @@
<template>
<BaseBlock
:title="`Карусель товаров - ${value.title || 'Без заголовока'}`"
@onRemove="$emit('onRemove')"
@onShowSettings="$emit('onShowSettings')"
>
<div class="tw:mt-3 tw:text-sm tw:dark:text-slate-300 tw:space-y-1">
<div>
<span class="tw:font-bold tw:dark:text-slate-200">Описание:</span>
{{ value.description }}
</div>
<div>
<span class="tw:font-bold tw:dark:text-slate-200 tw:mr-1">Категория:</span>
<CategoryLabel :id="value.data.category_id"/>
</div>
</div>
</BaseBlock>
</template>
<script setup>
import BaseBlock from "@/components/MainPageConfigurator/Blocks/BaseBlock.vue";
import CategoryLabel from "@/components/Form/CategoryLabel.vue";
const emit = defineEmits(['onRemove', 'onShowSettings']);
const props = defineProps({
value: {
type: Object,
required: true,
}
});
</script>

View File

@@ -3,6 +3,7 @@
<TabList> <TabList>
<Tab value="0">Настройки блока</Tab> <Tab value="0">Настройки блока</Tab>
<Tab value="1">Основные настройки</Tab> <Tab value="1">Основные настройки</Tab>
<slot name="tabs"></slot>
</TabList> </TabList>
<TabPanels> <TabPanels>
<TabPanel value="0"> <TabPanel value="0">
@@ -81,6 +82,7 @@
<TabPanel value="1"> <TabPanel value="1">
<slot></slot> <slot></slot>
</TabPanel> </TabPanel>
<slot name="panels"></slot>
</TabPanels> </TabPanels>
</Tabs> </Tabs>

View File

@@ -0,0 +1,220 @@
<template>
<div v-if="draft">
<BaseForm
v-model="draft"
:isChanged="isChanged"
@onApply="onApply"
@cancel="$emit('cancel')"
>
<template #default>
<pre>{{ draft }}</pre>
<div class="tw:space-y-6">
<Panel header="Основные настройки">
<div class="tw:space-y-6">
<!-- Категория -->
<FormItem label="Категория">
<template #default>
<CategorySelect
v-model="draft.data.category_id"
placeholder="Выберите категорию"
/>
</template>
<template #help>
Категория из которой выводить товары для карусели.
</template>
</FormItem>
<!-- Текст кнопки просмотра категории -->
<FormItem label="Текст на кнопке">
<template #default>
<InputText
v-model="draft.data.all_text"
placeholder="Смотреть всё"
class="tw:w-full"
/>
</template>
<template #help>
Текст для кнопки, которая открывает просмотр товаров у категории
</template>
</FormItem>
</div>
</Panel>
<Panel header="Настройки карусели">
<div class="tw:space-y-6">
<!-- Количество товаров в карусели -->
<FormItem label="Количество товаров в карусели">
<template #default>
<InputNumber
v-model="slidesPerView"
:min="2"
:max="5"
:step="0.5"
placeholder="2.5"
:showButtons="true"
/>
<span class="tw:text-gray-600 tw:whitespace-nowrap">шт.</span>
</template>
<template #help>
Введите количество товаров, которые должны отображаться одновременно в карусели (от 2 до 5).
Можно использовать дробные значения, чтобы часть следующего товара была видна.
</template>
</FormItem>
<!-- Расстояние между товарами -->
<FormItem label="Расстояние между товарами">
<template #default>
<InputNumber
v-model="spaceBetween"
:min="0"
:max="100"
placeholder="20"
:step="5"
:showButtons="true"
/>
<span class="tw:text-gray-600 tw:whitespace-nowrap">px</span>
</template>
<template #help>
Задайте промежуток между товарами в карусели в пикселях, чтобы слайды не сливались и выглядели
аккуратно.
</template>
</FormItem>
<!-- Режим Авто -->
<FormItem label="Автоматическая прокрутка">
<template #default>
<ToggleSwitch
v-model="isAutoplayEnabled"
/>
</template>
<template #help>
Включите автоматическую прокрутку карусели с заданной задержкой между переходами.
</template>
</FormItem>
<!-- Задержка -->
<FormItem v-if="isAutoplayEnabled" label="Задержка">
<template #default>
<InputNumber
v-model="autoplayDelay"
:min="1000"
:max="10000"
placeholder="3000"
:step="1000"
:showButtons="true"
/>
<span class="tw:text-gray-600 tw:whitespace-nowrap">мс</span>
</template>
<template #help>
Задержка между переходами в миллисекундах. Минимум 1000, максимум 10000.
</template>
</FormItem>
</div>
</Panel>
</div>
</template>
</BaseForm>
</div>
</template>
<script setup>
import {computed, defineExpose, onMounted, ref} from "vue";
import {md5} from "js-md5";
import BaseForm from "@/components/MainPageConfigurator/Forms/BaseForm.vue";
import FormItem from "@/components/MainPageConfigurator/Forms/FormItem.vue";
import CategorySelect from "@/components/Form/CategorySelect.vue";
import {Fieldset, InputNumber, InputText, Panel, ToggleSwitch} from "primevue";
const draft = ref(null);
const model = defineModel();
const emit = defineEmits(['cancel']);
const isChanged = computed(() => md5(JSON.stringify(model.value)) !== md5(JSON.stringify(draft.value)));
// Инициализация carousel, если его нет (только для записи)
function ensureCarousel() {
if (!draft.value.data.carousel) {
draft.value.data.carousel = {};
}
}
// Безопасное чтение значения из carousel
function getCarouselValue(key, defaultValue) {
return draft.value.data.carousel?.[key] ?? defaultValue;
}
// Computed для управления slides_per_view
const slidesPerView = computed({
get() {
return getCarouselValue('slides_per_view', 2.5);
},
set(value) {
ensureCarousel();
draft.value.data.carousel.slides_per_view = value;
}
});
// Computed для управления space_between
const spaceBetween = computed({
get() {
return getCarouselValue('space_between', 20);
},
set(value) {
ensureCarousel();
draft.value.data.carousel.space_between = value;
}
});
// Computed для управления autoplay (включен/выключен)
const isAutoplayEnabled = computed({
get() {
const autoplay = draft.value.data.carousel?.autoplay;
return autoplay !== false && autoplay !== null && autoplay !== undefined;
},
set(value) {
ensureCarousel();
if (value) {
// Если включаем, создаем объект с delay (используем текущее значение или 3000 по умолчанию)
const currentDelay = draft.value.data.carousel.autoplay?.delay || 3000;
draft.value.data.carousel.autoplay = { delay: currentDelay };
} else {
// Если выключаем, устанавливаем false
draft.value.data.carousel.autoplay = false;
}
}
});
// Computed для управления delay
const autoplayDelay = computed({
get() {
const autoplay = draft.value.data.carousel?.autoplay;
if (autoplay && typeof autoplay === 'object' && autoplay.delay) {
return autoplay.delay;
}
return 3000; // Значение по умолчанию
},
set(value) {
ensureCarousel();
// Убеждаемся, что autoplay - это объект
if (!draft.value.data.carousel.autoplay || draft.value.data.carousel.autoplay === false) {
draft.value.data.carousel.autoplay = { delay: value };
} else {
draft.value.data.carousel.autoplay.delay = value;
}
}
});
function onApply() {
model.value = JSON.parse(JSON.stringify(draft.value));
}
onMounted(async () => {
draft.value = JSON.parse(JSON.stringify(model.value));
// Не создаем carousel здесь, чтобы не изменять draft при инициализации
// carousel будет создан только при реальных изменениях пользователем
});
defineExpose({isChanged});
</script>

View File

@@ -4,17 +4,22 @@ import SliderForm from "@/components/MainPageConfigurator/Forms/SliderForm.vue";
import CategoriesTopForm from "@/components/MainPageConfigurator/Forms/CategoriesTopForm.vue"; import CategoriesTopForm from "@/components/MainPageConfigurator/Forms/CategoriesTopForm.vue";
import ProductsFeedBlock from "@/components/MainPageConfigurator/Blocks/ProductsFeedBlock.vue"; import ProductsFeedBlock from "@/components/MainPageConfigurator/Blocks/ProductsFeedBlock.vue";
import ProductsFeedForm from "@/components/MainPageConfigurator/Forms/ProductsFeedForm.vue"; import ProductsFeedForm from "@/components/MainPageConfigurator/Forms/ProductsFeedForm.vue";
import ProductsCarouselBlock
from "@/components/MainPageConfigurator/Blocks/ProductsCarouselBlock.vue";
import ProductsCarouselForm from "@/components/MainPageConfigurator/Forms/ProductsCarouselForm.vue";
export const blockToComponentMap = { export const blockToComponentMap = {
slider: SliderBlock, slider: SliderBlock,
categories_top: CategoriesTopBlock, categories_top: CategoriesTopBlock,
products_feed: ProductsFeedBlock, products_feed: ProductsFeedBlock,
products_carousel: ProductsCarouselBlock,
}; };
export const blockToFormMap = { export const blockToFormMap = {
slider: SliderForm, slider: SliderForm,
categories_top: CategoriesTopForm, categories_top: CategoriesTopForm,
products_feed: ProductsFeedForm, products_feed: ProductsFeedForm,
products_carousel: ProductsCarouselForm,
}; };
export const blocks = [ export const blocks = [
@@ -55,4 +60,20 @@ export const blocks = [
max_page_count: 10, max_page_count: 10,
}, },
}, },
{
type: 'products_carousel',
title: 'Карусель товаров',
description: 'Отображает товары в одну строку в виде прокручиваемой карусели.',
is_enabled: true,
goal_name: '',
data: {
category_id: null,
all_text: null,
carousel: {
slides_per_view: null,
space_between: null,
autoplay: false,
},
},
},
]; ];

View File

@@ -0,0 +1,19 @@
import {defineStore} from "pinia";
import {apiGet} from "@/utils/http.js";
export const useAutocompleteStore = defineStore('autocomplete', {
state: () => ({
categories: null,
}),
actions: {
async fetchCategories() {
if (this.categories !== null) return;
const response = await apiGet('getAutocompleteCategories');
if (response.success) {
this.categories = response.data;
}
},
}
});

View File

@@ -3,8 +3,8 @@
<head> <head>
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<link rel="icon" type="image/svg+xml" href="/vite.svg"/> <link rel="icon" type="image/svg+xml" href="/vite.svg"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>OpenCart Telegram Mini App</title> <title>TeleCart</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@@ -1,11 +1,8 @@
<template> <template>
<div class="telecart-dock fixed bottom-0 w-full z-50 px-10"> <div class="dock dock-lg select-none">
<div
class="telecart-dock-inner flex justify-around items-center bg-base-300/10 h-full backdrop-blur-md border-base-300/90 border">
<RouterLink <RouterLink
:to="{name: 'home'}" :to="{name: 'home'}"
:class="{'active': route.name === 'home'}" :class="{'dock-active': route.name === 'home'}"
class="telecart-dock-item"
@click="onDockItemClick" @click="onDockItemClick"
> >
<svg class="size-[1.2em]" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> <svg class="size-[1.2em]" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
@@ -23,20 +20,20 @@
<RouterLink <RouterLink
:to="{name: 'categories'}" :to="{name: 'categories'}"
:class="{'active': route.name === 'categories'}" :class="{'dock-active': route.name === 'categories'}"
class="telecart-dock-item"
@click="onDockItemClick" @click="onDockItemClick"
> >
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25A2.25 2.25 0 0 1 13.5 18v-2.25Z" /> class="size-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25A2.25 2.25 0 0 1 13.5 18v-2.25Z"/>
</svg> </svg>
<span class="dock-label">Каталог</span> <span class="dock-label">Каталог</span>
</RouterLink> </RouterLink>
<RouterLink <RouterLink
:to="{name: 'search'}" :to="{name: 'search'}"
:class="{'active': route.name === 'search'}" :class="{'dock-active': route.name === 'search'}"
class="telecart-dock-item"
@click="onDockItemClick" @click="onDockItemClick"
> >
<svg class="size-[1.2em]" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" <svg class="size-[1.2em]" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
@@ -50,8 +47,7 @@
<RouterLink <RouterLink
v-if="settings.store_enabled" v-if="settings.store_enabled"
:to="{name: 'cart'}" :to="{name: 'cart'}"
:class="{'active': route.name === 'cart'}" :class="{'dock-active': route.name === 'cart'}"
class="telecart-dock-item"
@click="onDockItemClick" @click="onDockItemClick"
> >
<div class="indicator"> <div class="indicator">
@@ -65,7 +61,6 @@
<span class="dock-label">Корзина</span> <span class="dock-label">Корзина</span>
</RouterLink> </RouterLink>
</div> </div>
</div>
</template> </template>
<script setup> <script setup>
@@ -82,31 +77,3 @@ function onDockItemClick() {
haptic.selectionChanged(); haptic.selectionChanged();
} }
</script> </script>
<style scoped>
.telecart-dock {
padding-bottom: calc(var(--tg-safe-area-inset-bottom, 0px) + 5px);
height: calc(70px + var(--tg-safe-area-inset-bottom, 0px));
}
.telecart-dock-inner {
border-radius: var(--radius-field);
border-width: var(--border);
border-style: solid;
}
.telecart-dock-item {
display: flex;
flex-direction: column;
align-items: center;
border-radius: var(--radius-field);
padding: 5px;
min-width: 50px;
}
.telecart-dock-item.active {
background-color: var(--color-primary);
backdrop-filter: blur(var(--blur-sm));
color: var(--color-primary-content);
}
</style>

View File

@@ -1,5 +1,5 @@
<template> <template>
<section class="px-4"> <section>
<header> <header>
<div v-if="block.title" class="font-bold uppercase text-center">{{ block.title }}</div> <div v-if="block.title" class="font-bold uppercase text-center">{{ block.title }}</div>
<div v-if="block.description" class="text-sm text-center">{{ block.description }}</div> <div v-if="block.description" class="text-sm text-center">{{ block.description }}</div>

View File

@@ -0,0 +1,75 @@
<template>
<section class="px-4">
<header class="flex justify-between items-center mb-2">
<div>
<div v-if="block.title" class="font-bold uppercase">{{ block.title }}</div>
<div v-if="block.description" class="text-sm">{{ block.description }}</div>
</div>
<div>
<RouterLink
:to="{name: 'product.categories.show',
params: { category_id: block.data.category_id }}"
class="btn btn-outline btn-xs"
>
{{ block.data.all_text || 'Смотреть всё' }}
</RouterLink>
</div>
</header>
<main>
<Swiper
class="select-none"
:slides-per-view="block.data?.carousel?.slides_per_view || 2.5"
:space-between="block.data?.carousel?.space_between || 20"
:autoplay="block.data?.carousel?.autoplay || false"
:freeMode="freeModeSettings"
:lazy="true"
>
<SwiperSlide v-for="product in block.data.products.data" :key="product.id">
<RouterLink
:to="{name: 'product.show', params: {id: product.id}}"
@click="slideClick(product)"
>
<div class="text-center">
<img :src="product.images[0].url" :alt="product.name" loading="lazy">
<h3 class="product-title mt-4 text-sm">{{ product.name }}</h3>
<div v-if="product.special" class="mt-1">
<p class="text-xs line-through mr-2">{{ product.price }}</p>
<p class="text-lg font-medium">{{ product.special }}</p>
</div>
<p v-else class="mt-1 text-lg font-medium">{{ product.price }}</p>
</div>
</RouterLink>
</SwiperSlide>
</Swiper>
</main>
</section>
</template>
<script setup>
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
import {Swiper, SwiperSlide} from "swiper/vue";
const yaMetrika = useYaMetrikaStore();
const freeModeSettings = {
enabled: false,
};
const props = defineProps({
block: {
type: Object,
required: true,
}
});
function slideClick(product) {
if (props.block.goal_name) {
yaMetrika.reachGoal(props.block.goal_name, {
product_id: product.id,
product_name: product.name,
});
}
}
</script>

View File

@@ -1,5 +1,5 @@
<template> <template>
<section class="px-4"> <section>
<header> <header>
<div v-if="block.title" class="font-bold uppercase text-center">{{ block.title }}</div> <div v-if="block.title" class="font-bold uppercase text-center">{{ block.title }}</div>
<div v-if="block.description" class="text-sm text-center">{{ block.description }}</div> <div v-if="block.description" class="text-sm text-center">{{ block.description }}</div>

View File

@@ -1,5 +1,5 @@
<template> <template>
<section class="px-4"> <section>
<header> <header>
<div v-if="block.title" class="font-bold uppercase text-center">{{ block.title }}</div> <div v-if="block.title" class="font-bold uppercase text-center">{{ block.title }}</div>
<div v-if="block.description" class="text-sm text-center mb-2">{{ block.description }}</div> <div v-if="block.description" class="text-sm text-center mb-2">{{ block.description }}</div>

View File

@@ -1,5 +1,9 @@
<template> <template>
<div v-if="blocks.blocks?.length > 0" v-for="(block, index) in blocks.blocks"> <div
v-if="blocks.blocks?.length > 0"
v-for="(block, index) in blocks.blocks"
class="mb-5"
>
<template v-if="blockTypeToComponentMap[block.type]"> <template v-if="blockTypeToComponentMap[block.type]">
<component <component
v-if="block.is_enabled" v-if="block.is_enabled"
@@ -29,11 +33,13 @@ import {useBlocksStore} from "@/stores/BlocksStore.js";
import ErrorBlock from "@/components/MainPage/Blocks/ErrorBlock.vue"; import ErrorBlock from "@/components/MainPage/Blocks/ErrorBlock.vue";
import ProductsFeedBlock from "@/components/MainPage/Blocks/ProductsFeedBlock.vue"; import ProductsFeedBlock from "@/components/MainPage/Blocks/ProductsFeedBlock.vue";
import EmptyBlocks from "@/components/MainPage/EmptyBlocks.vue"; import EmptyBlocks from "@/components/MainPage/EmptyBlocks.vue";
import ProductsCarouselBlock from "@/components/MainPage/Blocks/ProductsCarouselBlock.vue";
const blockTypeToComponentMap = { const blockTypeToComponentMap = {
slider: SliderBlock, slider: SliderBlock,
categories_top: CategoriesTopBlock, categories_top: CategoriesTopBlock,
products_feed: ProductsFeedBlock, products_feed: ProductsFeedBlock,
products_carousel: ProductsCarouselBlock,
error: ErrorBlock, error: ErrorBlock,
}; };

View File

@@ -43,6 +43,10 @@ export const useYaMetrikaStore = defineStore('ya_metrika', {
return; return;
} }
if (! target) {
return;
}
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);

View File

@@ -4,6 +4,8 @@ use Bastion\ApplicationFactory;
use Cart\User; use Cart\User;
use Openguru\OpenCartFramework\Application; use Openguru\OpenCartFramework\Application;
use Openguru\OpenCartFramework\Http\Response as HttpResponse; use Openguru\OpenCartFramework\Http\Response as HttpResponse;
use Openguru\OpenCartFramework\ImageTool\ImageTool;
use Openguru\OpenCartFramework\ImageTool\ImageToolInterface;
use Openguru\OpenCartFramework\Logger\LoggerInterface; use Openguru\OpenCartFramework\Logger\LoggerInterface;
use Openguru\OpenCartFramework\Logger\OpenCartLogAdapter; use Openguru\OpenCartFramework\Logger\OpenCartLogAdapter;
use Openguru\OpenCartFramework\OpenCart\Decorators\OcRegistryDecorator; use Openguru\OpenCartFramework\OpenCart\Decorators\OcRegistryDecorator;
@@ -127,11 +129,12 @@ class ControllerExtensionModuleTgshop extends Controller
->createApplication() ->createApplication()
->bootAndHandleRequest(); ->bootAndHandleRequest();
} catch (Exception $e) { } catch (Exception $e) {
$this->log->write('[TELECART] Error: ' . $e->getMessage()); $logger = new OpenCartLogAdapter($this->log, 'TeleCart');
$logger->logException($e);
http_response_code(HttpResponse::HTTP_INTERNAL_SERVER_ERROR); http_response_code(HttpResponse::HTTP_INTERNAL_SERVER_ERROR);
header('Content-Type: application/json'); header('Content-Type: application/json');
echo json_encode([ echo json_encode([
'error' => 'Ошибка сервера. Приносим свои извинения за неудобства.', 'error' => 'Server Error.',
], JSON_THROW_ON_ERROR); ], JSON_THROW_ON_ERROR);
} }
} }
@@ -272,6 +275,7 @@ class ControllerExtensionModuleTgshop extends Controller
$app = ApplicationFactory::create($items); $app = ApplicationFactory::create($items);
$app->bind(OcRegistryDecorator::class, fn() => new OcRegistryDecorator($this->registry)); $app->bind(OcRegistryDecorator::class, fn() => new OcRegistryDecorator($this->registry));
$app->bind(ImageToolInterface::class, fn() => new ImageTool(DIR_IMAGE, HTTPS_SERVER));
$app $app
->withLogger(fn() => new OpenCartLogAdapter( ->withLogger(fn() => new OpenCartLogAdapter(

View File

@@ -2,18 +2,51 @@
namespace Bastion\Handlers; namespace Bastion\Handlers;
use App\Services\SettingsService;
use Openguru\OpenCartFramework\Http\JsonResponse; use Openguru\OpenCartFramework\Http\JsonResponse;
use Openguru\OpenCartFramework\Http\Request; use Openguru\OpenCartFramework\Http\Request;
use Openguru\OpenCartFramework\OpenCart\Decorators\OcRegistryDecorator; use Openguru\OpenCartFramework\OpenCart\Decorators\OcRegistryDecorator;
use Openguru\OpenCartFramework\QueryBuilder\Builder;
use Openguru\OpenCartFramework\QueryBuilder\JoinClause;
use Openguru\OpenCartFramework\Support\Str; use Openguru\OpenCartFramework\Support\Str;
class AutocompleteHandler class AutocompleteHandler
{ {
private OcRegistryDecorator $registry; private OcRegistryDecorator $registry;
private Builder $queryBuilder;
private SettingsService $settings;
public function __construct(OcRegistryDecorator $registry) public function __construct(
{ OcRegistryDecorator $registry,
Builder $queryBuilder,
SettingsService $settings
) {
$this->registry = $registry; $this->registry = $registry;
$this->queryBuilder = $queryBuilder;
$this->settings = $settings;
}
public function getCategoriesFlat(): JsonResponse
{
$languageId = $this->settings->config()->getApp()->getLanguageId();
$categoriesFlat = $this->getFlatCategories($languageId);
return new JsonResponse([
'data' => $categoriesFlat,
]);
}
public function getCategories(): JsonResponse
{
$languageId = $this->settings->config()->getApp()->getLanguageId();
$categoriesFlat = $this->getFlatCategories($languageId);
$categories = $this->buildCategoryTree($categoriesFlat);
return new JsonResponse([
'data' => $categories,
]);
} }
public function getProductsById(Request $request): JsonResponse public function getProductsById(Request $request): JsonResponse
@@ -61,4 +94,53 @@ class AutocompleteHandler
'data' => $items, 'data' => $items,
]); ]);
} }
private function getFlatCategories(int $languageId): array
{
return $this->queryBuilder->newQuery()
->select([
'categories.category_id' => 'id',
'categories.parent_id' => 'parent_id',
'descriptions.name' => 'name',
'descriptions.description' => 'description',
])
->from(db_table('category'), 'categories')
->join(
db_table('category_description') . ' AS descriptions',
function (JoinClause $join) use ($languageId) {
$join->on('categories.category_id', '=', 'descriptions.category_id')
->where('descriptions.language_id', '=', $languageId);
}
)
->where('categories.status', '=', 1)
->orderBy('parent_id')
->orderBy('sort_order')
->get();
}
private function buildCategoryTree(array $flat, $parentId = 0): array
{
$branch = [];
foreach ($flat as $category) {
if ((int) $category['parent_id'] === (int) $parentId) {
$children = $this->buildCategoryTree($flat, $category['id']);
if ($children) {
$category['children'] = $children;
}
$branch[] = [
'key' => (int) $category['id'],
'label' => Str::htmlEntityEncode($category['name']),
'data' => [
'description' => Str::htmlEntityEncode($category['description']),
],
'icon' => null,
'children' => $category['children'] ?? [],
];
}
}
return $branch;
}
} }

View File

@@ -0,0 +1,58 @@
<?php
namespace Bastion\Handlers;
use App\Services\SettingsService;
use Openguru\OpenCartFramework\Http\JsonResponse;
use Openguru\OpenCartFramework\Http\Request;
use Openguru\OpenCartFramework\ImageTool\ImageToolInterface;
use Openguru\OpenCartFramework\QueryBuilder\Builder;
use Openguru\OpenCartFramework\QueryBuilder\JoinClause;
class DictionariesHandler
{
private Builder $queryBuilder;
private ImageToolInterface $ocImageTool;
private SettingsService $settings;
public function __construct(Builder $queryBuilder, ImageToolInterface $ocImageTool, SettingsService $settings)
{
$this->queryBuilder = $queryBuilder;
$this->ocImageTool = $ocImageTool;
$this->settings = $settings;
}
public function getCategories(Request $request): JsonResponse
{
$perPage = $request->get('perPage', 20);
$categoryIds = $request->json('category_ids', []);
$languageId = $this->settings->config()->getApp()->getLanguageId();
$data = $this->queryBuilder->newQuery()
->select([
'categories.category_id' => 'id',
'categories.parent_id' => 'parent_id',
'categories.image' => 'image',
'descriptions.name' => 'name',
'descriptions.description' => 'description',
])
->from(db_table('category'), 'categories')
->join(
db_table('category_description') . ' AS descriptions',
function (JoinClause $join) use ($languageId) {
$join->on('categories.category_id', '=', 'descriptions.category_id')
->where('descriptions.language_id', '=', $languageId);
}
)
->where('categories.status', '=', 1)
->when($categoryIds, function (Builder $query) use ($categoryIds) {
$query->whereIn('categories.category_id', $categoryIds);
})
->orderBy('parent_id')
->orderBy('sort_order')
->limit($perPage)
->get();
return new JsonResponse(compact('data'));
}
}

View File

@@ -1,6 +1,7 @@
<?php <?php
use Bastion\Handlers\AutocompleteHandler; use Bastion\Handlers\AutocompleteHandler;
use Bastion\Handlers\DictionariesHandler;
use Bastion\Handlers\SettingsHandler; use Bastion\Handlers\SettingsHandler;
use Bastion\Handlers\StatsHandler; use Bastion\Handlers\StatsHandler;
use Bastion\Handlers\TelegramHandler; use Bastion\Handlers\TelegramHandler;
@@ -15,4 +16,9 @@ return [
'getCategoriesById' => [AutocompleteHandler::class, 'getCategoriesById'], 'getCategoriesById' => [AutocompleteHandler::class, 'getCategoriesById'],
'getDashboardStats' => [StatsHandler::class, 'getDashboardStats'], 'getDashboardStats' => [StatsHandler::class, 'getDashboardStats'],
'tgGetMe' => [TelegramHandler::class, 'tgGetMe'], 'tgGetMe' => [TelegramHandler::class, 'tgGetMe'],
'getCategories' => [DictionariesHandler::class, 'getCategories'],
'getAutocompleteCategories' => [AutocompleteHandler::class, 'getCategories'],
'getAutocompleteCategoriesFlat' => [AutocompleteHandler::class, 'getCategoriesFlat'],
]; ];

View File

@@ -71,7 +71,8 @@ class CategoriesHandler
]); ]);
} }
public function buildCategoryTree(array $flat, $parentId = 0): array { public function buildCategoryTree(array $flat, $parentId = 0): array
{
$branch = []; $branch = [];
foreach ($flat as $category) { foreach ($flat as $category) {
@@ -81,7 +82,12 @@ class CategoriesHandler
$category['children'] = $children; $category['children'] = $children;
} }
$image = $this->ocImageTool->resize($category['image'] ?? '', self::THUMB_SIZE, self::THUMB_SIZE, 'no_image.png'); $image = $this->ocImageTool->resize(
$category['image'] ?? '',
self::THUMB_SIZE,
self::THUMB_SIZE,
'no_image.png'
);
$branch[] = [ $branch[] = [
'id' => (int) $category['id'], 'id' => (int) $category['id'],

View File

@@ -15,6 +15,7 @@ class BlocksService
'slider' => [self::class, 'processSlider'], 'slider' => [self::class, 'processSlider'],
'categories_top' => [self::class, 'processCategoriesTop'], 'categories_top' => [self::class, 'processCategoriesTop'],
'products_feed' => [self::class, 'processProductsFeed'], 'products_feed' => [self::class, 'processProductsFeed'],
'products_carousel' => [self::class, 'processProductsCarousel'],
]; ];
private LoggerInterface $logger; private LoggerInterface $logger;
@@ -22,25 +23,28 @@ class BlocksService
private CacheInterface $cache; private CacheInterface $cache;
private SettingsService $settings; private SettingsService $settings;
private Builder $queryBuilder; private Builder $queryBuilder;
private ProductsService $productsService;
public function __construct( public function __construct(
LoggerInterface $logger, LoggerInterface $logger,
ImageToolInterface $imageTool, ImageToolInterface $imageTool,
CacheInterface $cache, CacheInterface $cache,
SettingsService $settings, SettingsService $settings,
Builder $queryBuilder Builder $queryBuilder,
ProductsService $productsService
) { ) {
$this->logger = $logger; $this->logger = $logger;
$this->imageTool = $imageTool; $this->imageTool = $imageTool;
$this->cache = $cache; $this->cache = $cache;
$this->settings = $settings; $this->settings = $settings;
$this->queryBuilder = $queryBuilder; $this->queryBuilder = $queryBuilder;
$this->productsService = $productsService;
} }
public function process(array $block): array public function process(array $block): array
{ {
$blockType = $block['type']; $blockType = $block['type'];
$cacheKey = "block_$blockType"; $cacheKey = "block_{$blockType}_" . md5(serialize($block['data']));
$cacheTtlSeconds = 60; $cacheTtlSeconds = 60;
$data = $this->cache->get($cacheKey); $data = $this->cache->get($cacheKey);
@@ -118,4 +122,36 @@ class BlocksService
{ {
return $block; return $block;
} }
private function processProductsCarousel(array $block): array
{
$categoryId = $block['data']['category_id'];
$languageId = $this->settings->config()->getApp()->getLanguageId();
$params = [
'page' => 1,
'perPage' => 10,
'filters' => [
"operand" => "AND",
"rules" => [
"RULE_PRODUCT_CATEGORIES" => [
"criteria" => [
"product_category_ids" => [
"type" => "product_categories",
"params" => [
"operator" => "contains",
"value" => [$categoryId],
],
],
],
],
],
],
];
$response = $this->productsService->getProductsResponse($params, $languageId);
$block['data']['products'] = $response;
return $block;
}
} }

View File

@@ -53,7 +53,7 @@ class ProductsService
{ {
$page = $params['page']; $page = $params['page'];
$perPage = $params['perPage']; $perPage = $params['perPage'];
$search = $params['search']; $search = $params['search'] ?? false;
$categoryName = ''; $categoryName = '';
$imageWidth = 300; $imageWidth = 300;
$imageHeight = 300; $imageHeight = 300;