feat: new settings and mainpage blocks
This commit is contained in:
@@ -9,12 +9,14 @@
|
|||||||
"@vueuse/core": "^14.0.0",
|
"@vueuse/core": "^14.0.0",
|
||||||
"axios": "^1.13.1",
|
"axios": "^1.13.1",
|
||||||
"daisyui": "^5.4.2",
|
"daisyui": "^5.4.2",
|
||||||
|
"js-md5": "^0.8.3",
|
||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
"primevue": "^4.4.1",
|
"primevue": "^4.4.1",
|
||||||
"tailwindcss": "^4.1.16",
|
"tailwindcss": "^4.1.16",
|
||||||
"vue": "^3.5.22",
|
"vue": "^3.5.22",
|
||||||
"vue-router": "^4.6.3",
|
"vue-router": "^4.6.3",
|
||||||
|
"vuedraggable": "^4.1.0",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.37.0",
|
"@eslint/js": "^9.37.0",
|
||||||
@@ -576,6 +578,8 @@
|
|||||||
|
|
||||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
"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-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=="],
|
"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=="],
|
"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=="],
|
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||||
|
|
||||||
"speakingurl": ["speakingurl@14.0.1", "", {}, "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
||||||
|
|||||||
@@ -21,12 +21,14 @@
|
|||||||
"@vueuse/core": "^14.0.0",
|
"@vueuse/core": "^14.0.0",
|
||||||
"axios": "^1.13.1",
|
"axios": "^1.13.1",
|
||||||
"daisyui": "^5.4.2",
|
"daisyui": "^5.4.2",
|
||||||
|
"js-md5": "^0.8.3",
|
||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
"primevue": "^4.4.1",
|
"primevue": "^4.4.1",
|
||||||
"tailwindcss": "^4.1.16",
|
"tailwindcss": "^4.1.16",
|
||||||
"vue": "^3.5.22",
|
"vue": "^3.5.22",
|
||||||
"vue-router": "^4.6.3"
|
"vue-router": "^4.6.3",
|
||||||
|
"vuedraggable": "^4.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.37.0",
|
"@eslint/js": "^9.37.0",
|
||||||
|
|||||||
@@ -26,8 +26,8 @@
|
|||||||
<RouterLink :to="{name: 'orders'}">Заказы</RouterLink>
|
<RouterLink :to="{name: 'orders'}">Заказы</RouterLink>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li :class="{active: route.name === 'slider'}">
|
<li :class="{active: route.name === 'mainpage'}">
|
||||||
<RouterLink :to="{name: 'slider'}">Слайдер</RouterLink>
|
<RouterLink :to="{name: 'mainpage'}">Главная страница</RouterLink>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
@@ -36,20 +36,39 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<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>
|
</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 v-if="settings.isLoading"
|
||||||
<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">
|
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>
|
<i class="fa fa-spin fa-spinner tw:mr-5"></i>
|
||||||
<div>Загрузка...</div>
|
<div>Загрузка...</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Toast position="top-right"/>
|
<Toast position="top-right"/>
|
||||||
|
<ConfirmDialog/>
|
||||||
|
<ConfirmPopup group="popup"/>
|
||||||
</div>
|
</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 v-else
|
||||||
<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">
|
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>
|
<i class="fa fa-ban tw:text-4xl"></i>
|
||||||
<div class="tw:text-4xl">{{ settings.error }}</div>
|
<div class="tw:text-4xl">{{ settings.error }}</div>
|
||||||
<div>Обратитесь в поддержку</div>
|
<div>Обратитесь в поддержку</div>
|
||||||
@@ -61,15 +80,36 @@
|
|||||||
import {RouterView, useRoute} from 'vue-router';
|
import {RouterView, useRoute} from 'vue-router';
|
||||||
import {useSettingsStore} from "@/stores/settings.js";
|
import {useSettingsStore} from "@/stores/settings.js";
|
||||||
import Toast from 'primevue/toast';
|
import Toast from 'primevue/toast';
|
||||||
import { toastBus } from '@/utils/toastHelper';
|
import {toastBus} from '@/utils/toastHelper';
|
||||||
import {useToast} from "primevue";
|
import {useToast} from "primevue";
|
||||||
import Button from 'primevue/button';
|
import Button from 'primevue/button';
|
||||||
import TopLead from "@/components/TopLead.vue";
|
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 route = useRoute();
|
||||||
const settings = useSettingsStore();
|
const settings = useSettingsStore();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
toastBus.on('show', (data) => toast.add(data));
|
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>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -39,3 +39,10 @@ html {
|
|||||||
border-radius: unset;
|
border-radius: unset;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
legend.p-fieldset-legend {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: inherit;
|
||||||
|
width: auto;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -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>
|
|
||||||
@@ -8,6 +8,8 @@ import PrimeVue from 'primevue/config';
|
|||||||
import Aura from '@primeuix/themes/aura';
|
import Aura from '@primeuix/themes/aura';
|
||||||
import ToastService from 'primevue/toastservice';
|
import ToastService from 'primevue/toastservice';
|
||||||
import {definePreset} from "@primeuix/themes";
|
import {definePreset} from "@primeuix/themes";
|
||||||
|
import Tooltip from 'primevue/tooltip';
|
||||||
|
import ConfirmationService from 'primevue/confirmationservice';
|
||||||
|
|
||||||
const MyPreset = definePreset(Aura, {
|
const MyPreset = definePreset(Aura, {
|
||||||
|
|
||||||
@@ -34,6 +36,8 @@ onReady(async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
app.use(ToastService);
|
app.use(ToastService);
|
||||||
|
app.directive('tooltip', Tooltip);
|
||||||
|
app.use(ConfirmationService);
|
||||||
|
|
||||||
app.mount('#app');
|
app.mount('#app');
|
||||||
await useSettingsStore().fetchSettings();
|
await useSettingsStore().fetchSettings();
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
import {createMemoryHistory, createRouter} from 'vue-router';
|
import {createMemoryHistory, createRouter} from 'vue-router';
|
||||||
import SliderView from "@/views/SliderView.vue";
|
|
||||||
import GeneralView from "@/views/GeneralView.vue";
|
import GeneralView from "@/views/GeneralView.vue";
|
||||||
import TextsView from "@/views/TextsView.vue";
|
import TextsView from "@/views/TextsView.vue";
|
||||||
import OrdersView from "@/views/OrdersView.vue";
|
import OrdersView from "@/views/OrdersView.vue";
|
||||||
import TelegramView from "@/views/TelegramView.vue";
|
import TelegramView from "@/views/TelegramView.vue";
|
||||||
import MetricsView from "@/views/MetricsView.vue";
|
import MetricsView from "@/views/MetricsView.vue";
|
||||||
import StoreView from "@/views/StoreView.vue";
|
import StoreView from "@/views/StoreView.vue";
|
||||||
|
import MainPageView from "@/views/MainPageView.vue";
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createMemoryHistory(),
|
history: createMemoryHistory(),
|
||||||
routes: [
|
routes: [
|
||||||
{path: '/', name: 'general', component: GeneralView},
|
{path: '/', name: 'general', component: GeneralView},
|
||||||
{path: '/slider', name: 'slider', component: SliderView},
|
|
||||||
{path: '/orders', name: 'orders', component: OrdersView},
|
{path: '/orders', name: 'orders', component: OrdersView},
|
||||||
{path: '/texts', name: 'texts', component: TextsView},
|
{path: '/texts', name: 'texts', component: TextsView},
|
||||||
{path: '/telegram', name: 'telegram', component: TelegramView},
|
{path: '/telegram', name: 'telegram', component: TelegramView},
|
||||||
{path: '/metrics', name: 'metrics', component: MetricsView},
|
{path: '/metrics', name: 'metrics', component: MetricsView},
|
||||||
{path: '/store', name: 'store', component: StoreView},
|
{path: '/store', name: 'store', component: StoreView},
|
||||||
|
{path: '/mainpage', name: 'mainpage', component: MainPageView},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import {defineStore} from "pinia";
|
import {defineStore} from "pinia";
|
||||||
import {apiGet, apiPost} from "@/utils/http.js";
|
import {apiGet, apiPost} from "@/utils/http.js";
|
||||||
import {toastBus} from "@/utils/toastHelper.js";
|
import {toastBus} from "@/utils/toastHelper.js";
|
||||||
|
import {md5} from "js-md5";
|
||||||
|
|
||||||
export const useSettingsStore = defineStore('settings', {
|
export const useSettingsStore = defineStore('settings', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
originalItemsHash: null,
|
||||||
|
|
||||||
items: {
|
items: {
|
||||||
app: {
|
app: {
|
||||||
@@ -32,10 +34,6 @@ export const useSettingsStore = defineStore('settings', {
|
|||||||
|
|
||||||
store: {
|
store: {
|
||||||
enable_store: true,
|
enable_store: true,
|
||||||
mainpage_products: 'most_viewed',
|
|
||||||
featured_products: [],
|
|
||||||
mainpage_categories: 'latest10',
|
|
||||||
featured_categories: [],
|
|
||||||
feature_coupons: true,
|
feature_coupons: true,
|
||||||
feature_vouchers: true,
|
feature_vouchers: true,
|
||||||
},
|
},
|
||||||
@@ -63,6 +61,8 @@ export const useSettingsStore = defineStore('settings', {
|
|||||||
slides: [],
|
slides: [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
mainpage_blocks: [],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -74,6 +74,10 @@ export const useSettingsStore = defineStore('settings', {
|
|||||||
const filename = state.items.app.app_icon.substring(0, extIndex);
|
const filename = state.items.app.app_icon.substring(0, extIndex);
|
||||||
return `/image/cache/${filename}-100x100${ext}`;
|
return `/image/cache/${filename}-100x100${ext}`;
|
||||||
},
|
},
|
||||||
|
hasUnsavedChanges: (state) => {
|
||||||
|
if (!state.originalItemsHash) return false;
|
||||||
|
return md5(JSON.stringify(state.items)) !== state.originalItemsHash;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
@@ -86,6 +90,8 @@ export const useSettingsStore = defineStore('settings', {
|
|||||||
...this.items,
|
...this.items,
|
||||||
...response.data,
|
...response.data,
|
||||||
};
|
};
|
||||||
|
// Сохраняем хеш исходного состояния после загрузки
|
||||||
|
this.originalItemsHash = md5(JSON.stringify(this.items));
|
||||||
} else {
|
} else {
|
||||||
this.error = 'Возникли проблемы при загрузке настроек.';
|
this.error = 'Возникли проблемы при загрузке настроек.';
|
||||||
}
|
}
|
||||||
@@ -104,6 +110,8 @@ export const useSettingsStore = defineStore('settings', {
|
|||||||
detail: 'Настройки сохранены.',
|
detail: 'Настройки сохранены.',
|
||||||
life: 2000,
|
life: 2000,
|
||||||
});
|
});
|
||||||
|
// Обновляем хеш исходного состояния после успешного сохранения
|
||||||
|
this.originalItemsHash = md5(JSON.stringify(this.items));
|
||||||
} else {
|
} else {
|
||||||
toastBus.emit('show', {
|
toastBus.emit('show', {
|
||||||
severity: 'error',
|
severity: 'error',
|
||||||
@@ -114,7 +122,6 @@ export const useSettingsStore = defineStore('settings', {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
7
frontend/admin/src/utils/constants..js
Normal file
7
frontend/admin/src/utils/constants..js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export const sliderEffectOptions = {
|
||||||
|
slide: 'Слайд',
|
||||||
|
flip: 'Переворот',
|
||||||
|
cards: 'Карточки',
|
||||||
|
cube: 'Куб',
|
||||||
|
coverflow: 'Перекрывающиеся слайды',
|
||||||
|
};
|
||||||
7
frontend/admin/src/utils/helpers.js
Normal file
7
frontend/admin/src/utils/helpers.js
Normal 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}`;
|
||||||
|
}
|
||||||
7
frontend/admin/src/views/MainPageView.vue
Normal file
7
frontend/admin/src/views/MainPageView.vue
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<template>
|
||||||
|
<MainPageConfigurator/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import MainPageConfigurator from "@/components/MainPageConfigurator/MainPageConfigurator.vue";
|
||||||
|
</script>
|
||||||
@@ -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>
|
|
||||||
@@ -7,40 +7,6 @@
|
|||||||
вашем сайте. В этом режиме Telecart работает как каталог.</p>
|
вашем сайте. В этом режиме Telecart работает как каталог.</p>
|
||||||
</ItemBool>
|
</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">
|
<ItemBool label="Промокоды" v-model="settings.items.store.feature_coupons">
|
||||||
<p>
|
<p>
|
||||||
Позволяет использовать стандартные
|
Позволяет использовать стандартные
|
||||||
@@ -65,11 +31,7 @@ import ItemProductsSelect from "@/components/Settings/ItemProductsSelect.vue";
|
|||||||
import ItemCategoriesSelect from "@/components/Settings/ItemCategoriesSelect.vue";
|
import ItemCategoriesSelect from "@/components/Settings/ItemCategoriesSelect.vue";
|
||||||
|
|
||||||
const settings = useSettingsStore();
|
const settings = useSettingsStore();
|
||||||
const mainpage_products_options = {
|
|
||||||
most_viewed: 'Популярные товары',
|
|
||||||
latest: 'Последние сверху',
|
|
||||||
featured: 'Избранные товары (задать в поле ниже)',
|
|
||||||
};
|
|
||||||
const mainpage_categories_options = {
|
const mainpage_categories_options = {
|
||||||
no_categories: 'Отображать только кнопку "Каталог"',
|
no_categories: 'Отображать только кнопку "Каталог"',
|
||||||
latest10: 'Последние 10 категорий',
|
latest10: 'Последние 10 категорий',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="drawer h-full">
|
<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="drawer-content">
|
||||||
<div class="app-container h-full">
|
<div class="app-container h-full">
|
||||||
@@ -9,18 +9,18 @@
|
|||||||
<Navbar @drawer="toggleDrawer"/>
|
<Navbar @drawer="toggleDrawer"/>
|
||||||
|
|
||||||
<section class="telecart-main-section">
|
<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"/>
|
<AppDebugMessage v-if="settings.app_debug"/>
|
||||||
|
|
||||||
<RouterView v-slot="{ Component, route }">
|
<RouterView v-slot="{ Component, route }">
|
||||||
<KeepAlive include="Home" :key="filtersStore.paramsHashForRouter">
|
<KeepAlive include="Home" :key="filtersStore.paramsHashForRouter">
|
||||||
<component :is="Component" :key="route.fullPath" />
|
<component :is="Component" :key="route.fullPath"/>
|
||||||
</KeepAlive>
|
</KeepAlive>
|
||||||
</RouterView>
|
</RouterView>
|
||||||
|
|
||||||
<CartButton v-if="settings.store_enabled" />
|
<CartButton v-if="settings.store_enabled"/>
|
||||||
<Dock v-if="isAppDockShown" />
|
<Dock v-if="isAppDockShown"/>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -38,8 +38,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import {computed, onMounted, onUnmounted, ref, watch} from "vue";
|
import {computed, onMounted, onUnmounted, ref, watch} from "vue";
|
||||||
import {useWebAppViewport} from 'vue-tg';
|
import {FullscreenViewport, useMiniApp, useWebAppViewport} from 'vue-tg';
|
||||||
import {useMiniApp, FullscreenViewport} from 'vue-tg';
|
|
||||||
import {useRoute, useRouter} from "vue-router";
|
import {useRoute, useRouter} from "vue-router";
|
||||||
import {useSettingsStore} from "@/stores/SettingsStore.js";
|
import {useSettingsStore} from "@/stores/SettingsStore.js";
|
||||||
import {useProductFiltersStore} from "@/stores/ProductFiltersStore.js";
|
import {useProductFiltersStore} from "@/stores/ProductFiltersStore.js";
|
||||||
@@ -64,10 +63,10 @@ const haptic = window.Telegram.WebApp.HapticFeedback;
|
|||||||
const drawerOpen = ref(false);
|
const drawerOpen = ref(false);
|
||||||
|
|
||||||
const routesToHideAppDock = [
|
const routesToHideAppDock = [
|
||||||
'product.show',
|
'product.show',
|
||||||
'checkout',
|
'checkout',
|
||||||
'order_created',
|
'order_created',
|
||||||
'filters',
|
'filters',
|
||||||
];
|
];
|
||||||
|
|
||||||
const isAppDockShown = computed(() => {
|
const isAppDockShown = computed(() => {
|
||||||
@@ -80,7 +79,7 @@ function navigateBack() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggleDrawer() {
|
function toggleDrawer() {
|
||||||
drawerOpen.value = !drawerOpen.value
|
drawerOpen.value = !drawerOpen.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -21,6 +21,18 @@
|
|||||||
<span class="dock-label">Главная</span>
|
<span class="dock-label">Главная</span>
|
||||||
</RouterLink>
|
</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
|
<RouterLink
|
||||||
:to="{name: 'search'}"
|
:to="{name: 'search'}"
|
||||||
:class="{'active': route.name === 'search'}"
|
:class="{'active': route.name === 'search'}"
|
||||||
@@ -88,8 +100,8 @@ function onDockItemClick() {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-radius: var(--radius-field);
|
border-radius: var(--radius-field);
|
||||||
padding: 5px 13px;
|
padding: 5px;
|
||||||
min-width: 90px;
|
min-width: 50px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.telecart-dock-item.active {
|
.telecart-dock-item.active {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
26
frontend/spa/src/components/MainPage/Blocks/SliderBlock.vue
Normal file
26
frontend/spa/src/components/MainPage/Blocks/SliderBlock.vue
Normal 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>
|
||||||
34
frontend/spa/src/components/MainPage/EmptyBlocks.vue
Normal file
34
frontend/spa/src/components/MainPage/EmptyBlocks.vue
Normal 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>
|
||||||
41
frontend/spa/src/components/MainPage/MainPage.vue
Normal file
41
frontend/spa/src/components/MainPage/MainPage.vue
Normal 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>
|
||||||
@@ -1,50 +1,66 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="mx-auto max-w-2xl px-4 py-4 pb-14">
|
<div>
|
||||||
<h2 v-if="categoryName" class="text-lg font-bold mb-5 text-center">{{ categoryName }}</h2>
|
<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>
|
||||||
|
|
||||||
<template v-if="products.length > 0">
|
<template v-if="products.length > 0">
|
||||||
<div
|
<div
|
||||||
class="products-grid grid grid-cols-2 gap-x-5 gap-y-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 xl:gap-x-8"
|
class="products-grid grid grid-cols-2 gap-x-5 gap-y-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 xl:gap-x-8"
|
||||||
>
|
|
||||||
<RouterLink
|
|
||||||
v-for="(product, index) in products"
|
|
||||||
:key="product.id"
|
|
||||||
class="product-grid-card group"
|
|
||||||
:to="`/product/${product.id}`"
|
|
||||||
@click="productClick(product, index)"
|
|
||||||
>
|
>
|
||||||
<ProductImageSwiper :images="product.images"/>
|
<RouterLink
|
||||||
<h3 class="product-title mt-4 text-sm">{{ product.name }}</h3>
|
v-for="(product, index) in products"
|
||||||
|
:key="product.id"
|
||||||
|
class="product-grid-card group"
|
||||||
|
:to="`/product/${product.id}`"
|
||||||
|
@click="productClick(product, index)"
|
||||||
|
>
|
||||||
|
<ProductImageSwiper :images="product.images"/>
|
||||||
|
<h3 class="product-title mt-4 text-sm">{{ product.name }}</h3>
|
||||||
|
|
||||||
<div v-if="product.special" class="mt-1">
|
<div v-if="product.special" class="mt-1">
|
||||||
<p class="text-xs line-through mr-2">{{ product.price }}</p>
|
<p class="text-xs line-through mr-2">{{ product.price }}</p>
|
||||||
<p class="text-lg font-medium">{{ product.special }}</p>
|
<p class="text-lg font-medium">{{ product.special }}</p>
|
||||||
</div>
|
</div>
|
||||||
<p v-else class="mt-1 text-lg font-medium">{{ product.price }}</p>
|
<p v-else class="mt-1 text-lg font-medium">{{ product.price }}</p>
|
||||||
|
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<div ref="bottom" style="height: 1px;"></div>
|
<div ref="bottom" style="height: 1px;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isLoadingMore" class="text-center mt-5">
|
||||||
|
<span class="loading loading-spinner loading-md"></span> Загрузка товаров...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="hasMore === false" class="text-xs text-center mt-4 pt-4 mb-2 border-t">
|
||||||
|
{{ settings.texts.text_no_more_products }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-else-if="isLoading === true"
|
||||||
|
class="grid grid-cols-2 gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 xl:gap-x-8">
|
||||||
|
<div v-for="n in 8" :key="n" class="animate-pulse space-y-2">
|
||||||
|
<div class="aspect-square bg-gray-200 rounded-md"></div>
|
||||||
|
<div class="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||||
|
<div class="h-4 bg-gray-200 rounded w-1/2"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="isLoadingMore" class="text-center mt-5">
|
<NoProducts v-else/>
|
||||||
<span class="loading loading-spinner loading-md"></span> Загрузка товаров...
|
|
||||||
</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 }}
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div v-else-if="isLoading === true"
|
|
||||||
class="grid grid-cols-2 gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 xl:gap-x-8">
|
|
||||||
<div v-for="n in 8" :key="n" class="animate-pulse space-y-2">
|
|
||||||
<div class="aspect-square bg-gray-200 rounded-md"></div>
|
|
||||||
<div class="h-4 bg-gray-200 rounded w-3/4"></div>
|
|
||||||
<div class="h-4 bg-gray-200 rounded w-1/2"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NoProducts v-else/>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -55,9 +71,15 @@ import {useSettingsStore} from "@/stores/SettingsStore.js";
|
|||||||
import {ref} from "vue";
|
import {ref} from "vue";
|
||||||
import {useIntersectionObserver} from '@vueuse/core';
|
import {useIntersectionObserver} from '@vueuse/core';
|
||||||
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
|
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 yaMetrika = useYaMetrikaStore();
|
||||||
const settings = useSettingsStore();
|
const settings = useSettingsStore();
|
||||||
|
const filtersStore = useProductFiltersStore();
|
||||||
const bottom = ref(null);
|
const bottom = ref(null);
|
||||||
|
|
||||||
const emits = defineEmits(['loadMore']);
|
const emits = defineEmits(['loadMore']);
|
||||||
@@ -128,6 +150,11 @@ useIntersectionObserver(
|
|||||||
rootMargin: '400px 0',
|
rootMargin: '400px 0',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function showFilters() {
|
||||||
|
haptic.impactOccurred('soft');
|
||||||
|
router.push({name: 'filters'});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -1,25 +1,21 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div v-if="config.slides.length > 0" class="app-banner" :class="classList">
|
||||||
v-if="sliders.mainpage_slider.is_enabled && sliders.mainpage_slider.slides.length > 0"
|
|
||||||
class="app-banner"
|
|
||||||
:class="classList"
|
|
||||||
>
|
|
||||||
<Swiper
|
<Swiper
|
||||||
:effect="slideEffect"
|
:effect="slideEffect"
|
||||||
class="select-none"
|
class="select-none"
|
||||||
:slides-per-view="1"
|
:slides-per-view="1"
|
||||||
:space-between="sliders.mainpage_slider.space_between"
|
:space-between="config.space_between"
|
||||||
:pagination="pagination"
|
:pagination="pagination"
|
||||||
:lazy="true"
|
:lazy="true"
|
||||||
:modules="modules"
|
:modules="modules"
|
||||||
:scrollbar="scrollbar"
|
:scrollbar="scrollbar"
|
||||||
:free-mode="sliders.mainpage_slider.free_mode"
|
:free-mode="config.free_mode"
|
||||||
:loop="sliders.mainpage_slider.loop"
|
:loop="config.loop"
|
||||||
:autoplay="autoplay"
|
:autoplay="autoplay"
|
||||||
@swiper="onSwiper"
|
@swiper="onSwiper"
|
||||||
@slideChange="onSlideChange"
|
@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
|
<RouterLink
|
||||||
v-if="slide?.link?.type === 'category'"
|
v-if="slide?.link?.type === 'category'"
|
||||||
:to="{name: 'product.categories.show', params: {category_id: slide.link.value.category_id}}"
|
:to="{name: 'product.categories.show', params: {category_id: slide.link.value.category_id}}"
|
||||||
@@ -48,6 +44,16 @@
|
|||||||
</SwiperSlide>
|
</SwiperSlide>
|
||||||
</Swiper>
|
</Swiper>
|
||||||
</div>
|
</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>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -56,11 +62,21 @@ import 'swiper/css';
|
|||||||
import 'swiper/css/navigation';
|
import 'swiper/css/navigation';
|
||||||
import {YA_METRIKA_GOAL} from "@/constants/yaMetrikaGoals.js";
|
import {YA_METRIKA_GOAL} from "@/constants/yaMetrikaGoals.js";
|
||||||
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.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 {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 yaMetrika = useYaMetrikaStore();
|
||||||
const modules = [
|
const modules = [
|
||||||
Autoplay,
|
Autoplay,
|
||||||
@@ -72,15 +88,15 @@ const modules = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const classList = computed(() => {
|
const classList = computed(() => {
|
||||||
if (sliders.mainpage_slider.effect === 'cards') {
|
if (props.config.effect === 'cards') {
|
||||||
return ['px-8'];
|
return ['px-8'];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sliders.mainpage_slider.effect === 'flip') {
|
if (props.config.effect === 'flip') {
|
||||||
return ['px-4', 'pb-4', 'pt-4'];
|
return ['px-4', 'pb-4', 'pt-4'];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sliders.mainpage_slider.effect === 'cube') {
|
if (props.config.effect === 'cube') {
|
||||||
return ['px-4', 'pb-10'];
|
return ['px-4', 'pb-10'];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,19 +107,19 @@ const onSwiper = (swiper) => {
|
|||||||
console.log(swiper);
|
console.log(swiper);
|
||||||
};
|
};
|
||||||
const onSlideChange = () => {
|
const onSlideChange = () => {
|
||||||
console.log('slide change');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const slideEffect = computed(() => {
|
const slideEffect = computed(() => {
|
||||||
if (sliders.mainpage_slider.effect === 'slide') {
|
if (props.config.effect === 'slide') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return sliders.mainpage_slider.effect;
|
return props.config.effect;
|
||||||
});
|
});
|
||||||
|
|
||||||
const pagination = computed(() => {
|
const pagination = computed(() => {
|
||||||
if (sliders.mainpage_slider.pagination) {
|
if (props.config.pagination) {
|
||||||
return {
|
return {
|
||||||
clickable: true, dynamicBullets: false,
|
clickable: true, dynamicBullets: false,
|
||||||
};
|
};
|
||||||
@@ -112,7 +128,7 @@ const pagination = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const scrollbar = computed(() => {
|
const scrollbar = computed(() => {
|
||||||
if (sliders.mainpage_slider.scrollbar) {
|
if (props.config.scrollbar) {
|
||||||
return {
|
return {
|
||||||
hide: true,
|
hide: true,
|
||||||
};
|
};
|
||||||
@@ -121,7 +137,7 @@ const scrollbar = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const autoplay = computed(() => {
|
const autoplay = computed(() => {
|
||||||
if (sliders.mainpage_slider.autoplay) {
|
if (props.config.autoplay) {
|
||||||
return {
|
return {
|
||||||
delay: 3000,
|
delay: 3000,
|
||||||
reverseDirection: false,
|
reverseDirection: false,
|
||||||
@@ -132,9 +148,11 @@ const autoplay = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function sliderClick(slide) {
|
function sliderClick(slide) {
|
||||||
yaMetrika.reachGoal(YA_METRIKA_GOAL.SLIDER_HOME_CLICK, {
|
if (props.goalName) {
|
||||||
banner: slide.title,
|
yaMetrika.reachGoal(props.goalName, {
|
||||||
});
|
banner: slide.title,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openExternalLink(link, slide) {
|
function openExternalLink(link, slide) {
|
||||||
@@ -150,7 +168,7 @@ function openExternalLink(link, slide) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
console.debug('[Mainpage Slider] Status: ', sliders.mainpage_slider);
|
console.debug('[Mainpage Slider] Init with config: ', props.config);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ import 'swiper/element/bundle';
|
|||||||
import 'swiper/css/bundle';
|
import 'swiper/css/bundle';
|
||||||
import AppLoading from "@/AppLoading.vue";
|
import AppLoading from "@/AppLoading.vue";
|
||||||
import {useProductFiltersStore} from "@/stores/ProductFiltersStore.js";
|
import {useProductFiltersStore} from "@/stores/ProductFiltersStore.js";
|
||||||
import {useSlidersStore} from "@/stores/SlidersStore.js";
|
import {useBlocksStore} from "@/stores/BlocksStore.js";
|
||||||
register();
|
register();
|
||||||
|
|
||||||
const pinia = createPinia();
|
const pinia = createPinia();
|
||||||
@@ -27,23 +27,24 @@ app
|
|||||||
.use(VueTelegramPlugin);
|
.use(VueTelegramPlugin);
|
||||||
|
|
||||||
const settings = useSettingsStore();
|
const settings = useSettingsStore();
|
||||||
useSlidersStore().fetchMainpageSlider();
|
const blocks = useBlocksStore();
|
||||||
|
|
||||||
const appLoading = createApp(AppLoading);
|
const appLoading = createApp(AppLoading);
|
||||||
appLoading.mount('#app');
|
appLoading.mount('#app');
|
||||||
|
|
||||||
settings.load()
|
settings.load()
|
||||||
.then(() => window.Telegram.WebApp.lockOrientation())
|
.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(() => {
|
.then(() => {
|
||||||
if (settings.app_enabled === false) {
|
if (settings.app_enabled === false) {
|
||||||
throw new Error('App disabled (maintenance mode)');
|
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(() => {
|
.then(() => {
|
||||||
console.debug('[Init] Set theme attributes');
|
console.debug('[Init] Set theme attributes');
|
||||||
document.documentElement.setAttribute('data-theme', settings.theme[window.Telegram.WebApp.colorScheme]);
|
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]);
|
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(() => new AppMetaInitializer(settings).init())
|
||||||
.then(() => { appLoading.unmount(); app.mount('#app'); })
|
.then(() => { appLoading.unmount(); app.mount('#app'); })
|
||||||
.then(() => window.Telegram.WebApp.ready())
|
.then(() => window.Telegram.WebApp.ready())
|
||||||
|
|||||||
32
frontend/spa/src/stores/BlocksStore.js
Normal file
32
frontend/spa/src/stores/BlocksStore.js
Normal 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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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 = []) {
|
async findCategoryById(id, list = []) {
|
||||||
if (! id) return null;
|
if (! id) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -24,10 +24,11 @@ export const useSettingsStore = defineStore('settings', {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
texts: {
|
texts: {
|
||||||
no_more_products: 'Нет товаров',
|
text_no_more_products: 'Нет товаров',
|
||||||
empty_cart: 'Корзина пуста',
|
text_empty_cart: 'Корзина пуста',
|
||||||
order_created_success: 'Заказ успешно оформлен.',
|
text_order_created_success: 'Заказ успешно оформлен.',
|
||||||
},
|
},
|
||||||
|
mainpage_blocks: [],
|
||||||
}),
|
}),
|
||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
@@ -51,6 +52,7 @@ export const useSettingsStore = defineStore('settings', {
|
|||||||
this.feature_vouchers = settings.feature_vouchers;
|
this.feature_vouchers = settings.feature_vouchers;
|
||||||
this.currency_code = settings.currency_code;
|
this.currency_code = settings.currency_code;
|
||||||
this.texts = settings.texts;
|
this.texts = settings.texts;
|
||||||
|
this.mainpage_blocks = settings.mainpage_blocks;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -4,8 +4,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
html, body, #app {
|
html, body, #app {
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +56,12 @@ html {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.telecart-main-section {
|
.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 {
|
.swiper-pagination-bullets {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export const apiFetch = ofetch.create({
|
|||||||
options.headers = {
|
options.headers = {
|
||||||
...options.headers,
|
...options.headers,
|
||||||
'X-Telegram-InitData': encoded,
|
'X-Telegram-InitData': encoded,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -24,7 +24,7 @@ export const apiFetch = ofetch.create({
|
|||||||
async function ftch(action, query = null, json = null) {
|
async function ftch(action, query = null, json = null) {
|
||||||
const options = {
|
const options = {
|
||||||
method: json ? 'POST' : 'GET',
|
method: json ? 'POST' : 'GET',
|
||||||
}
|
};
|
||||||
if (query) options.query = query;
|
if (query) options.query = query;
|
||||||
if (json) options.body = json;
|
if (json) options.body = json;
|
||||||
|
|
||||||
@@ -92,8 +92,8 @@ export async function setVoucher(voucher) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchBanner() {
|
export async function processBlock(block) {
|
||||||
return await ftch('banner');
|
return await ftch('processBlock', null, block);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ftch;
|
export default ftch;
|
||||||
|
|||||||
@@ -125,7 +125,7 @@
|
|||||||
class="text-center rounded-2xl"
|
class="text-center rounded-2xl"
|
||||||
>
|
>
|
||||||
<div class="text-5xl mb-4">🛒</div>
|
<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>
|
<RouterLink class="btn btn-primary" to="/">Начать покупки</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<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>
|
<h2 class="text-3xl mb-5">Категории</h2>
|
||||||
|
|
||||||
<div v-if="categoriesStore.isLoading" class="flex flex-col gap-4">
|
<div v-if="categoriesStore.isLoading" class="flex flex-col gap-4">
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ const applyFilters = async () => {
|
|||||||
yaMetrika.reachGoal(YA_METRIKA_GOAL.FILTERS_APPLY);
|
yaMetrika.reachGoal(YA_METRIKA_GOAL.FILTERS_APPLY);
|
||||||
await nextTick();
|
await nextTick();
|
||||||
router.back();
|
router.back();
|
||||||
}
|
};
|
||||||
|
|
||||||
const resetFilters = async () => {
|
const resetFilters = async () => {
|
||||||
filtersStore.applied = filtersStore.default;
|
filtersStore.applied = filtersStore.default;
|
||||||
@@ -89,7 +89,7 @@ const resetFilters = async () => {
|
|||||||
await nextTick();
|
await nextTick();
|
||||||
window.scrollTo(0, 0);
|
window.scrollTo(0, 0);
|
||||||
router.back();
|
router.back();
|
||||||
}
|
};
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
console.debug('Filters: OnMounted');
|
console.debug('Filters: OnMounted');
|
||||||
|
|||||||
@@ -1,47 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="goodsRef" class="pb-20">
|
<div ref="goodsRef" class="pb-20">
|
||||||
<CategoriesInline/>
|
<MainPage/>
|
||||||
|
|
||||||
<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"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import ProductsList from "@/components/ProductsList.vue";
|
import {onActivated, onMounted} from "vue";
|
||||||
import CategoriesInline from "../components/CategoriesInline.vue";
|
|
||||||
import {onActivated, onMounted, ref, toRaw} from "vue";
|
|
||||||
import IconFunnel from "@/components/Icons/IconFunnel.vue";
|
|
||||||
import {useRouter} from "vue-router";
|
import {useRouter} from "vue-router";
|
||||||
import ftch from "@/utils/ftch.js";
|
|
||||||
import {useProductFiltersStore} from "@/stores/ProductFiltersStore.js";
|
import {useProductFiltersStore} from "@/stores/ProductFiltersStore.js";
|
||||||
import MainpageSlider from "@/components/MainpageSlider.vue";
|
|
||||||
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
|
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
|
||||||
import {YA_METRIKA_GOAL} from "@/constants/yaMetrikaGoals.js";
|
import {YA_METRIKA_GOAL} from "@/constants/yaMetrikaGoals.js";
|
||||||
import {useSettingsStore} from "@/stores/SettingsStore.js";
|
import {useSettingsStore} from "@/stores/SettingsStore.js";
|
||||||
|
import MainPage from "@/components/MainPage/MainPage.vue";
|
||||||
|
import {useBlocksStore} from "@/stores/BlocksStore.js";
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'Home'
|
name: 'Home'
|
||||||
@@ -52,79 +23,10 @@ const filtersStore = useProductFiltersStore();
|
|||||||
const yaMetrika = useYaMetrikaStore();
|
const yaMetrika = useYaMetrikaStore();
|
||||||
const haptic = window.Telegram.WebApp.HapticFeedback;
|
const haptic = window.Telegram.WebApp.HapticFeedback;
|
||||||
const settings = useSettingsStore();
|
const settings = useSettingsStore();
|
||||||
|
const blocks = useBlocksStore();
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onActivated(() => {
|
onActivated(() => {
|
||||||
|
console.debug("[Home] Home Activated");
|
||||||
yaMetrika.pushHit('/', {
|
yaMetrika.pushHit('/', {
|
||||||
title: 'Главная страница',
|
title: 'Главная страница',
|
||||||
});
|
});
|
||||||
@@ -134,8 +36,6 @@ onActivated(() => {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
window.document.title = 'Главная страница';
|
window.document.title = 'Главная страница';
|
||||||
console.debug("[Home] Home Mounted");
|
console.debug("[Home] Home Mounted");
|
||||||
console.debug("[Home] Scroll top");
|
|
||||||
await fetchProducts();
|
|
||||||
window.scrollTo(0, 0);
|
window.scrollTo(0, 0);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-2xl font-bold mb-3">Спасибо за заказ!</p>
|
<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">
|
<ul v-if="checkout.order" class="list w-full bg-base-200 mb-4">
|
||||||
<li class="list-row flex justify-between">
|
<li class="list-row flex justify-between">
|
||||||
|
|||||||
@@ -361,13 +361,11 @@ class ControllerExtensionModuleTgshop extends Controller
|
|||||||
'module_tgshop_owner_notification_template' => 'telegram.owner_notification_template',
|
'module_tgshop_owner_notification_template' => 'telegram.owner_notification_template',
|
||||||
'module_tgshop_text_order_created_success' => 'texts.text_order_created_success',
|
'module_tgshop_text_order_created_success' => 'texts.text_order_created_success',
|
||||||
'module_tgshop_enable_store' => 'store.enable_store',
|
'module_tgshop_enable_store' => 'store.enable_store',
|
||||||
'module_tgshop_mainpage_products' => 'store.mainpage_products',
|
|
||||||
'module_tgshop_yandex_metrika' => 'metrics.yandex_metrika_counter',
|
'module_tgshop_yandex_metrika' => 'metrics.yandex_metrika_counter',
|
||||||
'module_tgshop_customer_notification_template' => 'telegram.customer_notification_template',
|
'module_tgshop_customer_notification_template' => 'telegram.customer_notification_template',
|
||||||
'module_tgshop_feature_vouchers' => 'store.feature_vouchers',
|
'module_tgshop_feature_vouchers' => 'store.feature_vouchers',
|
||||||
'module_tgshop_order_default_status_id' => 'orders.order_default_status_id',
|
'module_tgshop_order_default_status_id' => 'orders.order_default_status_id',
|
||||||
'module_tgshop_feature_coupons' => 'store.feature_coupons',
|
'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_no_more_products' => 'texts.text_no_more_products',
|
||||||
'module_tgshop_text_empty_cart' => 'texts.text_empty_cart',
|
'module_tgshop_text_empty_cart' => 'texts.text_empty_cart',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -7,38 +7,6 @@ $_['text_module'] = 'Модули';
|
|||||||
$_['text_success'] = 'Настройки успешно изменены!';
|
$_['text_success'] = 'Настройки успешно изменены!';
|
||||||
$_['text_edit'] = 'Настройки';
|
$_['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
|
||||||
$_['entry_status'] = 'Статус';
|
$_['entry_status'] = 'Статус';
|
||||||
|
|
||||||
|
|||||||
@@ -33,18 +33,9 @@
|
|||||||
order_statuses: '{{ order_statuses | json_encode }}',
|
order_statuses: '{{ order_statuses | json_encode }}',
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
<div id="app">App Loading...</div>
|
<div id="app" class="telecart-admin-app">App Loading...</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{ footer }}
|
{{ 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>
|
|
||||||
@@ -5,11 +5,13 @@ use App\ApplicationFactory;
|
|||||||
use Cart\Cart;
|
use Cart\Cart;
|
||||||
use Cart\Currency;
|
use Cart\Currency;
|
||||||
use Cart\Tax;
|
use Cart\Tax;
|
||||||
|
use Openguru\OpenCartFramework\Http\Response as HttpResponse;
|
||||||
use Openguru\OpenCartFramework\ImageTool\ImageTool;
|
use Openguru\OpenCartFramework\ImageTool\ImageTool;
|
||||||
use Openguru\OpenCartFramework\ImageTool\ImageToolInterface;
|
use Openguru\OpenCartFramework\ImageTool\ImageToolInterface;
|
||||||
use Openguru\OpenCartFramework\Logger\LoggerInterface;
|
use Openguru\OpenCartFramework\Logger\LoggerInterface;
|
||||||
use Openguru\OpenCartFramework\Logger\OpenCartLogAdapter;
|
use Openguru\OpenCartFramework\Logger\OpenCartLogAdapter;
|
||||||
use Openguru\OpenCartFramework\OpenCart\Decorators\OcRegistryDecorator;
|
use Openguru\OpenCartFramework\OpenCart\Decorators\OcRegistryDecorator;
|
||||||
|
use Openguru\OpenCartFramework\Support\Arr;
|
||||||
|
|
||||||
$sysLibPath = rtrim(DIR_SYSTEM, '/') . '/library/oc_telegram_shop';
|
$sysLibPath = rtrim(DIR_SYSTEM, '/') . '/library/oc_telegram_shop';
|
||||||
$basePath = rtrim(DIR_APPLICATION, '/') . '/..';
|
$basePath = rtrim(DIR_APPLICATION, '/') . '/..';
|
||||||
@@ -23,6 +25,7 @@ if (is_readable($sysLibPath . '/oc_telegram_shop.phar')) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @property Config $config
|
* @property Config $config
|
||||||
|
* @property Log $log
|
||||||
*/
|
*/
|
||||||
class ControllerExtensionTgshopHandle extends Controller
|
class ControllerExtensionTgshopHandle extends Controller
|
||||||
{
|
{
|
||||||
@@ -32,98 +35,79 @@ class ControllerExtensionTgshopHandle extends Controller
|
|||||||
|
|
||||||
$this->load->model('catalog/product');
|
$this->load->model('catalog/product');
|
||||||
$this->load->model('checkout/order');
|
$this->load->model('checkout/order');
|
||||||
|
$this->load->model('setting/setting');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function index(): void
|
public function index(): void
|
||||||
{
|
{
|
||||||
$this->session->data['language'] = $this->config->get('config_language');
|
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([
|
$items = Arr::mergeArraysRecursively($json['module_telecart_settings'], [
|
||||||
'app_enabled' => filter_var($this->config->get('module_tgshop_status'), FILTER_VALIDATE_BOOLEAN),
|
'app' => [
|
||||||
'app_debug' => $appDebug,
|
'shop_base_url' => HTTPS_SERVER, // for catalog: HTTPS_SERVER, for admin: HTTPS_CATALOG
|
||||||
'oc_config_tax' => $this->config->get('config_tax'),
|
'language_id' => (int) $this->config->get('config_language_id'),
|
||||||
'oc_default_currency' => $this->config->get('config_currency'),
|
],
|
||||||
'oc_customer_group_id' => $this->config->get('config_customer_group_id'),
|
'logs' => [
|
||||||
// ID магазина, для которого будут создаваться заказы из Телеграм
|
'path' => DIR_LOGS,
|
||||||
'oc_store_id' => 0,
|
],
|
||||||
// Название магазина, для которого будут создаваться заказы из Телеграм
|
'database' => [
|
||||||
'oc_store_name' => $this->config->get('config_name'),
|
'host' => DB_HOSTNAME,
|
||||||
// ID статуса, с которым будут создаваться заказы через Телеграм по умолчанию.
|
'database' => DB_DATABASE,
|
||||||
'oc_order_status_id' => (int) $this->config->get('module_tgshop_order_default_status_id'),
|
'username' => DB_USERNAME,
|
||||||
'timezone' => $this->config->get('config_timezone', 'UTC'),
|
'password' => DB_PASSWORD,
|
||||||
'language_id' => (int) $this->config->get('config_language_id'),
|
'prefix' => DB_PREFIX,
|
||||||
'shop_base_url' => HTTPS_SERVER,
|
'port' => (int) DB_PORT,
|
||||||
'dir_image' => DIR_IMAGE,
|
],
|
||||||
'app_name' => $this->config->get('module_tgshop_app_name'),
|
'store' => [
|
||||||
'app_icon' => $this->config->get('module_tgshop_app_icon'),
|
'oc_store_id' => 0,
|
||||||
'theme_light' => $this->config->get('module_tgshop_theme_light'),
|
'oc_default_currency' => $this->config->get('config_currency'),
|
||||||
'theme_dark' => $this->config->get('module_tgshop_theme_dark'),
|
'oc_config_tax' => filter_var($this->config->get('config_tax'), FILTER_VALIDATE_BOOLEAN),
|
||||||
'mainpage_products' => $this->config->get('module_tgshop_mainpage_products'),
|
],
|
||||||
'featured_products' => (array) $this->config->get('module_tgshop_featured_products'),
|
'orders' => [
|
||||||
'mainpage_categories' => $this->config->get('module_tgshop_mainpage_categories'),
|
'oc_customer_group_id' => (int) $this->config->get('config_customer_group_id'),
|
||||||
'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' => [
|
|
||||||
'host' => DB_HOSTNAME,
|
|
||||||
'database' => DB_DATABASE,
|
|
||||||
'username' => DB_USERNAME,
|
|
||||||
'password' => DB_PASSWORD,
|
|
||||||
'prefix' => DB_PREFIX,
|
|
||||||
'port' => DB_PORT,
|
|
||||||
],
|
|
||||||
'logs' => [
|
|
||||||
'path' => DIR_LOGS,
|
|
||||||
],
|
|
||||||
'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'),
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$app->bind(OcModelCatalogProductAdapter::class, function () {
|
$appDebug = Arr::get($items, 'app.app_debug');
|
||||||
return new OcModelCatalogProductAdapter($this->model_catalog_product);
|
|
||||||
});
|
|
||||||
|
|
||||||
$app->bind(Url::class, fn() => $this->url);
|
$app = ApplicationFactory::create($items);
|
||||||
$app->bind(Currency::class, fn() => $this->currency);
|
|
||||||
$app->bind(Tax::class, fn() => $this->tax);
|
|
||||||
$app->bind(ImageToolInterface::class, fn() => new ImageTool(DIR_IMAGE, HTTPS_SERVER));
|
|
||||||
$app->bind(Cart::class, fn() => $this->cart);
|
|
||||||
$app->bind(OcRegistryDecorator::class, fn() => new OcRegistryDecorator($this->registry));
|
|
||||||
$app->singleton(Log::class, fn() => $this->log);
|
|
||||||
|
|
||||||
$app
|
$app->bind(OcModelCatalogProductAdapter::class, function () {
|
||||||
->withLogger(
|
return new OcModelCatalogProductAdapter($this->model_catalog_product);
|
||||||
fn() => new OpenCartLogAdapter(
|
});
|
||||||
$this->log,
|
|
||||||
'TeleCart',
|
$app->bind(Url::class, fn() => $this->url);
|
||||||
$appDebug ? LoggerInterface::LEVEL_DEBUG : LoggerInterface::LEVEL_WARNING,
|
$app->bind(Currency::class, fn() => $this->currency);
|
||||||
|
$app->bind(Tax::class, fn() => $this->tax);
|
||||||
|
$app->bind(ImageToolInterface::class, fn() => new ImageTool(DIR_IMAGE, HTTPS_SERVER));
|
||||||
|
$app->bind(Cart::class, fn() => $this->cart);
|
||||||
|
$app->bind(OcRegistryDecorator::class, fn() => new OcRegistryDecorator($this->registry));
|
||||||
|
$app->singleton(Log::class, fn() => $this->log);
|
||||||
|
|
||||||
|
$app
|
||||||
|
->withLogger(
|
||||||
|
fn() => new OpenCartLogAdapter(
|
||||||
|
$this->log,
|
||||||
|
'TeleCart',
|
||||||
|
$appDebug ? LoggerInterface::LEVEL_DEBUG : LoggerInterface::LEVEL_WARNING,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
->bootAndHandleRequest();
|
||||||
->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)
|
function extractPureJs($input)
|
||||||
@@ -153,15 +137,20 @@ class ControllerExtensionTgshopHandle extends Controller
|
|||||||
|
|
||||||
public function ya_metrika(): void
|
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');
|
||||||
$raw = $this->extractPureJs($raw);
|
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);
|
http_response_code(200);
|
||||||
header('Content-Type: application/javascript');
|
header('Content-Type: application/javascript');
|
||||||
header('Access-Control-Allow-Origin: *');
|
header('Access-Control-Allow-Origin: *');
|
||||||
header('Access-Control-Allow-Methods: GET, POST');
|
header('Access-Control-Allow-Methods: GET, POST');
|
||||||
header('Access-Control-Allow-Headers: Content-Type, Authorization');
|
header('Access-Control-Allow-Headers: Content-Type, Authorization');
|
||||||
header('Access-Control-Allow-Credentials: true');
|
header('Access-Control-Allow-Credentials: true');
|
||||||
echo $raw;
|
echo $raw;
|
||||||
|
} else {
|
||||||
|
http_response_code(404);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class ApplicationFactory
|
|||||||
{
|
{
|
||||||
public static function create(array $settings): Application
|
public static function create(array $settings): Application
|
||||||
{
|
{
|
||||||
$defaultConfig = require __DIR__ . '/config.php';
|
$defaultConfig = require __DIR__ . '/../src/config.php';
|
||||||
$routes = require __DIR__ . '/routes.php';
|
$routes = require __DIR__ . '/routes.php';
|
||||||
|
|
||||||
$merged = Arr::mergeArraysRecursively($defaultConfig, $settings);
|
$merged = Arr::mergeArraysRecursively($defaultConfig, $settings);
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ class SettingsHandler
|
|||||||
'orders',
|
'orders',
|
||||||
'texts',
|
'texts',
|
||||||
'sliders',
|
'sliders',
|
||||||
|
'mainpage_blocks',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return new JsonResponse(compact('data'));
|
return new JsonResponse(compact('data'));
|
||||||
|
|||||||
0
module/oc_telegram_shop/upload/oc_telegram_shop/bastion/Handlers/StatsHandler.php
Normal file → Executable file
0
module/oc_telegram_shop/upload/oc_telegram_shop/bastion/Handlers/StatsHandler.php
Normal file → Executable 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' => [],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
0
module/oc_telegram_shop/upload/oc_telegram_shop/framework/Config/SettingsInterface.php
Normal file → Executable file
0
module/oc_telegram_shop/upload/oc_telegram_shop/framework/Config/SettingsInterface.php
Normal file → Executable file
@@ -10,7 +10,6 @@ final class ConfigDTO
|
|||||||
private StoreDTO $store;
|
private StoreDTO $store;
|
||||||
private OrdersDTO $orders;
|
private OrdersDTO $orders;
|
||||||
private TextsDTO $texts;
|
private TextsDTO $texts;
|
||||||
private SlidersDTO $sliders;
|
|
||||||
private DatabaseDTO $database;
|
private DatabaseDTO $database;
|
||||||
private LogsDTO $logs;
|
private LogsDTO $logs;
|
||||||
|
|
||||||
@@ -21,7 +20,6 @@ final class ConfigDTO
|
|||||||
StoreDTO $store,
|
StoreDTO $store,
|
||||||
OrdersDTO $orders,
|
OrdersDTO $orders,
|
||||||
TextsDTO $texts,
|
TextsDTO $texts,
|
||||||
SlidersDTO $sliders,
|
|
||||||
DatabaseDTO $database,
|
DatabaseDTO $database,
|
||||||
LogsDTO $logs
|
LogsDTO $logs
|
||||||
) {
|
) {
|
||||||
@@ -31,7 +29,6 @@ final class ConfigDTO
|
|||||||
$this->store = $store;
|
$this->store = $store;
|
||||||
$this->orders = $orders;
|
$this->orders = $orders;
|
||||||
$this->texts = $texts;
|
$this->texts = $texts;
|
||||||
$this->sliders = $sliders;
|
|
||||||
$this->database = $database;
|
$this->database = $database;
|
||||||
$this->logs = $logs;
|
$this->logs = $logs;
|
||||||
}
|
}
|
||||||
@@ -66,11 +63,6 @@ final class ConfigDTO
|
|||||||
return $this->texts;
|
return $this->texts;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getSliders(): SlidersDTO
|
|
||||||
{
|
|
||||||
return $this->sliders;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getDatabase(): DatabaseDTO
|
public function getDatabase(): DatabaseDTO
|
||||||
{
|
{
|
||||||
return $this->database;
|
return $this->database;
|
||||||
@@ -89,7 +81,6 @@ final class ConfigDTO
|
|||||||
'logs' => $this->logs->toArray(),
|
'logs' => $this->logs->toArray(),
|
||||||
'metrics' => $this->metrics->toArray(),
|
'metrics' => $this->metrics->toArray(),
|
||||||
'orders' => $this->orders->toArray(),
|
'orders' => $this->orders->toArray(),
|
||||||
'sliders' => $this->sliders->toArray(),
|
|
||||||
'store' => $this->store->toArray(),
|
'store' => $this->store->toArray(),
|
||||||
'telegram' => $this->telegram->toArray(),
|
'telegram' => $this->telegram->toArray(),
|
||||||
'texts' => $this->texts->toArray(),
|
'texts' => $this->texts->toArray(),
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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';
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -5,28 +5,14 @@ namespace App\DTO\Settings;
|
|||||||
final class StoreDTO
|
final class StoreDTO
|
||||||
{
|
{
|
||||||
private bool $enableStore;
|
private bool $enableStore;
|
||||||
private string $mainpageProducts;
|
|
||||||
/** @var int[] */
|
|
||||||
private array $featuredProducts;
|
|
||||||
private string $mainpageCategories;
|
|
||||||
/** @var int[] */
|
|
||||||
private array $featuredCategories;
|
|
||||||
private bool $featureCoupons;
|
private bool $featureCoupons;
|
||||||
private bool $featureVouchers;
|
private bool $featureVouchers;
|
||||||
private string $ocDefaultCurrency;
|
private string $ocDefaultCurrency;
|
||||||
private bool $ocConfigTax;
|
private bool $ocConfigTax;
|
||||||
private int $ocStoreId;
|
private int $ocStoreId;
|
||||||
|
|
||||||
/**
|
|
||||||
* @param int[] $featuredProducts
|
|
||||||
* @param int[] $featuredCategories
|
|
||||||
*/
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
bool $enableStore,
|
bool $enableStore,
|
||||||
string $mainpageProducts,
|
|
||||||
array $featuredProducts,
|
|
||||||
string $mainpageCategories,
|
|
||||||
array $featuredCategories,
|
|
||||||
bool $featureCoupons,
|
bool $featureCoupons,
|
||||||
bool $featureVouchers,
|
bool $featureVouchers,
|
||||||
string $ocDefaultCurrency,
|
string $ocDefaultCurrency,
|
||||||
@@ -34,10 +20,6 @@ final class StoreDTO
|
|||||||
int $ocStoreId
|
int $ocStoreId
|
||||||
) {
|
) {
|
||||||
$this->enableStore = $enableStore;
|
$this->enableStore = $enableStore;
|
||||||
$this->mainpageProducts = $mainpageProducts;
|
|
||||||
$this->featuredProducts = $featuredProducts;
|
|
||||||
$this->mainpageCategories = $mainpageCategories;
|
|
||||||
$this->featuredCategories = $featuredCategories;
|
|
||||||
$this->featureCoupons = $featureCoupons;
|
$this->featureCoupons = $featureCoupons;
|
||||||
$this->featureVouchers = $featureVouchers;
|
$this->featureVouchers = $featureVouchers;
|
||||||
$this->ocDefaultCurrency = $ocDefaultCurrency;
|
$this->ocDefaultCurrency = $ocDefaultCurrency;
|
||||||
@@ -50,32 +32,6 @@ final class StoreDTO
|
|||||||
return $this->enableStore;
|
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
|
public function isFeatureCoupons(): bool
|
||||||
{
|
{
|
||||||
return $this->featureCoupons;
|
return $this->featureCoupons;
|
||||||
@@ -105,10 +61,6 @@ final class StoreDTO
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'enable_store' => $this->enableStore,
|
'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_coupons' => $this->featureCoupons,
|
||||||
'feature_vouchers' => $this->featureVouchers,
|
'feature_vouchers' => $this->featureVouchers,
|
||||||
'oc_default_currency' => $this->ocDefaultCurrency,
|
'oc_default_currency' => $this->ocDefaultCurrency,
|
||||||
|
|||||||
@@ -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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,17 +32,6 @@ class CategoriesHandler
|
|||||||
$languageId = $this->settings->config()->getApp()->getLanguageId();
|
$languageId = $this->settings->config()->getApp()->getLanguageId();
|
||||||
|
|
||||||
$perPage = $request->get('perPage', 100);
|
$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()
|
$categoriesFlat = $this->queryBuilder->newQuery()
|
||||||
->select([
|
->select([
|
||||||
@@ -61,12 +50,6 @@ class CategoriesHandler
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
->where('categories.status', '=', 1)
|
->where('categories.status', '=', 1)
|
||||||
->when(
|
|
||||||
$forMainPage && $mainpageCategories === 'featured' && $featuredCategories,
|
|
||||||
function (Builder $query) use ($featuredCategories) {
|
|
||||||
$query->whereIn('categories.category_id', $featuredCategories);
|
|
||||||
}
|
|
||||||
)
|
|
||||||
->orderBy('parent_id')
|
->orderBy('parent_id')
|
||||||
->orderBy('sort_order')
|
->orderBy('sort_order')
|
||||||
->get();
|
->get();
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
namespace App\Handlers;
|
namespace App\Handlers;
|
||||||
|
|
||||||
use App\Filters\ProductCategory;
|
use App\Filters\ProductCategory;
|
||||||
use App\Filters\ProductForMainPage;
|
|
||||||
use App\Filters\ProductPrice;
|
use App\Filters\ProductPrice;
|
||||||
use Openguru\OpenCartFramework\Http\JsonResponse;
|
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 => [
|
ProductCategory::NAME => [
|
||||||
'criteria' => [
|
'criteria' => [
|
||||||
'product_category_id' => [
|
'product_category_id' => [
|
||||||
|
|||||||
@@ -31,12 +31,13 @@ class ProductsHandler
|
|||||||
{
|
{
|
||||||
$page = (int) $request->json('page', 1);
|
$page = (int) $request->json('page', 1);
|
||||||
$perPage = min((int) $request->json('perPage', 6), 15);
|
$perPage = min((int) $request->json('perPage', 6), 15);
|
||||||
|
$maxPages = (int) $request->json('maxPages', 10);
|
||||||
$search = trim($request->get('search', ''));
|
$search = trim($request->get('search', ''));
|
||||||
$filters = $request->json('filters');
|
$filters = $request->json('filters');
|
||||||
$languageId = $this->settings->config()->getApp()->getLanguageId();
|
$languageId = $this->settings->config()->getApp()->getLanguageId();
|
||||||
|
|
||||||
$response = $this->productsService->getProductsResponse(
|
$response = $this->productsService->getProductsResponse(
|
||||||
compact('page', 'perPage', 'search', 'filters'),
|
compact('page', 'perPage', 'search', 'filters', 'maxPages'),
|
||||||
$languageId,
|
$languageId,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -64,17 +64,13 @@ class SettingsHandler
|
|||||||
'feature_vouchers' => $this->settings->config()->getStore()->isFeatureVouchers(),
|
'feature_vouchers' => $this->settings->config()->getStore()->isFeatureVouchers(),
|
||||||
'currency_code' => $this->settings->config()->getStore()->getOcDefaultCurrency(),
|
'currency_code' => $this->settings->config()->getStore()->getOcDefaultCurrency(),
|
||||||
'texts' => $this->settings->config()->getTexts()->toArray(),
|
'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
|
public function manifest(): JsonResponse
|
||||||
{
|
{
|
||||||
$appIcon = $this->settings->config()->getApp()->getAppIcon();
|
$manifest = [
|
||||||
$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([
|
|
||||||
'name' => $this->settings->config()->getApp()->getAppName(),
|
'name' => $this->settings->config()->getApp()->getAppName(),
|
||||||
'short_name' => $this->settings->config()->getApp()->getAppName(),
|
'short_name' => $this->settings->config()->getApp()->getAppName(),
|
||||||
'start_url' => '/image/catalog/tgshopspa/',
|
'start_url' => '/image/catalog/tgshopspa/',
|
||||||
@@ -82,7 +78,13 @@ class SettingsHandler
|
|||||||
'background_color' => '#ffffff',
|
'background_color' => '#ffffff',
|
||||||
'theme_color' => '#000000',
|
'theme_color' => '#000000',
|
||||||
'orientation' => 'portrait',
|
'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,
|
'src' => $icon192,
|
||||||
'sizes' => '192x192',
|
'sizes' => '192x192',
|
||||||
@@ -93,8 +95,10 @@ class SettingsHandler
|
|||||||
'sizes' => '512x512',
|
'sizes' => '512x512',
|
||||||
'type' => 'image/png',
|
'type' => 'image/png',
|
||||||
]
|
]
|
||||||
]
|
];
|
||||||
]);
|
}
|
||||||
|
|
||||||
|
return new JsonResponse($manifest);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testTgMessage(Request $request): JsonResponse
|
public function testTgMessage(Request $request): JsonResponse
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ use App\Exceptions\CustomExceptionHandler;
|
|||||||
use App\Filters\ProductAttribute;
|
use App\Filters\ProductAttribute;
|
||||||
use App\Filters\ProductCategories;
|
use App\Filters\ProductCategories;
|
||||||
use App\Filters\ProductCategory;
|
use App\Filters\ProductCategory;
|
||||||
use App\Filters\ProductForMainPage;
|
|
||||||
use App\Filters\ProductManufacturer;
|
use App\Filters\ProductManufacturer;
|
||||||
use App\Filters\ProductModel;
|
use App\Filters\ProductModel;
|
||||||
use App\Filters\ProductPrice;
|
use App\Filters\ProductPrice;
|
||||||
@@ -64,7 +63,6 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
ProductPrice::NAME => ProductPrice::class,
|
ProductPrice::NAME => ProductPrice::class,
|
||||||
ProductQuantity::NAME => ProductQuantity::class,
|
ProductQuantity::NAME => ProductQuantity::class,
|
||||||
ProductStatus::NAME => ProductStatus::class,
|
ProductStatus::NAME => ProductStatus::class,
|
||||||
ProductForMainPage::NAME => ProductForMainPage::class,
|
|
||||||
ProductCategory::NAME => ProductCategory::class,
|
ProductCategory::NAME => ProductCategory::class,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
0
module/oc_telegram_shop/upload/oc_telegram_shop/src/ServiceProviders/SettingsServiceProvider.php
Normal file → Executable file
0
module/oc_telegram_shop/upload/oc_telegram_shop/src/ServiceProviders/SettingsServiceProvider.php
Normal file → Executable 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,13 +6,8 @@ use App\DTO\Settings\AppDTO;
|
|||||||
use App\DTO\Settings\ConfigDTO;
|
use App\DTO\Settings\ConfigDTO;
|
||||||
use App\DTO\Settings\DatabaseDTO;
|
use App\DTO\Settings\DatabaseDTO;
|
||||||
use App\DTO\Settings\LogsDTO;
|
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\MetricsDTO;
|
||||||
use App\DTO\Settings\OrdersDTO;
|
use App\DTO\Settings\OrdersDTO;
|
||||||
use App\DTO\Settings\SlidersDTO;
|
|
||||||
use App\DTO\Settings\StoreDTO;
|
use App\DTO\Settings\StoreDTO;
|
||||||
use App\DTO\Settings\TelegramDTO;
|
use App\DTO\Settings\TelegramDTO;
|
||||||
use App\DTO\Settings\TextsDTO;
|
use App\DTO\Settings\TextsDTO;
|
||||||
@@ -22,7 +17,7 @@ class SettingsSerializerService
|
|||||||
{
|
{
|
||||||
public function fromArray(array $data): ConfigDTO
|
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) {
|
foreach ($keys as $key) {
|
||||||
if (! array_key_exists($key, $data)) {
|
if (! array_key_exists($key, $data)) {
|
||||||
throw new InvalidArgumentException("Settings key '$key' is required!");
|
throw new InvalidArgumentException("Settings key '$key' is required!");
|
||||||
@@ -35,7 +30,6 @@ class SettingsSerializerService
|
|||||||
$this->validateStore($data['store']);
|
$this->validateStore($data['store']);
|
||||||
$this->validateOrders($data['orders']);
|
$this->validateOrders($data['orders']);
|
||||||
$this->validateTexts($data['texts']);
|
$this->validateTexts($data['texts']);
|
||||||
$this->validateSliders($data['sliders']);
|
|
||||||
$this->validateDatabase($data['database']);
|
$this->validateDatabase($data['database']);
|
||||||
$this->validateLogs($data['logs']);
|
$this->validateLogs($data['logs']);
|
||||||
|
|
||||||
@@ -46,7 +40,6 @@ class SettingsSerializerService
|
|||||||
$this->deserializeStore($data['store']),
|
$this->deserializeStore($data['store']),
|
||||||
$this->deserializeOrders($data['orders']),
|
$this->deserializeOrders($data['orders']),
|
||||||
$this->deserializeTexts($data['texts']),
|
$this->deserializeTexts($data['texts']),
|
||||||
$this->deserializeSliders($data['sliders']),
|
|
||||||
$this->deserializeDatabase($data['database']),
|
$this->deserializeDatabase($data['database']),
|
||||||
$this->deserializeLogs($data['logs']),
|
$this->deserializeLogs($data['logs']),
|
||||||
);
|
);
|
||||||
@@ -146,10 +139,6 @@ class SettingsSerializerService
|
|||||||
|
|
||||||
return new StoreDTO(
|
return new StoreDTO(
|
||||||
$data['enable_store'] ?? true,
|
$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_coupons'] ?? true,
|
||||||
$data['feature_vouchers'] ?? true,
|
$data['feature_vouchers'] ?? true,
|
||||||
$data['oc_default_currency'],
|
$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 ====================
|
// ==================== Validation Methods ====================
|
||||||
|
|
||||||
private function validateApp(array $data): void
|
private function validateApp(array $data): void
|
||||||
@@ -340,44 +269,6 @@ class SettingsSerializerService
|
|||||||
throw new InvalidArgumentException('store.enable_store must be a boolean');
|
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'])) {
|
if (isset($data['feature_coupons']) && ! is_bool($data['feature_coupons'])) {
|
||||||
throw new InvalidArgumentException('store.feature_coupons must be a boolean');
|
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
|
private function deserializeLogs(array $logs): LogsDTO
|
||||||
{
|
{
|
||||||
return new LogsDTO(
|
return new LogsDTO(
|
||||||
|
|||||||
0
module/oc_telegram_shop/upload/oc_telegram_shop/src/Services/SettingsService.php
Normal file → Executable file
0
module/oc_telegram_shop/upload/oc_telegram_shop/src/Services/SettingsService.php
Normal file → Executable file
@@ -1,20 +1,79 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'config_timezone' => 'UTC',
|
'app' => [
|
||||||
'lang' => 'en-gb',
|
'app_enabled' => true,
|
||||||
'language_id' => 1,
|
'app_name' => 'Telecart',
|
||||||
'auth_user_id' => 0,
|
'app_icon' => null,
|
||||||
'base_url' => 'http://localhost',
|
"theme_light" => "light",
|
||||||
|
"theme_dark" => "dark",
|
||||||
'db' => [
|
"app_debug" => false
|
||||||
'host' => 'localhost',
|
|
||||||
'database' => 'not_set',
|
|
||||||
'username' => 'not_set',
|
|
||||||
'password' => 'not_set',
|
|
||||||
],
|
],
|
||||||
|
|
||||||
'logs' => [
|
'telegram' => [
|
||||||
'path' => 'not_set',
|
"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,
|
||||||
|
],
|
||||||
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Handlers\BannerHandler;
|
use App\Handlers\BannerHandler;
|
||||||
|
use App\Handlers\BlocksHandler;
|
||||||
use App\Handlers\CartHandler;
|
use App\Handlers\CartHandler;
|
||||||
use App\Handlers\CategoriesHandler;
|
use App\Handlers\CategoriesHandler;
|
||||||
use App\Handlers\FiltersHandler;
|
use App\Handlers\FiltersHandler;
|
||||||
@@ -27,7 +28,6 @@ return [
|
|||||||
'manifest' => [SettingsHandler::class, 'manifest'],
|
'manifest' => [SettingsHandler::class, 'manifest'],
|
||||||
'testTgMessage' => [SettingsHandler::class, 'testTgMessage'],
|
'testTgMessage' => [SettingsHandler::class, 'testTgMessage'],
|
||||||
|
|
||||||
'banner' => [BannerHandler::class, 'show'],
|
|
||||||
|
|
||||||
'webhook' => [TelegramHandler::class, 'webhook'],
|
'webhook' => [TelegramHandler::class, 'webhook'],
|
||||||
|
'processBlock' => [BlocksHandler::class, 'processBlock'],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ namespace Tests\Unit\Framework;
|
|||||||
use App\Filters\ProductAttribute;
|
use App\Filters\ProductAttribute;
|
||||||
use App\Filters\ProductCategories;
|
use App\Filters\ProductCategories;
|
||||||
use App\Filters\ProductCategory;
|
use App\Filters\ProductCategory;
|
||||||
use App\Filters\ProductForMainPage;
|
|
||||||
use App\Filters\ProductManufacturer;
|
use App\Filters\ProductManufacturer;
|
||||||
use App\Filters\ProductModel;
|
use App\Filters\ProductModel;
|
||||||
use App\Filters\ProductPrice;
|
use App\Filters\ProductPrice;
|
||||||
@@ -89,7 +88,6 @@ class CriteriaBuilderTest extends TestCase
|
|||||||
$rulesRegistry->register(ProductManufacturer::NAME, ProductManufacturer::class);
|
$rulesRegistry->register(ProductManufacturer::NAME, ProductManufacturer::class);
|
||||||
$rulesRegistry->register(ProductQuantity::NAME, ProductQuantity::class);
|
$rulesRegistry->register(ProductQuantity::NAME, ProductQuantity::class);
|
||||||
$rulesRegistry->register(ProductAttribute::NAME, ProductAttribute::class);
|
$rulesRegistry->register(ProductAttribute::NAME, ProductAttribute::class);
|
||||||
$rulesRegistry->register(ProductForMainPage::NAME, ProductForMainPage::class);
|
|
||||||
$rulesRegistry->register(ProductCategory::NAME, ProductCategory::class);
|
$rulesRegistry->register(ProductCategory::NAME, ProductCategory::class);
|
||||||
|
|
||||||
$this->builder = $application->get(Builder::class);
|
$this->builder = $application->get(Builder::class);
|
||||||
|
|||||||
0
module/oc_telegram_shop/upload/oc_telegram_shop/tests/Unit/Framework/ExecutionTimeProfilerTest.php
Normal file → Executable file
0
module/oc_telegram_shop/upload/oc_telegram_shop/tests/Unit/Framework/ExecutionTimeProfilerTest.php
Normal file → Executable file
0
module/oc_telegram_shop/upload/oc_telegram_shop/tests/Unit/Framework/JsonResponseTest.php
Normal file → Executable file
0
module/oc_telegram_shop/upload/oc_telegram_shop/tests/Unit/Framework/JsonResponseTest.php
Normal file → Executable file
0
module/oc_telegram_shop/upload/oc_telegram_shop/tests/Unit/Framework/PaginationHelperTest.php
Normal file → Executable file
0
module/oc_telegram_shop/upload/oc_telegram_shop/tests/Unit/Framework/PaginationHelperTest.php
Normal file → Executable file
0
module/oc_telegram_shop/upload/oc_telegram_shop/tests/Unit/Framework/QueryResultTest.php
Normal file → Executable file
0
module/oc_telegram_shop/upload/oc_telegram_shop/tests/Unit/Framework/QueryResultTest.php
Normal file → Executable file
0
module/oc_telegram_shop/upload/oc_telegram_shop/tests/Unit/Framework/RawExpressionTest.php
Normal file → Executable file
0
module/oc_telegram_shop/upload/oc_telegram_shop/tests/Unit/Framework/RawExpressionTest.php
Normal file → Executable file
0
module/oc_telegram_shop/upload/oc_telegram_shop/tests/Unit/Framework/UtilsTest.php
Normal file → Executable file
0
module/oc_telegram_shop/upload/oc_telegram_shop/tests/Unit/Framework/UtilsTest.php
Normal file → Executable 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"mainpage_products": "featured",
|
|
||||||
"featured_products": [1, 2, 3]
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"operand": "AND",
|
|
||||||
"rules": {
|
|
||||||
"RULE_PRODUCT_FOR_MAIN_PAGE": {
|
|
||||||
"criteria": {
|
|
||||||
"product_for_main_page": {
|
|
||||||
"type": "boolean",
|
|
||||||
"params": {
|
|
||||||
"operator": "equals",
|
|
||||||
"value": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"mainpage_products": "featured",
|
|
||||||
"featured_products": []
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"operand": "AND",
|
|
||||||
"rules": {
|
|
||||||
"RULE_PRODUCT_FOR_MAIN_PAGE": {
|
|
||||||
"criteria": {
|
|
||||||
"product_for_main_page": {
|
|
||||||
"type": "boolean",
|
|
||||||
"params": {
|
|
||||||
"operator": "equals",
|
|
||||||
"value": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"mainpage_products": "latest"
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"operand": "AND",
|
|
||||||
"rules": {
|
|
||||||
"RULE_PRODUCT_FOR_MAIN_PAGE": {
|
|
||||||
"criteria": {
|
|
||||||
"product_for_main_page": {
|
|
||||||
"type": "boolean",
|
|
||||||
"params": {
|
|
||||||
"operator": "equals",
|
|
||||||
"value": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"mainpage_products": "most_viewed"
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user