feat: new settings and mainpage blocks

This commit is contained in:
2025-11-11 00:30:39 +03:00
parent 5fb45000ac
commit 6176c720b1
97 changed files with 1842 additions and 1658 deletions

View File

@@ -9,12 +9,14 @@
"@vueuse/core": "^14.0.0",
"axios": "^1.13.1",
"daisyui": "^5.4.2",
"js-md5": "^0.8.3",
"mitt": "^3.0.1",
"pinia": "^3.0.3",
"primevue": "^4.4.1",
"tailwindcss": "^4.1.16",
"vue": "^3.5.22",
"vue-router": "^4.6.3",
"vuedraggable": "^4.1.0",
},
"devDependencies": {
"@eslint/js": "^9.37.0",
@@ -576,6 +578,8 @@
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"js-md5": ["js-md5@0.8.3", "", {}, "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="],
@@ -728,6 +732,8 @@
"sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="],
"sortablejs": ["sortablejs@1.14.0", "", {}, "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"speakingurl": ["speakingurl@14.0.1", "", {}, "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ=="],
@@ -778,6 +784,8 @@
"vue-router": ["vue-router@4.6.3", "", { "dependencies": { "@vue/devtools-api": "^6.6.4" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-ARBedLm9YlbvQomnmq91Os7ck6efydTSpRP3nuOKCvgJOHNrhRoJDSKtee8kcL1Vf7nz6U+PMBL+hTvR3bTVQg=="],
"vuedraggable": ["vuedraggable@4.1.0", "", { "dependencies": { "sortablejs": "1.14.0" }, "peerDependencies": { "vue": "^3.0.1" } }, "sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww=="],
"which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="],
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],

View File

@@ -21,12 +21,14 @@
"@vueuse/core": "^14.0.0",
"axios": "^1.13.1",
"daisyui": "^5.4.2",
"js-md5": "^0.8.3",
"mitt": "^3.0.1",
"pinia": "^3.0.3",
"primevue": "^4.4.1",
"tailwindcss": "^4.1.16",
"vue": "^3.5.22",
"vue-router": "^4.6.3"
"vue-router": "^4.6.3",
"vuedraggable": "^4.1.0"
},
"devDependencies": {
"@eslint/js": "^9.37.0",

View File

@@ -26,8 +26,8 @@
<RouterLink :to="{name: 'orders'}">Заказы</RouterLink>
</li>
<li :class="{active: route.name === 'slider'}">
<RouterLink :to="{name: 'slider'}">Слайдер</RouterLink>
<li :class="{active: route.name === 'mainpage'}">
<RouterLink :to="{name: 'mainpage'}">Главная страница</RouterLink>
</li>
</ul>
@@ -36,20 +36,39 @@
</section>
<section>
<Button label="Сохранить настройки" @click="settings.saveSettings"/>
<Divider/>
<div class="tw:flex tw:items-center tw:justify-start tw:gap-4">
<Button
label="Сохранить настройки"
:disabled="!settings.hasUnsavedChanges"
v-tooltip.top="settings.hasUnsavedChanges ? 'Сохранить изменения' : 'Нет изменений для сохранения'"
@click="settings.saveSettings"
/>
<div v-if="settings.hasUnsavedChanges"
class="tw:flex tw:items-center tw:gap-2 tw:text-red-600">
<i class="fa fa-exclamation-triangle"></i>
<span class="tw:text-sm">Есть несохранённые изменения</span>
</div>
</div>
</section>
<div v-if="settings.isLoading" class="tw:w-full tw:h-full tw:absolute tw:top-0 tw:left-0 tw:z-30 tw:backdrop-blur-sm">
<div class="tw:fixed tw:top-0 tw:left-0 tw:w-full tw:h-full tw:flex tw:justify-center tw:items-center tw:z-40 tw:text-4xl">
<div v-if="settings.isLoading"
class="tw:w-full tw:h-full tw:absolute tw:top-0 tw:left-0 tw:z-30 tw:backdrop-blur-sm">
<div
class="tw:fixed tw:top-0 tw:left-0 tw:w-full tw:h-full tw:flex tw:justify-center tw:items-center tw:z-40 tw:text-4xl">
<i class="fa fa-spin fa-spinner tw:mr-5"></i>
<div>Загрузка...</div>
</div>
</div>
<Toast position="top-right"/>
<ConfirmDialog/>
<ConfirmPopup group="popup"/>
</div>
<div v-else class="tw:w-full tw:h-full tw:absolute tw:top-0 tw:left-0 tw:z-30 tw:backdrop-blur-sm">
<div class="tw:fixed tw:top-0 tw:left-0 tw:w-full tw:h-full tw:flex tw:flex-col tw:justify-center tw:items-center tw:z-40">
<div v-else
class="tw:w-full tw:h-full tw:absolute tw:top-0 tw:left-0 tw:z-30 tw:backdrop-blur-sm">
<div
class="tw:fixed tw:top-0 tw:left-0 tw:w-full tw:h-full tw:flex tw:flex-col tw:justify-center tw:items-center tw:z-40">
<i class="fa fa-ban tw:text-4xl"></i>
<div class="tw:text-4xl">{{ settings.error }}</div>
<div>Обратитесь в поддержку</div>
@@ -61,15 +80,36 @@
import {RouterView, useRoute} from 'vue-router';
import {useSettingsStore} from "@/stores/settings.js";
import Toast from 'primevue/toast';
import { toastBus } from '@/utils/toastHelper';
import {toastBus} from '@/utils/toastHelper';
import {useToast} from "primevue";
import Button from 'primevue/button';
import TopLead from "@/components/TopLead.vue";
import Divider from 'primevue/divider';
import ConfirmDialog from 'primevue/confirmdialog';
import ConfirmPopup from 'primevue/confirmpopup';
import {onBeforeUnmount, onMounted} from "vue";
const route = useRoute();
const settings = useSettingsStore();
const toast = useToast();
toastBus.on('show', (data) => toast.add(data));
// Защита от обновления страницы или закрытия вкладки
function handleBeforeUnload(event) {
if (settings.hasUnsavedChanges) {
event.preventDefault();
event.returnValue = 'У вас есть несохранённые изменения. Вы уверены, что хотите покинуть страницу?';
return event.returnValue;
}
}
onMounted(() => {
window.addEventListener('beforeunload', handleBeforeUnload);
});
onBeforeUnmount(() => {
window.removeEventListener('beforeunload', handleBeforeUnload);
});
</script>

View File

@@ -39,3 +39,10 @@ html {
border-radius: unset;
margin: 0;
}
legend.p-fieldset-legend {
font-size: 14px;
line-height: inherit;
width: auto;
margin-bottom: 0;
}

View File

@@ -0,0 +1,70 @@
<template>
<div
class="tw:bg-white dark:tw:bg-slate-800 tw:rounded-lg tw:shadow-sm tw:border tw:border-slate-200 dark:tw:border-slate-700 tw:p-6 tw:mb-3">
<div class="tw:flex tw:justify-between tw:items-start">
<div>
<h3 class="p-card-title">
{{ title }}
</h3>
<slot/>
</div>
<div class="tw:flex tw:items-center tw:gap-2">
<Button
icon="fa fa-cog"
severity="contrast"
rounded
text
size="large"
@click="$emit('onShowSettings')"
/>
<Button
icon="fa fa-trash"
severity="danger"
rounded
text
size="large"
@click="confirmedRemove($event)"
/>
</div>
</div>
</div>
</template>
<script setup>
import {Button, useConfirm} from "primevue";
const props = defineProps({
title: {
type: String,
default: null,
},
});
const confirm = useConfirm();
const emit = defineEmits(['onRemove', 'onShowSettings']);
function confirmedRemove(event) {
confirm.require({
group: 'popup',
target: event.currentTarget,
message: 'Удалить блок?',
icon: 'pi pi-exclamation-triangle',
rejectProps: {
label: 'Отмена',
severity: 'secondary',
outlined: true
},
acceptProps: {
label: 'Удалить',
severity: 'danger'
},
accept: () => emit('onRemove'),
});
}
</script>
<style scoped lang="scss">
</style>

View File

@@ -0,0 +1,29 @@
<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">Кол-во категорий:</span>
{{ value.data.count }}
</div>
</div>
</BaseBlock>
</template>
<script setup>
import BaseBlock from "@/components/MainPageConfigurator/Blocks/BaseBlock.vue";
const emit = defineEmits(['onRemove', 'onShowSettings']);
const props = defineProps({
value: {
type: Object,
required: true,
}
});
</script>

View File

@@ -0,0 +1,30 @@
<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">Максимальное кол-во страниц:</span>
{{ value.data.max_page_count }}
</div>
</div>
</BaseBlock>
</template>
<script setup>
import BaseBlock from "@/components/MainPageConfigurator/Blocks/BaseBlock.vue";
const emit = defineEmits(['onRemove', 'onShowSettings']);
const props = defineProps({
value: {
type: Object,
required: true,
}
});
</script>

View File

@@ -0,0 +1,46 @@
<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.is_enabled ? 'Включен' : 'Выключен' }}
</div>
<div><span class="tw:font-bold tw:dark:text-slate-200">Эффект:</span>
{{ sliderEffectOptions[value.data.effect] || value.data.effect }}
</div>
<div><span class="tw:font-bold tw:dark:text-slate-200">Авто:</span>
{{ value.data.autoplay ? 'Включен' : 'Выключен' }}
</div>
<div><span class="tw:font-bold tw:dark:text-slate-200">Цель Яндекс.Метрики:</span>
{{ value.goal_name || 'Не задана' }}
</div>
</div>
<div class="tw:mt-6 tw:flex tw:flex-wrap tw:gap-4">
<img
v-if="value.data.slides && value.data.slides.length > 0"
v-for="slide in value.data.slides"
:alt="slide.title"
class="tw:w-24 tw:h-24 tw:object-cover tw:rounded-md tw:border-2 tw:border-slate-200 dark:tw:border-slate-600"
:src="getThumb(slide.image)"
/>
</div>
</BaseBlock>
</template>
<script setup>
import {getThumb} from "@/utils/helpers.js";
import {sliderEffectOptions} from "@/utils/constants..js";
import BaseBlock from "@/components/MainPageConfigurator/Blocks/BaseBlock.vue";
const emit = defineEmits(['onRemove', 'onShowSettings']);
const props = defineProps({
value: {
type: Object,
required: true,
}
});
</script>

View File

@@ -0,0 +1,128 @@
<template>
<Tabs value="0">
<TabList>
<Tab value="0">Настройки блока</Tab>
<Tab value="1">Основные настройки</Tab>
</TabList>
<TabPanels>
<TabPanel value="0">
<div class="tw:space-y-6">
<!-- Статус -->
<div>
<div class="tw:flex tw:items-center tw:justify-between tw:mb-2">
<label class="tw:font-medium tw:text-gray-700">
Статус
</label>
<ToggleSwitch v-model="model.is_enabled"/>
</div>
<small class="tw:block tw:text-sm tw:text-gray-500">
Показывать этот блок
</small>
</div>
<!-- Заголовок блока -->
<div>
<div class="tw:mb-2">
<label class="tw:font-medium tw:text-gray-700">
Заголовок блока
</label>
<InputText
v-model="model.title"
placeholder="заголовок блока"
class="tw:w-full"
/>
</div>
<small class="tw:block tw:text-sm tw:text-gray-500">
Текст, который будет выводиться в качестве заголовка блока на главной странице. Оставьте
пустым, если заголовок не требуется.
</small>
</div>
<!-- Описание блока -->
<div>
<div class="tw:mb-2">
<label class="tw:font-medium tw:text-gray-700">
Описание блока
</label>
<InputText
v-model="model.description"
placeholder="Описание блока"
class="tw:w-full"
/>
</div>
<small class="tw:block tw:text-sm tw:text-gray-500">
Описание выводится под заголовком блока уменьшенным шрифтом. Оставьте пустым, если
описание не требуется.
</small>
</div>
<!-- Цель Яндекс.Метрики -->
<div>
<div class="tw:mb-2">
<label class="tw:font-medium tw:text-gray-700">
Цель Яндекс.Метрики
</label>
<InputText
v-model="model.goal_name"
placeholder="Название цели для Яндекс.Метрики"
class="tw:w-full"
/>
</div>
<small class="tw:block tw:text-sm tw:text-gray-500">
Цель в Яндекс.Метрике для отслеживания кликов по блоку.
Оставьте пустым, если не нужно отслеживать клики по этому блоку.
</small>
</div>
</div>
</TabPanel>
<TabPanel value="1">
<slot></slot>
</TabPanel>
</TabPanels>
</Tabs>
<Divider/>
<div class="tw:flex tw:items-center tw:justify-between tw:gap-4">
<div class="tw:flex tw:gap-2">
<Button
label="Применить"
icon="fa fa-check"
v-tooltip.top="isChanged ? 'Применить изменения' : 'Нет изменений для сохранения'"
:disabled="isChanged === false"
@click="onApply"
/>
<Button label="Отмена" severity="secondary" @click="$emit('cancel')"/>
</div>
<div v-if="isChanged" class="tw:flex tw:items-center tw:gap-2 tw:text-amber-600">
<i class="fa fa-exclamation-triangle"></i>
<span class="tw:text-sm">Есть несохранённые изменения</span>
</div>
</div>
</template>
<script setup>
import {Button, Divider, InputText, Panel, ToggleSwitch} from 'primevue';
import Tabs from 'primevue/tabs';
import TabList from 'primevue/tablist';
import Tab from 'primevue/tab';
import TabPanels from 'primevue/tabpanels';
import TabPanel from 'primevue/tabpanel';
const model = defineModel();
const emit = defineEmits(['onApply', 'cancel']);
const props = defineProps({
isChanged: {
type: Boolean,
default: false,
}
});
function onApply() {
emit('onApply');
}
</script>

View File

@@ -0,0 +1,55 @@
<template>
<div v-if="draft">
<BaseForm
v-model="draft"
:isChanged="isChanged"
@onApply="onApply"
@cancel="$emit('cancel')"
>
<div class="tw:space-y-6">
<!-- Количество категорий -->
<FormItem label="Количество категорий">
<template #default>
<InputNumber
v-model="draft.data.count"
:min="0"
:max="100"
placeholder="10"
:showButtons="true"
/>
<span class="tw:text-gray-600 tw:whitespace-nowrap">шт.</span>
</template>
<template #help>
Количество категорий, которое нужно выводить в блоке. Если поставить 0, то будет
выводиться только кнопка "Каталог".
</template>
</FormItem>
</div>
</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 {InputNumber, Panel} from "primevue";
import FormItem from "@/components/MainPageConfigurator/Forms/FormItem.vue";
const draft = ref(null);
const model = defineModel();
const emit = defineEmits(['cancel']);
const isChanged = computed(() => md5(JSON.stringify(model.value)) !== md5(JSON.stringify(draft.value)));
function onApply() {
model.value = JSON.parse(JSON.stringify(draft.value));
}
onMounted(() => {
draft.value = JSON.parse(JSON.stringify(model.value));
});
defineExpose({isChanged});
</script>

View File

@@ -0,0 +1,25 @@
<template>
<!-- Расстояние между слайдами -->
<div>
<div class="tw:flex tw:items-center tw:justify-between tw:mb-2 tw:gap-4">
<label v-if="label" class="tw:font-medium tw:text-gray-700 tw:flex-shrink-0">
{{ label }}
</label>
<div class="tw:flex tw:items-center tw:gap-2 tw:flex-shrink-0">
<slot/>
</div>
</div>
<small class="tw:block tw:text-sm tw:text-gray-500">
<slot name="help"/>
</small>
</div>
</template>
<script setup>
const props = defineProps({
label: {
type: String,
default: null,
},
});
</script>

View File

@@ -0,0 +1,56 @@
<template>
<div v-if="draft">
<BaseForm
v-model="draft"
:isChanged="isChanged"
@onApply="onApply"
@cancel="$emit('cancel')"
>
<div class="tw:space-y-6">
<!-- Максимальное количество страниц -->
<FormItem label="Максимальное количество страниц">
<template #default>
<InputNumber
v-model="draft.data.max_page_count"
:min="1"
:max="100"
placeholder="10"
:showButtons="true"
/>
<span class="tw:text-gray-600 tw:whitespace-nowrap">страниц</span>
</template>
<template #help>
Укажите, сколько страниц товаров можно подгружать при бесконечной прокрутки.
После достижения этого лимита подгрузка остановится.
Ограничение страниц снижает нагрузку на сервер.
</template>
</FormItem>
</div>
</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 {InputNumber} from "primevue";
import FormItem from "@/components/MainPageConfigurator/Forms/FormItem.vue";
const draft = ref(null);
const model = defineModel();
const emit = defineEmits(['cancel']);
const isChanged = computed(() => md5(JSON.stringify(model.value)) !== md5(JSON.stringify(draft.value)));
function onApply() {
model.value = JSON.parse(JSON.stringify(draft.value));
}
onMounted(() => {
draft.value = JSON.parse(JSON.stringify(model.value));
});
defineExpose({isChanged});
</script>

View File

@@ -0,0 +1,271 @@
<template>
<div v-if="draft">
<BaseForm
v-model="draft"
:isChanged="isChanged"
@onApply="onApply"
@cancel="$emit('cancel')"
>
<!-- Основные настройки -->
<Panel header="Основные настройки" class="tw:mb-4">
<div class="tw:space-y-6">
<!-- Эффект смены слайдов -->
<div>
<div class="tw:flex tw:items-center tw:justify-between tw:mb-2">
<label class="tw:font-medium tw:text-gray-700">
Эффект смены слайдов
</label>
<Dropdown
v-model="draft.data.effect"
:options="effectOptionsList"
optionLabel="label"
optionValue="value"
placeholder="Выберите эффект"
class="tw:w-64"
/>
</div>
</div>
<!-- Пагинация -->
<div>
<div class="tw:flex tw:items-center tw:justify-between tw:mb-2">
<label class="tw:font-medium tw:text-gray-700">
Пагинация
</label>
<ToggleSwitch v-model="draft.data.pagination"/>
</div>
<small class="tw:block tw:text-sm tw:text-gray-500">
Показывать точки под слайдером для индикации текущего слайда.
</small>
</div>
<!-- Полоса прокрутки -->
<div>
<div class="tw:flex tw:items-center tw:justify-between tw:mb-2">
<label class="tw:font-medium tw:text-gray-700">
Полоса прокрутки
</label>
<ToggleSwitch v-model="draft.data.scrollbar"/>
</div>
<small class="tw:block tw:text-sm tw:text-gray-500">
Показывать полосу прокрутки под слайдером для навигации между слайдами.
</small>
</div>
<!-- Расстояние между слайдами -->
<div>
<div class="tw:flex tw:items-center tw:justify-between tw:mb-2 tw:gap-4">
<label class="tw:font-medium tw:text-gray-700 tw:flex-shrink-0">
Расстояние между слайдами
</label>
<div class="tw:flex tw:items-center tw:gap-2 tw:flex-shrink-0">
<InputNumber
v-model="draft.data.space_between"
:min="0"
:max="100"
placeholder="30"
:showButtons="true"
/>
<span class="tw:text-gray-600 tw:whitespace-nowrap">px</span>
</div>
</div>
<small class="tw:block tw:text-sm tw:text-gray-500">
Расстояние между слайдами в пикселях. По умолчанию - 30.
</small>
</div>
<!-- Свободный режим -->
<div>
<div class="tw:flex tw:items-center tw:justify-between tw:mb-2">
<label class="tw:font-medium tw:text-gray-700">
Свободный режим
</label>
<ToggleSwitch v-model="draft.data.free_mode"/>
</div>
<small class="tw:block tw:text-sm tw:text-gray-500">
Позволяет свободно прокручивать слайды без привязки к конкретным позициям.
</small>
</div>
<!-- Бесконечная прокрутка -->
<div>
<div class="tw:flex tw:items-center tw:justify-between tw:mb-2">
<label class="tw:font-medium tw:text-gray-700">
Бесконечная прокрутка
</label>
<ToggleSwitch v-model="draft.data.loop"/>
</div>
<small class="tw:block tw:text-sm tw:text-gray-500">
Включите этот режим, чтобы после последнего слайда слайдер продолжал прокрутку с
первого, создавая бесконечный цикл.
</small>
</div>
<!-- Автоматическая прокрутка -->
<div>
<div class="tw:flex tw:items-center tw:justify-between tw:mb-2">
<label class="tw:font-medium tw:text-gray-700">
Автоматическая прокрутка
</label>
<ToggleSwitch v-model="draft.data.autoplay"/>
</div>
<small class="tw:block tw:text-sm tw:text-gray-500">
Слайдер будет автоматически листать изображения каждые 3 секунды.
</small>
</div>
</div>
</Panel>
<!-- Слайды -->
<Panel header="Слайды">
<template #icons>
<Button
severity="success"
text
rounded
aria-label="Добавить слайд"
@click="addSlide"
>
<i class="fa fa-plus"></i> Добавить новый слайд
</Button>
</template>
<div v-if="draft.data.slides.length === 0" class="tw:text-center tw:py-8 tw:text-gray-500">
<i class="fa fa-image fa-3x tw:mb-4"></i>
<p class="tw:font-bold">Слайды не добавлены</p>
<Button
label="Добавить первый слайд"
severity="success"
outlined
class="tw:mt-4"
@click="addSlide"
>
<i class="fa fa-plus"></i>
</Button>
</div>
<div v-else class="tw:space-y-4">
<div
v-for="(slide, index) in draft.data.slides"
:key="index"
class="tw:bg-white tw:rounded-lg tw:border tw:border-gray-200 tw:p-4 tw:shadow-sm tw:relative"
>
<div class="tw:absolute tw:top-2 tw:right-2">
<Button
severity="danger"
text
rounded
aria-label="Удалить слайд"
@click="removeSlide($event, index)"
>
<i class="fa fa-trash tw:text-lg"></i>
</Button>
</div>
<div class="tw:flex">
<!-- Изображение -->
<div class="tw:mr-5">
<label class="tw:block tw:mb-2 tw:font-medium tw:text-gray-700">
Изображение
</label>
<OcImagePicker v-model="slide.image"/>
</div>
<!-- Поля -->
<div class="tw:space-y-4">
<div>
<label class="tw:block tw:mb-2 tw:font-medium tw:text-gray-700">
Заголовок слайда
</label>
<InputText
v-model="slide.title"
placeholder="Введите заголовок слайда"
class="tw:w-full"
/>
<small class="tw:block tw:text-sm tw:text-gray-500">
Заголовок слайда будет отправляться в цели Яндекс.Метрики
</small>
</div>
<div>
<label class="tw:block tw:mb-2 tw:font-medium tw:text-gray-700">
Ссылка
</label>
<LinkSelector v-model="slide.link"/>
</div>
</div>
</div>
</div>
</div>
</Panel>
</BaseForm>
</div>
</template>
<script setup>
import {computed, defineExpose, onMounted, ref} from "vue";
import OcImagePicker from "@/components/OcImagePicker.vue";
import LinkSelector from "@/components/Slider/LinkSelector.vue";
import {Button, Dropdown, InputNumber, InputText, Panel, ToggleSwitch, useConfirm} from 'primevue';
import {sliderEffectOptions} from "@/utils/constants..js";
import {md5} from "js-md5";
import BaseForm from "@/components/MainPageConfigurator/Forms/BaseForm.vue";
const confirm = useConfirm();
const draft = ref(null);
const slider = defineModel();
const isChanged = computed(() => md5(JSON.stringify(slider.value)) !== md5(JSON.stringify(draft.value)));
const effectOptionsList = computed(() => {
return Object.entries(sliderEffectOptions).map(([value, label]) => ({
value,
label,
}));
});
function removeSlide(event, index) {
confirm.require({
group: 'popup',
target: event.currentTarget,
message: 'Удалить слайд?',
icon: 'pi pi-exclamation-triangle',
rejectProps: {
label: 'Отмена',
severity: 'secondary',
outlined: true
},
acceptProps: {
label: 'Удалить',
severity: 'danger'
},
accept: () => draft.value.data.slides.splice(index, 1),
});
}
function addSlide() {
draft.value.data.slides.push({
title: '',
link: {
type: 'none',
value: null,
},
image: '',
});
}
function onApply() {
slider.value = JSON.parse(JSON.stringify(draft.value));
}
onMounted(() => {
draft.value = JSON.parse(JSON.stringify(slider.value));
});
defineExpose({isChanged});
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,195 @@
<template>
<div class="tw:flex tw:gap-4">
<section class="tw:w-1/3 tw:p-4 tw:bg-gray-100 tw:rounded-xl">
<header class="tw:font-bold tw:uppercase">Доступные блоки</header>
<div class="tw:mb-6">Перетяните блок, чтобы добавить на главную страницу</div>
<draggable
v-model="availableBlocks"
:group="{ name: 'blocks', pull: 'clone', put: false }"
:clone="cloneBlock"
item-key="type"
class="tw:space-y-2"
chosenClass="tw:scale-98"
>
<template #item="{ element, index }">
<Card class="tw:cursor-move">
<template #title>
<i class="fa fa-arrows"></i>
{{ element.title }}
</template>
<template #content>
<p class="m-0">
{{ element.description }}
</p>
</template>
</Card>
</template>
</draggable>
</section>
<section class="tw:w-full tw:rounded-xl tw:p-4 tw:bg-gray-100 tw:min-h-[400px] tw:relative">
<header class="tw:font-bold tw:uppercase">Блоки на главной странице</header>
<div class="tw:mb-6">Эти блоки будут отображены на главной странице в том же порядке. Перетяните блок, если хотите изменить порядок.</div>
<draggable
v-model="settings.items.mainpage_blocks"
:group="{ name: 'blocks', put: true }"
item-key="type"
class="tw:w-full tw:h-full tw:min-h-[400px]"
@change="onChange"
>
<template #item="{ element, index }">
<template v-if="blockToComponentMap[element.type]">
<component
:is="blockToComponentMap[element.type]"
:value="element"
@onRemove="removeBlock(index)"
@onShowSettings="showDrawer(index)"
/>
</template>
<div v-else>неподдерживаемый блок</div>
</template>
</draggable>
<div
v-if="!hasBlocks"
class="tw:absolute tw:inset-0 tw:flex tw:flex-col tw:items-center tw:justify-center tw:text-center tw:py-12 tw:px-4 tw:pointer-events-none"
>
<div class="tw:mb-6 tw:text-6xl tw:text-gray-400">
<i class="fa fa-inbox"></i>
</div>
<h3 class="tw:text-xl tw:font-semibold tw:text-gray-700 tw:mb-2">
Нет блоков на главной странице
</h3>
<p class="tw:text-gray-500 tw:max-w-md tw:mb-4">
Перетащите блок из левой панели, чтобы добавить его на главную страницу
</p>
</div>
</section>
<Drawer
:visible="isDrawerSettingsVisible"
@update:visible="closeDrawer"
:header="drawerTitle"
position="right"
:baseZIndex="1000"
class="tw:!w-full tw:md:!w-80 tw:lg:!w-[50rem]"
>
<template v-if="currentBlock && blockToFormMap[currentBlock.type]">
<component
:is="blockToFormMap[currentBlock.type]"
ref="currentBlockForm"
@cancel="closeDrawer"
:modelValue="settings.items.mainpage_blocks[drawerBlockIndex]"
@update:modelValue="updateBlockData"
/>
</template>
<div v-else>Unsupported block type</div>
</Drawer>
</div>
</template>
<script setup>
import draggable from 'vuedraggable';
import {Card, Drawer, useConfirm} from 'primevue';
import {computed, nextTick, ref} from "vue";
import {useSettingsStore} from "@/stores/settings.js";
import {
blocks,
blockToComponentMap,
blockToFormMap
} from "@/components/MainPageConfigurator/availableBlocks.js";
const settings = useSettingsStore();
const confirm = useConfirm();
const availableBlocks = ref(blocks);
const isDrawerSettingsVisible = ref(null);
const drawerBlockIndex = ref(null);
const currentBlockForm = ref(null);
const currentBlock = computed(() => {
if (drawerBlockIndex.value >= 0) {
return settings.items.mainpage_blocks[drawerBlockIndex.value];
}
return null;
});
const drawerTitle = computed(() => {
if (currentBlock.value) {
return `Редактирование ${currentBlock?.value?.type} - ${currentBlock?.value?.title || 'Без заголовка'}`;
}
return '';
});
const hasBlocks = computed(() => {
return settings.items.mainpage_blocks && settings.items.mainpage_blocks.length > 0;
});
function removeBlock(index) {
settings.items.mainpage_blocks.splice(index, 1);
}
function cloneBlock(block) {
const newBlock = JSON.parse(JSON.stringify(block));
newBlock.title = '';
newBlock.description = '';
return newBlock;
}
function showDrawer(blockIndex) {
if (currentBlock) {
drawerBlockIndex.value = blockIndex;
isDrawerSettingsVisible.value = true;
}
}
function closeDrawer() {
// Проверяем, есть ли несохраненные изменения
if (currentBlockForm.value?.isChanged === true) {
confirm.require({
message: 'У вас есть несохраненные изменения. Вы уверены, что хотите закрыть форму?',
header: 'Подтверждение закрытия',
icon: 'pi pi-exclamation-triangle',
rejectProps: {
label: 'Отмена',
severity: 'secondary',
outlined: true
},
acceptProps: {
label: 'Закрыть',
severity: 'danger'
},
accept: () => {
drawerBlockIndex.value = null;
isDrawerSettingsVisible.value = false;
}
});
} else {
drawerBlockIndex.value = null;
isDrawerSettingsVisible.value = false;
}
}
function onChange(update) {
if (update.added && update.added?.newIndex >= 0) {
showDrawer(update.added.newIndex);
}
}
function updateBlockData(newBlockData) {
if (drawerBlockIndex.value !== null && drawerBlockIndex.value >= 0) {
settings.items.mainpage_blocks.splice(drawerBlockIndex.value, 1, newBlockData);
nextTick(() => closeDrawer());
}
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,58 @@
import SliderBlock from "@/components/MainPageConfigurator/Blocks/SliderBlock.vue";
import CategoriesTopBlock from "@/components/MainPageConfigurator/Blocks/CategoriesTopBlock.vue";
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";
export const blockToComponentMap = {
slider: SliderBlock,
categories_top: CategoriesTopBlock,
products_feed: ProductsFeedBlock,
};
export const blockToFormMap = {
slider: SliderForm,
categories_top: CategoriesTopForm,
products_feed: ProductsFeedForm,
};
export const blocks = [
{
type: 'slider',
title: 'Слайдер',
description: 'Изображения объединённые в слайдер.',
is_enabled: true,
goal_name: '',
data: {
effect: "slide",
pagination: true,
scrollbar: false,
free_mode: false,
space_between: 30,
autoplay: false,
loop: false,
slides: [],
},
},
{
type: 'categories_top',
title: 'Топ категорий',
description: 'Виджет с кнопками популярных категорий и кнопкой «Каталог» для всех категорий.',
is_enabled: true,
goal_name: '',
data: {
count: 10,
},
},
{
type: 'products_feed',
title: 'Лента товаров',
description: 'Отображает товары в виде прокручиваемой ленты с возможностью подгрузки новых элементов по мере скролла.',
is_enabled: true,
goal_name: '',
data: {
max_page_count: 10,
},
},
];

View File

@@ -1,182 +0,0 @@
<template>
<input type="hidden" name="module_tgshop_mainpage_slider" :value="JSON.stringify(slider)">
<div class="alert alert-info">
<p>Здесь настраивается слайдер, который выводится на главной странице.</p>
<p>Рекомендуемые размеры изображений: <span class="text-bold">370×200px</span>, <span
class="text-bold">740×400px</span>,
<span class="text-bold">1110×600px</span> либо другие, в тех же пропорциях (1.85:1)<br>
Изображение будет автоматически обрезана под нужный формат. <br>
Заголовок можно оставить пустым, но рекомендуется заполнить для корректной работы целей
Яндекс.Метрики.</p>
</div>
<section>
<SettingsItem label="Статус">
<template #default>
<Switcher v-model="slider.is_enabled"/>
</template>
<template #help>
Показывать слайдер на главной странице.
Для отображения слайдера нужно добавить минимум 1 слайд.
</template>
</SettingsItem>
<SettingsItem label="Эффект смены слайдов">
<template #default>
<select v-model="slider.effect" class="form-control">
<option value="slide">Слайд</option>
<option value="flip">Переворот</option>
<option value="cards">Карточки</option>
<option value="cube">Куб</option>
<option value="coverflow">Перекрывающиеся слайды</option>
</select>
</template>
</SettingsItem>
<SettingsItem label="Пагинация">
<template #default>
<Switcher v-model="slider.pagination"/>
</template>
<template #help>
Показывать точки под слайдером для индикации текущего слайда.
</template>
</SettingsItem>
<SettingsItem label="Полоса прокрутки">
<template #default>
<Switcher v-model="slider.scrollbar"/>
</template>
<template #help>
Показывать полосу прокрутки под слайдером для навигации между слайдами.
</template>
</SettingsItem>
<SettingsItem label="Расстояние между слайдами">
<template #default>
<div class="tw:max-w-2xl">
<div class="input-group">
<input
v-model="slider.space_between"
type="number"
min="0"
max="100"
class="form-control"
placeholder="30"
/>
<span class="input-group-addon">px</span>
</div>
</div>
</template>
<template #help>
Расстояние между слайдами в пикселях. По умолчанию - 30.
</template>
</SettingsItem>
<SettingsItem label="Свободный режим">
<template #default>
<Switcher v-model="slider.free_mode"/>
</template>
<template #help>
Позволяет свободно прокручивать слайды без привязки к конкретным позициям.
</template>
</SettingsItem>
<SettingsItem label="Бесконечная прокрутка">
<template #default>
<Switcher v-model="slider.loop"/>
</template>
<template #help>
Включите этот режим, чтобы после последнего слайда слайдер продолжал прокрутку с первого, создавая бесконечный цикл.
</template>
</SettingsItem>
<SettingsItem label="Автоматическая прокрутка">
<template #default>
<Switcher v-model="slider.autoplay"/>
</template>
<template #help>
Слайдер будет автоматически листать изображения каждые 3 секунды
</template>
</SettingsItem>
</section>
<section>
<table class="table table-striped table-bordered table-hover">
<thead>
<tr>
<td class="text-left">Заголовок</td>
<td class="text-left">Ссылка</td>
<td class="text-center">Изображение</td>
<td>Действия</td>
</tr>
</thead>
<tbody>
<tr v-for="(slide, index) in slider.slides">
<td class="text-left">
<input v-model="slide.title" type="text" placeholder="Заголовок слайда"
class="form-control"/>
</td>
<td class="text-left" style="width: 30%;">
<LinkSelector v-model="slide.link"/>
</td>
<td class="text-center">
<OcImagePicker v-model="slide.image"/>
</td>
<td class="text-left">
<button type="button" class="btn btn-danger" @click="removeSlide(index)">
<i class="fa fa-minus-circle"></i>
</button>
</td>
</tr>
</tbody>
<tfoot>
<tr>
<td colspan="3"></td>
<td class="text-left">
<button @click="addSlide" type="button" class="btn btn-primary">
<i class="fa fa-plus-circle"></i>
</button>
</td>
</tr>
</tfoot>
</table>
</section>
</template>
<script setup>
import {onMounted, ref} from "vue";
import OcImagePicker from "@/components/OcImagePicker.vue";
import LinkSelector from "@/components/Slider/LinkSelector.vue";
import SettingsItem from "@/components/SettingsItem.vue";
import Switcher from "@/components/Switcher.vue";
const slider = defineModel();
function removeSlide(index) {
slider.value.slides.splice(index, 1);
}
function addSlide() {
slider.value.slides.push({
title: '',
link: {
type: 'none',
value: null,
},
image: '',
});
}
</script>
<style scoped>
.text-bold {
font-weight: bold;
}
</style>

View File

@@ -8,6 +8,8 @@ import PrimeVue from 'primevue/config';
import Aura from '@primeuix/themes/aura';
import ToastService from 'primevue/toastservice';
import {definePreset} from "@primeuix/themes";
import Tooltip from 'primevue/tooltip';
import ConfirmationService from 'primevue/confirmationservice';
const MyPreset = definePreset(Aura, {
@@ -34,6 +36,8 @@ onReady(async () => {
}
});
app.use(ToastService);
app.directive('tooltip', Tooltip);
app.use(ConfirmationService);
app.mount('#app');
await useSettingsStore().fetchSettings();

View File

@@ -1,22 +1,22 @@
import {createMemoryHistory, createRouter} from 'vue-router';
import SliderView from "@/views/SliderView.vue";
import GeneralView from "@/views/GeneralView.vue";
import TextsView from "@/views/TextsView.vue";
import OrdersView from "@/views/OrdersView.vue";
import TelegramView from "@/views/TelegramView.vue";
import MetricsView from "@/views/MetricsView.vue";
import StoreView from "@/views/StoreView.vue";
import MainPageView from "@/views/MainPageView.vue";
const router = createRouter({
history: createMemoryHistory(),
routes: [
{path: '/', name: 'general', component: GeneralView},
{path: '/slider', name: 'slider', component: SliderView},
{path: '/orders', name: 'orders', component: OrdersView},
{path: '/texts', name: 'texts', component: TextsView},
{path: '/telegram', name: 'telegram', component: TelegramView},
{path: '/metrics', name: 'metrics', component: MetricsView},
{path: '/store', name: 'store', component: StoreView},
{path: '/mainpage', name: 'mainpage', component: MainPageView},
],
});

View File

@@ -1,11 +1,13 @@
import {defineStore} from "pinia";
import {apiGet, apiPost} from "@/utils/http.js";
import {toastBus} from "@/utils/toastHelper.js";
import {md5} from "js-md5";
export const useSettingsStore = defineStore('settings', {
state: () => ({
isLoading: false,
error: null,
originalItemsHash: null,
items: {
app: {
@@ -32,10 +34,6 @@ export const useSettingsStore = defineStore('settings', {
store: {
enable_store: true,
mainpage_products: 'most_viewed',
featured_products: [],
mainpage_categories: 'latest10',
featured_categories: [],
feature_coupons: true,
feature_vouchers: true,
},
@@ -63,6 +61,8 @@ export const useSettingsStore = defineStore('settings', {
slides: [],
},
},
mainpage_blocks: [],
},
}),
@@ -74,6 +74,10 @@ export const useSettingsStore = defineStore('settings', {
const filename = state.items.app.app_icon.substring(0, extIndex);
return `/image/cache/${filename}-100x100${ext}`;
},
hasUnsavedChanges: (state) => {
if (!state.originalItemsHash) return false;
return md5(JSON.stringify(state.items)) !== state.originalItemsHash;
},
},
actions: {
@@ -86,6 +90,8 @@ export const useSettingsStore = defineStore('settings', {
...this.items,
...response.data,
};
// Сохраняем хеш исходного состояния после загрузки
this.originalItemsHash = md5(JSON.stringify(this.items));
} else {
this.error = 'Возникли проблемы при загрузке настроек.';
}
@@ -104,6 +110,8 @@ export const useSettingsStore = defineStore('settings', {
detail: 'Настройки сохранены.',
life: 2000,
});
// Обновляем хеш исходного состояния после успешного сохранения
this.originalItemsHash = md5(JSON.stringify(this.items));
} else {
toastBus.emit('show', {
severity: 'error',
@@ -114,7 +122,6 @@ export const useSettingsStore = defineStore('settings', {
}
this.isLoading = false;
},

View File

@@ -0,0 +1,7 @@
export const sliderEffectOptions = {
slide: 'Слайд',
flip: 'Переворот',
cards: 'Карточки',
cube: 'Куб',
coverflow: 'Перекрывающиеся слайды',
};

View File

@@ -0,0 +1,7 @@
export function getThumb(imageUrl) {
if (!imageUrl) return '/image/cache/no_image-100x100.png';
const extIndex = imageUrl.lastIndexOf('.');
const ext = imageUrl.substring(extIndex);
const filename = imageUrl.substring(0, extIndex);
return `/image/cache/${filename}-100x100${ext}`;
}

View File

@@ -0,0 +1,7 @@
<template>
<MainPageConfigurator/>
</template>
<script setup>
import MainPageConfigurator from "@/components/MainPageConfigurator/MainPageConfigurator.vue";
</script>

View File

@@ -1,10 +0,0 @@
<template>
<Slider v-model="settings.items.sliders.mainpage_slider"/>
</template>
<script setup>
import Slider from "@/components/Slider/Slider.vue";
import {useSettingsStore} from "@/stores/settings.js";
const settings = useSettingsStore();
</script>

View File

@@ -7,40 +7,6 @@
вашем сайте. В этом режиме Telecart работает как каталог.</p>
</ItemBool>
<ItemSelect
label="Товары на главной"
v-model="settings.items.store.mainpage_products"
:items="mainpage_products_options"
>
Выберите, какие товары показывать на главной странице магазина в Telegram.
Это влияет на первую видимую секцию каталога для пользователя.
</ItemSelect>
<ItemProductsSelect
label="Избранные товары"
v-model="settings.items.store.featured_products"
>
На главной странице будут отображаться избранные товары, если вы выберете этот вариант в
настройке Товары на главной. Если товары не выбраны, то будут показаны популярные товары.
</ItemProductsSelect>
<ItemSelect
label="Категории на главной"
v-model="settings.items.store.mainpage_categories"
:items="mainpage_categories_options"
>
Выберите, какие товары показывать на главной странице магазина в Telegram.
Это влияет на первую видимую секцию каталога для пользователя.
</ItemSelect>
<ItemCategoriesSelect
label="Избранные категории"
v-model="settings.items.store.featured_categories"
>
На главной странице будут отображаться эти категории,
если вы выберете этот вариант в настройке Категории на главной.
</ItemCategoriesSelect>
<ItemBool label="Промокоды" v-model="settings.items.store.feature_coupons">
<p>
Позволяет использовать стандартные
@@ -65,11 +31,7 @@ import ItemProductsSelect from "@/components/Settings/ItemProductsSelect.vue";
import ItemCategoriesSelect from "@/components/Settings/ItemCategoriesSelect.vue";
const settings = useSettingsStore();
const mainpage_products_options = {
most_viewed: 'Популярные товары',
latest: 'Последние сверху',
featured: 'Избранные товары (задать в поле ниже)',
};
const mainpage_categories_options = {
no_categories: 'Отображать только кнопку "Каталог"',
latest10: 'Последние 10 категорий',

View File

@@ -1,6 +1,6 @@
<template>
<div class="drawer h-full">
<input id="app-drawer" type="checkbox" class="drawer-toggle" v-model="drawerOpen" />
<input id="app-drawer" type="checkbox" class="drawer-toggle" v-model="drawerOpen"/>
<div class="drawer-content">
<div class="app-container h-full">
@@ -9,18 +9,18 @@
<Navbar @drawer="toggleDrawer"/>
<section class="telecart-main-section">
<FullscreenViewport v-if="platform === 'ios' || platform === 'android'" />
<FullscreenViewport v-if="platform === 'ios' || platform === 'android'"/>
<AppDebugMessage v-if="settings.app_debug"/>
<RouterView v-slot="{ Component, route }">
<KeepAlive include="Home" :key="filtersStore.paramsHashForRouter">
<component :is="Component" :key="route.fullPath" />
<component :is="Component" :key="route.fullPath"/>
</KeepAlive>
</RouterView>
<CartButton v-if="settings.store_enabled" />
<Dock v-if="isAppDockShown" />
<CartButton v-if="settings.store_enabled"/>
<Dock v-if="isAppDockShown"/>
</section>
</div>
</div>
@@ -38,8 +38,7 @@
<script setup>
import {computed, onMounted, onUnmounted, ref, watch} from "vue";
import {useWebAppViewport} from 'vue-tg';
import {useMiniApp, FullscreenViewport} from 'vue-tg';
import {FullscreenViewport, useMiniApp, useWebAppViewport} from 'vue-tg';
import {useRoute, useRouter} from "vue-router";
import {useSettingsStore} from "@/stores/SettingsStore.js";
import {useProductFiltersStore} from "@/stores/ProductFiltersStore.js";
@@ -80,7 +79,7 @@ function navigateBack() {
}
function toggleDrawer() {
drawerOpen.value = !drawerOpen.value
drawerOpen.value = !drawerOpen.value;
}
watch(

View File

@@ -1,28 +0,0 @@
<template>
<div class="flex items-center justify-center p-5 gap-2 flex-wrap">
<RouterLink class="btn btn-md" to="/categories">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
<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>
Каталог
</RouterLink>
<RouterLink
v-for="category in categoriesStore.topCategories"
class="btn btn-md max-w-[12rem]"
:to="{name: 'product.categories.show', params: {category_id: category.id}}"
@click="onCategoryClick"
>
<span class="overflow-hidden text-ellipsis whitespace-nowrap">{{ category.name }}</span>
</RouterLink>
</div>
</template>
<script setup>
import {useCategoriesStore} from "@/stores/CategoriesStore.js";
const categoriesStore = useCategoriesStore();
function onCategoryClick() {
window.Telegram.WebApp.HapticFeedback.impactOccurred('soft');
}
</script>

View File

@@ -21,6 +21,18 @@
<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: 'search'}"
:class="{'active': route.name === 'search'}"
@@ -88,8 +100,8 @@ function onDockItemClick() {
flex-direction: column;
align-items: center;
border-radius: var(--radius-field);
padding: 5px 13px;
min-width: 90px;
padding: 5px;
min-width: 50px;
}
.telecart-dock-item.active {

View File

@@ -0,0 +1,46 @@
<template>
<section class="px-4">
<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>
</header>
<main>
<div class="flex items-center justify-center p-5 gap-2 flex-wrap">
<RouterLink class="btn btn-md" to="/categories">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
class="size-5">
<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>
Каталог
</RouterLink>
<RouterLink
v-for="category in block.data?.categories || []"
class="btn btn-md max-w-[12rem]"
:to="{name: 'product.categories.show', params: {category_id: category.id}}"
@click="onCategoryClick"
>
<span class="overflow-hidden text-ellipsis whitespace-nowrap">{{ category.name }}</span>
</RouterLink>
</div>
</main>
</section>
</template>
<script setup>
import {ref} from "vue";
const isLoading = ref(false);
const props = defineProps({
block: {
type: Object,
required: true,
}
});
function onCategoryClick() {
window.Telegram.WebApp.HapticFeedback.impactOccurred('soft');
}
</script>

View File

@@ -0,0 +1,8 @@
<template>
<div role="alert" class="alert alert-error">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>Проблема при отображении блока.</span>
</div>
</template>

View File

@@ -0,0 +1,110 @@
<template>
<section class="px-4">
<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>
</header>
<main>
<ProductsList
:products="products"
:hasMore="hasMore"
:isLoading="isLoading"
:isLoadingMore="isLoadingMore"
@loadMore="onLoadMore"
/>
</main>
</section>
</template>
<script setup>
import {onMounted, ref, toRaw} from "vue";
import ProductsList from "@/components/ProductsList.vue";
import {useProductFiltersStore} from "@/stores/ProductFiltersStore.js";
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
import {useSettingsStore} from "@/stores/SettingsStore.js";
import ftch from "@/utils/ftch.js";
const filtersStore = useProductFiltersStore();
const yaMetrika = useYaMetrikaStore();
const settings = useSettingsStore();
const products = ref([]);
const hasMore = ref(false);
const isLoading = ref(false);
const isLoadingMore = ref(false);
const page = ref(1);
const perPage = 20;
const props = defineProps({
block: {
type: Object,
required: true,
}
});
async function fetchProducts() {
try {
isLoading.value = true;
console.debug('Home: Load products for Main Page.');
console.debug('Home: Fetch products from server using filters: ', toRaw(filtersStore.applied));
const response = await ftch('products', null, toRaw({
page: page.value,
maxPages: props.block.data.max_page_count,
perPage: perPage,
filters: filtersStore.applied,
}));
products.value = response.data;
hasMore.value = response.meta.hasMore;
console.debug('ProductsFeedBlock: Products for main page loaded.');
yaMetrika.dataLayerPush({
ecommerce: {
currencyCode: settings.currency_code,
impressions: products.value.map((product, index) => {
return {
id: product.id,
name: product.name,
price: product.final_price_numeric,
brand: product.manufacturer_name,
category: product.category_name,
list: 'Главная страница',
position: index,
discount: product.price_numeric - product.final_price_numeric,
quantity: product.product_quantity,
};
}),
},
});
} catch (error) {
console.error(error);
} finally {
isLoading.value = false;
}
}
async function onLoadMore() {
try {
console.debug('ProductsFeedBlock: onLoadMore');
if (isLoading.value === true || isLoadingMore.value === true || hasMore.value === false) return;
isLoadingMore.value = true;
page.value++;
console.debug('ProductsFeedBlock: Load more for page ', page.value, ' using filters: ', toRaw(filtersStore.applied));
const response = await ftch('products', null, toRaw({
page: page.value,
maxPages: props.block.data.max_page_count,
filters: filtersStore.applied,
}));
products.value.push(...response.data);
hasMore.value = response.meta.hasMore;
} catch (error) {
console.error(error);
} finally {
isLoadingMore.value = false;
}
}
onMounted(async () => {
console.debug("[Products Feed] Mounted");
await fetchProducts();
});
</script>

View File

@@ -0,0 +1,26 @@
<template>
<section class="px-4">
<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>
</header>
<main>
<Slider :config="block.data" :goalName="block.goal_name"/>
</main>
</section>
</template>
<script setup>
import Slider from "@/components/Slider.vue";
const props = defineProps({
block: {
type: Object,
required: true,
}
});
</script>
<style scoped lang="scss">
</style>

View File

@@ -0,0 +1,34 @@
<template>
<div class="flex flex-col items-center justify-center text-center py-16 px-4">
<div class="mb-6">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-16 text-base-content/40">
<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>
</div>
<h2 class="text-xl font-semibold mb-3">Главная страница пуста</h2>
<p class="text-sm text-base-content/70 mb-2 max-w-md">
На главной странице не сконфигурировано ни одного блока для отображения.
</p>
<p class="text-sm text-base-content/70 max-w-md">
Перейдите в настройки модуля <span class="font-semibold">TeleCart</span> и добавьте блоки на главную страницу.
</p>
<div class="mt-6 p-4 bg-base-200 rounded-lg max-w-md">
<div class="flex items-start gap-3">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5 text-info shrink-0 mt-0.5">
<path stroke-linecap="round" stroke-linejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" />
</svg>
<p class="text-xs text-base-content/60 text-left">
Вы можете добавить слайдеры, категории, ленты товаров и другие блоки для создания красивой главной страницы.
</p>
</div>
</div>
</div>
</template>
<script setup>
</script>
<style scoped lang="scss">
</style>

View File

@@ -0,0 +1,41 @@
<template>
<div v-if="blocks.blocks?.length > 0" v-for="(block, index) in blocks.blocks">
<template v-if="blockTypeToComponentMap[block.type]">
<component
v-if="block.is_enabled"
:is="blockTypeToComponentMap[block.type]"
:block="block"
/>
</template>
<div v-else-if="blockTypeToComponentMap[block.type] === undefined">
<div role="alert" class="alert alert-error mx-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span>Unsupported Block Type: <span class="font-bold">{{ block.type }}</span></span>
</div>
</div>
</div>
<EmptyBlocks v-else/>
</template>
<script setup>
import SliderBlock from "@/components/MainPage/Blocks/SliderBlock.vue";
import CategoriesTopBlock from "@/components/MainPage/Blocks/CategoriesTopBlock.vue";
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";
const blockTypeToComponentMap = {
slider: SliderBlock,
categories_top: CategoriesTopBlock,
products_feed: ProductsFeedBlock,
error: ErrorBlock,
};
const blocks = useBlocksStore();
</script>

View File

@@ -1,4 +1,5 @@
<template>
<div>
<div class="mx-auto max-w-2xl px-4 py-4 pb-14">
<h2 v-if="categoryName" class="text-lg font-bold mb-5 text-center">{{ categoryName }}</h2>
@@ -31,7 +32,7 @@
</div>
<div v-else-if="hasMore === false" class="text-xs text-center mt-4 pt-4 mb-2 border-t">
{{ settings.texts.no_more_products }}
{{ settings.texts.text_no_more_products }}
</div>
</template>
@@ -46,6 +47,21 @@
<NoProducts v-else/>
</div>
<div class="fixed z-50 w-full opacity-90" style="bottom: calc(var(--tg-safe-area-inset-bottom, 0px) + 80px);">
<div class="flex justify-center">
<button
@click="showFilters"
class="btn shadow-xl relative"
:class="{'btn-accent' : filtersStore.isFiltersChanged}"
>
<IconFunnel/>
Фильтры
<span v-if="filtersStore.isFiltersChanged" class="status status-primary"></span>
</button>
</div>
</div>
</div>
</template>
<script setup>
@@ -55,9 +71,15 @@ import {useSettingsStore} from "@/stores/SettingsStore.js";
import {ref} from "vue";
import {useIntersectionObserver} from '@vueuse/core';
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
import IconFunnel from "@/components/Icons/IconFunnel.vue";
import {useProductFiltersStore} from "@/stores/ProductFiltersStore.js";
import {useRouter} from "vue-router";
const router = useRouter();
const haptic = window.Telegram.WebApp.HapticFeedback;
const yaMetrika = useYaMetrikaStore();
const settings = useSettingsStore();
const filtersStore = useProductFiltersStore();
const bottom = ref(null);
const emits = defineEmits(['loadMore']);
@@ -128,6 +150,11 @@ useIntersectionObserver(
rootMargin: '400px 0',
}
);
function showFilters() {
haptic.impactOccurred('soft');
router.push({name: 'filters'});
}
</script>
<style scoped>

View File

@@ -1,25 +1,21 @@
<template>
<div
v-if="sliders.mainpage_slider.is_enabled && sliders.mainpage_slider.slides.length > 0"
class="app-banner"
:class="classList"
>
<div v-if="config.slides.length > 0" class="app-banner" :class="classList">
<Swiper
:effect="slideEffect"
class="select-none"
:slides-per-view="1"
:space-between="sliders.mainpage_slider.space_between"
:space-between="config.space_between"
:pagination="pagination"
:lazy="true"
:modules="modules"
:scrollbar="scrollbar"
:free-mode="sliders.mainpage_slider.free_mode"
:loop="sliders.mainpage_slider.loop"
:free-mode="config.free_mode"
:loop="config.loop"
:autoplay="autoplay"
@swiper="onSwiper"
@slideChange="onSlideChange"
>
<SwiperSlide v-for="slide in sliders.mainpage_slider.slides" :key="slide.id">
<SwiperSlide v-for="slide in config.slides" :key="slide.id">
<RouterLink
v-if="slide?.link?.type === 'category'"
:to="{name: 'product.categories.show', params: {category_id: slide.link.value.category_id}}"
@@ -48,6 +44,16 @@
</SwiperSlide>
</Swiper>
</div>
<div v-else>
<div role="alert" class="alert alert-warning">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
<span>У слайдера не загружены изображения.</span>
</div>
</div>
</template>
<script setup>
@@ -56,11 +62,21 @@ import 'swiper/css';
import 'swiper/css/navigation';
import {YA_METRIKA_GOAL} from "@/constants/yaMetrikaGoals.js";
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
import {EffectCoverflow, EffectCards, EffectCube, EffectFlip, Scrollbar, Autoplay} from 'swiper/modules';
import {Autoplay, EffectCards, EffectCoverflow, EffectCube, EffectFlip, Scrollbar} from 'swiper/modules';
import {computed, onMounted} from "vue";
import {useSlidersStore} from "@/stores/SlidersStore.js";
const sliders = useSlidersStore();
const props = defineProps({
config: {
type: Object,
required: true,
},
goalName: {
type: String,
default: null,
}
});
const yaMetrika = useYaMetrikaStore();
const modules = [
Autoplay,
@@ -72,15 +88,15 @@ const modules = [
];
const classList = computed(() => {
if (sliders.mainpage_slider.effect === 'cards') {
if (props.config.effect === 'cards') {
return ['px-8'];
}
if (sliders.mainpage_slider.effect === 'flip') {
if (props.config.effect === 'flip') {
return ['px-4', 'pb-4', 'pt-4'];
}
if (sliders.mainpage_slider.effect === 'cube') {
if (props.config.effect === 'cube') {
return ['px-4', 'pb-10'];
}
@@ -91,19 +107,19 @@ const onSwiper = (swiper) => {
console.log(swiper);
};
const onSlideChange = () => {
console.log('slide change');
};
const slideEffect = computed(() => {
if (sliders.mainpage_slider.effect === 'slide') {
if (props.config.effect === 'slide') {
return null;
}
return sliders.mainpage_slider.effect;
return props.config.effect;
});
const pagination = computed(() => {
if (sliders.mainpage_slider.pagination) {
if (props.config.pagination) {
return {
clickable: true, dynamicBullets: false,
};
@@ -112,7 +128,7 @@ const pagination = computed(() => {
});
const scrollbar = computed(() => {
if (sliders.mainpage_slider.scrollbar) {
if (props.config.scrollbar) {
return {
hide: true,
};
@@ -121,7 +137,7 @@ const scrollbar = computed(() => {
});
const autoplay = computed(() => {
if (sliders.mainpage_slider.autoplay) {
if (props.config.autoplay) {
return {
delay: 3000,
reverseDirection: false,
@@ -132,9 +148,11 @@ const autoplay = computed(() => {
});
function sliderClick(slide) {
yaMetrika.reachGoal(YA_METRIKA_GOAL.SLIDER_HOME_CLICK, {
if (props.goalName) {
yaMetrika.reachGoal(props.goalName, {
banner: slide.title,
});
}
}
function openExternalLink(link, slide) {
@@ -150,7 +168,7 @@ function openExternalLink(link, slide) {
}
onMounted(() => {
console.debug('[Mainpage Slider] Status: ', sliders.mainpage_slider);
console.debug('[Mainpage Slider] Init with config: ', props.config);
});
</script>

View File

@@ -16,7 +16,7 @@ import 'swiper/element/bundle';
import 'swiper/css/bundle';
import AppLoading from "@/AppLoading.vue";
import {useProductFiltersStore} from "@/stores/ProductFiltersStore.js";
import {useSlidersStore} from "@/stores/SlidersStore.js";
import {useBlocksStore} from "@/stores/BlocksStore.js";
register();
const pinia = createPinia();
@@ -27,23 +27,24 @@ app
.use(VueTelegramPlugin);
const settings = useSettingsStore();
useSlidersStore().fetchMainpageSlider();
const blocks = useBlocksStore();
const appLoading = createApp(AppLoading);
appLoading.mount('#app');
settings.load()
.then(() => window.Telegram.WebApp.lockOrientation())
.then(async () => {
console.debug('Load default filters for the main page');
const filtersStore = useProductFiltersStore();
filtersStore.applied = await filtersStore.fetchFiltersForMainPage();
})
.then(() => {
if (settings.app_enabled === false) {
throw new Error('App disabled (maintenance mode)');
}
})
.then(() => blocks.processBlocks(settings.mainpage_blocks))
.then(async () => {
console.debug('Load default filters for the main page');
const filtersStore = useProductFiltersStore();
filtersStore.applied = await filtersStore.fetchFiltersForMainPage();
})
.then(() => {
console.debug('[Init] Set theme attributes');
document.documentElement.setAttribute('data-theme', settings.theme[window.Telegram.WebApp.colorScheme]);
@@ -57,11 +58,6 @@ settings.load()
document.documentElement.style.setProperty(key, settings.theme.variables[key]);
}
})
.then(() => {
console.debug('[Init] Load front page categories and products.');
const categoriesStore = useCategoriesStore();
categoriesStore.fetchTopCategories();
})
.then(() => new AppMetaInitializer(settings).init())
.then(() => { appLoading.unmount(); app.mount('#app'); })
.then(() => window.Telegram.WebApp.ready())

View File

@@ -0,0 +1,32 @@
import {defineStore} from "pinia";
import {processBlock} from "@/utils/ftch.js";
export const useBlocksStore = defineStore('blocks', {
state: () => ({
blocks: [],
}),
actions: {
async processBlocks(rawBlocks) {
const results = await Promise.allSettled(
rawBlocks.map(block => {
console.debug('[Blocks Store]: Process block ', block);
return processBlock(block)
.then(response => response);
})
);
this.blocks = results
.map(r => {
if (r.status === 'fulfilled') {
return r.value.data;
} else {
return {
is_enabled: true,
type: 'error',
};
}
});
}
},
});

View File

@@ -25,20 +25,6 @@ export const useCategoriesStore = defineStore('categories', {
}
},
async fetchTopCategories() {
try {
this.isLoading = true;
const response = await ftch('categoriesList', {
forMainPage: true,
});
this.topCategories = response.data;
} catch (error) {
console.error(error);
} finally {
this.isLoading = false;
}
},
async findCategoryById(id, list = []) {
if (! id) return null;

View File

@@ -24,10 +24,11 @@ export const useSettingsStore = defineStore('settings', {
}
},
texts: {
no_more_products: 'Нет товаров',
empty_cart: 'Корзина пуста',
order_created_success: 'Заказ успешно оформлен.',
text_no_more_products: 'Нет товаров',
text_empty_cart: 'Корзина пуста',
text_order_created_success: 'Заказ успешно оформлен.',
},
mainpage_blocks: [],
}),
actions: {
@@ -51,6 +52,7 @@ export const useSettingsStore = defineStore('settings', {
this.feature_vouchers = settings.feature_vouchers;
this.currency_code = settings.currency_code;
this.texts = settings.texts;
this.mainpage_blocks = settings.mainpage_blocks;
}
}
});

View File

@@ -1,26 +0,0 @@
import {defineStore} from "pinia";
import {fetchBanner} from "@/utils/ftch.js";
export const useSlidersStore = defineStore('sliders', {
state: () => ({
mainpage_slider: {
is_enabled: false,
space_between: 30,
autoplay: false,
effect: 'cube', // null, flip, cards, cube
pagination: false,
scrollbar: false,
free_mode: false,
loop: false,
slides: [],
},
}),
actions: {
async fetchMainpageSlider() {
console.debug('[Sliders Store] Fetch mainpage slider from server.');
const response = await fetchBanner();
this.mainpage_slider = Object.assign({}, this.mainpage_slider, response.data);
}
},
});

View File

@@ -4,8 +4,6 @@
}
html, body, #app {
width: 100%;
height: 100%;
overflow-x: hidden;
}
@@ -58,7 +56,12 @@ html {
}
.telecart-main-section {
padding-top: calc(var(--tg-content-safe-area-inset-top, 0rem) + var(--tg-safe-area-inset-top, 0rem) + var(--tc-navbar-min-height));
padding-top: calc(
var(--tg-content-safe-area-inset-top, 0rem)
+ var(--tg-safe-area-inset-top, 0rem)
+ var(--tc-navbar-min-height)
+ 1rem
);
}
.swiper-pagination-bullets {

View File

@@ -16,7 +16,7 @@ export const apiFetch = ofetch.create({
options.headers = {
...options.headers,
'X-Telegram-InitData': encoded,
}
};
}
},
});
@@ -24,7 +24,7 @@ export const apiFetch = ofetch.create({
async function ftch(action, query = null, json = null) {
const options = {
method: json ? 'POST' : 'GET',
}
};
if (query) options.query = query;
if (json) options.body = json;
@@ -92,8 +92,8 @@ export async function setVoucher(voucher) {
});
}
export async function fetchBanner() {
return await ftch('banner');
export async function processBlock(block) {
return await ftch('processBlock', null, block);
}
export default ftch;

View File

@@ -125,7 +125,7 @@
class="text-center rounded-2xl"
>
<div class="text-5xl mb-4">🛒</div>
<p class="text-lg mb-3">{{ settings.texts.empty_cart }}</p>
<p class="text-lg mb-3">{{ settings.texts.text_empty_cart }}</p>
<RouterLink class="btn btn-primary" to="/">Начать покупки</RouterLink>
</div>
</div>

View File

@@ -1,5 +1,5 @@
<template>
<div class="mx-auto max-w-2xl px-4 py-4 sm:px-6 sm:py-24 lg:max-w-7xl lg:px-8 mb-5">
<div class="mx-auto max-w-2xl px-4 py-4 sm:px-6 sm:py-24 lg:max-w-7xl lg:px-8 mb-5 pb-20">
<h2 class="text-3xl mb-5">Категории</h2>
<div v-if="categoriesStore.isLoading" class="flex flex-col gap-4">

View File

@@ -79,7 +79,7 @@ const applyFilters = async () => {
yaMetrika.reachGoal(YA_METRIKA_GOAL.FILTERS_APPLY);
await nextTick();
router.back();
}
};
const resetFilters = async () => {
filtersStore.applied = filtersStore.default;
@@ -89,7 +89,7 @@ const resetFilters = async () => {
await nextTick();
window.scrollTo(0, 0);
router.back();
}
};
onMounted(async () => {
console.debug('Filters: OnMounted');

View File

@@ -1,47 +1,18 @@
<template>
<div ref="goodsRef" class="pb-20">
<CategoriesInline/>
<div class="overflow-hidden">
<MainpageSlider/>
</div>
<div class="px-5 fixed z-50 w-full opacity-90" style="bottom: calc(var(--tg-safe-area-inset-bottom, 0px) + 80px);">
<div class="flex justify-center">
<button
@click="showFilters"
class="btn shadow-xl relative"
:class="{'btn-accent' : filtersStore.isFiltersChanged}"
>
<IconFunnel/>
Фильтры
<span v-if="filtersStore.isFiltersChanged" class="status status-primary"></span>
</button>
</div>
</div>
<ProductsList
:products="products"
:hasMore="hasMore"
:isLoading="isLoading"
:isLoadingMore="isLoadingMore"
@loadMore="onLoadMore"
/>
<MainPage/>
</div>
</template>
<script setup>
import ProductsList from "@/components/ProductsList.vue";
import CategoriesInline from "../components/CategoriesInline.vue";
import {onActivated, onMounted, ref, toRaw} from "vue";
import IconFunnel from "@/components/Icons/IconFunnel.vue";
import {onActivated, onMounted} from "vue";
import {useRouter} from "vue-router";
import ftch from "@/utils/ftch.js";
import {useProductFiltersStore} from "@/stores/ProductFiltersStore.js";
import MainpageSlider from "@/components/MainpageSlider.vue";
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
import {YA_METRIKA_GOAL} from "@/constants/yaMetrikaGoals.js";
import {useSettingsStore} from "@/stores/SettingsStore.js";
import MainPage from "@/components/MainPage/MainPage.vue";
import {useBlocksStore} from "@/stores/BlocksStore.js";
defineOptions({
name: 'Home'
@@ -52,79 +23,10 @@ const filtersStore = useProductFiltersStore();
const yaMetrika = useYaMetrikaStore();
const haptic = window.Telegram.WebApp.HapticFeedback;
const settings = useSettingsStore();
const products = ref([]);
const hasMore = ref(false);
const isLoading = ref(false);
const isLoadingMore = ref(false);
const page = ref(1);
const perPage = 20;
function showFilters() {
haptic.impactOccurred('soft');
router.push({name: 'filters'});
}
async function fetchProducts() {
try {
isLoading.value = true;
console.debug('Home: Load products for Main Page.');
console.debug('Home: Fetch products from server using filters: ', toRaw(filtersStore.applied));
const response = await ftch('products', null, toRaw({
page: page.value,
perPage: perPage,
filters: filtersStore.applied,
}));
products.value = response.data;
hasMore.value = response.meta.hasMore;
console.debug('Home: Products for main page loaded.');
yaMetrika.dataLayerPush({
ecommerce: {
currencyCode: settings.currency_code,
impressions: products.value.map((product, index) => {
return {
id: product.id,
name: product.name,
price: product.final_price_numeric,
brand: product.manufacturer_name,
category: product.category_name,
list: 'Главная страница',
position: index,
discount: product.price_numeric - product.final_price_numeric,
quantity: product.product_quantity,
};
}),
},
});
} catch (error) {
console.error(error);
} finally {
isLoading.value = false;
}
}
async function onLoadMore() {
try {
console.debug('Home: onLoadMore');
if (isLoading.value === true || isLoadingMore.value === true || hasMore.value === false) return;
isLoadingMore.value = true;
page.value++;
console.debug('Home: Load more for page ', page.value, ' using filters: ', toRaw(filtersStore.applied));
const response = await ftch('products', null, toRaw({
page: page.value,
filters: filtersStore.applied,
}));
products.value.push(...response.data);
hasMore.value = response.meta.hasMore;
} catch (error) {
console.error(error);
} finally {
isLoadingMore.value = false;
}
}
const blocks = useBlocksStore();
onActivated(() => {
console.debug("[Home] Home Activated");
yaMetrika.pushHit('/', {
title: 'Главная страница',
});
@@ -134,8 +36,6 @@ onActivated(() => {
onMounted(async () => {
window.document.title = 'Главная страница';
console.debug("[Home] Home Mounted");
console.debug("[Home] Scroll top");
await fetchProducts();
window.scrollTo(0, 0);
});
</script>

View File

@@ -8,7 +8,7 @@
</div>
<p class="text-2xl font-bold mb-3">Спасибо за заказ!</p>
<p class="text-center mb-4">{{ settings.texts.order_created_success }}</p>
<p class="text-center mb-4">{{ settings.texts.text_order_created_success }}</p>
<ul v-if="checkout.order" class="list w-full bg-base-200 mb-4">
<li class="list-row flex justify-between">

View File

@@ -361,13 +361,11 @@ class ControllerExtensionModuleTgshop extends Controller
'module_tgshop_owner_notification_template' => 'telegram.owner_notification_template',
'module_tgshop_text_order_created_success' => 'texts.text_order_created_success',
'module_tgshop_enable_store' => 'store.enable_store',
'module_tgshop_mainpage_products' => 'store.mainpage_products',
'module_tgshop_yandex_metrika' => 'metrics.yandex_metrika_counter',
'module_tgshop_customer_notification_template' => 'telegram.customer_notification_template',
'module_tgshop_feature_vouchers' => 'store.feature_vouchers',
'module_tgshop_order_default_status_id' => 'orders.order_default_status_id',
'module_tgshop_feature_coupons' => 'store.feature_coupons',
'module_tgshop_mainpage_categories' => 'store.mainpage_categories',
'module_tgshop_text_no_more_products' => 'texts.text_no_more_products',
'module_tgshop_text_empty_cart' => 'texts.text_empty_cart',
];

View File

@@ -7,38 +7,6 @@ $_['text_module'] = 'Модули';
$_['text_success'] = 'Настройки успешно изменены!';
$_['text_edit'] = 'Настройки';
$_['tab_telegram'] = 'Telegram';
$_['tab_statistics'] = 'Статистика';
$_['tab_shop'] = 'Магазин';
$_['tab_orders'] = 'Заказы';
$_['tab_texts'] = 'Тексты';
$_['lbl_module_tgshop_status'] = 'Статус';
$_['lbl_module_tgshop_app_name'] = 'Название приложения';
$_['lbl_module_tgshop_app_icon'] = 'Иконка приложения';
$_['lbl_module_tgshop_bot_token'] = 'Telegram Bot Token';
$_['lbl_module_tgshop_chat_id'] = 'Chat ID для уведомлений';
$_['lbl_module_tgshop_owner_notification_template'] = 'Шаблон уведомления о новом заказе владельцу';
$_['lbl_module_tgshop_customer_notification_template'] = 'Шаблон уведомления о новом заказе покупателю';
$_['lbl_module_tgshop_yandex_metrika'] = 'Код счётчика Яндекс Метрики';
$_['lbl_module_tgshop_theme_light'] = 'Светлая тема';
$_['lbl_module_tgshop_theme_dark'] = 'Тёмная тема';
$_['lbl_module_tgshop_mainpage_products'] = 'Товары на главной';
$_['lbl_module_tgshop_featured_products'] = 'Избранные товары';
$_['lbl_module_tgshop_order_customer_group_id'] = 'Группа покупателей';
$_['lbl_module_tgshop_order_default_status_id'] = 'Статус заказов';
$_['lbl_module_tgshop_mini_app_url'] = 'Ссылка на Telegram Mini App';
$_['lbl_module_tgshop_mainpage_categories'] = 'Категории на главной';
$_['lbl_module_tgshop_featured_categories'] = 'Избранные категории';
$_['lbl_module_tgshop_enable_store'] = 'Разрешить покупки';
$_['lbl_module_tgshop_feature_coupons'] = 'Промокоды';
$_['lbl_module_tgshop_feature_vouchers'] = 'Подарочные сертификаты';
$_['lbl_module_tgshop_home_banner_id'] = 'Баннер на главной';
$_['lbl_module_tgshop_debug'] = 'Режим разработчика';
$_['lbl_module_tgshop_text_no_more_products'] = 'Текст в конце списка товаров';
$_['lbl_module_tgshop_text_empty_cart'] = 'Текст пустой корзины';
$_['lbl_module_tgshop_text_order_created_success'] = 'Текст для успешного заказа';
// Entry
$_['entry_status'] = 'Статус';

View File

@@ -33,18 +33,9 @@
order_statuses: '{{ order_statuses | json_encode }}',
};
</script>
<div id="app">App Loading...</div>
<div id="app" class="telecart-admin-app">App Loading...</div>
</div>
</div>
</div>
</div>
{{ footer }}
<script>
const $element = $('#thumb-image-module_tgshop_app_icon');
$('#button-clear').on('click', function () {
$element.find('img').attr('src', $element.find('img').attr('data-placeholder'));
$element.parent().find('input').val('');
$element.popover('destroy');
});
</script>

View File

@@ -5,11 +5,13 @@ use App\ApplicationFactory;
use Cart\Cart;
use Cart\Currency;
use Cart\Tax;
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;
use Openguru\OpenCartFramework\Support\Arr;
$sysLibPath = rtrim(DIR_SYSTEM, '/') . '/library/oc_telegram_shop';
$basePath = rtrim(DIR_APPLICATION, '/') . '/..';
@@ -23,6 +25,7 @@ if (is_readable($sysLibPath . '/oc_telegram_shop.phar')) {
/**
* @property Config $config
* @property Log $log
*/
class ControllerExtensionTgshopHandle extends Controller
{
@@ -32,77 +35,49 @@ class ControllerExtensionTgshopHandle extends Controller
$this->load->model('catalog/product');
$this->load->model('checkout/order');
$this->load->model('setting/setting');
}
public function index(): void
{
try {
$this->session->data['language'] = $this->config->get('config_language');
$appDebug = filter_var($this->config->get('module_tgshop_debug'), FILTER_VALIDATE_BOOLEAN);
$json = $this->model_setting_setting->getSetting('module_telecart');
if (! isset($json['module_telecart_settings'])) {
$json['module_telecart_settings'] = [];
}
$app = ApplicationFactory::create([
'app_enabled' => filter_var($this->config->get('module_tgshop_status'), FILTER_VALIDATE_BOOLEAN),
'app_debug' => $appDebug,
'oc_config_tax' => $this->config->get('config_tax'),
'oc_default_currency' => $this->config->get('config_currency'),
'oc_customer_group_id' => $this->config->get('config_customer_group_id'),
// ID магазина, для которого будут создаваться заказы из Телеграм
'oc_store_id' => 0,
// Название магазина, для которого будут создаваться заказы из Телеграм
'oc_store_name' => $this->config->get('config_name'),
// ID статуса, с которым будут создаваться заказы через Телеграм по умолчанию.
'oc_order_status_id' => (int) $this->config->get('module_tgshop_order_default_status_id'),
'timezone' => $this->config->get('config_timezone', 'UTC'),
$items = Arr::mergeArraysRecursively($json['module_telecart_settings'], [
'app' => [
'shop_base_url' => HTTPS_SERVER, // for catalog: HTTPS_SERVER, for admin: HTTPS_CATALOG
'language_id' => (int) $this->config->get('config_language_id'),
'shop_base_url' => HTTPS_SERVER,
'dir_image' => DIR_IMAGE,
'app_name' => $this->config->get('module_tgshop_app_name'),
'app_icon' => $this->config->get('module_tgshop_app_icon'),
'theme_light' => $this->config->get('module_tgshop_theme_light'),
'theme_dark' => $this->config->get('module_tgshop_theme_dark'),
'mainpage_products' => $this->config->get('module_tgshop_mainpage_products'),
'featured_products' => (array) $this->config->get('module_tgshop_featured_products'),
'mainpage_categories' => $this->config->get('module_tgshop_mainpage_categories'),
'featured_categories' => (array) $this->config->get('module_tgshop_featured_categories'),
'store_enabled' => filter_var($this->config->get('module_tgshop_enable_store'), FILTER_VALIDATE_BOOLEAN),
'base_url' => HTTPS_SERVER,
'ya_metrika_counter' => trim($this->config->get('module_tgshop_yandex_metrika')),
'ya_metrika_enabled' => ! empty(trim($this->config->get('module_tgshop_yandex_metrika'))),
'telegram' => [
'bot_token' => $this->config->get('module_tgshop_bot_token'),
'chat_id' => $this->config->get('module_tgshop_chat_id'),
'owner_notification_template' => $this->config->get('module_tgshop_owner_notification_template'),
'customer_notification_template' => $this->config->get('module_tgshop_customer_notification_template'),
],
'db' => [
'logs' => [
'path' => DIR_LOGS,
],
'database' => [
'host' => DB_HOSTNAME,
'database' => DB_DATABASE,
'username' => DB_USERNAME,
'password' => DB_PASSWORD,
'prefix' => DB_PREFIX,
'port' => DB_PORT,
'port' => (int) DB_PORT,
],
'logs' => [
'path' => DIR_LOGS,
'store' => [
'oc_store_id' => 0,
'oc_default_currency' => $this->config->get('config_currency'),
'oc_config_tax' => filter_var($this->config->get('config_tax'), FILTER_VALIDATE_BOOLEAN),
],
'cache_categories_main' => 60 * 10,
'cache_products_main' => 60 * 10,
'feature_coupons' => filter_var(
$this->config->get('module_tgshop_feature_coupons'),
FILTER_VALIDATE_BOOLEAN
),
'feature_vouchers' => filter_var(
$this->config->get('module_tgshop_feature_vouchers'),
FILTER_VALIDATE_BOOLEAN
),
'mainpage_slider' => $this->safeJsonDecode($this->config->get('module_tgshop_mainpage_slider'), []),
'texts' => [
'no_more_products' => $this->config->get('module_tgshop_text_no_more_products'),
'empty_cart' => $this->config->get('module_tgshop_text_empty_cart'),
'order_created_success' => $this->config->get('module_tgshop_text_order_created_success'),
'orders' => [
'oc_customer_group_id' => (int) $this->config->get('config_customer_group_id'),
],
]);
$appDebug = Arr::get($items, 'app.app_debug');
$app = ApplicationFactory::create($items);
$app->bind(OcModelCatalogProductAdapter::class, function () {
return new OcModelCatalogProductAdapter($this->model_catalog_product);
});
@@ -124,6 +99,15 @@ class ControllerExtensionTgshopHandle extends Controller
)
)
->bootAndHandleRequest();
} catch (Exception $e) {
$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' => 'Server Error.',
], JSON_THROW_ON_ERROR);
}
}
function extractPureJs($input)
@@ -153,7 +137,9 @@ class ControllerExtensionTgshopHandle extends Controller
public function ya_metrika(): void
{
$raw = html_entity_decode($this->config->get('module_tgshop_yandex_metrika'), ENT_QUOTES | ENT_HTML5);
$json = $this->model_setting_setting->getSetting('module_telecart');
if (isset($json['module_telecart_settings'])) {
$raw = Arr::get($json, 'module_telecart_settings.metrics.yandex_metrika_counter');
$raw = $this->extractPureJs($raw);
http_response_code(200);
@@ -163,5 +149,8 @@ class ControllerExtensionTgshopHandle extends Controller
header('Access-Control-Allow-Headers: Content-Type, Authorization');
header('Access-Control-Allow-Credentials: true');
echo $raw;
} else {
http_response_code(404);
}
}
}

View File

@@ -17,7 +17,7 @@ class ApplicationFactory
{
public static function create(array $settings): Application
{
$defaultConfig = require __DIR__ . '/config.php';
$defaultConfig = require __DIR__ . '/../src/config.php';
$routes = require __DIR__ . '/routes.php';
$merged = Arr::mergeArraysRecursively($defaultConfig, $settings);

View File

@@ -51,6 +51,7 @@ class SettingsHandler
'orders',
'texts',
'sliders',
'mainpage_blocks',
]);
return new JsonResponse(compact('data'));

View File

@@ -1,84 +0,0 @@
<?php
return [
'app' => [
'app_enabled' => true,
'app_name' => 'Telecart',
'app_icon' => null,
"theme_light" => "light",
"theme_dark" => "dark",
"app_debug" => false
],
'telegram' => [
"bot_token" => "",
"chat_id" => null,
"owner_notification_template" => <<<TEXT
*Новый заказ \#{order_id}* в магазине *{store_name}*
*Покупатель:* {customer}
*Email:* {email}
*Телефон:* {phone}
*IP:* {ip}
*Адрес доставки:*
{address}
*Комментарий:*
{comment}
*Сумма заказа:* {total}
*Дата оформления:* {created_at}
TEXT,
"customer_notification_template" => <<<TEXT
Спасибо за Ваш заказ в магазине *{store_name}*
*Номер заказа* \#{order_id}
*Сумма заказа:* {total}
*Дата оформления:* {created_at}
Мы свяжемся с вами при необходимости\.
Хорошего дня\!
TEXT,
"mini_app_url" => "",
],
"metrics" => [
"yandex_metrika_enabled" => false,
"yandex_metrika_counter" => "",
],
'store' => [
'enable_store' => true,
'mainpage_products' => 'most_viewed',
'featured_products' => [],
'mainpage_categories' => 'latest10',
'featured_categories' => [],
'feature_coupons' => true,
'feature_vouchers' => true,
],
'texts' => [
'text_no_more_products' => 'Это всё по текущему запросу. Попробуйте уточнить фильтры или поиск.',
'text_empty_cart' => 'Ваша корзина пуста.',
'text_order_created_success' => 'Ваш заказ успешно оформлен и будет обработан в ближайшее время.'
],
'orders' => [
'order_default_status_id' => 1,
],
'sliders' => [
'mainpage_slider' => [
'is_enabled' => false,
'effect' => 'slide',
'pagination' => true,
'scrollbar' => false,
'free_mode' => false,
'space_between' => 30,
'autoplay' => false,
'loop' => false,
'slides' => [],
],
],
];

View File

@@ -10,7 +10,6 @@ final class ConfigDTO
private StoreDTO $store;
private OrdersDTO $orders;
private TextsDTO $texts;
private SlidersDTO $sliders;
private DatabaseDTO $database;
private LogsDTO $logs;
@@ -21,7 +20,6 @@ final class ConfigDTO
StoreDTO $store,
OrdersDTO $orders,
TextsDTO $texts,
SlidersDTO $sliders,
DatabaseDTO $database,
LogsDTO $logs
) {
@@ -31,7 +29,6 @@ final class ConfigDTO
$this->store = $store;
$this->orders = $orders;
$this->texts = $texts;
$this->sliders = $sliders;
$this->database = $database;
$this->logs = $logs;
}
@@ -66,11 +63,6 @@ final class ConfigDTO
return $this->texts;
}
public function getSliders(): SlidersDTO
{
return $this->sliders;
}
public function getDatabase(): DatabaseDTO
{
return $this->database;
@@ -89,7 +81,6 @@ final class ConfigDTO
'logs' => $this->logs->toArray(),
'metrics' => $this->metrics->toArray(),
'orders' => $this->orders->toArray(),
'sliders' => $this->sliders->toArray(),
'store' => $this->store->toArray(),
'telegram' => $this->telegram->toArray(),
'texts' => $this->texts->toArray(),

View File

@@ -1,42 +0,0 @@
<?php
namespace App\DTO\Settings\MainpageSlider;
final class LinkDTO
{
private string $type;
private ?LinkValueDTO $value;
public function __construct(
string $type,
?LinkValueDTO $value
) {
$this->type = $type;
$this->value = $value;
}
public function getType(): string
{
return $this->type;
}
public function getValue(): ?LinkValueDTO
{
return $this->value;
}
public function toArray(): array
{
$result = [
'type' => $this->type,
'value' => null,
];
if ($this->value !== null) {
$result['value'] = $this->value->toArray();
}
return $result;
}
}

View File

@@ -1,10 +0,0 @@
<?php
namespace App\DTO\Settings\MainpageSlider;
final class LinkType
{
public const NONE = 'none';
public const CATEGORY = 'category';
public const PRODUCT = 'product';
}

View File

@@ -1,55 +0,0 @@
<?php
namespace App\DTO\Settings\MainpageSlider;
final class LinkValueDTO
{
private ?int $categoryId;
private ?string $name;
private ?int $productId;
public function __construct(
?int $categoryId = null,
?string $name = null,
?int $productId = null
) {
$this->categoryId = $categoryId;
$this->name = $name;
$this->productId = $productId;
}
public function getCategoryId(): ?int
{
return $this->categoryId;
}
public function getName(): ?string
{
return $this->name;
}
public function getProductId(): ?int
{
return $this->productId;
}
public function toArray(): array
{
$result = [];
if ($this->categoryId !== null) {
$result['category_id'] = $this->categoryId;
}
if ($this->name !== null) {
$result['name'] = $this->name;
}
if ($this->productId !== null) {
$result['product_id'] = $this->productId;
}
return $result;
}
}

View File

@@ -1,111 +0,0 @@
<?php
namespace App\DTO\Settings\MainpageSlider;
final class MainpageSliderDTO
{
private bool $isEnabled;
private string $effect;
private bool $pagination;
private bool $scrollbar;
private bool $freeMode;
private int $spaceBetween;
private bool $autoplay;
private bool $loop;
/** @var SlideDTO[] */
private array $slides;
/**
* @param SlideDTO[] $slides
*/
public function __construct(
bool $isEnabled,
string $effect,
bool $pagination,
bool $scrollbar,
bool $freeMode,
int $spaceBetween,
bool $autoplay,
bool $loop,
array $slides
) {
$this->isEnabled = $isEnabled;
$this->effect = $effect;
$this->pagination = $pagination;
$this->scrollbar = $scrollbar;
$this->freeMode = $freeMode;
$this->spaceBetween = $spaceBetween;
$this->autoplay = $autoplay;
$this->loop = $loop;
$this->slides = $slides;
}
public function isEnabled(): bool
{
return $this->isEnabled;
}
public function getEffect(): string
{
return $this->effect;
}
public function isPagination(): bool
{
return $this->pagination;
}
public function isScrollbar(): bool
{
return $this->scrollbar;
}
public function isFreeMode(): bool
{
return $this->freeMode;
}
public function getSpaceBetween(): int
{
return $this->spaceBetween;
}
public function isAutoplay(): bool
{
return $this->autoplay;
}
public function isLoop(): bool
{
return $this->loop;
}
/**
* @return SlideDTO[]
*/
public function getSlides(): array
{
return $this->slides;
}
public function toArray(): array
{
$slides = [];
foreach ($this->slides as $slide) {
$slides[] = $slide->toArray();
}
return [
'is_enabled' => $this->isEnabled,
'effect' => $this->effect,
'pagination' => $this->pagination,
'scrollbar' => $this->scrollbar,
'free_mode' => $this->freeMode,
'space_between' => $this->spaceBetween,
'autoplay' => $this->autoplay,
'loop' => $this->loop,
'slides' => $slides,
];
}
}

View File

@@ -1,45 +0,0 @@
<?php
namespace App\DTO\Settings\MainpageSlider;
final class SlideDTO
{
private string $title;
private LinkDTO $link;
private string $image;
public function __construct(
string $title,
LinkDTO $link,
string $image
) {
$this->title = $title;
$this->link = $link;
$this->image = $image;
}
public function getTitle(): string
{
return $this->title;
}
public function getLink(): LinkDTO
{
return $this->link;
}
public function getImage(): string
{
return $this->image;
}
public function toArray(): array
{
return [
'title' => $this->title,
'link' => $this->link->toArray(),
'image' => $this->image,
];
}
}

View File

@@ -1,28 +0,0 @@
<?php
namespace App\DTO\Settings;
use App\DTO\Settings\MainpageSlider\MainpageSliderDTO;
final class SlidersDTO
{
private MainpageSliderDTO $mainpageSlider;
public function __construct(MainpageSliderDTO $mainpageSlider)
{
$this->mainpageSlider = $mainpageSlider;
}
public function getMainpageSlider(): MainpageSliderDTO
{
return $this->mainpageSlider;
}
public function toArray(): array
{
return [
'mainpage_slider' => $this->mainpageSlider->toArray(),
];
}
}

View File

@@ -5,28 +5,14 @@ namespace App\DTO\Settings;
final class StoreDTO
{
private bool $enableStore;
private string $mainpageProducts;
/** @var int[] */
private array $featuredProducts;
private string $mainpageCategories;
/** @var int[] */
private array $featuredCategories;
private bool $featureCoupons;
private bool $featureVouchers;
private string $ocDefaultCurrency;
private bool $ocConfigTax;
private int $ocStoreId;
/**
* @param int[] $featuredProducts
* @param int[] $featuredCategories
*/
public function __construct(
bool $enableStore,
string $mainpageProducts,
array $featuredProducts,
string $mainpageCategories,
array $featuredCategories,
bool $featureCoupons,
bool $featureVouchers,
string $ocDefaultCurrency,
@@ -34,10 +20,6 @@ final class StoreDTO
int $ocStoreId
) {
$this->enableStore = $enableStore;
$this->mainpageProducts = $mainpageProducts;
$this->featuredProducts = $featuredProducts;
$this->mainpageCategories = $mainpageCategories;
$this->featuredCategories = $featuredCategories;
$this->featureCoupons = $featureCoupons;
$this->featureVouchers = $featureVouchers;
$this->ocDefaultCurrency = $ocDefaultCurrency;
@@ -50,32 +32,6 @@ final class StoreDTO
return $this->enableStore;
}
public function getMainpageProducts(): string
{
return $this->mainpageProducts;
}
/**
* @return int[]
*/
public function getFeaturedProducts(): array
{
return $this->featuredProducts;
}
public function getMainpageCategories(): string
{
return $this->mainpageCategories;
}
/**
* @return int[]
*/
public function getFeaturedCategories(): array
{
return $this->featuredCategories;
}
public function isFeatureCoupons(): bool
{
return $this->featureCoupons;
@@ -105,10 +61,6 @@ final class StoreDTO
{
return [
'enable_store' => $this->enableStore,
'mainpage_products' => $this->mainpageProducts,
'featured_products' => $this->featuredProducts,
'mainpage_categories' => $this->mainpageCategories,
'featured_categories' => $this->featuredCategories,
'feature_coupons' => $this->featureCoupons,
'feature_vouchers' => $this->featureVouchers,
'oc_default_currency' => $this->ocDefaultCurrency,

View File

@@ -1,51 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filters;
use Openguru\OpenCartFramework\CriteriaBuilder\Criterion;
use Openguru\OpenCartFramework\CriteriaBuilder\Rules\BaseRule;
use Openguru\OpenCartFramework\QueryBuilder\Builder;
class ProductForMainPage extends BaseRule
{
public const NAME = 'RULE_PRODUCT_FOR_MAIN_PAGE';
public static function initWithDefaults(): BaseRule
{
return new static(static::NAME, [
'product_for_main_page' => new Criterion(static::CRITERIA_OPTION_PRODUCT_MODEL, [
'operator' => static::CRITERIA_OPERATOR_EQUALS,
'value' => false,
]),
]);
}
public function apply(Builder $builder, $operand): void
{
$criterion = $this->criteria[static::CRITERIA_OPTION_PRODUCT_FOR_MAIN_PAGE] ?? false;
if (! $criterion || $criterion->params['value'] === false) {
return;
}
$featuredProducts = config('featured_products', []);
$mainpageProducts = config('mainpage_products');
if ($mainpageProducts === 'featured' && $featuredProducts) {
$builder->whereIn('products.product_id', $featuredProducts);
return;
}
if ($mainpageProducts === 'latest') {
$builder->orders = [];
$builder->orderBy('products.date_modified', 'DESC');
return;
}
if ($mainpageProducts === 'most_viewed') {
$builder->orders = [];
$builder->orderBy('products.viewed', 'DESC');
}
}
}

View File

@@ -1,43 +0,0 @@
<?php
namespace App\Handlers;
use App\Services\SettingsService;
use Openguru\OpenCartFramework\Http\JsonResponse;
use Openguru\OpenCartFramework\ImageTool\ImageToolInterface;
use Openguru\OpenCartFramework\OpenCart\Decorators\OcRegistryDecorator;
class BannerHandler
{
private OcRegistryDecorator $registry;
private ImageToolInterface $imageTool;
private SettingsService $settings;
public function __construct(OcRegistryDecorator $registry, ImageToolInterface $imageTool, SettingsService $settings)
{
$this->registry = $registry;
$this->imageTool = $imageTool;
$this->settings = $settings;
$this->registry->load->model('design/banner');
}
public function show(): JsonResponse
{
$slider = $this->settings->config()->getSliders()->getMainpageSlider();
$data = [];
foreach ($slider->getSlides() as $index => $slide) {
if (is_file(DIR_IMAGE . $slide->getImage())) {
$data['slides'][$index] = [
'id' => $index,
'title' => $slide->getTitle(),
'link' => $slide->getLink(),
'image' => $this->imageTool->cover($slide->getImage(), 1110, 600),
];
}
}
return new JsonResponse(compact('data'));
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Handlers;
use App\Services\BlocksService;
use Openguru\OpenCartFramework\Http\JsonResponse;
use Openguru\OpenCartFramework\Http\Request;
class BlocksHandler
{
private BlocksService $blocksService;
public function __construct(BlocksService $blocksService)
{
$this->blocksService = $blocksService;
}
public function processBlock(Request $request): JsonResponse
{
$block = $request->json();
$data = $this->blocksService->process($block);
return new JsonResponse(compact('data'));
}
}

View File

@@ -32,17 +32,6 @@ class CategoriesHandler
$languageId = $this->settings->config()->getApp()->getLanguageId();
$perPage = $request->get('perPage', 100);
$forMainPage = filter_var($request->get('forMainPage', false), FILTER_VALIDATE_BOOLEAN);
$featuredCategories = $this->settings->config()->getStore()->getFeaturedCategories();
$mainpageCategories = $this->settings->config()->getStore()->getMainpageCategories();
if ($forMainPage && $mainpageCategories === 'no_categories') {
return new JsonResponse(['data' => []]);
}
if ($forMainPage && $mainpageCategories === 'latest10') {
$perPage = 10;
}
$categoriesFlat = $this->queryBuilder->newQuery()
->select([
@@ -61,12 +50,6 @@ class CategoriesHandler
}
)
->where('categories.status', '=', 1)
->when(
$forMainPage && $mainpageCategories === 'featured' && $featuredCategories,
function (Builder $query) use ($featuredCategories) {
$query->whereIn('categories.category_id', $featuredCategories);
}
)
->orderBy('parent_id')
->orderBy('sort_order')
->get();

View File

@@ -3,7 +3,6 @@
namespace App\Handlers;
use App\Filters\ProductCategory;
use App\Filters\ProductForMainPage;
use App\Filters\ProductPrice;
use Openguru\OpenCartFramework\Http\JsonResponse;
@@ -29,18 +28,6 @@ class FiltersHandler
],
],
ProductForMainPage::NAME => [
'criteria' => [
'product_for_main_page' => [
'type' => 'boolean',
'params' => [
'operator' => 'equals',
'value' => true,
],
],
],
],
ProductCategory::NAME => [
'criteria' => [
'product_category_id' => [

View File

@@ -31,12 +31,13 @@ class ProductsHandler
{
$page = (int) $request->json('page', 1);
$perPage = min((int) $request->json('perPage', 6), 15);
$maxPages = (int) $request->json('maxPages', 10);
$search = trim($request->get('search', ''));
$filters = $request->json('filters');
$languageId = $this->settings->config()->getApp()->getLanguageId();
$response = $this->productsService->getProductsResponse(
compact('page', 'perPage', 'search', 'filters'),
compact('page', 'perPage', 'search', 'filters', 'maxPages'),
$languageId,
);

View File

@@ -64,17 +64,13 @@ class SettingsHandler
'feature_vouchers' => $this->settings->config()->getStore()->isFeatureVouchers(),
'currency_code' => $this->settings->config()->getStore()->getOcDefaultCurrency(),
'texts' => $this->settings->config()->getTexts()->toArray(),
'mainpage_slider' => $this->settings->config()->getSliders()->getMainpageSlider()->toArray(),
'mainpage_blocks' => $this->settings->get('mainpage_blocks', []),
]);
}
public function manifest(): JsonResponse
{
$appIcon = $this->settings->config()->getApp()->getAppIcon();
$icon192 = $this->imageTool->resize($appIcon, 192, 192, 'no_image.png', 'png');
$icon512 = $this->imageTool->resize($appIcon, 512, 512, 'no_image.png', 'png');
return new JsonResponse([
$manifest = [
'name' => $this->settings->config()->getApp()->getAppName(),
'short_name' => $this->settings->config()->getApp()->getAppName(),
'start_url' => '/image/catalog/tgshopspa/',
@@ -82,7 +78,13 @@ class SettingsHandler
'background_color' => '#ffffff',
'theme_color' => '#000000',
'orientation' => 'portrait',
'icons' => [
];
$appIcon = $this->settings->config()->getApp()->getAppIcon();
if ($appIcon) {
$icon192 = $this->imageTool->resize($appIcon, 192, 192, 'no_image.png', 'png');
$icon512 = $this->imageTool->resize($appIcon, 512, 512, 'no_image.png', 'png');
$manifest['icons'] = [
[
'src' => $icon192,
'sizes' => '192x192',
@@ -93,8 +95,10 @@ class SettingsHandler
'sizes' => '512x512',
'type' => 'image/png',
]
]
]);
];
}
return new JsonResponse($manifest);
}
public function testTgMessage(Request $request): JsonResponse

View File

@@ -6,7 +6,6 @@ use App\Exceptions\CustomExceptionHandler;
use App\Filters\ProductAttribute;
use App\Filters\ProductCategories;
use App\Filters\ProductCategory;
use App\Filters\ProductForMainPage;
use App\Filters\ProductManufacturer;
use App\Filters\ProductModel;
use App\Filters\ProductPrice;
@@ -64,7 +63,6 @@ class AppServiceProvider extends ServiceProvider
ProductPrice::NAME => ProductPrice::class,
ProductQuantity::NAME => ProductQuantity::class,
ProductStatus::NAME => ProductStatus::class,
ProductForMainPage::NAME => ProductForMainPage::class,
ProductCategory::NAME => ProductCategory::class,
]);
}

View File

@@ -0,0 +1,110 @@
<?php
namespace App\Services;
use Openguru\OpenCartFramework\Cache\CacheInterface;
use Openguru\OpenCartFramework\ImageTool\ImageToolInterface;
use Openguru\OpenCartFramework\Logger\LoggerInterface;
use Openguru\OpenCartFramework\QueryBuilder\Builder;
use Openguru\OpenCartFramework\QueryBuilder\JoinClause;
use RuntimeException;
class BlocksService
{
private static array $processors = [
'slider' => [self::class, 'processSlider'],
'categories_top' => [self::class, 'processCategoriesTop'],
'products_feed' => [self::class, 'processProductsFeed'],
];
private LoggerInterface $logger;
private ImageToolInterface $imageTool;
private CacheInterface $cache;
private SettingsService $settings;
private Builder $queryBuilder;
public function __construct(
LoggerInterface $logger,
ImageToolInterface $imageTool,
CacheInterface $cache,
SettingsService $settings,
Builder $queryBuilder
) {
$this->logger = $logger;
$this->imageTool = $imageTool;
$this->cache = $cache;
$this->settings = $settings;
$this->queryBuilder = $queryBuilder;
}
public function process(array $block): array
{
$method = self::$processors[$block['type']] ?? null;
if (! $method) {
throw new RuntimeException('Processor for block type ' . $block['type'] . ' does not exist');
}
return call_user_func_array($method, [$block]);
}
private function processSlider(array $block): array
{
$slides = $block['data']['slides'];
foreach ($slides as $slideIndex => $slide) {
if (is_file(DIR_IMAGE . $slide['image'])) {
$block['data']['slides'][$slideIndex]['image'] = $this->imageTool->cover(
$slide['image'],
1110,
600
);
}
}
return $block;
}
private function processCategoriesTop(array $block): array
{
$count = $block['data']['count'];
$languageId = $this->settings->config()->getApp()->getLanguageId();
$categories = [];
if ($count > 0) {
$categories = $this->queryBuilder->newQuery()
->select([
'categories.category_id' => 'id',
'descriptions.name' => 'name',
])
->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)
->where('categories.parent_id', '=', 0)
->orderBy('sort_order')
->orderBy('descriptions.name')
->limit($count)
->get();
$categories = array_map(static function ($category) {
$category['id'] = (int) $category['id'];
return $category;
}, $categories);
}
$block['data']['categories'] = $categories;
return $block;
}
private function processProductsFeed(array $block): array
{
return $block;
}
}

View File

@@ -6,13 +6,8 @@ use App\DTO\Settings\AppDTO;
use App\DTO\Settings\ConfigDTO;
use App\DTO\Settings\DatabaseDTO;
use App\DTO\Settings\LogsDTO;
use App\DTO\Settings\MainpageSlider\LinkDTO;
use App\DTO\Settings\MainpageSlider\LinkValueDTO;
use App\DTO\Settings\MainpageSlider\MainpageSliderDTO;
use App\DTO\Settings\MainpageSlider\SlideDTO;
use App\DTO\Settings\MetricsDTO;
use App\DTO\Settings\OrdersDTO;
use App\DTO\Settings\SlidersDTO;
use App\DTO\Settings\StoreDTO;
use App\DTO\Settings\TelegramDTO;
use App\DTO\Settings\TextsDTO;
@@ -22,7 +17,7 @@ class SettingsSerializerService
{
public function fromArray(array $data): ConfigDTO
{
$keys = ['app', 'telegram', 'metrics', 'store', 'orders', 'texts', 'sliders', 'database', 'logs'];
$keys = ['app', 'telegram', 'metrics', 'store', 'orders', 'texts', 'database', 'logs'];
foreach ($keys as $key) {
if (! array_key_exists($key, $data)) {
throw new InvalidArgumentException("Settings key '$key' is required!");
@@ -35,7 +30,6 @@ class SettingsSerializerService
$this->validateStore($data['store']);
$this->validateOrders($data['orders']);
$this->validateTexts($data['texts']);
$this->validateSliders($data['sliders']);
$this->validateDatabase($data['database']);
$this->validateLogs($data['logs']);
@@ -46,7 +40,6 @@ class SettingsSerializerService
$this->deserializeStore($data['store']),
$this->deserializeOrders($data['orders']),
$this->deserializeTexts($data['texts']),
$this->deserializeSliders($data['sliders']),
$this->deserializeDatabase($data['database']),
$this->deserializeLogs($data['logs']),
);
@@ -146,10 +139,6 @@ class SettingsSerializerService
return new StoreDTO(
$data['enable_store'] ?? true,
$data['mainpage_products'] ?? 'most_viewed',
$data['featured_products'] ?? [],
$data['mainpage_categories'] ?? 'latest10',
$data['featured_categories'] ?? [],
$data['feature_coupons'] ?? true,
$data['feature_vouchers'] ?? true,
$data['oc_default_currency'],
@@ -183,66 +172,6 @@ class SettingsSerializerService
);
}
private function deserializeSliders(array $data): SlidersDTO
{
return new SlidersDTO(
$this->deserializeMainpageSlider($data['mainpage_slider'] ?? [])
);
}
private function deserializeMainpageSlider(array $data): MainpageSliderDTO
{
$slides = [];
if (isset($data['slides']) && is_array($data['slides'])) {
foreach ($data['slides'] as $slideData) {
$slides[] = $this->deserializeSlide($slideData);
}
}
return new MainpageSliderDTO(
$data['is_enabled'] ?? false,
$data['effect'] ?? 'slide',
$data['pagination'] ?? true,
$data['scrollbar'] ?? false,
$data['free_mode'] ?? false,
$data['space_between'] ?? 30,
$data['autoplay'] ?? false,
$data['loop'] ?? false,
$slides
);
}
private function deserializeSlide(array $data): SlideDTO
{
return new SlideDTO(
$data['title'] ?? '',
$this->deserializeLink($data['link'] ?? []),
$data['image'] ?? ''
);
}
private function deserializeLink(array $data): LinkDTO
{
$value = null;
if (isset($data['value'])) {
$value = $this->deserializeLinkValue($data['value']);
}
return new LinkDTO(
$data['type'] ?? 'none',
$value
);
}
private function deserializeLinkValue(array $data): LinkValueDTO
{
return new LinkValueDTO(
$data['category_id'] ?? null,
$data['name'] ?? null,
$data['product_id'] ?? null
);
}
// ==================== Validation Methods ====================
private function validateApp(array $data): void
@@ -340,44 +269,6 @@ class SettingsSerializerService
throw new InvalidArgumentException('store.enable_store must be a boolean');
}
if (isset($data['mainpage_products']) && ! is_string($data['mainpage_products'])) {
throw new InvalidArgumentException('store.mainpage_products must be a string');
}
if (isset($data['featured_products'])) {
if (! is_array($data['featured_products'])) {
throw new InvalidArgumentException('store.featured_products must be an array');
}
foreach ($data['featured_products'] as $index => $productId) {
if (! is_int($productId)) {
throw new InvalidArgumentException("store.featured_products[$index] must be an integer");
}
if ($productId <= 0) {
throw new InvalidArgumentException("store.featured_products[$index] must be a positive integer");
}
}
}
if (isset($data['mainpage_categories']) && ! is_string($data['mainpage_categories'])) {
throw new InvalidArgumentException('store.mainpage_categories must be a string');
}
if (isset($data['featured_categories'])) {
if (! is_array($data['featured_categories'])) {
throw new InvalidArgumentException('store.featured_categories must be an array');
}
foreach ($data['featured_categories'] as $index => $categoryId) {
if (! is_int($categoryId)) {
throw new InvalidArgumentException("store.featured_categories[$index] must be an integer");
}
if ($categoryId <= 0) {
throw new InvalidArgumentException(
"store.featured_categories[$index] must be a positive integer"
);
}
}
}
if (isset($data['feature_coupons']) && ! is_bool($data['feature_coupons'])) {
throw new InvalidArgumentException('store.feature_coupons must be a boolean');
}
@@ -454,178 +345,6 @@ class SettingsSerializerService
}
}
private function validateSliders(array $data): void
{
if (isset($data['mainpage_slider'])) {
if (! is_array($data['mainpage_slider'])) {
throw new InvalidArgumentException('sliders.mainpage_slider must be an object');
}
$this->validateMainpageSlider($data['mainpage_slider']);
}
}
private function validateMainpageSlider(array $data): void
{
if (isset($data['is_enabled']) && ! is_bool($data['is_enabled'])) {
throw new InvalidArgumentException('sliders.mainpage_slider.is_enabled must be a boolean');
}
if (isset($data['effect'])) {
if (! is_string($data['effect'])) {
throw new InvalidArgumentException('sliders.mainpage_slider.effect must be a string');
}
$allowedEffects = ['slide', 'fade', 'cube', 'coverflow', 'flip'];
if (! in_array($data['effect'], $allowedEffects, true)) {
throw new InvalidArgumentException(
'sliders.mainpage_slider.effect must be one of: ' . implode(', ', $allowedEffects)
);
}
}
if (isset($data['pagination']) && ! is_bool($data['pagination'])) {
throw new InvalidArgumentException('sliders.mainpage_slider.pagination must be a boolean');
}
if (isset($data['scrollbar']) && ! is_bool($data['scrollbar'])) {
throw new InvalidArgumentException('sliders.mainpage_slider.scrollbar must be a boolean');
}
if (isset($data['free_mode']) && ! is_bool($data['free_mode'])) {
throw new InvalidArgumentException('sliders.mainpage_slider.free_mode must be a boolean');
}
if (isset($data['space_between'])) {
if (! is_int($data['space_between'])) {
throw new InvalidArgumentException('sliders.mainpage_slider.space_between must be an integer');
}
if ($data['space_between'] < 0) {
throw new InvalidArgumentException(
'sliders.mainpage_slider.space_between must be a non-negative integer'
);
}
}
if (isset($data['autoplay']) && ! is_bool($data['autoplay'])) {
throw new InvalidArgumentException('sliders.mainpage_slider.autoplay must be a boolean');
}
if (isset($data['loop']) && ! is_bool($data['loop'])) {
throw new InvalidArgumentException('sliders.mainpage_slider.loop must be a boolean');
}
if (isset($data['slides'])) {
if (! is_array($data['slides'])) {
throw new InvalidArgumentException('sliders.mainpage_slider.slides must be an array');
}
foreach ($data['slides'] as $index => $slideData) {
if (! is_array($slideData)) {
throw new InvalidArgumentException("sliders.mainpage_slider.slides[$index] must be an object");
}
$this->validateSlide($slideData, $index);
}
}
}
private function validateSlide(array $data, int $index): void
{
if (isset($data['title']) && ! is_string($data['title'])) {
throw new InvalidArgumentException("sliders.mainpage_slider.slides[$index].title must be a string");
}
if (isset($data['link'])) {
if (! is_array($data['link'])) {
throw new InvalidArgumentException("sliders.mainpage_slider.slides[$index].link must be an object");
}
$this->validateLink($data['link'], $index);
}
if (isset($data['image']) && ! is_string($data['image'])) {
throw new InvalidArgumentException("sliders.mainpage_slider.slides[$index].image must be a string");
}
}
private function validateLink(array $data, int $slideIndex): void
{
if (isset($data['type'])) {
if (! is_string($data['type'])) {
throw new InvalidArgumentException(
"sliders.mainpage_slider.slides[$slideIndex].link.type must be a string"
);
}
$allowedTypes = ['none', 'category', 'product'];
if (! in_array($data['type'], $allowedTypes, true)) {
throw new InvalidArgumentException(
"sliders.mainpage_slider.slides[$slideIndex].link.type must be one of: " . implode(
', ',
$allowedTypes
)
);
}
}
if (isset($data['value'])) {
if ($data['value'] !== null) {
if (! is_array($data['value'])) {
throw new InvalidArgumentException(
"sliders.mainpage_slider.slides[$slideIndex].link.value must be an object or null"
);
}
$this->validateLinkValue($data['value'], $data['type'] ?? 'none', $slideIndex);
}
}
}
private function validateLinkValue(array $data, string $linkType, int $slideIndex): void
{
if ($linkType === 'category') {
if (isset($data['category_id'])) {
if (! is_int($data['category_id'])) {
throw new InvalidArgumentException(
"sliders.mainpage_slider.slides[$slideIndex].link.value.category_id must be an integer"
);
}
if ($data['category_id'] <= 0) {
throw new InvalidArgumentException(
"sliders.mainpage_slider.slides[$slideIndex].link.value.category_id must be a positive integer"
);
}
}
if (isset($data['name']) && ! is_string($data['name'])) {
throw new InvalidArgumentException(
"sliders.mainpage_slider.slides[$slideIndex].link.value.name must be a string"
);
}
} elseif ($linkType === 'product') {
if (isset($data['product_id'])) {
if (! is_int($data['product_id'])) {
throw new InvalidArgumentException(
"sliders.mainpage_slider.slides[$slideIndex].link.value.product_id must be an integer"
);
}
if ($data['product_id'] <= 0) {
throw new InvalidArgumentException(
"sliders.mainpage_slider.slides[$slideIndex].link.value.product_id must be a positive integer"
);
}
}
if (isset($data['name']) && ! is_string($data['name'])) {
throw new InvalidArgumentException(
"sliders.mainpage_slider.slides[$slideIndex].link.value.name must be a string"
);
}
}
// Проверяем, что не переданы лишние поля
$allowedFields = ['category_id', 'product_id', 'name'];
foreach (array_keys($data) as $field) {
if (! in_array($field, $allowedFields, true)) {
throw new InvalidArgumentException(
"sliders.mainpage_slider.slides[$slideIndex].link.value contains unknown field: $field"
);
}
}
}
private function deserializeLogs(array $logs): LogsDTO
{
return new LogsDTO(

View File

@@ -1,20 +1,79 @@
<?php
return [
'config_timezone' => 'UTC',
'lang' => 'en-gb',
'language_id' => 1,
'auth_user_id' => 0,
'base_url' => 'http://localhost',
'db' => [
'host' => 'localhost',
'database' => 'not_set',
'username' => 'not_set',
'password' => 'not_set',
'app' => [
'app_enabled' => true,
'app_name' => 'Telecart',
'app_icon' => null,
"theme_light" => "light",
"theme_dark" => "dark",
"app_debug" => false
],
'logs' => [
'path' => 'not_set',
'telegram' => [
"bot_token" => "",
"chat_id" => null,
"owner_notification_template" => <<<TEXT
*Новый заказ \#{order_id}* в магазине *{store_name}*
*Покупатель:* {customer}
*Email:* {email}
*Телефон:* {phone}
*IP:* {ip}
*Адрес доставки:*
{address}
*Комментарий:*
{comment}
*Сумма заказа:* {total}
*Дата оформления:* {created_at}
TEXT,
"customer_notification_template" => <<<TEXT
Спасибо за Ваш заказ в магазине *{store_name}*
*Номер заказа* \#{order_id}
*Сумма заказа:* {total}
*Дата оформления:* {created_at}
Мы свяжемся с вами при необходимости\.
Хорошего дня\!
TEXT,
"mini_app_url" => "",
],
"metrics" => [
"yandex_metrika_enabled" => false,
"yandex_metrika_counter" => "",
],
'store' => [
'enable_store' => true,
'feature_coupons' => true,
'feature_vouchers' => true,
],
'texts' => [
'text_no_more_products' => 'Это всё по текущему запросу. Попробуйте уточнить фильтры или поиск.',
'text_empty_cart' => 'Ваша корзина пуста.',
'text_order_created_success' => 'Ваш заказ успешно оформлен и будет обработан в ближайшее время.'
],
'orders' => [
'order_default_status_id' => 1,
],
'mainpage_blocks' => [
[
'type' => 'products_feed',
'title' => '',
'description' => '',
'is_enabled' => true,
'goal_name' => '',
'data' => [
'max_page_count' => 10,
],
],
],
];

View File

@@ -1,6 +1,7 @@
<?php
use App\Handlers\BannerHandler;
use App\Handlers\BlocksHandler;
use App\Handlers\CartHandler;
use App\Handlers\CategoriesHandler;
use App\Handlers\FiltersHandler;
@@ -27,7 +28,6 @@ return [
'manifest' => [SettingsHandler::class, 'manifest'],
'testTgMessage' => [SettingsHandler::class, 'testTgMessage'],
'banner' => [BannerHandler::class, 'show'],
'webhook' => [TelegramHandler::class, 'webhook'],
'processBlock' => [BlocksHandler::class, 'processBlock'],
];

View File

@@ -5,7 +5,6 @@ namespace Tests\Unit\Framework;
use App\Filters\ProductAttribute;
use App\Filters\ProductCategories;
use App\Filters\ProductCategory;
use App\Filters\ProductForMainPage;
use App\Filters\ProductManufacturer;
use App\Filters\ProductModel;
use App\Filters\ProductPrice;
@@ -89,7 +88,6 @@ class CriteriaBuilderTest extends TestCase
$rulesRegistry->register(ProductManufacturer::NAME, ProductManufacturer::class);
$rulesRegistry->register(ProductQuantity::NAME, ProductQuantity::class);
$rulesRegistry->register(ProductAttribute::NAME, ProductAttribute::class);
$rulesRegistry->register(ProductForMainPage::NAME, ProductForMainPage::class);
$rulesRegistry->register(ProductCategory::NAME, ProductCategory::class);
$this->builder = $application->get(Builder::class);

View File

@@ -1,16 +0,0 @@
{
"operand": "AND",
"rules": {
"RULE_PRODUCT_FOR_MAIN_PAGE": {
"criteria": {
"product_for_main_page": {
"type": "boolean",
"params": {
"operator": "equals",
"value": true
}
}
}
}
}
}

View File

@@ -1,13 +0,0 @@
SELECT
products.product_id AS product_id,
products.image AS image,
product_description.name AS name,
products.model AS model,
products.price AS price,
products.quantity AS quantity,
products.status AS STATUS,
products.noindex AS noindex
FROM
oc_product AS products
INNER JOIN oc_product_description AS product_description ON products.product_id = product_description.product_id
AND product_description.language_id = 1

View File

@@ -1,4 +0,0 @@
{
"mainpage_products": "featured",
"featured_products": [1, 2, 3]
}

View File

@@ -1,16 +0,0 @@
{
"operand": "AND",
"rules": {
"RULE_PRODUCT_FOR_MAIN_PAGE": {
"criteria": {
"product_for_main_page": {
"type": "boolean",
"params": {
"operator": "equals",
"value": true
}
}
}
}
}
}

View File

@@ -1,13 +0,0 @@
SELECT
products.product_id AS product_id,
products.image AS image,
product_description.name AS name,
products.model AS model,
products.price AS price,
products.quantity AS quantity,
products.status AS STATUS,
products.noindex AS noindex
FROM
oc_product AS products
INNER JOIN oc_product_description AS product_description ON products.product_id = product_description.product_id
AND product_description.language_id = 1

View File

@@ -1,4 +0,0 @@
{
"mainpage_products": "featured",
"featured_products": []
}

View File

@@ -1,16 +0,0 @@
{
"operand": "AND",
"rules": {
"RULE_PRODUCT_FOR_MAIN_PAGE": {
"criteria": {
"product_for_main_page": {
"type": "boolean",
"params": {
"operator": "equals",
"value": true
}
}
}
}
}
}

View File

@@ -1,13 +0,0 @@
SELECT
products.product_id AS product_id,
products.image AS image,
product_description.name AS name,
products.model AS model,
products.price AS price,
products.quantity AS quantity,
products.status AS STATUS,
products.noindex AS noindex
FROM
oc_product AS products
INNER JOIN oc_product_description AS product_description ON products.product_id = product_description.product_id
AND product_description.language_id = 1

View File

@@ -1,16 +0,0 @@
{
"operand": "AND",
"rules": {
"RULE_PRODUCT_FOR_MAIN_PAGE": {
"criteria": {
"product_for_main_page": {
"type": "boolean",
"params": {
"operator": "equals",
"value": true
}
}
}
}
}
}

View File

@@ -1,13 +0,0 @@
SELECT
products.product_id AS product_id,
products.image AS image,
product_description.name AS name,
products.model AS model,
products.price AS price,
products.quantity AS quantity,
products.status AS STATUS,
products.noindex AS noindex
FROM
oc_product AS products
INNER JOIN oc_product_description AS product_description ON products.product_id = product_description.product_id
AND product_description.language_id = 1