Files
tehran_taj/apps/user-app/src/components/product/SingleProduct.vue
T
s.esmaeiliandClaude Opus 5 460cf276b6 parity(api,apps): delivery-mode gaps — master OTP, non-blocking SMS, faToEn, v2 cart cap
D20: OTP_MASTER_CODE login bypass read only from the environment, disabled when
unset, audited on every use, with a boot banner while armed. SMS no longer
aborts the boot in production — it warns and declines silently, so no message
can stop an operation.

D19: port Utility::faTOen onto admin-entered mobiles (before the uniqueness
lookup, which could previously be bypassed with Persian digits), and the
create-only welcome SMS, byte-identical to the reference and fire-and-forget.

user-app-v2 was missing the cart stock ceiling entirely: capQty/maxQty, the
un-orderable guard and the qty clamp are ported, leaving v2's own design intact.
The two cart stores are now byte-identical. isVariantOrderable and the
orderable-first default selection ported too.

jest 45/45; typecheck clean on api, admin-panel, user-app, user-app-v2.

[ref: henza_server/app/Http/Controllers/Adm/AdminController.php:333,348,364,378-387]
[ref: henza_server/app/Models/Utility.php:26]

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 18:35:33 +03:30

1058 lines
31 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!--
Product detail — redesigned to match the new home-screen design language
(components/home/*): LESS colour tokens, BEM class names, ink surfaces with a
wine accent, hairline borders and soft long shadows.
WHAT IS SHOWN IS REAL. Every figure comes from data the API genuinely
returns today:
- title, productCode, description, category, brand
- the selected variant's rash weight (گرم) and making charge (اجرت ٪)
- stock, from ProductVariant.qty / isPreOrder
No rial price is printed: a finished piece's price is the live gold rate ×
weight plus wage, computed at order time and not stored on the product, so
printing one here would be invented. Karat and clasp type are likewise absent
from the schema and therefore absent from the page.
FIXED — a real bug, not a restyle. The previous version ran, at module scope:
history.pushState(null, location.href)
window.onpopstate = () => history.go(1)
Because ant-design-vue's Drawer mounts its slot content eagerly, this fired
for *every* product card rendered on the home screen, not just when a detail
view opened, and permanently hijacked the browser/hardware back button for
the rest of the session (forcing it forward). It was carried over from legacy
and flagged in the old file's header as the most impactful bug in the port.
It is removed here; closing is handled by close() alone, which is what the
drawer and the /product/:id route both already rely on.
-->
<template>
<Spinner :spinning="loading" tip="درحال دریافت اطلاعات" v-if="flag && productDetail.productRef.value">
<article class="pd">
<!-- Gallery -->
<div class="pd__gallery">
<button type="button" class="pd__round pd__round--back" aria-label="بازگشت" @click="close">
<Icon icon="vuesax-linear:arrow-right-1" size="19" />
</button>
<button
type="button"
class="pd__round pd__round--fav"
:class="{ 'is-on': favorites.isFavorite(id) }"
aria-label="علاقه‌مندی"
@click="toggleFav"
>
<Icon :icon="favorites.isFavorite(id) ? 'vuesax-linear:heart-fill' : 'vuesax-linear:heart'" size="19" />
</button>
<GallerySlider
v-if="hasImages"
v-model:index="imageIndex"
:slides="slides"
:alt="title"
class="pd__stage"
@tap="openPreview"
>
<template #dots="{ index, go }">
<div v-if="slides.length > 1" class="pd__counter">{{ index + 1 }}/{{ slides.length }}</div>
<div v-if="slides.length > 1" class="pd__dots">
<button
v-for="(s, i) in slides"
:key="s"
type="button"
class="pd__dot"
:class="{ 'is-on': i === index }"
:aria-label="`تصویر ${i + 1}`"
@click.stop="go(i)"
/>
</div>
</template>
</GallerySlider>
<div v-else class="pd__stage pd__stage--empty">
<Icon icon="vuesax-linear:diamonds" size="46" />
</div>
</div>
<!-- Sheet -->
<div class="pd__sheet">
<header class="pd__head">
<div class="pd__headMain">
<h1 class="pd__title">{{ title }}</h1>
<div class="pd__sub">
<span v-if="productCode" class="pd__code">{{ productCode }}</span>
<span v-if="categoryTitle" class="pd__dotSep">·</span>
<span v-if="categoryTitle">{{ categoryTitle }}</span>
</div>
</div>
<span class="pd__stock" :class="stockClass">{{ stockLabel }}</span>
</header>
<!-- Headline figures -->
<div class="pd__figures">
<div class="pd__figure">
<span class="pd__figureLabel">وزن تقریبی</span>
<span class="pd__figureValue">
{{ min_gram }}
<span class="pd__figureUnit">گرم</span>
</span>
</div>
<div v-if="wage != null" class="pd__figure">
<span class="pd__figureLabel">اجرت</span>
<span class="pd__figureValue">
{{ wageLabel }}
<span class="pd__figureUnit">درصد</span>
</span>
</div>
</div>
<!--
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 visibleVariants"
:key="item.id"
type="button"
class="pd__variant"
: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 -->
<section class="pd__block">
<h2 class="pd__blockTitle">مشخصات</h2>
<dl class="pd__specs">
<div class="pd__spec">
<dt><Icon icon="vuesax-linear:weight" size="15" /> وزن</dt>
<dd>{{ min_gram }} گرم</dd>
</div>
<div v-if="wage != null" class="pd__spec">
<dt><Icon icon="vuesax-linear:percentage-square" size="15" /> اجرت</dt>
<dd>{{ wageLabel }}٪</dd>
</div>
<div v-if="selectedVariant" class="pd__spec">
<dt><Icon icon="vuesax-linear:diamonds" size="15" /> تنوع</dt>
<dd>{{ selectedVariant.title }}</dd>
</div>
<div v-if="categoryTitle" class="pd__spec">
<dt><Icon icon="vuesax-linear:category" size="15" /> دسته‌بندی</dt>
<dd>{{ categoryTitle }}</dd>
</div>
<div v-if="brandTitle" class="pd__spec">
<dt><Icon icon="vuesax-linear:shop" size="15" /> برند</dt>
<dd>{{ brandTitle }}</dd>
</div>
</dl>
</section>
<!-- Description -->
<section v-if="body" class="pd__block">
<h2 class="pd__blockTitle">توضیحات</h2>
<p class="pd__body" :class="{ 'is-clamped': !bodyOpen }">{{ body }}</p>
<button v-if="bodyIsLong" type="button" class="pd__more" @click="bodyOpen = !bodyOpen">
{{ bodyOpen ? 'بستن' : 'ادامه مطلب' }}
</button>
</section>
<!-- Trust -->
<div class="pd__trust">
<span><Icon icon="vuesax-linear:shield-tick" size="16" /> خرید امن</span>
<span><Icon icon="vuesax-linear:verify" size="16" /> اصالت کالا</span>
</div>
</div>
<!-- Action bar -->
<div class="pd__bar">
<div v-if="inCart" class="pd__qty">
<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>
<button
type="button"
class="pd__qtyBtn"
:aria-label="cartQty === 1 ? 'حذف' : 'کاهش'"
@click="decrement"
>
<Icon :icon="cartQty === 1 ? 'vuesax-linear:trash' : 'vuesax-linear:minus-square'" size="16" />
</button>
</div>
<button
v-if="!inCart"
type="button"
class="pd__cta"
:disabled="!is_exist"
@click="isUserLoggedIn() ? addToCart() : (loginNotify = true)"
>
{{ is_exist ? 'افزودن به سفارش' : 'ناموجود' }}
<Icon v-if="is_exist" icon="vuesax-linear:arrow-left-2" size="17" />
</button>
<router-link v-else class="pd__cta pd__cta--ghost" :to="{ name: 'cart' }">
مشاهده سبد خرید
<Icon icon="vuesax-linear:arrow-left-2" size="17" />
</router-link>
</div>
</article>
<!-- Full-screen image preview, teleported to <body> so it is never
clipped by this component's own scroll/overflow context (the drawer
mount, in particular). Opened by tapping the inline gallery. -->
<Teleport to="body">
<Transition name="pd-preview">
<div v-if="previewOpen" class="pd__preview" @click.self="closePreview">
<button type="button" class="pd__previewClose" aria-label="بستن" @click="closePreview">
<Icon icon="vuesax-linear:add" size="20" />
</button>
<div v-if="slides.length > 1" class="pd__previewCounter">{{ imageIndex + 1 }} / {{ slides.length }}</div>
<GallerySlider
v-model:index="imageIndex"
:slides="slides"
:alt="title"
class="pd__previewStage"
fit="contain"
/>
<div v-if="slides.length > 1" class="pd__previewThumbs no-scrollbar">
<button
v-for="(s, i) in slides"
:key="s"
type="button"
class="pd__previewThumb"
:class="{ 'is-on': i === imageIndex }"
:aria-label="`تصویر ${i + 1}`"
@click="imageIndex = i"
>
<img :src="s" :alt="`${title} ${i + 1}`" draggable="false" />
</button>
</div>
</div>
</Transition>
</Teleport>
<NotifyVant :timeOut="3000" v-model="notify" :color="'background-color: #14161A'">
<div class="font-bold text-right mb-2">ثبت سفارش</div>
<div>محصول با موفقیت به سبد خرید افزوده شد</div>
</NotifyVant>
<DialogVant :timeOut="-1" v-model:show="loginNotify" :closeOnClickOverlay="true">
<template v-slot:img>
<img :src="loginDialogImage" alt="" class="opacity-80" />
</template>
<template v-slot:title>
<div class="p-8 text-justify">
برای دسترسی به تمامی امکانات نرم افزار هنزاگلد، ابتدا گزینه ورود/ثبت نام را لمس کنید و در صورت نداشتن حساب کاربری، ثبت نام نمایید.
</div>
</template>
<template v-slot:button>
<div class="text-center font-bold" @click="router.push({ name: 'login' })">ورود/ثبت نام</div>
<div class="text-center" @click="loginNotify = false">بیخیال</div>
</template>
</DialogVant>
</Spinner>
</template>
<script setup lang="ts">
import { computed, onBeforeMount, onBeforeUnmount, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import Icon from '../icon/Icon.vue'
import GallerySlider from './GallerySlider.vue'
import Spinner from '../Spinner.vue'
import NotifyVant from '../NotifyVant.vue'
import DialogVant from '../DialogVant.vue'
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'
import loginDialogImage from '../../assets/img/empty/dialogEmpty1.svg'
const props = defineProps<{ id: string }>()
const emit = defineEmits<{ (e: 'closeDrawer'): void }>()
const router = useRouter()
const favorites = useFavoritesStore()
const cart = useCartStore()
// Used two ways, as in legacy: embedded in a bottom Drawer by the product
// cards, and as a standalone page for deep links (/product/:id). In the drawer
// case the parent listens for closeDrawer; on the routed page there is no
// parent, so fall back to history.back().
const close = () => {
emit('closeDrawer')
if (router.currentRoute.value.name === 'singleProduct') router.back()
}
const notify = ref(false)
const loginNotify = ref(false)
const imageIndex = ref(0)
const bodyOpen = ref(false)
const productDetail = useProductDetail(props.id)
const loading = productDetail.loading
const flag = productDetail.flag
// useProduct needs a real ProductSummary; give it an empty placeholder until
// the detail fetch resolves.
const emptyProduct = {
id: props.id,
title: '',
description: null,
variants: [],
images: [],
category: null,
brand: null,
collections: [],
}
const productAdapter = computed(() => useProduct(productDetail.productRef.value ?? emptyProduct))
const id = computed(() => productAdapter.value.id.value)
const title = computed(() => productAdapter.value.title.value)
const productCode = computed(() => productAdapter.value.productCode.value)
const variants = computed(() => productAdapter.value.variants.value)
const selectedVariant = computed({
get: () => productAdapter.value.selectedVariant.value,
set: (v) => (productAdapter.value.selectedVariant.value = v),
})
const min_gram = computed(() => productAdapter.value.min_gram.value)
const wage = computed(() => productAdapter.value.wage.value)
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 ?? '')
const brandTitle = computed(() => productDetail.productRef.value?.brand?.title ?? '')
const hasImages = computed(() => (productDetail.productRef.value?.images.length ?? 0) > 0)
const slides = computed(() => productDetail.productRef.value?.images.map((i) => i.url) ?? [])
// Keep the gallery index valid if the product (and so its image list) changes.
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
// full-screen viewer teleported to <body>, with its own GallerySlider kept in
// lockstep via the shared `imageIndex` v-model.
const previewOpen = ref(false)
function openPreview() {
if (!hasImages.value) return
previewOpen.value = true
// Teleported to <body>, so without this the page (or the Drawer) would
// still scroll behind the preview.
document.body.style.overflow = 'hidden'
}
function closePreview() {
previewOpen.value = false
document.body.style.overflow = ''
}
const wageLabel = computed(() => {
const w = wage.value
if (w == null) return ''
return w % 1 === 0 ? String(w) : w.toFixed(2)
})
const stockLabel = computed(() => {
if (qty.value > 0) return `${qty.value} عدد موجود`
return isPreOrder.value ? 'پیش‌سفارش' : 'ناموجود'
})
const stockClass = computed(() => {
if (qty.value > 0) return 'is-in'
return isPreOrder.value ? 'is-pre' : 'is-out'
})
const storeProduct = computed<CartItem>(() => ({
vid: selectedVariant.value?.id ?? '',
note: null,
pid: productDetail.productRef.value?.id ?? props.id,
product: productDetail.productRef.value ?? (emptyProduct as never),
qty: 1,
}))
// Quantity control reuses the store's updateFeild(...,'qty') path, the same one
// CartItem uses; the store has no dedicated increment/decrement.
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))
// 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)
return
}
cart.updateFeild(cartQty.value - 1, cartIndex.value, storeProduct.value, 'qty')
}
const addToCart = () => {
// The store refuses an un-orderable variant; only confirm when it accepted.
// Previously the "added to cart" toast fired either way, telling the customer
// something had been added when nothing had. Kept in step with user-app-v2.
if (!cart.addToCart(storeProduct.value)) return
notify.value = true
}
const toggleFav = (e: Event) => {
e.stopPropagation()
favorites.toggle(props.id)
}
onBeforeMount(async () => {
await productDetail.load()
})
// If the preview is left open when this instance goes away — the drawer is
// closed, or the routed page is navigated away from — the body scroll lock it
// set would otherwise outlive it and leave the rest of the app unscrollable.
onBeforeUnmount(() => {
if (previewOpen.value) document.body.style.overflow = ''
})
</script>
<style scoped lang="less">
.pd {
position: relative;
min-height: 100vh;
background: @canvasSoft;
padding-bottom: 104px;
}
/* ── Gallery ─────────────────────────────────────────────── */
.pd__gallery {
position: relative;
}
.pd__stage {
position: relative;
aspect-ratio: 1 / 1;
max-height: 52vh;
background: @hairlineSoft;
overflow: hidden;
}
.pd__stage--empty {
display: grid;
place-items: center;
color: @inkFaint;
}
.pd__art {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.pd__round {
position: absolute;
top: 16px;
z-index: 5;
width: 38px;
height: 38px;
display: grid;
place-items: center;
border: 0;
border-radius: 50%;
cursor: pointer;
color: @inkSoft;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(8px);
box-shadow: 0 6px 18px -10px rgba(20, 22, 26, 0.5);
transition: transform 0.18s ease, color 0.18s ease;
}
.pd__round:active {
transform: scale(0.92);
}
.pd__round--back {
inset-inline-start: 16px;
}
.pd__round--fav {
inset-inline-end: 16px;
}
.pd__round--fav.is-on {
color: @wine;
}
.pd__counter {
position: absolute;
top: 16px;
inset-inline-start: 50%;
transform: translateX(-50%);
padding: 4px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
color: #fff;
background: rgba(20, 22, 26, 0.62);
backdrop-filter: blur(4px);
direction: ltr;
}
.pd__dots {
position: absolute;
bottom: 14px;
inset-inline-end: 14px;
display: flex;
gap: 5px;
}
.pd__dot {
width: 6px;
height: 6px;
padding: 0;
border: 0;
border-radius: 999px;
cursor: pointer;
background: rgba(255, 255, 255, 0.55);
transition: width 0.2s ease, background 0.2s ease;
}
.pd__dot.is-on {
width: 18px;
background: #fff;
}
/* ── Sheet ───────────────────────────────────────────────── */
.pd__sheet {
position: relative;
margin-top: -20px;
border-radius: 22px 22px 0 0;
background: @canvas;
padding: 20px 18px 8px;
}
.pd__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.pd__title {
margin: 0;
font-size: 17px;
font-weight: 800;
line-height: 1.7;
color: @ink;
}
.pd__sub {
display: flex;
align-items: center;
gap: 6px;
padding-top: 5px;
font-size: 11.5px;
color: @inkFaint;
}
.pd__code {
direction: ltr;
font-weight: 700;
color: @inkMuted;
}
.pd__dotSep {
color: @hairline;
}
.pd__stock {
flex-shrink: 0;
padding: 6px 11px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 700;
white-space: nowrap;
}
.pd__stock.is-in {
color: #15803d;
background: #eef7f0;
}
.pd__stock.is-pre {
color: @wine;
background: @wineWash;
}
.pd__stock.is-out {
color: @inkMuted;
background: @hairlineSoft;
}
/* ── Figures ─────────────────────────────────────────────── */
.pd__figures {
display: flex;
gap: 10px;
margin-top: 16px;
}
.pd__figure {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
padding: 13px 14px;
border-radius: 16px;
background: @hairlineSoft;
}
.pd__figureLabel {
font-size: 11px;
color: @inkFaint;
}
.pd__figureValue {
font-size: 19px;
font-weight: 800;
color: @ink;
direction: ltr;
text-align: end;
}
.pd__figureUnit {
font-size: 11px;
font-weight: 500;
color: @inkFaint;
padding-inline-start: 3px;
}
/* ── Blocks ──────────────────────────────────────────────── */
.pd__block {
margin-top: 22px;
}
.pd__blockTitle {
margin: 0 0 11px;
font-size: 13.5px;
font-weight: 800;
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: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.pd__variant {
display: flex;
flex-direction: column;
gap: 3px;
padding: 8px;
border-radius: 14px;
cursor: pointer;
text-align: center;
align-items: center;
background: @canvas;
border: 1px solid @hairline;
transition: border-color 0.18s ease, background 0.18s ease;
}
.pd__variant.is-on {
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;
color: @ink;
}
.pd__variantWeight {
font-size: 10.5px;
color: @inkFaint;
}
.pd__variant.is-on .pd__variantTitle {
color: #fff;
}
.pd__variant.is-on .pd__variantWeight {
color: rgba(255, 255, 255, 0.6);
}
.pd__specs {
margin: 0;
border: 1px solid @hairline;
border-radius: 16px;
overflow: hidden;
}
.pd__spec {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border-bottom: 1px solid @hairlineSoft;
}
.pd__spec:last-child {
border-bottom: 0;
}
.pd__spec dt {
display: flex;
align-items: center;
gap: 7px;
margin: 0;
font-size: 12.5px;
color: @inkMuted;
}
.pd__spec dd {
margin: 0;
font-size: 12.5px;
font-weight: 700;
color: @ink;
}
.pd__body {
margin: 0;
font-size: 12.5px;
line-height: 2.1;
color: @inkMuted;
text-align: justify;
}
.pd__body.is-clamped {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.pd__more {
margin-top: 8px;
padding: 0;
border: 0;
background: none;
cursor: pointer;
font-family: inherit;
font-size: 11.5px;
font-weight: 700;
color: @wine;
}
.pd__trust {
display: flex;
gap: 18px;
margin-top: 22px;
padding: 13px 0 4px;
border-top: 1px solid @hairlineSoft;
}
.pd__trust span {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11.5px;
color: @inkFaint;
}
/* ── Action bar ──────────────────────────────────────────── */
.pd__bar {
position: fixed;
bottom: 0;
inset-inline: 0;
z-index: 20;
display: flex;
align-items: center;
gap: 10px;
max-width: 768px;
margin: 0 auto;
padding: 12px 16px calc(12px + env(safe-area-inset-bottom, 0px));
background: @canvas;
border-top: 1px solid @hairline;
}
.pd__qty {
display: inline-flex;
align-items: center;
gap: 9px;
flex-shrink: 0;
padding: 7px 9px;
border-radius: 999px;
border: 1px solid @hairline;
background: @hairlineSoft;
}
.pd__qtyBtn {
width: 27px;
height: 27px;
display: grid;
place-items: center;
border: 0;
border-radius: 50%;
cursor: pointer;
color: @ink;
background: @canvas;
}
.pd__qtyValue {
min-width: 16px;
text-align: center;
font-size: 13px;
font-weight: 800;
color: @ink;
}
.pd__cta {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 48px;
border: 0;
border-radius: 14px;
cursor: pointer;
font-family: inherit;
font-size: 14px;
font-weight: 800;
text-decoration: none;
color: #fff;
background: @ink;
transition: transform 0.18s ease, opacity 0.18s ease;
}
.pd__cta:active {
transform: scale(0.99);
}
.pd__cta:disabled {
cursor: default;
opacity: 0.45;
}
.pd__cta--ghost {
color: @ink;
background: @canvas;
border: 1px solid @ink;
}
/* ── Full-screen preview ─────────────────────────────────── */
/* Teleported to <body>, so it sits outside .pd entirely — see the template
comment on why. z-index cleared well above ant-design-vue's own Drawer
layer (@zindex-modal) so the preview is unconditionally topmost regardless
of which Teleport happens to mount last. */
.pd__preview {
position: fixed;
inset: 0;
z-index: 2000;
display: flex;
flex-direction: column;
background: rgba(10, 10, 12, 0.55);
backdrop-filter: blur(28px) saturate(140%);
}
.pd__previewClose {
position: absolute;
top: calc(14px + env(safe-area-inset-top, 0px));
inset-inline-end: 14px;
z-index: 2;
width: 38px;
height: 38px;
display: grid;
place-items: center;
border: 0;
border-radius: 12px;
cursor: pointer;
color: #fff;
background: rgba(0, 0, 0, 0.35);
backdrop-filter: blur(10px);
transform: rotate(45deg);
transition: transform 0.18s ease, background 0.18s ease;
}
.pd__previewClose:active {
transform: rotate(45deg) scale(0.92);
}
.pd__previewCounter {
position: absolute;
top: calc(24px + env(safe-area-inset-top, 0px));
inset-inline-start: 0;
inset-inline-end: 0;
z-index: 2;
text-align: center;
font-size: 12px;
font-weight: 700;
color: rgba(255, 255, 255, 0.75);
direction: ltr;
pointer-events: none;
}
.pd__previewStage {
flex: 1;
min-height: 0;
}
.pd__previewThumbs {
flex-shrink: 0;
display: flex;
justify-content: center;
gap: 8px;
overflow-x: auto;
scroll-snap-type: x proximity;
padding: 10px 16px calc(18px + env(safe-area-inset-bottom, 0px));
}
.pd__previewThumb {
scroll-snap-align: center;
flex: 0 0 auto;
width: 52px;
height: 52px;
padding: 6px;
border: 0;
border-radius: 12px;
cursor: pointer;
overflow: hidden;
background: #fff;
opacity: 0.65;
outline: 2px solid transparent;
outline-offset: 2px;
transition: opacity 0.2s ease, outline-color 0.2s ease, transform 0.18s ease;
}
.pd__previewThumb:active {
transform: scale(0.94);
}
.pd__previewThumb.is-on {
opacity: 1;
outline-color: #fff;
}
.pd__previewThumb img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
-webkit-user-drag: none;
user-select: none;
}
.pd-preview-enter-active,
.pd-preview-leave-active {
transition: opacity 0.24s ease;
}
.pd-preview-enter-from,
.pd-preview-leave-to {
opacity: 0;
}
</style>