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)
This commit is contained in:
2025-12-25 22:34:23 +03:00
parent ce2ea9dea1
commit afade85d00
23 changed files with 147 additions and 35 deletions

View File

@@ -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: {

View File

@@ -62,6 +62,10 @@
<li><strong>Resize</strong> - изменяет размер изображения с сохранением пропорций (без обрезки)</li>
</ul>
</ItemSelect>
<ItemBool label="Тактильная обратная связь (Haptic Feedback)" v-model="settings.items.app.haptic_enabled">
Включить виброотклик при взаимодействии с элементами интерфейса. Если выключено, тактильная обратная связь не будет использоваться.
</ItemBool>
</template>
<script setup>

View File

@@ -62,6 +62,7 @@ import AppDebugMessage from "@/components/AppDebugMessage.vue";
import PrivacyPolicy from "@/components/PrivacyPolicy.vue";
import BrowserNotSupported from "@/BrowserNotSupported.vue";
import {useSwipeBack} from "@/composables/useSwipeBack.js";
import {useHapticFeedback} from "@/composables/useHapticFeedback.js";
const router = useRouter();
const route = useRoute();
@@ -69,7 +70,7 @@ const settings = useSettingsStore();
const filtersStore = useProductFiltersStore();
const keyboardStore = useKeyboardStore();
const backButton = window.Telegram.WebApp.BackButton;
const haptic = window.Telegram.WebApp.HapticFeedback;
const haptic = useHapticFeedback();
const swiperBack = useSwipeBack();
const routesToHideAppDock = [

View File

@@ -18,10 +18,12 @@
import {computed, onMounted} from "vue";
import {useCartStore} from "@/stores/CartStore.js";
import {useRoute, useRouter} from "vue-router";
import {useHapticFeedback} from "@/composables/useHapticFeedback.js";
const cart = useCartStore();
const router = useRouter();
const route = useRoute();
const haptic = useHapticFeedback();
const isCartBtnShow = computed(() => {
return route.name === 'product.show';
@@ -29,7 +31,7 @@ const isCartBtnShow = computed(() => {
function openCart() {
window.Telegram.WebApp.HapticFeedback.selectionChanged();
haptic.selectionChanged();
router.push({name: 'cart'});
}

View File

@@ -84,12 +84,13 @@ import {useRoute} from "vue-router";
import {useCartStore} from "@/stores/CartStore.js";
import {useSettingsStore} from "@/stores/SettingsStore.js";
import {useTgData} from "@/composables/useTgData.js";
import {useHapticFeedback} from "@/composables/useHapticFeedback.js";
const route = useRoute();
const cart = useCartStore();
const settings = useSettingsStore();
const tgData = useTgData();
const haptic = window.Telegram.WebApp.HapticFeedback;
const haptic = useHapticFeedback();
function onDockItemClick(event) {
haptic.selectionChanged();

View File

@@ -35,6 +35,7 @@
import {Navigation, Pagination, Zoom} from "swiper/modules";
import {Swiper, SwiperSlide} from "swiper/vue";
import {onMounted} from "vue";
import {useHapticFeedback} from "@/composables/useHapticFeedback.js";
const props = defineProps({
images: {
@@ -49,11 +50,12 @@ const props = defineProps({
});
const emits = defineEmits(['close']);
const haptic = useHapticFeedback();
let canVibrate = true;
function vibrate() {
if (!canVibrate) return;
window.Telegram.WebApp.HapticFeedback.impactOccurred('soft');
haptic.impactOccurred('soft');
canVibrate = false;
setTimeout(() => {
canVibrate = true;
@@ -61,7 +63,7 @@ function vibrate() {
}
function onClose() {
window.Telegram.WebApp.HapticFeedback.impactOccurred('medium');
haptic.impactOccurred('medium');
emits('close');
}
</script>

View File

@@ -30,8 +30,10 @@
<script setup>
import {ref} from "vue";
import {useHapticFeedback} from "@/composables/useHapticFeedback.js";
const isLoading = ref(false);
const haptic = useHapticFeedback();
const props = defineProps({
block: {
@@ -41,6 +43,6 @@ const props = defineProps({
});
function onCategoryClick() {
window.Telegram.WebApp.HapticFeedback.impactOccurred('soft');
haptic.impactOccurred('soft');
}
</script>

View File

@@ -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,

View File

@@ -8,6 +8,7 @@
<script setup>
import {computed} from "vue";
import {useHapticFeedback} from "@/composables/useHapticFeedback.js";
const model = defineModel();
const props = defineProps({
@@ -22,6 +23,8 @@ const props = defineProps({
}
});
const haptic = useHapticFeedback();
const btnClassList = computed(() => {
let classList = ['btn'];
if (props.size) {
@@ -33,7 +36,7 @@ const btnClassList = computed(() => {
function inc() {
if (props.disabled) return;
window.Telegram.WebApp.HapticFeedback.selectionChanged();
haptic.selectionChanged();
if (props.max && model.value + 1 > props.max) {
model.value = props.max;
@@ -46,7 +49,7 @@ function inc() {
function dec() {
if (props.disabled) return;
window.Telegram.WebApp.HapticFeedback.selectionChanged();
haptic.selectionChanged();
if (model.value - 1 >= 1) {
model.value--;

View File

@@ -26,13 +26,15 @@
<script setup>
import {useRouter} from "vue-router";
import {useSearchStore} from "@/stores/SearchStore.js";
import {useHapticFeedback} from "@/composables/useHapticFeedback.js";
const router = useRouter();
const haptic = useHapticFeedback();
function showSearchPage() {
router.push({name: 'search'});
useSearchStore().reset();
window.Telegram.WebApp.HapticFeedback.impactOccurred('medium');
haptic.impactOccurred('medium');
}
</script>

View File

@@ -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);
},
};
}

View File

@@ -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;
}

View File

@@ -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 = 'Возникла ошибка при создании заказа.';

View File

@@ -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;
}
}
});

View File

@@ -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) {

View File

@@ -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() {

View File

@@ -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));

View File

@@ -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();

View File

@@ -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 () => {

View File

@@ -10,6 +10,7 @@ return [
"app_debug" => false,
'image_aspect_ratio' => '1:1',
'image_crop_algorithm' => 'cover',
'haptic_enabled' => true,
],
'telegram' => [

View File

@@ -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(),
];
}
}

View File

@@ -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(),
]);
}

View File

@@ -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
);
}