Refactored i18n configuration, replacing `DEFAULT_LOCALE` with `DEFAULT_LOCALE_FALLBACK` and enhancing environment-based locale validation. Improved cookie persistence for cart and wishlist, ensuring fallback handling for unauthenticated users. Enhancements: - Added `createProjectKey` utility for consistent project key generation. - Reworked cart and wishlist composables (`useOrderOverwrite`, `useWishlistOverwrite`) to decouple product identifier and handle cookies robustly. - Centralized `DEFAULT_LOCALE` logic for better maintainability. - Refined `useOrderSync` and `useWishlistSync` for clean synchronization across auth states. - Updated SCSS in hero and header styles for alignment corrections. Breaking Changes: `DEFAULT_LOCALE` constant removed; replaced with runtime config and fallback logic. Consumers must adapt to `DEFAULT_LOCALE_FALLBACK` and `$appHelpers.DEFAULT_LOCALE`.
692 lines
No EOL
17 KiB
Vue
692 lines
No EOL
17 KiB
Vue
<template>
|
|
<div class="product">
|
|
<div class="container">
|
|
<div class="product__wrapper">
|
|
<div class="product__block">
|
|
<div class="product__images">
|
|
<div class="product__images-gallery">
|
|
<div
|
|
v-for="(image, idx) in images"
|
|
:key="idx"
|
|
@click="selectImage(image)"
|
|
:class="[{ active: image === selectedImage }]"
|
|
>
|
|
<nuxt-img
|
|
:src="image"
|
|
:alt="product?.name"
|
|
format="webp"
|
|
densities="x1"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<nuxt-img
|
|
:src="selectedImage"
|
|
:alt="product?.name"
|
|
class="product__images-main"
|
|
format="webp"
|
|
densities="x1"
|
|
/>
|
|
</div>
|
|
<div class="product__main">
|
|
<p class="product__main-brand">{{ product?.brand.name }}</p>
|
|
<h1 class="product__title">{{ product?.name }}</h1>
|
|
<el-rate
|
|
class="product__main-rate"
|
|
:model-value="product?.rating"
|
|
allow-half
|
|
disabled
|
|
/>
|
|
<p class="product__main-price">{{ product?.price }} $</p>
|
|
<p class="product__main-stock">{{ t('cards.product.stock') }} {{ product?.quantity }}</p>
|
|
<p class="product__main-description">{{ product?.description }}</p>
|
|
<div class="tools" v-if="isProductInCart">
|
|
<button
|
|
class="tools__item tools__item-button"
|
|
@click="overwriteOrder({
|
|
type: 'remove',
|
|
productUuid: product.uuid,
|
|
productName: product.name
|
|
})"
|
|
>
|
|
-
|
|
</button>
|
|
<span class="tools__item tools__item-count" v-text="'X' + productInCartQuantity" />
|
|
<button
|
|
class="tools__item tools__item-button"
|
|
@click="overwriteOrder({
|
|
type: 'add',
|
|
productUuid: product.uuid,
|
|
productName: product.name
|
|
})"
|
|
>
|
|
+
|
|
</button>
|
|
</div>
|
|
<ui-button
|
|
v-else
|
|
class="product__main-button"
|
|
@click="overwriteOrder({
|
|
type: 'add',
|
|
productUuid: product.uuid,
|
|
productName: product.name
|
|
})"
|
|
:type="'button'"
|
|
:isLoading="addLoading"
|
|
>
|
|
{{ t('buttons.addToCart') }}
|
|
</ui-button>
|
|
<ui-button
|
|
class="product__main-button"
|
|
@click="overwriteWishlist({
|
|
type: (isProductInWishlist ? 'remove' : 'add'),
|
|
productUuid: product.uuid,
|
|
productName: product.name,
|
|
})"
|
|
:type="'button'"
|
|
:style="'secondary'"
|
|
>
|
|
<icon name="mdi:cards-heart-outline" size="16" />
|
|
{{ isProductInWishlist ? t('buttons.removeFromWishlist') : t('buttons.addToWishlist') }}
|
|
</ui-button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<!-- <client-only>-->
|
|
<!-- <div class="characteristics" id="characteristics" v-if="attributes.length">-->
|
|
<!-- <div class="characteristics__wrapper">-->
|
|
<!-- <h6 class="characteristics__title">{{ t('product.characteristics') }}</h6>-->
|
|
<!-- <div class="characteristics__list">-->
|
|
<!-- <div-->
|
|
<!-- class="characteristics__column"-->
|
|
<!-- v-for="group in attributes"-->
|
|
<!-- :key="group.uuid"-->
|
|
<!-- >-->
|
|
<!-- <h6 class="characteristics__column-title">{{ group.name }}</h6>-->
|
|
<!-- <p-->
|
|
<!-- class="characteristics__item"-->
|
|
<!-- v-for="item in group.items"-->
|
|
<!-- :key="item.uuid"-->
|
|
<!-- >-->
|
|
<!-- <span class="characteristics__item-label"><span>{{ item.name }}</span></span>-->
|
|
<!-- <span class="characteristics__item-value">{{ item.valuesStr }}</span>-->
|
|
<!-- </p>-->
|
|
<!-- </div>-->
|
|
<!-- </div>-->
|
|
<!-- </div>-->
|
|
<!-- </div>-->
|
|
<!-- </client-only>-->
|
|
<div class="similar">
|
|
<h6 class="similar__title">{{ t('product.similar') }}</h6>
|
|
<div class="similar__inner">
|
|
<div class="similar__button prev" ref="prevButton">
|
|
<icon name="material-symbols:arrow-back-ios-new-rounded" size="30" />
|
|
</div>
|
|
<client-only>
|
|
<swiper
|
|
class="similar__swiper"
|
|
:modules="[Navigation]"
|
|
:spaceBetween="30"
|
|
:breakpoints="{
|
|
200: {
|
|
slidesPerView: 4
|
|
}
|
|
}"
|
|
:navigation="{ prevEl: '.prev', nextEl: '.next' }"
|
|
>
|
|
<swiper-slide
|
|
v-for="prod in products"
|
|
:key="prod.node.uuid"
|
|
>
|
|
<cards-product
|
|
:product="prod.node"
|
|
/>
|
|
</swiper-slide>
|
|
</swiper>
|
|
</client-only>
|
|
<div class="similar__button next" ref="nextButton">
|
|
<icon name="material-symbols:arrow-back-ios-new-rounded" size="30" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import {useProductBySlug, useProducts} from '@composables/products';
|
|
import {usePageTitle} from '@composables/utils';
|
|
import {useScrollTo} from '@composables/scrollTo';
|
|
import { Swiper, SwiperSlide } from 'swiper/vue';
|
|
import {Navigation} from 'swiper/modules';
|
|
import {useWishlistOverwrite} from '@composables/wishlist';
|
|
import {useOrderOverwrite} from '@composables/orders';
|
|
import {useDefaultSeo} from '@composables/seo';
|
|
|
|
const route = useRoute();
|
|
const {t, locale} = useI18n();
|
|
const wishlistStore = useWishlistStore();
|
|
const cartStore = useCartStore();
|
|
const userStore = useUserStore();
|
|
const { $appHelpers } = useNuxtApp();
|
|
|
|
const { setPageTitle } = usePageTitle();
|
|
const { scrollTo } = useScrollTo();
|
|
|
|
const slug = useRouteParams<string>('slug');
|
|
|
|
const { overwriteWishlist } = useWishlistOverwrite();
|
|
const { addLoading, removeLoading, overwriteOrder } = useOrderOverwrite();
|
|
const { product, seoMeta } = await useProductBySlug(slug.value);
|
|
|
|
const cookieWishlist = useCookie($appHelpers.COOKIES_WISHLIST_KEY, {
|
|
default: () => [],
|
|
path: '/',
|
|
});
|
|
|
|
const isAuthenticated = computed(() => userStore.isAuthenticated);
|
|
|
|
const meta = useDefaultSeo(seoMeta.value || null);
|
|
|
|
if (meta) {
|
|
useSeoMeta({
|
|
title: meta.title || $appHelpers.APP_NAME,
|
|
description: meta.description || meta.title || $appHelpers.APP_NAME,
|
|
ogTitle: meta.og.title || undefined,
|
|
ogDescription: meta.og.description || meta.title || $appHelpers.APP_NAME,
|
|
ogType: meta.og.type || undefined,
|
|
ogUrl: meta.og.url || undefined,
|
|
ogImage: meta.og.image || undefined,
|
|
twitterCard: meta.twitter.card || undefined,
|
|
twitterTitle: meta.twitter.title || undefined,
|
|
twitterDescription: meta.twitter.description || undefined,
|
|
robots: meta.robots,
|
|
});
|
|
|
|
useHead({
|
|
link: [
|
|
meta.canonical ? { rel: 'canonical', href: meta.canonical } : {},
|
|
].filter(Boolean) as any,
|
|
meta: [{ property: 'og:locale', content: locale.value }],
|
|
script: meta.jsonLd.map((obj: any) => ({
|
|
type: 'application/ld+json',
|
|
innerHTML: JSON.stringify(obj),
|
|
})),
|
|
__dangerouslyDisableSanitizersByTagID: Object.fromEntries(
|
|
meta.jsonLd.map((_, i: number) => [`ldjson-${i}`, ['innerHTML']])
|
|
),
|
|
});
|
|
}
|
|
|
|
const { products } = useProducts({ categoriesSlugs: product.value?.category.slug });
|
|
|
|
const isProductInWishlist = computed(() => {
|
|
if (isAuthenticated.value) {
|
|
return !!wishlistStore.wishlist?.products?.edges.find(
|
|
(el) => el?.node?.uuid === product.value.uuid
|
|
);
|
|
} else {
|
|
return (cookieWishlist.value ?? []).includes(product.value.uuid);
|
|
}
|
|
});
|
|
|
|
const isProductInCart = computed(() => {
|
|
if (isAuthenticated.value) {
|
|
return !!cartStore.currentOrder?.orderProducts?.edges.find(
|
|
(prod) => prod.node.product.uuid === product.value?.uuid
|
|
);
|
|
} else {
|
|
return (cookieCart.value ?? []).some(
|
|
(item) => item.product.uuid === product.value?.uuid
|
|
);
|
|
}
|
|
});
|
|
|
|
const productInCartQuantity = computed(() => {
|
|
if (isAuthenticated.value) {
|
|
const productEdge = cartStore.currentOrder?.orderProducts?.edges.find(
|
|
(prod) => prod.node.product.uuid === product.value.uuid
|
|
);
|
|
return productEdge?.node.quantity ?? 0;
|
|
} else {
|
|
const cartItem = (cookieCart.value ?? []).find(
|
|
(item) => item.product === product.value.uuid
|
|
);
|
|
return cartItem?.quantity ?? 0;
|
|
}
|
|
});
|
|
|
|
const images = computed<string[]>(() =>
|
|
product.value
|
|
? product.value.images.edges.map(e => e.node.image)
|
|
: []
|
|
);
|
|
|
|
const attributes = computed(() => {
|
|
const edges = product.value?.attributeGroups.edges ?? [];
|
|
|
|
const mainIndex = edges.findIndex(e => e.node.name === 'Основные характеристики');
|
|
const ordered = mainIndex >= 0
|
|
? [edges[mainIndex], ...edges.slice(0, mainIndex), ...edges.slice(mainIndex + 1)]
|
|
: edges;
|
|
|
|
return ordered.map(groupEdge => {
|
|
const { node } = groupEdge
|
|
return {
|
|
uuid: node.uuid,
|
|
name: node.name,
|
|
items: node.attributes.map(attr => ({
|
|
uuid: attr.uuid,
|
|
name: attr.name,
|
|
valuesStr: attr.values.map(v => v.value).join(', ')
|
|
}))
|
|
};
|
|
});
|
|
});
|
|
|
|
const selectedImage = ref<string>(images.value[0] ?? '');
|
|
const selectImage = (image: string) => {
|
|
selectedImage.value = image;
|
|
};
|
|
|
|
const prevButton = ref<HTMLElement | null>(null);
|
|
const nextButton = ref<HTMLElement | null>(null);
|
|
|
|
setPageTitle(product.value?.name ?? 'Product');
|
|
|
|
watch(
|
|
() => route.params.slug,
|
|
async (newSlug) => {
|
|
if (typeof newSlug === 'string') {
|
|
const { product } = await useProductBySlug(newSlug);
|
|
setPageTitle(product.value?.name ?? 'Product');
|
|
}
|
|
}
|
|
);
|
|
</script>
|
|
|
|
<style scoped lang="scss">
|
|
.product {
|
|
background-color: $white;
|
|
padding-block: 50px 100px;
|
|
|
|
&__title {
|
|
font-family: "Playfair Display", sans-serif;
|
|
color: #1e1e1e;
|
|
font-size: 36px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
&__block {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
gap: 50px;
|
|
}
|
|
|
|
&__images {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 26px;
|
|
|
|
&-gallery {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
|
|
& div {
|
|
cursor: pointer;
|
|
width: 75px;
|
|
height: 75px;
|
|
padding: 5px;
|
|
border-radius: $default_border_radius;
|
|
background-color: $white;
|
|
border: 2px solid $white;
|
|
transition: 0.2s;
|
|
|
|
@include hover {
|
|
box-shadow: 0 0 10px 1px $accent;
|
|
|
|
&.active {
|
|
box-shadow: 0 0 0 0 transparent;
|
|
}
|
|
}
|
|
|
|
&.active {
|
|
border: 2px solid $accent;
|
|
}
|
|
|
|
& img {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: contain;
|
|
border-radius: $default_border_radius;
|
|
}
|
|
}
|
|
}
|
|
|
|
&-main {
|
|
width: 550px;
|
|
border-radius: $default_border_radius;
|
|
object-fit: contain;
|
|
}
|
|
}
|
|
|
|
&__main {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
|
|
&-brand {
|
|
color: #1a1a1a;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
letter-spacing: -0.15px;
|
|
}
|
|
|
|
&-rate {
|
|
margin-block: 10px 14px;
|
|
}
|
|
|
|
&-price {
|
|
color: #1a1a1a;
|
|
font-size: 30px;
|
|
font-weight: 600;
|
|
letter-spacing: -0.5px;
|
|
}
|
|
|
|
&-quantity {
|
|
color: #1a1a1a;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
letter-spacing: -0.15px;
|
|
}
|
|
|
|
&-description {
|
|
color: #374151;
|
|
font-size: 16px;
|
|
font-weight: 400;
|
|
letter-spacing: -0.5px;
|
|
}
|
|
|
|
&-button {
|
|
margin-top: 25px;
|
|
}
|
|
}
|
|
|
|
&__info {
|
|
width: 400px;
|
|
flex-shrink: 0;
|
|
border-radius: $default_border_radius;
|
|
background-color: $accent;
|
|
padding: 5px;
|
|
|
|
&-inner {
|
|
border-radius: $default_border_radius;
|
|
border: 2px solid $white;
|
|
padding: 25px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 25px;
|
|
}
|
|
|
|
&-top {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
|
|
& p {
|
|
color: $white;
|
|
font-size: 16px;
|
|
font-weight: 500;
|
|
}
|
|
|
|
& img {
|
|
width: 75px;
|
|
border-radius: $default_border_radius;
|
|
}
|
|
}
|
|
|
|
&-price {
|
|
width: fit-content;
|
|
background-color: $accentDark;
|
|
border-radius: $default_border_radius;
|
|
border: 1px solid $white;
|
|
padding: 7px 20px;
|
|
|
|
font-size: 40px;
|
|
font-weight: 700;
|
|
color: $white;
|
|
}
|
|
|
|
&-bottom {
|
|
width: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 10px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
&-button {
|
|
width: unset !important;
|
|
padding-inline: 25px !important;
|
|
background-color: $white !important;
|
|
color: $accent !important;
|
|
|
|
font-size: 20px !important;
|
|
|
|
@include hover {
|
|
background-color: #e3e3e3 !important;
|
|
}
|
|
}
|
|
|
|
&-wishlist {
|
|
cursor: pointer;
|
|
width: 41px;
|
|
height: 41px;
|
|
flex-shrink: 0;
|
|
background-color: $white;
|
|
border-radius: $default_border_radius;
|
|
display: grid;
|
|
place-items: center;
|
|
transition: 0.2s;
|
|
|
|
font-size: 22px;
|
|
color: $accent;
|
|
|
|
@include hover {
|
|
background-color: #e3e3e3;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
.tools {
|
|
width: 100%;
|
|
border-radius: 4px;
|
|
background-color: #111827;
|
|
display: grid;
|
|
grid-template-columns: 1fr 2fr 1fr;
|
|
height: 40px;
|
|
|
|
&__item {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 100%;
|
|
height: 100%;
|
|
|
|
&-count {
|
|
border-left: 1px solid $accent;
|
|
border-right: 1px solid $accent;
|
|
|
|
color: $white;
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
&-button {
|
|
cursor: pointer;
|
|
background-color: #111827;
|
|
border-radius: 4px 0 0 4px;
|
|
transition: 0.2s;
|
|
|
|
color: $white;
|
|
font-size: 18px;
|
|
font-weight: 500;
|
|
|
|
@include hover {
|
|
background-color: #222c41;
|
|
color: $white;
|
|
}
|
|
|
|
&:last-child {
|
|
border-radius: 0 4px 4px 0;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
.characteristics {
|
|
padding-top: 100px;
|
|
|
|
&__wrapper {
|
|
background-color: $white;
|
|
border-radius: $default_border_radius;
|
|
box-shadow: 0 0 20px 2px rgba(0, 0, 0, 0.2);
|
|
padding: 20px;
|
|
}
|
|
|
|
&__title {
|
|
padding-bottom: 10px;
|
|
border-bottom: 2px solid $accentDark;
|
|
color: $black;
|
|
font-size: 24px;
|
|
font-weight: 700;
|
|
}
|
|
|
|
&__list {
|
|
margin-top: 20px;
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit,minmax(100px, 30%));
|
|
gap: 40px;
|
|
}
|
|
|
|
&__column {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
|
|
&-title {
|
|
margin-bottom: 5px;
|
|
color: $black;
|
|
font-weight: 700;
|
|
font-size: 16px;
|
|
}
|
|
}
|
|
|
|
&__item {
|
|
display: grid;
|
|
grid-template-columns: repeat(2,1fr);
|
|
width: 100%;
|
|
gap: 5px;
|
|
font-size: 14px;
|
|
|
|
&-label {
|
|
position: relative;
|
|
font-weight: 500;
|
|
|
|
& span {
|
|
position: relative;
|
|
z-index: 1;
|
|
background-color: $white;
|
|
padding-bottom: 1px;
|
|
}
|
|
|
|
&::after {
|
|
border-bottom: 1px dashed $accent;
|
|
content: "";
|
|
min-width: 100%;
|
|
position: absolute;
|
|
right: 0;
|
|
top: 1em;
|
|
}
|
|
}
|
|
|
|
&-value {
|
|
& span {
|
|
&:first-child {
|
|
& span {
|
|
&:first-child {
|
|
display: none;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
.similar {
|
|
margin-top: 100px;
|
|
background-color: $white;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 25px;
|
|
padding: 45px 25px;
|
|
border-radius: 8px;
|
|
border: 1px solid #e5e7eb;
|
|
|
|
&__title {
|
|
font-family: "Playfair Display", sans-serif;
|
|
color: $black;
|
|
font-size: 30px;
|
|
font-weight: 600;
|
|
letter-spacing: -0.5px;
|
|
}
|
|
|
|
&__inner {
|
|
display: flex;
|
|
align-items: stretch;
|
|
}
|
|
|
|
&__swiper {
|
|
width: 100%;
|
|
padding-block: 25px;
|
|
padding-inline: 25px;
|
|
}
|
|
|
|
&__button {
|
|
cursor: pointer;
|
|
height: fit-content;
|
|
margin-block: auto;
|
|
flex-shrink: 0;
|
|
display: grid;
|
|
place-items: center;
|
|
padding-inline: 10px;
|
|
aspect-ratio: 1;
|
|
border-radius: 50%;
|
|
background-color: $white;
|
|
transition: 0.2s;
|
|
|
|
@include hover {
|
|
background-color: $accentLight;
|
|
|
|
& span {
|
|
color: $white;
|
|
}
|
|
}
|
|
|
|
& span {
|
|
transition: 0.2s;
|
|
color: $accent;
|
|
}
|
|
|
|
&.next {
|
|
& span {
|
|
transform: rotate(180deg);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
</style> |