feat: added new products_carousel bock type
This commit is contained in:
52
frontend/admin/src/components/Form/CategoryLabel.vue
Normal file
52
frontend/admin/src/components/Form/CategoryLabel.vue
Normal 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>
|
||||
50
frontend/admin/src/components/Form/CategorySelect.vue
Normal file
50
frontend/admin/src/components/Form/CategorySelect.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
19
frontend/admin/src/stores/autocomplete.js
Normal file
19
frontend/admin/src/stores/autocomplete.js
Normal 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;
|
||||
}
|
||||
},
|
||||
}
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -1,70 +1,65 @@
|
||||
<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">
|
||||
<RouterLink
|
||||
:to="{name: 'home'}"
|
||||
:class="{'active': route.name === 'home'}"
|
||||
class="telecart-dock-item"
|
||||
@click="onDockItemClick"
|
||||
>
|
||||
<svg class="size-[1.2em]" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<g fill="currentColor" stroke-linejoin="miter" stroke-linecap="butt">
|
||||
<polyline points="1 11 12 2 23 11" fill="none" stroke="currentColor" stroke-miterlimit="10"
|
||||
stroke-width="2"></polyline>
|
||||
<path d="m5,13v7c0,1.105.895,2,2,2h10c1.105,0,2-.895,2-2v-7" fill="none" stroke="currentColor"
|
||||
stroke-linecap="square" stroke-miterlimit="10" stroke-width="2"></path>
|
||||
<line x1="12" y1="22" x2="12" y2="18" fill="none" stroke="currentColor" stroke-linecap="square"
|
||||
stroke-miterlimit="10" stroke-width="2"></line>
|
||||
</g>
|
||||
</svg>
|
||||
<span class="dock-label">Главная</span>
|
||||
</RouterLink>
|
||||
<div class="dock dock-lg select-none">
|
||||
<RouterLink
|
||||
:to="{name: 'home'}"
|
||||
: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">
|
||||
<g fill="currentColor" stroke-linejoin="miter" stroke-linecap="butt">
|
||||
<polyline points="1 11 12 2 23 11" fill="none" stroke="currentColor" stroke-miterlimit="10"
|
||||
stroke-width="2"></polyline>
|
||||
<path d="m5,13v7c0,1.105.895,2,2,2h10c1.105,0,2-.895,2-2v-7" fill="none" stroke="currentColor"
|
||||
stroke-linecap="square" stroke-miterlimit="10" stroke-width="2"></path>
|
||||
<line x1="12" y1="22" x2="12" y2="18" fill="none" stroke="currentColor" stroke-linecap="square"
|
||||
stroke-miterlimit="10" stroke-width="2"></line>
|
||||
</g>
|
||||
</svg>
|
||||
<span class="dock-label">Главная</span>
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink
|
||||
:to="{name: 'categories'}"
|
||||
:class="{'active': route.name === 'categories'}"
|
||||
class="telecart-dock-item"
|
||||
@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>
|
||||
<span class="dock-label">Каталог</span>
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
:to="{name: 'categories'}"
|
||||
: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>
|
||||
<span class="dock-label">Каталог</span>
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink
|
||||
:to="{name: 'search'}"
|
||||
:class="{'active': route.name === 'search'}"
|
||||
class="telecart-dock-item"
|
||||
@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"
|
||||
stroke="currentColor">
|
||||
<RouterLink
|
||||
:to="{name: 'search'}"
|
||||
: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"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"/>
|
||||
</svg>
|
||||
<span class="dock-label">Поиск</span>
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink
|
||||
v-if="settings.store_enabled"
|
||||
:to="{name: 'cart'}"
|
||||
:class="{'dock-active': route.name === 'cart'}"
|
||||
@click="onDockItemClick"
|
||||
>
|
||||
<div class="indicator">
|
||||
<span class="indicator-item indicator-end badge badge-secondary badge-xs">{{ cart.productsCount }}</span>
|
||||
<svg class="size-[1.2em]" 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="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"/>
|
||||
d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 0 0-3 3h15.75m-12.75-3h11.218c1.121-2.3 2.1-4.684 2.924-7.138a60.114 60.114 0 0 0-16.536-1.84M7.5 14.25 5.106 5.272M6 20.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm12.75 0a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z"/>
|
||||
</svg>
|
||||
<span class="dock-label">Поиск</span>
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink
|
||||
v-if="settings.store_enabled"
|
||||
:to="{name: 'cart'}"
|
||||
:class="{'active': route.name === 'cart'}"
|
||||
class="telecart-dock-item"
|
||||
@click="onDockItemClick"
|
||||
>
|
||||
<div class="indicator">
|
||||
<span class="indicator-item indicator-end badge badge-secondary badge-xs">{{ cart.productsCount }}</span>
|
||||
<svg class="size-[1.2em]" 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="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 0 0-3 3h15.75m-12.75-3h11.218c1.121-2.3 2.1-4.684 2.924-7.138a60.114 60.114 0 0 0-16.536-1.84M7.5 14.25 5.106 5.272M6 20.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm12.75 0a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="dock-label">Корзина</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
<span class="dock-label">Корзина</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
];
|
||||
|
||||
@@ -61,7 +61,7 @@ class CategoriesHandler
|
||||
return new JsonResponse([
|
||||
'data' => array_map(static function ($category) {
|
||||
return [
|
||||
'id' => (int)$category['id'],
|
||||
'id' => (int) $category['id'],
|
||||
'image' => $category['image'] ?? '',
|
||||
'name' => Utils::htmlEntityEncode($category['name']),
|
||||
'description' => $category['description'],
|
||||
@@ -71,20 +71,26 @@ class CategoriesHandler
|
||||
]);
|
||||
}
|
||||
|
||||
public function buildCategoryTree(array $flat, $parentId = 0): array {
|
||||
public function buildCategoryTree(array $flat, $parentId = 0): array
|
||||
{
|
||||
$branch = [];
|
||||
|
||||
foreach ($flat as $category) {
|
||||
if ((int)$category['parent_id'] === (int)$parentId) {
|
||||
if ((int) $category['parent_id'] === (int) $parentId) {
|
||||
$children = $this->buildCategoryTree($flat, $category['id']);
|
||||
if ($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[] = [
|
||||
'id' => (int)$category['id'],
|
||||
'id' => (int) $category['id'],
|
||||
'image' => $image,
|
||||
'name' => Utils::htmlEntityEncode($category['name']),
|
||||
'description' => $category['description'],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ class ProductsService
|
||||
{
|
||||
$page = $params['page'];
|
||||
$perPage = $params['perPage'];
|
||||
$search = $params['search'];
|
||||
$search = $params['search'] ?? false;
|
||||
$categoryName = '';
|
||||
$imageWidth = 300;
|
||||
$imageHeight = 300;
|
||||
|
||||
Reference in New Issue
Block a user