admin user and ui panel
This commit is contained in:
@@ -1,27 +1,38 @@
|
||||
<!--
|
||||
Editorial hero for the home screen. Replaces the legacy swiper banner with a
|
||||
self-contained, dependency-free slider: no swiper CSS to fight, no autoplay
|
||||
jitter, and it keeps working when a slide has no artwork.
|
||||
Editorial hero for the home screen. Self-contained, dependency-free slider:
|
||||
no swiper CSS to fight, no autoplay jitter, and it keeps working when a
|
||||
slide has no artwork.
|
||||
|
||||
Swipe support: the app sets `direction: rtl !important` on #app (see
|
||||
src/App.vue), so — same fix as GallerySlider.vue — the track is an isolated
|
||||
`direction: ltr` subtree (.showcase__track). Inside it, index 0 is first and
|
||||
a positive translateX moves right, plain LTR arithmetic, regardless of the
|
||||
page's own direction. The drag gesture itself is still read in screen space
|
||||
(dragging left = next slide) so it feels natural to an RTL user; only the
|
||||
track's internal layout/maths is pinned to LTR.
|
||||
-->
|
||||
<template>
|
||||
<section class="showcase" v-if="slides.length">
|
||||
<div class="showcase__frame">
|
||||
<transition-group name="showcase-fade" tag="div" class="showcase__stage">
|
||||
<article v-for="(slide, i) in slides" :key="slide.key" v-show="i === index" class="showcase__slide">
|
||||
<img v-if="slide.image" :src="slide.image" alt="" class="showcase__art" />
|
||||
<div class="showcase__scrim" aria-hidden="true"></div>
|
||||
|
||||
<div class="showcase__copy">
|
||||
<span class="showcase__eyebrow">{{ slide.eyebrow }}</span>
|
||||
<h2 class="showcase__title">{{ slide.title }}</h2>
|
||||
<p class="showcase__sub">{{ slide.subtitle }}</p>
|
||||
<button type="button" class="showcase__cta" @click="emit('select', slide)">
|
||||
{{ slide.cta }}
|
||||
<Icon icon="vuesax-linear:arrow-left-2" size="17" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
ref="root"
|
||||
class="showcase__frame"
|
||||
:class="{ 'is-dragging': dragging }"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointermove="onPointerMove"
|
||||
@pointerup="onPointerUp"
|
||||
@pointercancel="onPointerCancel"
|
||||
>
|
||||
<div class="showcase__track" :style="trackStyle">
|
||||
<article
|
||||
v-for="(slide, i) in slides"
|
||||
:key="slide.key"
|
||||
class="showcase__slide"
|
||||
:class="{ 'is-active': i === index }"
|
||||
@click="onSlideClick(slide)"
|
||||
>
|
||||
<img v-if="slide.image" :src="slide.image" alt="" class="showcase__art" draggable="false" />
|
||||
</article>
|
||||
</transition-group>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="showcase__dots" v-if="slides.length > 1">
|
||||
@@ -39,8 +50,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import Icon from '../icon/Icon.vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
export interface ShowcaseSlide {
|
||||
key: string
|
||||
@@ -50,6 +60,9 @@ export interface ShowcaseSlide {
|
||||
cta: string
|
||||
image?: string
|
||||
categoryId?: string
|
||||
/** Admin-uploaded slide's own destination (manage/Const.vue), when it has
|
||||
* one. Takes priority over `categoryId` in the click handler. */
|
||||
link?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{ slides: ShowcaseSlide[] }>()
|
||||
@@ -75,6 +88,98 @@ onMounted(restart)
|
||||
onBeforeUnmount(() => {
|
||||
if (timer) clearInterval(timer)
|
||||
})
|
||||
|
||||
// ── Drag / swipe ─────────────────────────────────────────────
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
const dragging = ref(false)
|
||||
const dragX = ref(0)
|
||||
let pointerId: number | null = null
|
||||
let startX = 0
|
||||
let startY = 0
|
||||
let trackWidth = 0
|
||||
let isSwipe = false
|
||||
|
||||
const DRAG_COMMIT_RATIO = 0.18
|
||||
const TAP_MAX_PX = 6
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
if (props.slides.length < 2 || pointerId !== null) return
|
||||
const el = root.value
|
||||
if (!el) return
|
||||
pointerId = e.pointerId
|
||||
startX = e.clientX
|
||||
startY = e.clientY
|
||||
trackWidth = el.getBoundingClientRect().width || 1
|
||||
dragX.value = 0
|
||||
dragging.value = true
|
||||
isSwipe = false
|
||||
el.setPointerCapture?.(e.pointerId)
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!dragging.value || e.pointerId !== pointerId) return
|
||||
const dx = e.clientX - startX
|
||||
const dy = e.clientY - startY
|
||||
if (!isSwipe && Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > TAP_MAX_PX) isSwipe = true
|
||||
if (isSwipe) dragX.value = dx
|
||||
}
|
||||
|
||||
function endDrag() {
|
||||
if (!dragging.value) return
|
||||
const dx = dragX.value
|
||||
const ratio = dx / trackWidth
|
||||
|
||||
dragging.value = false
|
||||
pointerId = null
|
||||
dragX.value = 0
|
||||
|
||||
// Screen-space drag direction: dragging left (negative dx) is "next slide",
|
||||
// matching what the gesture means to the person doing it.
|
||||
if (ratio <= -DRAG_COMMIT_RATIO && index.value < props.slides.length - 1) index.value++
|
||||
else if (ratio >= DRAG_COMMIT_RATIO && index.value > 0) index.value--
|
||||
|
||||
if (isSwipe) restart()
|
||||
isSwipe = false
|
||||
}
|
||||
|
||||
function onPointerUp(e: PointerEvent) {
|
||||
if (e.pointerId !== pointerId) return
|
||||
endDrag()
|
||||
}
|
||||
|
||||
function onPointerCancel(e: PointerEvent) {
|
||||
if (e.pointerId !== pointerId) return
|
||||
dragging.value = false
|
||||
pointerId = null
|
||||
dragX.value = 0
|
||||
isSwipe = false
|
||||
}
|
||||
|
||||
function onSlideClick(slide: ShowcaseSlide) {
|
||||
// A drag also fires a trailing click on release; only treat it as a tap
|
||||
// (i.e. open the slide) when the gesture never turned into a swipe.
|
||||
if (isSwipe) return
|
||||
emit('select', slide)
|
||||
}
|
||||
|
||||
const trackStyle = computed(() => {
|
||||
const base = -index.value * 100
|
||||
const dragPercent = trackWidth ? (dragX.value / trackWidth) * 100 : 0
|
||||
return {
|
||||
transform: `translate3d(${base + dragPercent}%, 0, 0)`,
|
||||
transition: dragging.value ? 'none' : undefined,
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (pointerId !== null && root.value) {
|
||||
try {
|
||||
root.value.releasePointerCapture(pointerId)
|
||||
} catch {
|
||||
/* element may already be detached */
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
@@ -82,90 +187,80 @@ onBeforeUnmount(() => {
|
||||
position: relative;
|
||||
border-radius: 22px;
|
||||
overflow: hidden;
|
||||
background: @ink;
|
||||
// Neutral rather than @ink: this only shows before artwork loads, and a
|
||||
// dark fill read as an unwanted border wherever it was visible at all.
|
||||
background: @hairlineSoft;
|
||||
box-shadow: 0 26px 50px -34px rgba(20, 22, 26, 0.85);
|
||||
aspect-ratio: 320 / 208;
|
||||
touch-action: pan-y;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
// Forces its own compositing layer so `overflow: hidden` actually clips the
|
||||
// translated track on Safari/iOS — without this, a sliver of the next
|
||||
// slide peeks past the rounded edge because the browser composites the
|
||||
// transformed child (.showcase__track) on a layer that ignores the
|
||||
// parent's clip until it's promoted the same way.
|
||||
transform: translateZ(0);
|
||||
-webkit-transform: translateZ(0);
|
||||
}
|
||||
|
||||
.showcase__stage {
|
||||
position: relative;
|
||||
display: block;
|
||||
aspect-ratio: 320 / 208;
|
||||
// Isolated from the page's `direction: rtl` — see the file header. Everything
|
||||
// under this node uses plain LTR layout and LTR-signed transforms.
|
||||
.showcase__track {
|
||||
direction: ltr;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
// A gentle, slightly-overshooting ease — softer settle than a linear/
|
||||
// ease-out curve, which is what makes the swipe feel "alive" rather than
|
||||
// mechanical. Slowed down a touch from the old 0.42s so the motion reads
|
||||
// as a deliberate glide instead of a snap.
|
||||
transition: transform 0.56s cubic-bezier(0.16, 0.86, 0.3, 1);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.showcase__frame.is-dragging .showcase__track {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.showcase__slide {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
position: relative;
|
||||
flex: 0 0 100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
// Clips its own image's scale-in (.showcase__art below) to this slide's own
|
||||
// box, so the 1.06x zoom never bleeds into the neighbouring slide.
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.showcase__art {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
// A plain block filling the slide, NOT an absolutely-positioned box.
|
||||
// `position: absolute` + `inset` made the image's own box a second,
|
||||
// independently-rounded measurement of the same area the frame already
|
||||
// sizes via `aspect-ratio`; on real devices the two disagreed by a
|
||||
// fraction of a pixel and the frame's dark background showed through as a
|
||||
// hairline border around the artwork. Sizing it as ordinary in-flow
|
||||
// content removes that second measurement entirely.
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
opacity: 0.5;
|
||||
transform: scale(1.04);
|
||||
-webkit-user-drag: none;
|
||||
user-select: none;
|
||||
// Never settles below 1x: the "breathe in" flourish now runs from a larger
|
||||
// scale down to exactly 1, so the image is always at least as big as the
|
||||
// slide and can never reveal what sits behind it.
|
||||
transform: scale(1.06);
|
||||
transition: transform 0.56s cubic-bezier(0.16, 0.86, 0.3, 1);
|
||||
}
|
||||
|
||||
.showcase__scrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(255deg, rgba(122, 27, 51, 0.55) 0%, rgba(122, 27, 51, 0) 52%),
|
||||
linear-gradient(to left, rgba(20, 22, 26, 0.2) 0%, rgba(20, 22, 26, 0.92) 72%);
|
||||
.showcase__slide.is-active .showcase__art {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.showcase__copy {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
padding: 22px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.showcase__eyebrow {
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 1.6px;
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
padding-bottom: 9px;
|
||||
}
|
||||
|
||||
.showcase__title {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
line-height: 1.35;
|
||||
color: #fff;
|
||||
margin: 0 0 7px;
|
||||
}
|
||||
|
||||
.showcase__sub {
|
||||
font-size: 12.5px;
|
||||
line-height: 1.85;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
margin: 0 0 16px;
|
||||
max-width: 30ch;
|
||||
}
|
||||
|
||||
.showcase__cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
padding: 9px 16px;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
color: @ink;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
font-family: inherit;
|
||||
transition: transform 0.18s ease, background 0.18s ease;
|
||||
}
|
||||
.showcase__cta:active {
|
||||
transform: scale(0.97);
|
||||
.showcase__frame.is-dragging .showcase__art {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.showcase__dots {
|
||||
@@ -183,19 +278,10 @@ onBeforeUnmount(() => {
|
||||
cursor: pointer;
|
||||
border-radius: 999px;
|
||||
background: @hairline;
|
||||
transition: all 0.25s ease;
|
||||
transition: width 0.42s cubic-bezier(0.16, 0.86, 0.3, 1), background 0.3s ease;
|
||||
}
|
||||
.showcase__dot.is-active {
|
||||
width: 20px;
|
||||
background: @wine;
|
||||
}
|
||||
|
||||
.showcase-fade-enter-active,
|
||||
.showcase-fade-leave-active {
|
||||
transition: opacity 0.55s ease;
|
||||
}
|
||||
.showcase-fade-enter-from,
|
||||
.showcase-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -30,7 +30,13 @@
|
||||
|
||||
<div class="ci__row">
|
||||
<div class="ci__qty">
|
||||
<button type="button" class="ci__qtyBtn" aria-label="افزودن" @click="increment">
|
||||
<button
|
||||
type="button"
|
||||
class="ci__qtyBtn"
|
||||
aria-label="افزودن"
|
||||
:disabled="!canIncrement"
|
||||
@click="increment"
|
||||
>
|
||||
<Icon icon="vuesax-linear:add" size="16" />
|
||||
</button>
|
||||
<span class="ci__qtyValue">{{ value.qty }}</span>
|
||||
@@ -71,7 +77,13 @@ const emit = defineEmits<{ (e: 'note', value: number): void }>()
|
||||
const cart = useCartStore()
|
||||
const index = cart.find_index(props.value)
|
||||
|
||||
// Stock ceiling, same rule as the product page: uncapped for a pre-order
|
||||
// variant, otherwise limited to what is on hand. The store clamps as well.
|
||||
const maxQty = computed(() => cart.maxQty(props.value))
|
||||
const canIncrement = computed(() => maxQty.value === null || props.value.qty < maxQty.value)
|
||||
|
||||
const increment = () => {
|
||||
if (!canIncrement.value) return
|
||||
cart.updateFeild(props.value.qty + 1, index, props.value, 'qty')
|
||||
}
|
||||
const decrement = () => {
|
||||
|
||||
@@ -102,22 +102,55 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Variants -->
|
||||
<section v-if="variants && variants.length > 1" class="pd__block">
|
||||
<h2 class="pd__blockTitle">انتخاب وزن</h2>
|
||||
<div class="pd__variants no-scrollbar">
|
||||
<!--
|
||||
Variants. Each chip carries the variant's OWN gallery picture when the
|
||||
admin picked one, so two variants of the same weight (say plain vs.
|
||||
stone-set) are told apart at a glance instead of only by title — and
|
||||
several weights of one design correctly show the same photo.
|
||||
|
||||
Shown from one variant up (not two): with a picture and a stock badge
|
||||
the chip is worth seeing even when there is only one choice.
|
||||
|
||||
The row wraps rather than scrolling sideways: past ~4 chips a
|
||||
horizontal strip hides the rest off-screen, and products here can have
|
||||
10+ weights. Beyond two rows the extra chips collapse behind a toggle.
|
||||
-->
|
||||
<section v-if="variants && variants.length" class="pd__block">
|
||||
<h2 class="pd__blockTitle">انتخاب تنوع</h2>
|
||||
<div class="pd__variants">
|
||||
<button
|
||||
v-for="item in variants"
|
||||
v-for="item in visibleVariants"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="pd__variant"
|
||||
:class="{ 'is-on': selectedVariant?.id === item.id }"
|
||||
:class="{
|
||||
'is-on': selectedVariant?.id === item.id,
|
||||
'is-out': !isOrderable(item),
|
||||
}"
|
||||
:disabled="!isOrderable(item)"
|
||||
@click="selectedVariant = item"
|
||||
>
|
||||
<img
|
||||
v-if="variantImage(item)"
|
||||
class="pd__variantImg"
|
||||
:src="variantImage(item)"
|
||||
:alt="item.title"
|
||||
/>
|
||||
<span class="pd__variantTitle">{{ item.title }}</span>
|
||||
<span class="pd__variantWeight">{{ Number(item.rashWeightGrams) }} گرم</span>
|
||||
<span class="pd__variantStock" :class="stockChipClass(item)">
|
||||
{{ stockChipLabel(item) }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-if="variants.length > VARIANT_PREVIEW_COUNT"
|
||||
type="button"
|
||||
class="pd__variantsMore"
|
||||
@click="showAllVariants = !showAllVariants"
|
||||
>
|
||||
{{ showAllVariants ? 'نمایش کمتر' : `نمایش همه (${variants.length})` }}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<!-- Specs -->
|
||||
@@ -166,7 +199,13 @@
|
||||
<!-- Action bar -->
|
||||
<div class="pd__bar">
|
||||
<div v-if="inCart" class="pd__qty">
|
||||
<button type="button" class="pd__qtyBtn" aria-label="افزایش" @click="increment">
|
||||
<button
|
||||
type="button"
|
||||
class="pd__qtyBtn"
|
||||
aria-label="افزایش"
|
||||
:disabled="!canIncrement"
|
||||
@click="increment"
|
||||
>
|
||||
<Icon icon="vuesax-linear:add" size="16" />
|
||||
</button>
|
||||
<span class="pd__qtyValue">{{ cartQty }}</span>
|
||||
@@ -264,8 +303,9 @@ import GallerySlider from './GallerySlider.vue'
|
||||
import Spinner from '../Spinner.vue'
|
||||
import NotifyVant from '../NotifyVant.vue'
|
||||
import DialogVant from '../DialogVant.vue'
|
||||
import { useProduct } from '../../composables/useProduct'
|
||||
import { useProduct, isVariantOrderable } from '../../composables/useProduct'
|
||||
import { useProductDetail } from '../../composables/useProductDetail'
|
||||
import type { ProductVariantSummary } from '../../api/client'
|
||||
import { useFavoritesStore } from '../../stores/favoritesStore'
|
||||
import { useCartStore, type CartItem } from '../../stores/cartStore'
|
||||
import { isUserLoggedIn } from '../../utils/checkAccess'
|
||||
@@ -324,6 +364,34 @@ const qty = computed(() => productAdapter.value.qty.value)
|
||||
const isPreOrder = computed(() => productAdapter.value.isPreOrder.value)
|
||||
const is_exist = computed(() => productAdapter.value.is_exist.value)
|
||||
|
||||
// ── Variant chips ───────────────────────────────────────────────
|
||||
// Two rows' worth is shown up front; the rest sit behind "نمایش همه" so a
|
||||
// product with 10+ weights does not push the description off the screen.
|
||||
const VARIANT_PREVIEW_COUNT = 6
|
||||
const showAllVariants = ref(false)
|
||||
const visibleVariants = computed(() =>
|
||||
showAllVariants.value ? variants.value : variants.value.slice(0, VARIANT_PREVIEW_COUNT),
|
||||
)
|
||||
|
||||
const isOrderable = (v: ProductVariantSummary) => isVariantOrderable(v)
|
||||
|
||||
/**
|
||||
* The chip picture: the variant's own image when the admin picked one, else the
|
||||
* product's main photo so chips never render as empty boxes. Variants sharing
|
||||
* one image (same design, several weights) intentionally show the same picture.
|
||||
*/
|
||||
const variantImage = (v: ProductVariantSummary) =>
|
||||
v.image?.url ?? productDetail.productRef.value?.images?.[0]?.url ?? ''
|
||||
|
||||
const stockChipLabel = (v: ProductVariantSummary) => {
|
||||
if ((v.qty ?? 0) > 0) return 'موجود'
|
||||
return v.isPreOrder ? 'پیشسفارش' : 'ناموجود'
|
||||
}
|
||||
const stockChipClass = (v: ProductVariantSummary) => {
|
||||
if ((v.qty ?? 0) > 0) return 'is-in'
|
||||
return v.isPreOrder ? 'is-pre' : 'is-none'
|
||||
}
|
||||
|
||||
const body = computed(() => productDetail.productRef.value?.description ?? '')
|
||||
const bodyIsLong = computed(() => body.value.length > 120)
|
||||
const categoryTitle = computed(() => productDetail.productRef.value?.category?.title ?? '')
|
||||
@@ -336,6 +404,16 @@ watch(slides, (list) => {
|
||||
if (imageIndex.value > list.length - 1) imageIndex.value = 0
|
||||
})
|
||||
|
||||
// Picking a variant scrolls the gallery to that variant's photo, so the chosen
|
||||
// chip and the big picture always agree. Only moves when the variant actually
|
||||
// has an image and it is one of the product's own slides.
|
||||
watch(selectedVariant, (v) => {
|
||||
const url = v?.image?.url
|
||||
if (!url) return
|
||||
const i = slides.value.indexOf(url)
|
||||
if (i > -1) imageIndex.value = i
|
||||
})
|
||||
|
||||
// ── Full-screen preview ─────────────────────────────────────────
|
||||
// Tapping the inline gallery (a plain tap, not a swipe — GallerySlider tells
|
||||
// the two apart itself and only emits `tap` for the former) opens a
|
||||
@@ -385,7 +463,14 @@ const inCart = computed(() => cart.isInCart(storeProduct.value))
|
||||
const cartIndex = computed(() => cart.find_index(storeProduct.value))
|
||||
const cartQty = computed(() => (cartIndex.value > -1 ? cart.items[cartIndex.value].qty : 0))
|
||||
|
||||
const increment = () => cart.updateFeild(cartQty.value + 1, cartIndex.value, storeProduct.value, 'qty')
|
||||
// Stock ceiling: a pre-order variant may exceed what is on hand, anything else
|
||||
// stops at its qty. The store clamps too — this just keeps the button honest.
|
||||
const cartMaxQty = computed(() => cart.maxQty(storeProduct.value))
|
||||
const canIncrement = computed(() => cartMaxQty.value === null || cartQty.value < cartMaxQty.value)
|
||||
const increment = () => {
|
||||
if (!canIncrement.value) return
|
||||
cart.updateFeild(cartQty.value + 1, cartIndex.value, storeProduct.value, 'qty')
|
||||
}
|
||||
const decrement = () => {
|
||||
if (cartQty.value <= 1) {
|
||||
cart.remove(storeProduct.value)
|
||||
@@ -623,20 +708,22 @@ onBeforeUnmount(() => {
|
||||
color: @ink;
|
||||
}
|
||||
|
||||
/* Wraps instead of scrolling sideways — see the template comment: a product
|
||||
may have 10+ weights and off-screen chips are chips nobody taps. */
|
||||
.pd__variants {
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.pd__variant {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 10px 14px;
|
||||
padding: 8px;
|
||||
border-radius: 14px;
|
||||
cursor: pointer;
|
||||
text-align: start;
|
||||
text-align: center;
|
||||
align-items: center;
|
||||
background: @canvas;
|
||||
border: 1px solid @hairline;
|
||||
transition: border-color 0.18s ease, background 0.18s ease;
|
||||
@@ -645,6 +732,54 @@ onBeforeUnmount(() => {
|
||||
background: @ink;
|
||||
border-color: @ink;
|
||||
}
|
||||
.pd__variantImg {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
object-fit: cover;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
/* Out of stock and not pre-orderable: still listed, so the customer can see
|
||||
the weight exists, but visibly dimmed and not tappable. */
|
||||
.pd__variant.is-out {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.pd__variantStock {
|
||||
font-size: 9.5px;
|
||||
font-weight: 700;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.pd__variantStock.is-in {
|
||||
color: #1a7f3c;
|
||||
background: rgba(26, 127, 60, 0.1);
|
||||
}
|
||||
.pd__variantStock.is-pre {
|
||||
color: #1f6feb;
|
||||
background: rgba(31, 111, 235, 0.1);
|
||||
}
|
||||
.pd__variantStock.is-none {
|
||||
color: @inkFaint;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.pd__variant.is-on .pd__variantStock {
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
.pd__variantsMore {
|
||||
margin-top: 10px;
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid @hairline;
|
||||
background: transparent;
|
||||
color: @ink;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pd__variantTitle {
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
|
||||
Reference in New Issue
Block a user