From afade85d004872d10929119db3ac95ee3acd0251 Mon Sep 17 00:00:00 2001 From: Nikita Kiselev Date: Thu, 25 Dec 2025 22:34:23 +0300 Subject: [PATCH] feat: add haptic feedback toggle setting - Add haptic_enabled field to AppDTO with default value true - Update SettingsSerializerService to deserialize haptic_enabled - Add haptic_enabled setting in admin panel (GeneralView) with toggle - Update admin settings store to include haptic_enabled default value - Update SPA SettingsStore to load haptic_enabled from API - Refactor useHapticFeedback composable to return safe wrapper object - Replace all direct window.Telegram.WebApp.HapticFeedback usage with composable - Update useHapticScroll to use useHapticFeedback composable - Add getHapticFeedback helper function in CheckoutStore for store usage - Add haptic_enabled default value to app.php configuration - All haptic feedback methods now check settings internally - Remove redundant if checks from components (handled in composable) --- frontend/admin/src/stores/settings.js | 1 + frontend/admin/src/views/GeneralView.vue | 4 ++ frontend/spa/src/App.vue | 3 +- frontend/spa/src/components/CartButton.vue | 4 +- frontend/spa/src/components/Dock.vue | 3 +- .../src/components/FullScreenImageViewer.vue | 6 ++- .../MainPage/Blocks/CategoriesTopBlock.vue | 4 +- frontend/spa/src/components/ProductsList.vue | 5 ++- frontend/spa/src/components/Quantity.vue | 7 +++- frontend/spa/src/components/SearchInput.vue | 4 +- .../spa/src/composables/useHapticFeedback.js | 38 +++++++++++++++++- .../spa/src/composables/useHapticScroll.js | 15 ++++--- frontend/spa/src/stores/CheckoutStore.js | 40 ++++++++++++++++++- frontend/spa/src/stores/SettingsStore.js | 2 + frontend/spa/src/views/Account.vue | 8 ++-- frontend/spa/src/views/Cart.vue | 4 +- frontend/spa/src/views/Filters.vue | 3 +- frontend/spa/src/views/Home.vue | 3 +- frontend/spa/src/views/Product.vue | 12 +++--- .../upload/oc_telegram_shop/configs/app.php | 1 + .../src/DTO/Settings/AppDTO.php | 11 ++++- .../src/Handlers/SettingsHandler.php | 1 + .../Services/SettingsSerializerService.php | 3 +- 23 files changed, 147 insertions(+), 35 deletions(-) diff --git a/frontend/admin/src/stores/settings.js b/frontend/admin/src/stores/settings.js index 79a58e1..a5afb68 100644 --- a/frontend/admin/src/stores/settings.js +++ b/frontend/admin/src/stores/settings.js @@ -20,6 +20,7 @@ export const useSettingsStore = defineStore('settings', { privacy_policy_link: null, image_aspect_ratio: '1:1', image_crop_algorithm: 'cover', + haptic_enabled: true, }, telegram: { diff --git a/frontend/admin/src/views/GeneralView.vue b/frontend/admin/src/views/GeneralView.vue index 38b782e..dea2655 100644 --- a/frontend/admin/src/views/GeneralView.vue +++ b/frontend/admin/src/views/GeneralView.vue @@ -62,6 +62,10 @@
  • Resize - изменяет размер изображения с сохранением пропорций (без обрезки)
  • + + + Включить виброотклик при взаимодействии с элементами интерфейса. Если выключено, тактильная обратная связь не будет использоваться. + diff --git a/frontend/spa/src/components/MainPage/Blocks/CategoriesTopBlock.vue b/frontend/spa/src/components/MainPage/Blocks/CategoriesTopBlock.vue index 03a683a..d9c8529 100644 --- a/frontend/spa/src/components/MainPage/Blocks/CategoriesTopBlock.vue +++ b/frontend/spa/src/components/MainPage/Blocks/CategoriesTopBlock.vue @@ -30,8 +30,10 @@ diff --git a/frontend/spa/src/components/ProductsList.vue b/frontend/spa/src/components/ProductsList.vue index 3b5db85..f5f25ba 100644 --- a/frontend/spa/src/components/ProductsList.vue +++ b/frontend/spa/src/components/ProductsList.vue @@ -66,9 +66,10 @@ import IconFunnel from "@/components/Icons/IconFunnel.vue"; import {useProductFiltersStore} from "@/stores/ProductFiltersStore.js"; import {useRouter} from "vue-router"; import PriceTitle from "@/components/ProductItem/PriceTitle.vue"; +import {useHapticFeedback} from "@/composables/useHapticFeedback.js"; const router = useRouter(); -const haptic = window.Telegram.WebApp.HapticFeedback; +const haptic = useHapticFeedback(); const yaMetrika = useYaMetrikaStore(); const settings = useSettingsStore(); const filtersStore = useProductFiltersStore(); @@ -104,7 +105,7 @@ const props = defineProps({ }); function productClick(product, index) { - window.Telegram.WebApp.HapticFeedback.selectionChanged(); + haptic.selectionChanged(); yaMetrika.dataLayerPush({ "ecommerce": { "currencyCode": settings.currency_code, diff --git a/frontend/spa/src/components/Quantity.vue b/frontend/spa/src/components/Quantity.vue index 7350d89..21db9c2 100644 --- a/frontend/spa/src/components/Quantity.vue +++ b/frontend/spa/src/components/Quantity.vue @@ -8,6 +8,7 @@ diff --git a/frontend/spa/src/composables/useHapticFeedback.js b/frontend/spa/src/composables/useHapticFeedback.js index 00c8d92..04c5220 100644 --- a/frontend/spa/src/composables/useHapticFeedback.js +++ b/frontend/spa/src/composables/useHapticFeedback.js @@ -1,3 +1,39 @@ +import {useSettingsStore} from "@/stores/SettingsStore.js"; + +/** + * Composable для безопасной работы с HapticFeedback + * Проверяет настройку haptic_enabled перед использованием + * @returns {object} Объект с методами haptic feedback, которые безопасно вызываются + */ export function useHapticFeedback() { - return window.Telegram?.WebApp?.HapticFeedback; + const settings = useSettingsStore(); + const haptic = window.Telegram?.WebApp?.HapticFeedback; + + // Возвращаем обёртку с методами, которые проверяют настройку при каждом вызове + return { + impactOccurred: (style) => { + // Проверяем настройку при каждом вызове (реактивно) + const isHapticEnabled = settings.haptic_enabled !== false; + if (!haptic || !isHapticEnabled || !haptic.impactOccurred) { + return; + } + haptic.impactOccurred(style); + }, + selectionChanged: () => { + // Проверяем настройку при каждом вызове (реактивно) + const isHapticEnabled = settings.haptic_enabled !== false; + if (!haptic || !isHapticEnabled || !haptic.selectionChanged) { + return; + } + haptic.selectionChanged(); + }, + notificationOccurred: (type) => { + // Проверяем настройку при каждом вызове (реактивно) + const isHapticEnabled = settings.haptic_enabled !== false; + if (!haptic || !isHapticEnabled || !haptic.notificationOccurred) { + return; + } + haptic.notificationOccurred(type); + }, + }; } diff --git a/frontend/spa/src/composables/useHapticScroll.js b/frontend/spa/src/composables/useHapticScroll.js index f6003c1..47719c2 100644 --- a/frontend/spa/src/composables/useHapticScroll.js +++ b/frontend/spa/src/composables/useHapticScroll.js @@ -1,4 +1,5 @@ import {ref} from 'vue'; +import {useHapticFeedback} from './useHapticFeedback.js'; /** * Composable для Haptic Feedback по свайпу @@ -13,6 +14,7 @@ export function useHapticScroll( feedback = 'soft' ) { const lastTranslate = ref(0); + const haptic = useHapticFeedback(); return function ( swiper @@ -20,14 +22,11 @@ export function useHapticScroll( const current = Math.abs(swiper.translate); const delta = Math.abs(current - lastTranslate.value); - if (delta > threshold) { - const haptic = window.Telegram?.WebApp?.HapticFeedback; - if (haptic) { - if (type === 'impactOccurred' && haptic.impactOccurred) { - haptic.impactOccurred(feedback); - } else if (type === 'selectionChanged' && haptic.selectionChanged) { - haptic.selectionChanged(); - } + if (delta > threshold && haptic) { + if (type === 'impactOccurred' && haptic.impactOccurred) { + haptic.impactOccurred(feedback); + } else if (type === 'selectionChanged' && haptic.selectionChanged) { + haptic.selectionChanged(); } lastTranslate.value = current; } diff --git a/frontend/spa/src/stores/CheckoutStore.js b/frontend/spa/src/stores/CheckoutStore.js index 1649465..ccc9e65 100644 --- a/frontend/spa/src/stores/CheckoutStore.js +++ b/frontend/spa/src/stores/CheckoutStore.js @@ -9,6 +9,40 @@ import {usePulseStore} from "@/stores/Pulse.js"; import {TC_PULSE_EVENTS} from "@/constants/tPulseEvents.js"; import {nextTick} from "vue"; +/** + * Helper функция для получения haptic feedback (можно использовать в store) + * @returns {object} Объект с методами haptic feedback + */ +function getHapticFeedback() { + const settings = useSettingsStore(); + const haptic = window.Telegram?.WebApp?.HapticFeedback; + + // Возвращаем обёртку с методами, которые проверяют настройку при каждом вызове + return { + impactOccurred: (style) => { + const isHapticEnabled = settings.haptic_enabled !== false; + if (!haptic || !isHapticEnabled || !haptic.impactOccurred) { + return; + } + haptic.impactOccurred(style); + }, + selectionChanged: () => { + const isHapticEnabled = settings.haptic_enabled !== false; + if (!haptic || !isHapticEnabled || !haptic.selectionChanged) { + return; + } + haptic.selectionChanged(); + }, + notificationOccurred: (type) => { + const isHapticEnabled = settings.haptic_enabled !== false; + if (!haptic || !isHapticEnabled || !haptic.notificationOccurred) { + return; + } + haptic.notificationOccurred(type); + }, + }; +} + export const useCheckoutStore = defineStore('checkout', { state: () => ({ form: {}, @@ -95,7 +129,8 @@ export const useCheckoutStore = defineStore('checkout', { }); }); - await window.Telegram.WebApp.HapticFeedback.notificationOccurred('success'); + const haptic = getHapticFeedback(); + await haptic.notificationOccurred('success'); await useCartStore().getProducts(); } catch (error) { if (error.response?.status === 422) { @@ -104,7 +139,8 @@ export const useCheckoutStore = defineStore('checkout', { console.error('Server error', error); } - window.Telegram.WebApp.HapticFeedback.notificationOccurred('error'); + const haptic = getHapticFeedback(); + haptic.notificationOccurred('error'); this.errorMessage = 'Возникла ошибка при создании заказа.'; diff --git a/frontend/spa/src/stores/SettingsStore.js b/frontend/spa/src/stores/SettingsStore.js index d799398..77939a1 100644 --- a/frontend/spa/src/stores/SettingsStore.js +++ b/frontend/spa/src/stores/SettingsStore.js @@ -15,6 +15,7 @@ export const useSettingsStore = defineStore('settings', { feature_vouchers: false, show_category_products_button: true, currency_code: null, + haptic_enabled: true, theme: { light: 'light', dark: 'dark', variables: { '--product_list_title_max_lines': 2, @@ -53,6 +54,7 @@ export const useSettingsStore = defineStore('settings', { this.mainpage_blocks = settings.mainpage_blocks; this.privacy_policy_link = settings.privacy_policy_link; this.image_aspect_ratio = settings.image_aspect_ratio; + this.haptic_enabled = settings.haptic_enabled ?? true; } } }); diff --git a/frontend/spa/src/views/Account.vue b/frontend/spa/src/views/Account.vue index 9601eab..87aae2e 100644 --- a/frontend/spa/src/views/Account.vue +++ b/frontend/spa/src/views/Account.vue @@ -82,6 +82,7 @@ import {onMounted, onUnmounted} from "vue"; import {useRoute} from "vue-router"; import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js"; import {usePulseStore} from "@/stores/Pulse.js"; +import {useHapticFeedback} from "@/composables/useHapticFeedback.js"; defineOptions({ name: 'Account' @@ -92,6 +93,7 @@ const settings = useSettingsStore(); const route = useRoute(); const yaMetrika = useYaMetrikaStore(); const pulse = usePulseStore(); +const haptic = useHapticFeedback(); const username = computed(() => { if (tgData?.user?.first_name || tgData?.user?.last_name) { @@ -136,7 +138,7 @@ function openManagerChat() { return; } - window.Telegram.WebApp.HapticFeedback.selectionChanged(); + haptic.selectionChanged(); // Формируем ссылку для открытия чата с менеджером const managerUsername = String(settings.manager_username).trim(); @@ -149,7 +151,7 @@ function openManagerChat() { } function handleHomeScreenAdded() { - window.Telegram.WebApp.HapticFeedback.notificationOccurred('success'); + haptic.notificationOccurred('success'); window.Telegram.WebApp.showAlert('Приложение успешно добавлено на главный экран!'); isHomeScreenAdded.value = true; } @@ -169,7 +171,7 @@ function checkHomeScreenSupport() { } function addToHomeScreen() { - window.Telegram.WebApp.HapticFeedback.selectionChanged(); + haptic.selectionChanged(); // Используем Telegram Web App API для добавления на главный экран if (window.Telegram?.WebApp?.addToHomeScreen) { diff --git a/frontend/spa/src/views/Cart.vue b/frontend/spa/src/views/Cart.vue index 3f8828f..caf0ff2 100644 --- a/frontend/spa/src/views/Cart.vue +++ b/frontend/spa/src/views/Cart.vue @@ -153,12 +153,14 @@ import {useSettingsStore} from "@/stores/SettingsStore.js"; import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js"; import {YA_METRIKA_GOAL} from "@/constants/yaMetrikaGoals.js"; import BaseViewWrapper from "@/views/BaseViewWrapper.vue"; +import {useHapticFeedback} from "@/composables/useHapticFeedback.js"; const route = useRoute(); const yaMetrika = useYaMetrikaStore(); const cart = useCartStore(); const router = useRouter(); const settings = useSettingsStore(); +const haptic = useHapticFeedback(); // const componentMap = { // radio: OptionRadio, @@ -174,7 +176,7 @@ const lastTotal = computed(() => { function removeItem(cartItem, cartId, index) { cart.removeItem(cartItem, cartId, index); - window.Telegram.WebApp.HapticFeedback.notificationOccurred('error'); + haptic.notificationOccurred('error'); } function goToCheckout() { diff --git a/frontend/spa/src/views/Filters.vue b/frontend/spa/src/views/Filters.vue index 9150095..7519153 100644 --- a/frontend/spa/src/views/Filters.vue +++ b/frontend/spa/src/views/Filters.vue @@ -53,6 +53,7 @@ import {useRoute, useRouter} from "vue-router"; import ProductCategory from "@/components/ProductFilters/Components/ProductCategory/ProductCategory.vue"; import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js"; import {YA_METRIKA_GOAL} from "@/constants/yaMetrikaGoals.js"; +import {useHapticFeedback} from "@/composables/useHapticFeedback.js"; defineOptions({ name: 'Filters' @@ -70,7 +71,7 @@ const route = useRoute(); const emit = defineEmits(['close', 'apply']); const filtersStore = useProductFiltersStore(); -const haptic = window.Telegram.WebApp.HapticFeedback; +const haptic = useHapticFeedback(); const applyFilters = async () => { filtersStore.applied = JSON.parse(JSON.stringify(filtersStore.draft)); diff --git a/frontend/spa/src/views/Home.vue b/frontend/spa/src/views/Home.vue index 2325bbf..e870349 100644 --- a/frontend/spa/src/views/Home.vue +++ b/frontend/spa/src/views/Home.vue @@ -17,6 +17,7 @@ import {useSettingsStore} from "@/stores/SettingsStore.js"; import MainPage from "@/components/MainPage/MainPage.vue"; import {useBlocksStore} from "@/stores/BlocksStore.js"; import Navbar from "@/components/Navbar.vue"; +import {useHapticFeedback} from "@/composables/useHapticFeedback.js"; defineOptions({ name: 'Home' @@ -25,7 +26,7 @@ defineOptions({ const router = useRouter(); const filtersStore = useProductFiltersStore(); const yaMetrika = useYaMetrikaStore(); -const haptic = window.Telegram.WebApp.HapticFeedback; +const haptic = useHapticFeedback(); const settings = useSettingsStore(); const blocks = useBlocksStore(); diff --git a/frontend/spa/src/views/Product.vue b/frontend/spa/src/views/Product.vue index 73acb0e..cc246e3 100644 --- a/frontend/spa/src/views/Product.vue +++ b/frontend/spa/src/views/Product.vue @@ -231,6 +231,7 @@ import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js"; import {YA_METRIKA_GOAL} from "@/constants/yaMetrikaGoals.js"; import Loader from "@/components/Loader.vue"; import SingleProductImageSwiper from "@/components/SingleProductImageSwiper.vue"; +import {useHapticFeedback} from "@/composables/useHapticFeedback.js"; const route = useRoute(); const productId = computed(() => route.params.id); @@ -247,6 +248,7 @@ const settings = useSettingsStore(); const yaMetrika = useYaMetrikaStore(); const imagesLoaded = ref(false); const images = ref([]); +const haptic = useHapticFeedback(); const canAddToCart = computed(() => { if (!product.value || product.value.options === undefined || product.value.options?.length === 0) { @@ -269,7 +271,7 @@ async function onCartBtnClick() { if (isInCart.value === false) { await cart.addProduct(productId.value, product.value.name, product.value.price, quantity.value, product.value.options); isInCart.value = true; - window.Telegram.WebApp.HapticFeedback.notificationOccurred('success'); + haptic.notificationOccurred('success'); yaMetrika.reachGoal(YA_METRIKA_GOAL.ADD_TO_CART, { price: product.value.final_price_numeric, currency: product.value.currency, @@ -294,12 +296,12 @@ async function onCartBtnClick() { } }); } else { - window.Telegram.WebApp.HapticFeedback.selectionChanged(); + haptic.selectionChanged(); await router.push({'name': 'cart'}); } } catch (e) { - await window.Telegram.WebApp.HapticFeedback.notificationOccurred('error'); + await haptic.notificationOccurred('error'); error.value = e.message; } } @@ -322,7 +324,7 @@ function openManagerChat() { return; } - window.Telegram.WebApp.HapticFeedback.selectionChanged(); + haptic.selectionChanged(); // Формируем ссылку для открытия чата с менеджером // manager_username должен быть username (например, @username) @@ -340,7 +342,7 @@ function openManagerChat() { function setQuantity(newQuantity) { quantity.value = newQuantity; - window.Telegram.WebApp.HapticFeedback.selectionChanged(); + haptic.selectionChanged(); } onMounted(async () => { diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/configs/app.php b/module/oc_telegram_shop/upload/oc_telegram_shop/configs/app.php index 29764e7..cdf5b3d 100755 --- a/module/oc_telegram_shop/upload/oc_telegram_shop/configs/app.php +++ b/module/oc_telegram_shop/upload/oc_telegram_shop/configs/app.php @@ -10,6 +10,7 @@ return [ "app_debug" => false, 'image_aspect_ratio' => '1:1', 'image_crop_algorithm' => 'cover', + 'haptic_enabled' => true, ], 'telegram' => [ diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/src/DTO/Settings/AppDTO.php b/module/oc_telegram_shop/upload/oc_telegram_shop/src/DTO/Settings/AppDTO.php index 2fe864d..de2804a 100755 --- a/module/oc_telegram_shop/upload/oc_telegram_shop/src/DTO/Settings/AppDTO.php +++ b/module/oc_telegram_shop/upload/oc_telegram_shop/src/DTO/Settings/AppDTO.php @@ -12,6 +12,7 @@ final class AppDTO private bool $appDebug; private int $languageId; private string $shopBaseUrl; + private bool $hapticEnabled; public function __construct( bool $appEnabled, @@ -21,7 +22,8 @@ final class AppDTO string $themeDark, bool $appDebug, int $languageId, - string $shopBaseUrl + string $shopBaseUrl, + bool $hapticEnabled = true ) { $this->appEnabled = $appEnabled; $this->appName = $appName; @@ -31,6 +33,7 @@ final class AppDTO $this->appDebug = $appDebug; $this->languageId = $languageId; $this->shopBaseUrl = $shopBaseUrl; + $this->hapticEnabled = $hapticEnabled; } public function isAppEnabled(): bool @@ -73,6 +76,11 @@ final class AppDTO return $this->shopBaseUrl; } + public function isHapticEnabled(): bool + { + return $this->hapticEnabled; + } + public function toArray(): array { return [ @@ -84,6 +92,7 @@ final class AppDTO 'app_debug' => $this->isAppDebug(), 'language_id' => $this->getLanguageId(), 'shop_base_url' => $this->getShopBaseUrl(), + 'haptic_enabled' => $this->isHapticEnabled(), ]; } } diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/src/Handlers/SettingsHandler.php b/module/oc_telegram_shop/upload/oc_telegram_shop/src/Handlers/SettingsHandler.php index c514282..7ed6e12 100755 --- a/module/oc_telegram_shop/upload/oc_telegram_shop/src/Handlers/SettingsHandler.php +++ b/module/oc_telegram_shop/upload/oc_telegram_shop/src/Handlers/SettingsHandler.php @@ -55,6 +55,7 @@ class SettingsHandler 'mainpage_blocks' => $this->settings->get('mainpage_blocks', []), 'privacy_policy_link' => $this->settings->get('app.privacy_policy_link'), 'image_aspect_ratio' => $this->settings->get('app.image_aspect_ratio', '1:1'), + 'haptic_enabled' => $appConfig->isHapticEnabled(), ]); } diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/src/Services/SettingsSerializerService.php b/module/oc_telegram_shop/upload/oc_telegram_shop/src/Services/SettingsSerializerService.php index 30a507e..3d3b6e7 100755 --- a/module/oc_telegram_shop/upload/oc_telegram_shop/src/Services/SettingsSerializerService.php +++ b/module/oc_telegram_shop/upload/oc_telegram_shop/src/Services/SettingsSerializerService.php @@ -80,7 +80,8 @@ class SettingsSerializerService $data['theme_dark'] ?? 'dark', $data['app_debug'] ?? false, $data['language_id'], - $data['shop_base_url'] + $data['shop_base_url'], + $data['haptic_enabled'] ?? true ); }