schon/storefront/app/components/cards/product.vue
Alexandr SaVBaD Waltz 556354a44d feat(storefront): overhaul theming system and unify SCSS variables
Revamped the theming system with new SCSS variables for consistent styling across light and dark themes. Replaced static color values with dynamic variables for maintainability and improved theme adaptability. Updated components and layouts to use the new variables.

- Moved theme plugin logic for optimized handling of theme cookies and attributes.
- Enhanced `useThemes` composable for simplified client-side updates and SSR support.
- Replaced redundant SCSS color definitions with centralized variables.
- Improved page structure by introducing `ui-title` for reusable section headers.
- Unified transitions and border-radius for consistent design language.

Breaking Changes:
Theming system restructured—migrate to `$main`, `$primary`, and related variables for SCSS colors. Remove usage of `--color-*` variables in templates and styles.
2026-03-01 20:16:05 +03:00

518 lines
No EOL
12 KiB
Vue

<template>
<div
class="card"
:class="{ 'card__list': isList }"
>
<div
class="card__wishlist"
@click="overwriteWishlist({
type: (isProductInWishlist ? 'remove' : 'add'),
productUuid: product.uuid,
productName: product.name,
})"
>
<icon style="color: #dc2626;" name="mdi:cards-heart" size="16" v-if="isProductInWishlist" />
<icon name="mdi:cards-heart-outline" size="16" v-else />
</div>
<div class="card__wrapper">
<nuxt-link-locale
:to="`/product/${product.slug}`"
class="card__link"
>
<div class="card__block">
<client-only>
<swiper
v-if="images.length"
@swiper="onSwiper"
:modules="[EffectFade, Pagination]"
effect="fade"
:slides-per-view="1"
:pagination="paginationOptions"
class="card__swiper"
>
<swiper-slide
v-for="(img, i) in images"
:key="i"
class="card__swiper-slide"
>
<nuxt-img
:src="img"
:alt="product.name"
loading="lazy"
class="card__swiper-image"
format="webp"
densities="x1"
/>
</swiper-slide>
</swiper>
<div class="card__image-placeholder" />
<div
v-for="(image, idx) in images"
:key="idx"
class="card__block-hover"
:style="{ left: `${(100/ images.length) * idx}%`, width: `${100/ images.length}%` }"
@mouseenter="goTo(idx)"
@mouseleave="goTo(0)"
/>
</client-only>
</div>
</nuxt-link-locale>
</div>
<div class="card__content">
<div class="card__content-inner">
<div class="card__brand">{{ product.brand.name }}</div>
<p class="card__name">{{ product.name }}</p>
<el-rate
class="card__rating"
v-model="rating"
size="large"
disabled
/>
<div class="card__price">{{ product.price }} $</div>
</div>
<div class="card__bottom">
<div class="card__bottom-inner">
<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="card__bottom-button"
@click="overwriteOrder({
type: 'add',
productUuid: product.uuid,
productName: product.name
})"
:type="'button'"
:isLoading="addLoading"
>
{{ t('buttons.addToCart') }}
</ui-button>
<ui-button
:type="'button'"
class="card__bottom-button"
:style="'secondary'"
@click="buyProduct(product.uuid)"
:isLoading="buyLoading"
>
{{ t('buttons.buyNow') }}
</ui-button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type {IProduct} from '@types';
import { Swiper, SwiperSlide } from 'swiper/vue';
import { EffectFade, Pagination } from 'swiper/modules';
import {useWishlistOverwrite} from '@composables/wishlist';
import {useOrderOverwrite} from '@composables/orders';
import {useProductBuy} from "@composables/products";
const props = defineProps<{
product: IProduct;
isList?: boolean;
}>();
const {t} = useI18n();
const wishlistStore = useWishlistStore();
const cartStore = useCartStore();
const userStore = useUserStore();
const { $appHelpers } = useNuxtApp();
const { overwriteWishlist } = useWishlistOverwrite();
const { addLoading, removeLoading, overwriteOrder } = useOrderOverwrite();
const { buyProduct, loading: buyLoading } = useProductBuy();
const cookieWishlist = useCookie($appHelpers.COOKIES_WISHLIST_KEY, {
default: () => [],
path: '/',
});
const cookieCart = useCookie($appHelpers.COOKIES_CART_KEY, {
default: () => [],
path: '/',
});
const isAuthenticated = computed(() => userStore.isAuthenticated);
const isProductInWishlist = computed(() => {
if (isAuthenticated.value) {
return !!wishlistStore.wishlist?.products?.edges.find(
(el) => el?.node?.uuid === props.product.uuid
);
} else {
return (cookieWishlist.value ?? []).includes(props.product.uuid);
}
});
const isProductInCart = computed(() => {
if (isAuthenticated.value) {
return !!cartStore.currentOrder?.orderProducts?.edges.find(
(prod) => prod.node.product.uuid === props.product?.uuid
);
} else {
return (cookieCart.value ?? []).some(
(item) => item.product === props.product?.uuid
);
}
});
const productInCartQuantity = computed(() => {
if (isAuthenticated.value) {
const productEdge = cartStore.currentOrder?.orderProducts?.edges.find(
(prod) => prod.node.product.uuid === props.product.uuid
);
return productEdge?.node.quantity ?? 0;
} else {
const cartItem = (cookieCart.value ?? []).find(
(item) => item.product === props.product.uuid
);
return cartItem?.quantity ?? 0;
}
});
const rating = computed(() => {
return props.product.feedbacks.edges[0]?.node?.rating ?? 5;
});
const images = computed(() =>
props.product.images.edges.map(e => e.node.image)
);
const paginationOptions = computed(() =>
images.value.length > 1
? {
clickable: true,
bulletClass: 'swiper-pagination-line',
bulletActiveClass: 'swiper-pagination-line--active'
}
: false
);
const swiperRef = ref<any>(null);
function onSwiper(swiper: any) {
swiperRef.value = swiper;
}
function goTo(index: number) {
swiperRef.value?.slideTo(index);
}
</script>
<style lang="scss" scoped>
.card {
border-radius: $default_border_radius;
border: 1px solid $border;
width: 100%;
background-color: $main;
position: relative;
height: 100%;
display: flex;
flex-direction: column;
&__list {
flex-direction: row;
align-items: stretch;
gap: 50px;
padding: 15px;
& .card__link {
width: fit-content;
padding: 0;
}
& .card__block {
width: 150px;
height: 150px;
}
& .card__content {
width: 100%;
flex-direction: row;
align-items: flex-end;
justify-content: space-between;
padding: 0;
&-inner {
align-self: flex-start;
}
}
& .tools {
width: 136px;
}
& .card__bottom {
margin-top: 0;
width: fit-content;
flex-shrink: 0;
padding: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: flex-end;
&-inner {
flex-direction: column;
align-items: flex-end;
gap: 10px;
}
&-button {
width: fit-content;
padding-inline: 25px;
}
}
& .card__wrapper {
flex-direction: row;
}
}
@include hover {
border-color: $border_hover;
box-shadow: 0 0 10px 1px $border;
}
&__wrapper {
display: flex;
flex-direction: column;
justify-content: space-between;
}
&__link {
display: block;
width: 100%;
}
&__block {
position: relative;
width: 100%;
height: 200px;
overflow: hidden;
&-hover {
position: absolute;
top: 0;
left: 0;
width: 20%;
height: 100%;
z-index: 2;
cursor: pointer;
background: transparent;
}
}
&__swiper {
width: 100%;
height: 100%;
position: relative;
z-index: 1;
padding-bottom: 10px;
&-image {
width: 100%;
height: 100%;
object-fit: contain;
}
}
&__image {
&-placeholder {
width: 100%;
height: 200px;
background-color: $primary;
}
}
&__content {
padding: 10px 20px 20px 20px;
display: flex;
flex-direction: column;
gap: 15px;
&-inner {
display: flex;
flex-direction: column;
gap: 4px;
}
}
&__brand {
font-size: 12px;
font-weight: 400;
letter-spacing: -0.2px;
color: $text;
}
&__price {
font-weight: 600;
font-size: 16px;
letter-spacing: -0.5px;
color: $primary_dark;
}
&__rating {
margin-block: 4px 10px;
}
&__name {
word-break: break-all;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
overflow: hidden;
color: $primary_dark;
font-weight: 500;
font-size: 14px;
letter-spacing: -0.5px;
}
&__quantity {
width: fit-content;
background-color: rgba($secondary, 0.5);
border-radius: $less_border_radius;
padding: 5px 10px;
font-size: 14px;
}
&__wishlist {
cursor: pointer;
width: fit-content;
background-color: $main;
box-shadow: 0 2px 4px 0 $primary_shadow;
position: absolute;
top: 16px;
right: 16px;
z-index: 3;
border-radius: 50%;
padding: 12px;
@include hover {
box-shadow: 0 2px 4px 0 $primary_shadow_hover;
}
& span {
color: $secondary;
display: block;
}
}
&__bottom {
&-inner {
display: flex;
flex-direction: column;
gap: 10px;
}
&-button {
padding-block: 10px !important;
border-radius: $less_border_radius;
}
&-wishlist {
cursor: pointer;
width: 34px;
height: 34px;
flex-shrink: 0;
background-color: $primary;
border-radius: $less_border_radius;
display: grid;
place-items: center;
font-size: 22px;
color: $main;
@include hover {
background-color: $primary_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_hover;
border-right: 1px solid $primary_hover;
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;
}
}
}
}
:deep(.swiper-pagination) {
bottom: 0;
display: flex;
justify-content: center;
gap: 6px;
}
:deep(.swiper-pagination-line) {
display: inline-block;
width: 24px;
height: 2px;
background-color: rgba($primary_dark, 0.3);
border-radius: 0;
opacity: 1;
}
:deep(.swiper-pagination-line--active) {
background-color: $primary_dark;
}
</style>