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>
<Tab value="0">Настройки блока</Tab>
<Tab value="1">Основные настройки</Tab>
<slot name="tabs"></slot>
</TabList>
<TabPanels>
<TabPanel value="0">
@@ -81,6 +82,7 @@
<TabPanel value="1">
<slot></slot>
</TabPanel>
<slot name="panels"></slot>
</TabPanels>
</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 ProductsFeedBlock from "@/components/MainPageConfigurator/Blocks/ProductsFeedBlock.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 = {
slider: SliderBlock,
categories_top: CategoriesTopBlock,
products_feed: ProductsFeedBlock,
products_carousel: ProductsCarouselBlock,
};
export const blockToFormMap = {
slider: SliderForm,
categories_top: CategoriesTopForm,
products_feed: ProductsFeedForm,
products_carousel: ProductsCarouselForm,
};
export const blocks = [
@@ -55,4 +60,20 @@ export const blocks = [
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>
<meta charset="UTF-8"/>
<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">
<title>OpenCart Telegram Mini App</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>TeleCart</title>
</head>
<body>
<div id="app"></div>

View File

@@ -1,11 +1,8 @@
<template>
<div class="telecart-dock fixed bottom-0 w-full z-50 px-10">
<div
class="telecart-dock-inner flex justify-around items-center bg-base-300/10 h-full backdrop-blur-md border-base-300/90 border">
<div class="dock dock-lg select-none">
<RouterLink
:to="{name: 'home'}"
:class="{'active': route.name === 'home'}"
class="telecart-dock-item"
:class="{'dock-active': route.name === 'home'}"
@click="onDockItemClick"
>
<svg class="size-[1.2em]" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
@@ -23,20 +20,20 @@
<RouterLink
:to="{name: 'categories'}"
:class="{'active': route.name === 'categories'}"
class="telecart-dock-item"
:class="{'dock-active': route.name === 'categories'}"
@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">
<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 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="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>
<span class="dock-label">Каталог</span>
</RouterLink>
<RouterLink
:to="{name: 'search'}"
:class="{'active': route.name === 'search'}"
class="telecart-dock-item"
:class="{'dock-active': route.name === 'search'}"
@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"
@@ -50,8 +47,7 @@
<RouterLink
v-if="settings.store_enabled"
:to="{name: 'cart'}"
:class="{'active': route.name === 'cart'}"
class="telecart-dock-item"
:class="{'dock-active': route.name === 'cart'}"
@click="onDockItemClick"
>
<div class="indicator">
@@ -65,7 +61,6 @@
<span class="dock-label">Корзина</span>
</RouterLink>
</div>
</div>
</template>
<script setup>
@@ -82,31 +77,3 @@ function onDockItemClick() {
haptic.selectionChanged();
}
</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>
<section class="px-4">
<section>
<header>
<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>

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>
<section class="px-4">
<section>
<header>
<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>

View File

@@ -1,5 +1,5 @@
<template>
<section class="px-4">
<section>
<header>
<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>

View File

@@ -1,5 +1,9 @@
<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]">
<component
v-if="block.is_enabled"
@@ -29,11 +33,13 @@ import {useBlocksStore} from "@/stores/BlocksStore.js";
import ErrorBlock from "@/components/MainPage/Blocks/ErrorBlock.vue";
import ProductsFeedBlock from "@/components/MainPage/Blocks/ProductsFeedBlock.vue";
import EmptyBlocks from "@/components/MainPage/EmptyBlocks.vue";
import ProductsCarouselBlock from "@/components/MainPage/Blocks/ProductsCarouselBlock.vue";
const blockTypeToComponentMap = {
slider: SliderBlock,
categories_top: CategoriesTopBlock,
products_feed: ProductsFeedBlock,
products_carousel: ProductsCarouselBlock,
error: ErrorBlock,
};

View File

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

View File

@@ -4,6 +4,8 @@ use Bastion\ApplicationFactory;
use Cart\User;
use Openguru\OpenCartFramework\Application;
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\OpenCartLogAdapter;
use Openguru\OpenCartFramework\OpenCart\Decorators\OcRegistryDecorator;
@@ -127,11 +129,12 @@ class ControllerExtensionModuleTgshop extends Controller
->createApplication()
->bootAndHandleRequest();
} 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);
header('Content-Type: application/json');
echo json_encode([
'error' => 'Ошибка сервера. Приносим свои извинения за неудобства.',
'error' => 'Server Error.',
], JSON_THROW_ON_ERROR);
}
}
@@ -272,6 +275,7 @@ class ControllerExtensionModuleTgshop extends Controller
$app = ApplicationFactory::create($items);
$app->bind(OcRegistryDecorator::class, fn() => new OcRegistryDecorator($this->registry));
$app->bind(ImageToolInterface::class, fn() => new ImageTool(DIR_IMAGE, HTTPS_SERVER));
$app
->withLogger(fn() => new OpenCartLogAdapter(

View File

@@ -2,18 +2,51 @@
namespace Bastion\Handlers;
use App\Services\SettingsService;
use Openguru\OpenCartFramework\Http\JsonResponse;
use Openguru\OpenCartFramework\Http\Request;
use Openguru\OpenCartFramework\OpenCart\Decorators\OcRegistryDecorator;
use Openguru\OpenCartFramework\QueryBuilder\Builder;
use Openguru\OpenCartFramework\QueryBuilder\JoinClause;
use Openguru\OpenCartFramework\Support\Str;
class AutocompleteHandler
{
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->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
@@ -61,4 +94,53 @@ class AutocompleteHandler
'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
use Bastion\Handlers\AutocompleteHandler;
use Bastion\Handlers\DictionariesHandler;
use Bastion\Handlers\SettingsHandler;
use Bastion\Handlers\StatsHandler;
use Bastion\Handlers\TelegramHandler;
@@ -15,4 +16,9 @@ return [
'getCategoriesById' => [AutocompleteHandler::class, 'getCategoriesById'],
'getDashboardStats' => [StatsHandler::class, 'getDashboardStats'],
'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 = [];
foreach ($flat as $category) {
@@ -81,7 +82,12 @@ class CategoriesHandler
$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[] = [
'id' => (int) $category['id'],

View File

@@ -15,6 +15,7 @@ class BlocksService
'slider' => [self::class, 'processSlider'],
'categories_top' => [self::class, 'processCategoriesTop'],
'products_feed' => [self::class, 'processProductsFeed'],
'products_carousel' => [self::class, 'processProductsCarousel'],
];
private LoggerInterface $logger;
@@ -22,25 +23,28 @@ class BlocksService
private CacheInterface $cache;
private SettingsService $settings;
private Builder $queryBuilder;
private ProductsService $productsService;
public function __construct(
LoggerInterface $logger,
ImageToolInterface $imageTool,
CacheInterface $cache,
SettingsService $settings,
Builder $queryBuilder
Builder $queryBuilder,
ProductsService $productsService
) {
$this->logger = $logger;
$this->imageTool = $imageTool;
$this->cache = $cache;
$this->settings = $settings;
$this->queryBuilder = $queryBuilder;
$this->productsService = $productsService;
}
public function process(array $block): array
{
$blockType = $block['type'];
$cacheKey = "block_$blockType";
$cacheKey = "block_{$blockType}_" . md5(serialize($block['data']));
$cacheTtlSeconds = 60;
$data = $this->cache->get($cacheKey);
@@ -118,4 +122,36 @@ class BlocksService
{
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'];
$perPage = $params['perPage'];
$search = $params['search'];
$search = $params['search'] ?? false;
$categoryName = '';
$imageWidth = 300;
$imageHeight = 300;