diff --git a/frontend/admin/bun.lock b/frontend/admin/bun.lock index ed406be..52ad3f2 100644 --- a/frontend/admin/bun.lock +++ b/frontend/admin/bun.lock @@ -9,12 +9,14 @@ "@vueuse/core": "^14.0.0", "axios": "^1.13.1", "daisyui": "^5.4.2", + "js-md5": "^0.8.3", "mitt": "^3.0.1", "pinia": "^3.0.3", "primevue": "^4.4.1", "tailwindcss": "^4.1.16", "vue": "^3.5.22", "vue-router": "^4.6.3", + "vuedraggable": "^4.1.0", }, "devDependencies": { "@eslint/js": "^9.37.0", @@ -576,6 +578,8 @@ "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + "js-md5": ["js-md5@0.8.3", "", {}, "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], @@ -728,6 +732,8 @@ "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], + "sortablejs": ["sortablejs@1.14.0", "", {}, "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "speakingurl": ["speakingurl@14.0.1", "", {}, "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ=="], @@ -778,6 +784,8 @@ "vue-router": ["vue-router@4.6.3", "", { "dependencies": { "@vue/devtools-api": "^6.6.4" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-ARBedLm9YlbvQomnmq91Os7ck6efydTSpRP3nuOKCvgJOHNrhRoJDSKtee8kcL1Vf7nz6U+PMBL+hTvR3bTVQg=="], + "vuedraggable": ["vuedraggable@4.1.0", "", { "dependencies": { "sortablejs": "1.14.0" }, "peerDependencies": { "vue": "^3.0.1" } }, "sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww=="], + "which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="], "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], diff --git a/frontend/admin/package.json b/frontend/admin/package.json index f953149..a2489d9 100644 --- a/frontend/admin/package.json +++ b/frontend/admin/package.json @@ -21,12 +21,14 @@ "@vueuse/core": "^14.0.0", "axios": "^1.13.1", "daisyui": "^5.4.2", + "js-md5": "^0.8.3", "mitt": "^3.0.1", "pinia": "^3.0.3", "primevue": "^4.4.1", "tailwindcss": "^4.1.16", "vue": "^3.5.22", - "vue-router": "^4.6.3" + "vue-router": "^4.6.3", + "vuedraggable": "^4.1.0" }, "devDependencies": { "@eslint/js": "^9.37.0", diff --git a/frontend/admin/src/App.vue b/frontend/admin/src/App.vue index c02445b..9776f3c 100644 --- a/frontend/admin/src/App.vue +++ b/frontend/admin/src/App.vue @@ -26,8 +26,8 @@ Заказы -
  • - Слайдер +
  • + Главная страница
  • @@ -36,20 +36,39 @@
    -
    -
    -
    +
    +
    Загрузка...
    + +
    -
    -
    +
    +
    {{ settings.error }}
    Обратитесь в поддержку
    @@ -61,15 +80,36 @@ import {RouterView, useRoute} from 'vue-router'; import {useSettingsStore} from "@/stores/settings.js"; import Toast from 'primevue/toast'; -import { toastBus } from '@/utils/toastHelper'; +import {toastBus} from '@/utils/toastHelper'; import {useToast} from "primevue"; import Button from 'primevue/button'; import TopLead from "@/components/TopLead.vue"; +import Divider from 'primevue/divider'; +import ConfirmDialog from 'primevue/confirmdialog'; +import ConfirmPopup from 'primevue/confirmpopup'; +import {onBeforeUnmount, onMounted} from "vue"; const route = useRoute(); const settings = useSettingsStore(); const toast = useToast(); toastBus.on('show', (data) => toast.add(data)); + +// Защита от обновления страницы или закрытия вкладки +function handleBeforeUnload(event) { + if (settings.hasUnsavedChanges) { + event.preventDefault(); + event.returnValue = 'У вас есть несохранённые изменения. Вы уверены, что хотите покинуть страницу?'; + return event.returnValue; + } +} + +onMounted(() => { + window.addEventListener('beforeunload', handleBeforeUnload); +}); + +onBeforeUnmount(() => { + window.removeEventListener('beforeunload', handleBeforeUnload); +}); diff --git a/frontend/admin/src/assets/main.css b/frontend/admin/src/assets/main.css index f31b547..3abaa46 100644 --- a/frontend/admin/src/assets/main.css +++ b/frontend/admin/src/assets/main.css @@ -39,3 +39,10 @@ html { border-radius: unset; margin: 0; } + +legend.p-fieldset-legend { + font-size: 14px; + line-height: inherit; + width: auto; + margin-bottom: 0; +} diff --git a/frontend/admin/src/components/MainPageConfigurator/Blocks/BaseBlock.vue b/frontend/admin/src/components/MainPageConfigurator/Blocks/BaseBlock.vue new file mode 100644 index 0000000..f6f44d5 --- /dev/null +++ b/frontend/admin/src/components/MainPageConfigurator/Blocks/BaseBlock.vue @@ -0,0 +1,70 @@ + + + + + diff --git a/frontend/admin/src/components/MainPageConfigurator/Blocks/CategoriesTopBlock.vue b/frontend/admin/src/components/MainPageConfigurator/Blocks/CategoriesTopBlock.vue new file mode 100644 index 0000000..e98310a --- /dev/null +++ b/frontend/admin/src/components/MainPageConfigurator/Blocks/CategoriesTopBlock.vue @@ -0,0 +1,29 @@ + + + diff --git a/frontend/admin/src/components/MainPageConfigurator/Blocks/ProductsFeedBlock.vue b/frontend/admin/src/components/MainPageConfigurator/Blocks/ProductsFeedBlock.vue new file mode 100644 index 0000000..34ec27b --- /dev/null +++ b/frontend/admin/src/components/MainPageConfigurator/Blocks/ProductsFeedBlock.vue @@ -0,0 +1,30 @@ + + + diff --git a/frontend/admin/src/components/MainPageConfigurator/Blocks/SliderBlock.vue b/frontend/admin/src/components/MainPageConfigurator/Blocks/SliderBlock.vue new file mode 100644 index 0000000..326a8ed --- /dev/null +++ b/frontend/admin/src/components/MainPageConfigurator/Blocks/SliderBlock.vue @@ -0,0 +1,46 @@ + + + diff --git a/frontend/admin/src/components/MainPageConfigurator/Forms/BaseForm.vue b/frontend/admin/src/components/MainPageConfigurator/Forms/BaseForm.vue new file mode 100644 index 0000000..dc5bd0b --- /dev/null +++ b/frontend/admin/src/components/MainPageConfigurator/Forms/BaseForm.vue @@ -0,0 +1,128 @@ + + + diff --git a/frontend/admin/src/components/MainPageConfigurator/Forms/CategoriesTopForm.vue b/frontend/admin/src/components/MainPageConfigurator/Forms/CategoriesTopForm.vue new file mode 100644 index 0000000..15b5e84 --- /dev/null +++ b/frontend/admin/src/components/MainPageConfigurator/Forms/CategoriesTopForm.vue @@ -0,0 +1,55 @@ + + + diff --git a/frontend/admin/src/components/MainPageConfigurator/Forms/FormItem.vue b/frontend/admin/src/components/MainPageConfigurator/Forms/FormItem.vue new file mode 100644 index 0000000..9f8b9c6 --- /dev/null +++ b/frontend/admin/src/components/MainPageConfigurator/Forms/FormItem.vue @@ -0,0 +1,25 @@ + + + diff --git a/frontend/admin/src/components/MainPageConfigurator/Forms/ProductsFeedForm.vue b/frontend/admin/src/components/MainPageConfigurator/Forms/ProductsFeedForm.vue new file mode 100644 index 0000000..6073915 --- /dev/null +++ b/frontend/admin/src/components/MainPageConfigurator/Forms/ProductsFeedForm.vue @@ -0,0 +1,56 @@ + + + diff --git a/frontend/admin/src/components/MainPageConfigurator/Forms/SliderForm.vue b/frontend/admin/src/components/MainPageConfigurator/Forms/SliderForm.vue new file mode 100644 index 0000000..a037648 --- /dev/null +++ b/frontend/admin/src/components/MainPageConfigurator/Forms/SliderForm.vue @@ -0,0 +1,271 @@ + + + + + diff --git a/frontend/admin/src/components/MainPageConfigurator/MainPageConfigurator.vue b/frontend/admin/src/components/MainPageConfigurator/MainPageConfigurator.vue new file mode 100644 index 0000000..3cc75b8 --- /dev/null +++ b/frontend/admin/src/components/MainPageConfigurator/MainPageConfigurator.vue @@ -0,0 +1,195 @@ + + + + + diff --git a/frontend/admin/src/components/MainPageConfigurator/availableBlocks.js b/frontend/admin/src/components/MainPageConfigurator/availableBlocks.js new file mode 100644 index 0000000..0aaa3e1 --- /dev/null +++ b/frontend/admin/src/components/MainPageConfigurator/availableBlocks.js @@ -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, + }, + }, +]; diff --git a/frontend/admin/src/components/Slider/Slider.vue b/frontend/admin/src/components/Slider/Slider.vue deleted file mode 100644 index 6b178a8..0000000 --- a/frontend/admin/src/components/Slider/Slider.vue +++ /dev/null @@ -1,182 +0,0 @@ - - - - - diff --git a/frontend/admin/src/main.js b/frontend/admin/src/main.js index 4097131..f3cfd6b 100644 --- a/frontend/admin/src/main.js +++ b/frontend/admin/src/main.js @@ -8,6 +8,8 @@ import PrimeVue from 'primevue/config'; import Aura from '@primeuix/themes/aura'; import ToastService from 'primevue/toastservice'; import {definePreset} from "@primeuix/themes"; +import Tooltip from 'primevue/tooltip'; +import ConfirmationService from 'primevue/confirmationservice'; const MyPreset = definePreset(Aura, { @@ -34,6 +36,8 @@ onReady(async () => { } }); app.use(ToastService); + app.directive('tooltip', Tooltip); + app.use(ConfirmationService); app.mount('#app'); await useSettingsStore().fetchSettings(); diff --git a/frontend/admin/src/router/index.js b/frontend/admin/src/router/index.js index 5bbba6a..e281564 100644 --- a/frontend/admin/src/router/index.js +++ b/frontend/admin/src/router/index.js @@ -1,22 +1,22 @@ import {createMemoryHistory, createRouter} from 'vue-router'; -import SliderView from "@/views/SliderView.vue"; import GeneralView from "@/views/GeneralView.vue"; import TextsView from "@/views/TextsView.vue"; import OrdersView from "@/views/OrdersView.vue"; import TelegramView from "@/views/TelegramView.vue"; import MetricsView from "@/views/MetricsView.vue"; import StoreView from "@/views/StoreView.vue"; +import MainPageView from "@/views/MainPageView.vue"; const router = createRouter({ history: createMemoryHistory(), routes: [ {path: '/', name: 'general', component: GeneralView}, - {path: '/slider', name: 'slider', component: SliderView}, {path: '/orders', name: 'orders', component: OrdersView}, {path: '/texts', name: 'texts', component: TextsView}, {path: '/telegram', name: 'telegram', component: TelegramView}, {path: '/metrics', name: 'metrics', component: MetricsView}, {path: '/store', name: 'store', component: StoreView}, + {path: '/mainpage', name: 'mainpage', component: MainPageView}, ], }); diff --git a/frontend/admin/src/stores/settings.js b/frontend/admin/src/stores/settings.js index 1c77814..db229f8 100644 --- a/frontend/admin/src/stores/settings.js +++ b/frontend/admin/src/stores/settings.js @@ -1,11 +1,13 @@ import {defineStore} from "pinia"; import {apiGet, apiPost} from "@/utils/http.js"; import {toastBus} from "@/utils/toastHelper.js"; +import {md5} from "js-md5"; export const useSettingsStore = defineStore('settings', { state: () => ({ isLoading: false, error: null, + originalItemsHash: null, items: { app: { @@ -32,10 +34,6 @@ export const useSettingsStore = defineStore('settings', { store: { enable_store: true, - mainpage_products: 'most_viewed', - featured_products: [], - mainpage_categories: 'latest10', - featured_categories: [], feature_coupons: true, feature_vouchers: true, }, @@ -63,6 +61,8 @@ export const useSettingsStore = defineStore('settings', { slides: [], }, }, + + mainpage_blocks: [], }, }), @@ -74,6 +74,10 @@ export const useSettingsStore = defineStore('settings', { const filename = state.items.app.app_icon.substring(0, extIndex); return `/image/cache/${filename}-100x100${ext}`; }, + hasUnsavedChanges: (state) => { + if (!state.originalItemsHash) return false; + return md5(JSON.stringify(state.items)) !== state.originalItemsHash; + }, }, actions: { @@ -86,6 +90,8 @@ export const useSettingsStore = defineStore('settings', { ...this.items, ...response.data, }; + // Сохраняем хеш исходного состояния после загрузки + this.originalItemsHash = md5(JSON.stringify(this.items)); } else { this.error = 'Возникли проблемы при загрузке настроек.'; } @@ -104,6 +110,8 @@ export const useSettingsStore = defineStore('settings', { detail: 'Настройки сохранены.', life: 2000, }); + // Обновляем хеш исходного состояния после успешного сохранения + this.originalItemsHash = md5(JSON.stringify(this.items)); } else { toastBus.emit('show', { severity: 'error', @@ -114,7 +122,6 @@ export const useSettingsStore = defineStore('settings', { } - this.isLoading = false; }, diff --git a/frontend/admin/src/utils/constants..js b/frontend/admin/src/utils/constants..js new file mode 100644 index 0000000..4da83d8 --- /dev/null +++ b/frontend/admin/src/utils/constants..js @@ -0,0 +1,7 @@ +export const sliderEffectOptions = { + slide: 'Слайд', + flip: 'Переворот', + cards: 'Карточки', + cube: 'Куб', + coverflow: 'Перекрывающиеся слайды', +}; diff --git a/frontend/admin/src/utils/helpers.js b/frontend/admin/src/utils/helpers.js new file mode 100644 index 0000000..7e50644 --- /dev/null +++ b/frontend/admin/src/utils/helpers.js @@ -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}`; +} diff --git a/frontend/admin/src/views/MainPageView.vue b/frontend/admin/src/views/MainPageView.vue new file mode 100644 index 0000000..d1d6aa2 --- /dev/null +++ b/frontend/admin/src/views/MainPageView.vue @@ -0,0 +1,7 @@ + + + diff --git a/frontend/admin/src/views/SliderView.vue b/frontend/admin/src/views/SliderView.vue deleted file mode 100644 index 152fce9..0000000 --- a/frontend/admin/src/views/SliderView.vue +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/frontend/admin/src/views/StoreView.vue b/frontend/admin/src/views/StoreView.vue index 6e07e75..54d2870 100644 --- a/frontend/admin/src/views/StoreView.vue +++ b/frontend/admin/src/views/StoreView.vue @@ -7,40 +7,6 @@ вашем сайте. В этом режиме Telecart работает как каталог.

    - - Выберите, какие товары показывать на главной странице магазина в Telegram. - Это влияет на первую видимую секцию каталога для пользователя. - - - - На главной странице будут отображаться избранные товары, если вы выберете этот вариант в - настройке “Товары на главной”. Если товары не выбраны, то будут показаны популярные товары. - - - - Выберите, какие товары показывать на главной странице магазина в Telegram. - Это влияет на первую видимую секцию каталога для пользователя. - - - - На главной странице будут отображаться эти категории, - если вы выберете этот вариант в настройке “Категории на главной”. - -

    Позволяет использовать стандартные @@ -65,11 +31,7 @@ import ItemProductsSelect from "@/components/Settings/ItemProductsSelect.vue"; import ItemCategoriesSelect from "@/components/Settings/ItemCategoriesSelect.vue"; const settings = useSettingsStore(); -const mainpage_products_options = { - most_viewed: 'Популярные товары', - latest: 'Последние сверху', - featured: 'Избранные товары (задать в поле ниже)', -}; + const mainpage_categories_options = { no_categories: 'Отображать только кнопку "Каталог"', latest10: 'Последние 10 категорий', diff --git a/frontend/spa/src/App.vue b/frontend/spa/src/App.vue index 3831b8c..9a7c403 100644 --- a/frontend/spa/src/App.vue +++ b/frontend/spa/src/App.vue @@ -1,6 +1,6 @@ + + diff --git a/frontend/spa/src/components/MainPage/Blocks/ErrorBlock.vue b/frontend/spa/src/components/MainPage/Blocks/ErrorBlock.vue new file mode 100644 index 0000000..88ac763 --- /dev/null +++ b/frontend/spa/src/components/MainPage/Blocks/ErrorBlock.vue @@ -0,0 +1,8 @@ + diff --git a/frontend/spa/src/components/MainPage/Blocks/ProductsFeedBlock.vue b/frontend/spa/src/components/MainPage/Blocks/ProductsFeedBlock.vue new file mode 100644 index 0000000..2a6eeff --- /dev/null +++ b/frontend/spa/src/components/MainPage/Blocks/ProductsFeedBlock.vue @@ -0,0 +1,110 @@ + + + diff --git a/frontend/spa/src/components/MainPage/Blocks/SliderBlock.vue b/frontend/spa/src/components/MainPage/Blocks/SliderBlock.vue new file mode 100644 index 0000000..47d580c --- /dev/null +++ b/frontend/spa/src/components/MainPage/Blocks/SliderBlock.vue @@ -0,0 +1,26 @@ + + + + + \ No newline at end of file diff --git a/frontend/spa/src/components/MainPage/EmptyBlocks.vue b/frontend/spa/src/components/MainPage/EmptyBlocks.vue new file mode 100644 index 0000000..cdefa2e --- /dev/null +++ b/frontend/spa/src/components/MainPage/EmptyBlocks.vue @@ -0,0 +1,34 @@ + + + + + \ No newline at end of file diff --git a/frontend/spa/src/components/MainPage/MainPage.vue b/frontend/spa/src/components/MainPage/MainPage.vue new file mode 100644 index 0000000..da20edc --- /dev/null +++ b/frontend/spa/src/components/MainPage/MainPage.vue @@ -0,0 +1,41 @@ + + + diff --git a/frontend/spa/src/components/ProductsList.vue b/frontend/spa/src/components/ProductsList.vue index 47003ff..bc72602 100644 --- a/frontend/spa/src/components/ProductsList.vue +++ b/frontend/spa/src/components/ProductsList.vue @@ -1,50 +1,66 @@ - -

    -
    -
    -
    -
    -
    +
    - +
    +
    + +
    +
    @@ -55,9 +71,15 @@ import {useSettingsStore} from "@/stores/SettingsStore.js"; import {ref} from "vue"; import {useIntersectionObserver} from '@vueuse/core'; import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js"; +import IconFunnel from "@/components/Icons/IconFunnel.vue"; +import {useProductFiltersStore} from "@/stores/ProductFiltersStore.js"; +import {useRouter} from "vue-router"; +const router = useRouter(); +const haptic = window.Telegram.WebApp.HapticFeedback; const yaMetrika = useYaMetrikaStore(); const settings = useSettingsStore(); +const filtersStore = useProductFiltersStore(); const bottom = ref(null); const emits = defineEmits(['loadMore']); @@ -128,6 +150,11 @@ useIntersectionObserver( rootMargin: '400px 0', } ); + +function showFilters() { + haptic.impactOccurred('soft'); + router.push({name: 'filters'}); +}