schon/storefront/app/pages/product/[slug].vue
Alexandr SaVBaD Waltz d4b2839502 feat(storefront): enhance category and product templates with HTML rendering
Updated catalog and product pages to render descriptions using HTML (`v-html`), allowing richer content presentation. Improved characteristics section for products, reintroducing previously commented-out functionality with refined structure and styling.

- Enabled `v-html` in `categorySlug.vue` and `slug.vue` for proper HTML display in descriptions.
- Restored and revamped characteristics section with better styling and layout.
- Adjusted SCSS for consistent typography and spacing in characteristics.

No breaking changes introduced—enhancements improve presentation and maintain code quality.
2026-03-01 22:13:24 +03:00

714 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>
<div class="product__main-description" v-html="product?.description" />
<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>
<div class="product__main-buttons">
<ui-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>
<ui-button
:type="'button'"
:style="'secondary'"
@click="buyProduct(product.uuid)"
:isLoading="buyLoading"
>
{{ t('buttons.buyNow') }}
</ui-button>
</div>
</div>
</div>
</div>
<client-only>
<div class="characteristics" id="characteristics" v-if="attributes.length">
<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>
</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, useProductBuy} 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 { buyProduct, loading: buyLoading } = useProductBuy();
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 cookieCart = useCookie($appHelpers.COOKIES_CART_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: $main;
padding-block: 50px 100px;
&__title {
font-family: "Playfair Display", sans-serif;
color: $primary_dark;
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: $less_border_radius;
background-color: $main;
border: 2px solid $main;
@include hover {
box-shadow: 0 0 10px 1px $primary;
&.active {
box-shadow: 0 0 0 0 transparent;
}
}
&.active {
border: 2px solid $primary;
}
& img {
width: 100%;
height: 100%;
object-fit: contain;
border-radius: $less_border_radius;
}
}
}
&-main {
width: 550px;
border-radius: $less_border_radius;
object-fit: contain;
}
}
&__main {
display: flex;
flex-direction: column;
gap: 10px;
&-brand {
color: $primary_dark;
font-size: 14px;
font-weight: 500;
letter-spacing: -0.15px;
}
&-rate {
margin-block: 10px 14px;
}
&-price {
color: $primary_dark;
font-size: 30px;
font-weight: 600;
letter-spacing: -0.5px;
}
&-quantity {
color: $primary_dark;
font-size: 14px;
font-weight: 500;
letter-spacing: -0.15px;
}
&-description {
color: $secondary;
font-size: 16px;
font-weight: 400;
letter-spacing: -0.5px;
}
&-buttons {
display: flex;
align-items: center;
justify-content: space-between;
& .button {
width: 49%;
}
}
&-button {
margin-top: 25px;
}
}
&__info {
width: 400px;
flex-shrink: 0;
border-radius: $less_border_radius;
background-color: $primary;
padding: 5px;
&-inner {
border-radius: $less_border_radius;
border: 2px solid $main;
padding: 25px;
display: flex;
flex-direction: column;
gap: 25px;
}
&-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
& p {
color: $main;
font-size: 16px;
font-weight: 500;
}
& img {
width: 75px;
border-radius: $less_border_radius;
}
}
&-price {
width: fit-content;
background-color: $primary_dark;
border-radius: $less_border_radius;
border: 1px solid $main;
padding: 7px 20px;
font-size: 40px;
font-weight: 700;
color: $main;
}
&-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: $main !important;
color: $primary !important;
font-size: 20px !important;
@include hover {
background-color: $main_hover !important;
}
}
&-wishlist {
cursor: pointer;
width: 41px;
height: 41px;
flex-shrink: 0;
background-color: $main;
border-radius: $less_border_radius;
display: grid;
place-items: center;
font-size: 22px;
color: $primary;
@include hover {
background-color: $main_hover;
}
}
}
}
.tools {
width: 100%;
border-radius: $less_border_radius;
background-color: $primary;
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 $primary;
border-right: 1px solid $primary;
color: $main;
font-size: 16px;
font-weight: 600;
}
&-button {
cursor: pointer;
background-color: $primary;
border-radius: $less_border_radius 0 0 $less_border_radius;
color: $main;
font-size: 18px;
font-weight: 500;
@include hover {
background-color: $primary_hover;
color: $main;
}
&:last-child {
border-radius: 0 $less_border_radius $less_border_radius 0;
}
}
}
}
.characteristics {
margin-top: 100px;
display: flex;
flex-direction: column;
gap: 25px;
padding: 45px 25px;
border-radius: $default_border_radius;
border: 1px solid $border;
&__title {
font-family: "Playfair Display", sans-serif;
color: $primary_dark;
font-size: 30px;
font-weight: 600;
letter-spacing: -0.5px;
}
&__list {
margin-top: 20px;
display: grid;
grid-template-columns: repeat(auto-fit,minmax(30%, 30%));
gap: 40px;
}
&__column {
display: flex;
flex-direction: column;
gap: 8px;
&-title {
margin-bottom: 5px;
color: $primary_dark;
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: $main;
padding-bottom: 1px;
color: $text;
font-size: 16px;
font-weight: 400;
letter-spacing: -0.5px;
}
&::after {
border-bottom: 1px dashed $primary;
content: "";
min-width: 100%;
position: absolute;
right: 0;
top: 1em;
}
}
&-value {
color: $primary_dark;
font-size: 16px;
font-weight: 500;
letter-spacing: -0.5px;
& span {
&:first-child {
& span {
&:first-child {
display: none;
}
}
}
}
}
}
}
.similar {
margin-top: 100px;
background-color: $main;
display: flex;
flex-direction: column;
gap: 25px;
padding: 45px 25px;
border-radius: $default_border_radius;
border: 1px solid $border;
&__title {
font-family: "Playfair Display", sans-serif;
color: $primary_dark;
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: transparent;
@include hover {
background-color: $link_secondary;
}
& span {
color: $primary;
}
&.next {
& span {
transform: rotate(180deg);
}
}
}
}
</style>