feat(storefront): enhance error handling and navigation across pages

Added a global error page to improve user experience during navigation issues, with localized messages and redirect options. Enhanced error handling for brand, product, and category slug composables by introducing explicit 404 responses.

- Introduced `/error.vue` template for custom error displays using `NuxtError`.
- Updated `useBrandBySlug`, `useProductBySlug`, `useCategoryBySlug` to throw 404 errors when data is not found.
- Expanded i18n files (`en-gb.json` and `ru-ru.json`) with additional error-related translations.
- Replaced plain text input with a `.search`-scoped class for cleaner styling.

Enhances robustness and user feedback during navigation errors. No breaking changes introduced.
This commit is contained in:
Alexandr SaVBaD Waltz 2026-03-04 16:27:52 +03:00
parent ef8be78d51
commit c889c20b61
10 changed files with 156 additions and 16 deletions

View file

@ -18,35 +18,35 @@
<nuxt-link-locale <nuxt-link-locale
to="/shop" to="/shop"
class="header__nav-item" class="header__nav-item"
:class="[{ active: route.name.includes('shop') }]" :class="[{ active: route.name?.includes('shop') }]"
> >
{{ t('header.nav.shop') }} {{ t('header.nav.shop') }}
</nuxt-link-locale> </nuxt-link-locale>
<nuxt-link-locale <nuxt-link-locale
to="/catalog" to="/catalog"
class="header__nav-item" class="header__nav-item"
:class="[{ active: route.name.includes('catalog') }]" :class="[{ active: route.name?.includes('catalog') }]"
> >
{{ t('header.nav.catalog') }} {{ t('header.nav.catalog') }}
</nuxt-link-locale> </nuxt-link-locale>
<nuxt-link-locale <nuxt-link-locale
to="/brands" to="/brands"
class="header__nav-item" class="header__nav-item"
:class="[{ active: route.name.includes('brands') }]" :class="[{ active: route.name?.includes('brands') }]"
> >
{{ t('header.nav.brands') }} {{ t('header.nav.brands') }}
</nuxt-link-locale> </nuxt-link-locale>
<nuxt-link-locale <nuxt-link-locale
to="/blog" to="/blog"
class="header__nav-item" class="header__nav-item"
:class="[{ active: route.name.includes('blog') }]" :class="[{ active: route.name?.includes('blog') }]"
> >
{{ t('header.nav.blog') }} {{ t('header.nav.blog') }}
</nuxt-link-locale> </nuxt-link-locale>
<nuxt-link-locale <nuxt-link-locale
to="/contact" to="/contact"
class="header__nav-item" class="header__nav-item"
:class="[{ active: route.name.includes('contact') }]" :class="[{ active: route.name?.includes('contact') }]"
> >
{{ t('header.nav.contact') }} {{ t('header.nav.contact') }}
</nuxt-link-locale> </nuxt-link-locale>

View file

@ -40,7 +40,7 @@
font-weight: 400; font-weight: 400;
letter-spacing: -0.5px; letter-spacing: -0.5px;
} }
:deep(.title__wrapper div) { :deep(.title__wrapper .search) {
position: relative; position: relative;
& span { & span {

View file

@ -8,6 +8,14 @@ export async function useBrandBySlug(slug: string) {
slug, slug,
}); });
if (!data.value?.brands?.edges?.length) {
throw createError({
status: 404,
statusText: 'Brand not found',
fatal: true
});
}
watch(error, (err) => { watch(error, (err) => {
if (err) { if (err) {
console.error('useBrandsBySlug error:', err); console.error('useBrandsBySlug error:', err);

View file

@ -13,6 +13,14 @@ export async function useCategoryBySlug(slug: string) {
}); });
const minMaxPrices = computed(() => category.value?.minMaxPrices ?? { minPrice: 0, maxPrice: 50000 }); const minMaxPrices = computed(() => category.value?.minMaxPrices ?? { minPrice: 0, maxPrice: 50000 });
if (!data.value?.categories?.edges?.length) {
throw createError({
status: 404,
statusText: 'Category not found',
fatal: true
});
}
watch(error, (err) => { watch(error, (err) => {
if (err) { if (err) {
console.error('useCategoryBySlug error:', err); console.error('useCategoryBySlug error:', err);

View file

@ -13,6 +13,14 @@ export async function useProductBySlug(slug: string) {
product.value = result; product.value = result;
} }
if (!data.value?.products?.edges?.length) {
throw createError({
status: 404,
statusText: 'Product not found',
fatal: true
});
}
watch(error, (err) => { watch(error, (err) => {
if (err) { if (err) {
console.error('useProductBySlug error:', err); console.error('useProductBySlug error:', err);

82
storefront/app/error.vue Normal file
View file

@ -0,0 +1,82 @@
<script setup lang="ts">
import type { NuxtError } from '#app';
import {usePageTitle} from "@composables/utils";
const props = defineProps<{ error: NuxtError }>();
const {t} = useI18n();
const localePath = useLocalePath();
const { setPageTitle } = usePageTitle();
const handleError = () => {
clearError({ redirect: localePath('/') });
setTimeout(() => window.location.reload(), 500);
};
setPageTitle(props.error.status ?? t('errors.main'));
</script>
<template>
<div class="error">
<div class="container">
<client-only>
<div class="error__wrapper">
<h1 class="error__title">{{ error.status }}</h1>
<h6 class="error__subtitle">{{ error.status === 404 ? t('errors.pageNotFound') : error.statusText }}</h6>
<p class="error__text" v-if="error.status === 404">{{ t('errors.404') }}</p>
<ui-button
:type="'button'"
class="error__button"
@click="handleError"
>
{{ t('buttons.backToHome') }}
</ui-button>
</div>
</client-only>
</div>
</div>
</template>
<style lang="scss" scoped>
.error {
height: 100vh;
background-color: $border;
padding-block: 60px;
&__wrapper {
display: flex;
flex-direction: column;
align-items: center;
}
&__title {
font-family: "Playfair Display", sans-serif;
color: $primary;
font-size: 192px;
font-weight: 700;
letter-spacing: -0.5px;
}
&__subtitle {
margin-block: 30px 20px;
font-family: "Playfair Display", sans-serif;
color: $primary;
font-size: 36px;
font-weight: 600;
letter-spacing: -0.5px;
}
&__text {
color: $text;
font-size: 20px;
font-weight: 400;
letter-spacing: -0.5px;
}
&__button {
margin-top: 30px;
padding-inline: 30px;
}
}
</style>

View file

@ -9,7 +9,7 @@
:alt="brand.name" :alt="brand.name"
/> />
<h1>{{ brand.name }}</h1> <h1>{{ brand.name }}</h1>
<p>{{ brand.description }}</p> <div v-html="brand.description"></div>
</ui-title> </ui-title>
<div class="brand__main"> <div class="brand__main">
<div class="container"> <div class="container">

View file

@ -3,7 +3,7 @@
<ui-title> <ui-title>
<h1>{{ t('brands.title') }}</h1> <h1>{{ t('brands.title') }}</h1>
<p>{{ t('brands.text') }}</p> <p>{{ t('brands.text') }}</p>
<div class="brands__top-search"> <div class="search">
<input <input
type="text" type="text"
inputmode="text" inputmode="text"

View file

@ -21,7 +21,8 @@
"sendMessage": "Send Message", "sendMessage": "Send Message",
"saveChanges": "Save Changes", "saveChanges": "Save Changes",
"clearAll": "Clear All", "clearAll": "Clear All",
"buyNow": "Buy Now" "buyNow": "Buy Now",
"backToHome": "Back To Home"
}, },
"errors": { "errors": {
"required": "This field is required!", "required": "This field is required!",
@ -32,7 +33,11 @@
"needNumber": "Please include number.", "needNumber": "Please include number.",
"needMin": "Min. 8 characters", "needMin": "Min. 8 characters",
"needSpecial": "Please include a special character: #.?!$%^&*'()_+=:;\"'/>.<,|\\-", "needSpecial": "Please include a special character: #.?!$%^&*'()_+=:;\"'/>.<,|\\-",
"pageNotFound": "Page not found" "pageNotFound": "Page not found",
"404": "The page you're looking for doesn't exist or has been moved.",
"brandNotFound": "Brand not found",
"productNotFound": "Product not found",
"categoryNotFound": "Category not found"
}, },
"fields": { "fields": {
"search": "Search", "search": "Search",
@ -49,7 +54,8 @@
"confirmPassword": "Confirm password", "confirmPassword": "Confirm password",
"confirmNewPassword": "Confirm new password", "confirmNewPassword": "Confirm new password",
"brandsSearch": "Search brands by name...", "brandsSearch": "Search brands by name...",
"promocode": "Enter promocode" "promocode": "Enter promocode",
"address": "Start typing the address"
}, },
"checkboxes": { "checkboxes": {
"remember": "Remember me", "remember": "Remember me",
@ -72,6 +78,8 @@
"reset": "If specified email exists in our system, we will send a password recovery email!", "reset": "If specified email exists in our system, we will send a password recovery email!",
"newPassword": "You have successfully changed your password!", "newPassword": "You have successfully changed your password!",
"contactUs": "Your message was sent successfully!", "contactUs": "Your message was sent successfully!",
"createAddress": "You have successfully added a new address!",
"deleteAddress": "You have successfully deleted an address!",
"addToCart": "{product} has been added to the cart!", "addToCart": "{product} has been added to the cart!",
"removeFromCart": "{product} has been removed from the cart!", "removeFromCart": "{product} has been removed from the cart!",
"removeAllFromCart": "You have successfully emptied the cart!", "removeAllFromCart": "You have successfully emptied the cart!",
@ -196,7 +204,8 @@
"return": "Return Policy", "return": "Return Policy",
"faq": "FAQ", "faq": "FAQ",
"shipping": "Shipping Information", "shipping": "Shipping Information",
"about": "About Us" "about": "About Us",
"addresses": "Addresses"
}, },
"contact": { "contact": {
"title": "Get in Touch", "title": "Get in Touch",
@ -280,6 +289,14 @@
"until": "Until", "until": "Until",
"empty": "You don't have any promocodes." "empty": "You don't have any promocodes."
}, },
"addresses": {
"title": "Addresses",
"title1": "Add New Address",
"title2": "Your saved addresses",
"search": {
"empty": "Nothing found"
}
},
"logout": "Logout" "logout": "Logout"
}, },
"demo": { "demo": {

View file

@ -21,7 +21,8 @@
"sendMessage": "Отправить сообщение", "sendMessage": "Отправить сообщение",
"saveChanges": "Сохранить изменения", "saveChanges": "Сохранить изменения",
"clearAll": "Очистить всё", "clearAll": "Очистить всё",
"buyNow": "Купить Сейчас" "buyNow": "Купить Сейчас",
"backToHome": "Обратно на Главную"
}, },
"errors": { "errors": {
"required": "Это поле обязательно!", "required": "Это поле обязательно!",
@ -32,7 +33,11 @@
"needNumber": "Добавьте цифру.", "needNumber": "Добавьте цифру.",
"needMin": "Мин. 8 символов", "needMin": "Мин. 8 символов",
"needSpecial": "Добавьте спецсимвол: #.?!$%^&*'()_+=:;\"'/>.<,|\\-", "needSpecial": "Добавьте спецсимвол: #.?!$%^&*'()_+=:;\"'/>.<,|\\-",
"pageNotFound": "Страница не найдена" "pageNotFound": "Страница не найдена",
"404": "Страница, которую вы ищете, не существует или была перемещена.",
"brandNotFound": "Бренд не найден",
"productNotFound": "Товар не найден",
"categoryNotFound": "Категория не найдена"
}, },
"fields": { "fields": {
"search": "Поиск", "search": "Поиск",
@ -49,7 +54,8 @@
"confirmPassword": "Подтвердите пароль", "confirmPassword": "Подтвердите пароль",
"confirmNewPassword": "Подтвердите новый пароль", "confirmNewPassword": "Подтвердите новый пароль",
"brandsSearch": "Поиск брендов по названию...", "brandsSearch": "Поиск брендов по названию...",
"promocode": "Введите промокод" "promocode": "Введите промокод",
"address": "Начните вводить адрес"
}, },
"checkboxes": { "checkboxes": {
"remember": "Запомнить меня", "remember": "Запомнить меня",
@ -72,6 +78,8 @@
"reset": "Если указанный email существует в нашей системе, мы отправим на него письмо для восстановления пароля!", "reset": "Если указанный email существует в нашей системе, мы отправим на него письмо для восстановления пароля!",
"newPassword": "Вы успешно изменили пароль!", "newPassword": "Вы успешно изменили пароль!",
"contactUs": "Ваше сообщение успешно отправлено!", "contactUs": "Ваше сообщение успешно отправлено!",
"createAddress": "Вы успешно добавили новый адрес!",
"deleteAddress": "Вы успешно удалили адрес!",
"addToCart": "{product} добавлен в корзину!", "addToCart": "{product} добавлен в корзину!",
"removeFromCart": "{product} удален из корзины!", "removeFromCart": "{product} удален из корзины!",
"removeAllFromCart": "Корзина успешно очищена!", "removeAllFromCart": "Корзина успешно очищена!",
@ -196,7 +204,8 @@
"return": "Политика возврата", "return": "Политика возврата",
"faq": "Часто задаваемые вопросы", "faq": "Часто задаваемые вопросы",
"shipping": "Информация о доставке", "shipping": "Информация о доставке",
"about": "О нас" "about": "О нас",
"addresses": "Адреса"
}, },
"contact": { "contact": {
"title": "Свяжитесь с нами", "title": "Свяжитесь с нами",
@ -280,6 +289,14 @@
"until": "До", "until": "До",
"empty": "У вас нет промокодов." "empty": "У вас нет промокодов."
}, },
"addresses": {
"title": "Адреса",
"title1": "Добавить Новый Адрес",
"title2": "Ваши сохраненные адреса",
"search": {
"empty": "Ничего не найдено"
}
},
"logout": "Выйти" "logout": "Выйти"
}, },
"demo": { "demo": {