Files
tehran_tala/apps/user-app-v2/src/components/product/SingleProduct.vue
T

1111 lines
37 KiB
Vue
Raw Normal View History

<!--
Product detail — rebuilt against the supplied reference layout, in the home
screen's own design language (components/home/*): LESS colour tokens, BEM
names, @hairlineSoft panels with the same 32px corner radius ProductPanel and
CategoryGrid use, @ink as the heavy surface and @wine as the accent.
Structure, top to bottom — the reference's, not the old stacked-sheet one:
1. a full-bleed swipeable gallery with floating round controls (back /
favourite / cart) and a vertical dot rail down its trailing edge
2. a rounded sheet that rides up over the image, opening with the category
eyebrow on one side and the stock badge on the other
3. the title, with the headline figures (weight, making charge) set beside
it in the place the reference gives the price
4. a chip row for variants — the reference's size selector, filled @ink
when chosen
5. description with an inline "ادامه مطلب" toggle, then the spec table
6. a split action bar: a secondary pill (category / quantity) beside the
dark primary button
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. The reference's rating ("4.8 / 120
reviews") and its struck-through original price are likewise absent from the
schema and therefore absent from the page — their slot is given to the real
weight and wage figures instead, which is why the header reads as two figures
rather than one price.
-->
<template>
<Spinner :spinning="loading" tip="درحال دریافت اطلاعات" v-if="flag && productDetail.productRef.value">
<article class="pd" ref="scroller" @scroll.passive="onScroll">
<!-- ── Header ────────────────────────────────────────── -->
<!-- One bar for both states. At rest it is transparent and its buttons
float on the photo; once the sheet has covered the image it gains a
solid background, a hairline and the product's title, so the same
controls become a real header instead of a second set of buttons
appearing from nowhere. -->
<header class="pd__header" :class="{ 'is-solid': headerSolid }">
<div class="pd__controls">
<button type="button" class="pd__round" aria-label="بازگشت" @click="close">
<Icon icon="vuesax-linear:arrow-right-1" size="18" />
</button>
<!-- Only meaningful once the bar is solid: while the photo is still
showing, the title is already set large inside the sheet. -->
<h2 class="pd__headerTitle">{{ title }}</h2>
<div class="pd__controlsEnd">
<button
type="button"
class="pd__round"
:class="{ 'is-on': favorites.isFavorite(id) }"
aria-label="علاقه‌مندی"
@click="toggleFav"
>
<Icon :icon="favorites.isFavorite(id) ? 'vuesax-linear:heart-fill' : 'vuesax-linear:heart'" size="18" />
</button>
<router-link class="pd__round" :to="{ name: 'cart' }" aria-label="سبد خرید">
<Icon icon="vuesax-linear:bag-2" size="18" />
<span v-if="cart.items.length" class="pd__roundBadge">{{ cart.items.length }}</span>
</router-link>
</div>
</div>
</header>
<!-- ── Gallery ───────────────────────────────────────── -->
<div class="pd__gallery">
<div
v-if="hasImages"
class="pd__stage"
@touchstart.passive="onTouchStart"
@touchmove.passive="onTouchMove"
@touchend="onTouchEnd"
>
<div class="pd__track" :class="{ 'is-dragging': dragging }" :style="{ transform: `translate3d(${trackOffset}, 0, 0)` }">
<div v-for="src in slides" :key="src" class="pd__slide">
<img :src="src" :alt="title" class="pd__art" />
</div>
</div>
<!-- The reference's dot rail: vertical, pinned to the trailing edge. -->
<div v-if="slides.length > 1" class="pd__rail">
<button
v-for="(s, i) in slides"
:key="s"
type="button"
class="pd__railDot"
:class="{ 'is-on': i === imageIndex }"
:aria-label="`تصویر ${i + 1}`"
@click="imageIndex = i"
/>
</div>
</div>
<div v-else class="pd__stage pd__stage--empty">
<Icon icon="vuesax-linear:diamonds" size="27" />
</div>
</div>
<!-- ── Scrolling content ─────────────────────────────── -->
<!-- Sits above the stuck gallery and scrolls over it. The gallery keeps
its place in the flow, so this starts below the image at rest and
needs no spacer of its own. -->
<div class="pd__scroll">
<div class="pd__sheet">
<div class="pd__grip" aria-hidden="true"></div>
<!-- Eyebrow row: the reference's "New Season" ⟷ rating line. Ours
carries the real category and the real stock state. -->
<div class="pd__eyebrow">
<span class="pd__eyebrowText">{{ categoryTitle || 'محصول' }}</span>
<span class="pd__stock" :class="stockClass">
<span class="pd__stockDot" aria-hidden="true"></span>
{{ stockLabel }}
</span>
</div>
<!-- Title ⟷ figures, the reference's title/price split. -->
<header class="pd__head">
<div class="pd__headMain">
<h1 class="pd__title">{{ title }}</h1>
<p v-if="productCode" class="pd__code">{{ productCode }}</p>
</div>
<div class="pd__figures">
<div class="pd__figure">
<span class="pd__figureValue">{{ min_gram }}</span>
<span class="pd__figureLabel">گرم</span>
</div>
<div v-if="wage != null" class="pd__figure pd__figure--accent">
<span class="pd__figureValue">{{ wageLabel }}٪</span>
<span class="pd__figureLabel">اجرت</span>
</div>
</div>
</header>
<!-- Variant chips — the reference's size row. -->
<section v-if="variants && variants.length > 1" class="pd__chips no-scrollbar">
<button
v-for="item in variants"
:key="item.id"
type="button"
class="pd__chip"
:class="{ 'is-on': selectedVariant?.id === item.id }"
@click="selectedVariant = item"
>
{{ item.title }}
</button>
</section>
<!-- Description, with the reference's inline "Read More". -->
<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>
<!-- 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>
</div>
</div>
<!-- ── Action bar ────────────────────────────────────── -->
<div class="pd__bar">
<!-- The reference's left pill. When the piece is not yet in the cart
it deep-links to its category (a real destination); once it is, it
becomes the quantity stepper, which has to live somewhere and this
is the slot the layout already gives it. -->
<div v-if="inCart" class="pd__qty">
<button
type="button"
class="pd__qtyBtn"
:aria-label="cartQty === 1 ? 'حذف' : 'کاهش'"
@click="decrement"
>
<Icon :icon="cartQty === 1 ? 'vuesax-linear:trash' : 'vuesax-linear:minus'" size="15" />
</button>
<span class="pd__qtyValue">{{ cartQty }}</span>
<button type="button" class="pd__qtyBtn" aria-label="افزایش" @click="increment">
<Icon icon="vuesax-linear:add" size="15" />
</button>
</div>
<button v-else-if="categoryTitle" type="button" class="pd__pill" @click="goCategory">
<Icon icon="vuesax-linear:category" size="17" />
<span class="pd__pillText">{{ categoryTitle }}</span>
</button>
<button
v-if="!inCart"
type="button"
class="pd__cta"
:disabled="!is_exist"
@click="isUserLoggedIn() ? addToCart() : (loginNotify = true)"
>
<Icon v-if="is_exist" icon="vuesax-linear:bag-2" size="17" />
{{ is_exist ? 'افزودن به سفارش' : 'ناموجود' }}
</button>
<router-link v-else class="pd__cta" :to="{ name: 'cart' }">
<Icon icon="vuesax-linear:bag-2" size="17" />
مشاهده سبد خرید
</router-link>
</div>
</article>
<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, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import Icon from '../icon/Icon.vue'
import Spinner from '../Spinner.vue'
import NotifyVant from '../NotifyVant.vue'
import DialogVant from '../DialogVant.vue'
import { useProduct } from '../../composables/useProduct'
import { useProductDetail } from '../../composables/useProductDetail'
import { useFavoritesStore } from '../../stores/favoritesStore'
import { useFiltersStore } from '../../stores/filtersStore'
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 filters = useFiltersStore()
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)
// ── Header state ─────────────────────────────────────────────
// The bar turns solid once the sheet has scrolled up far enough to have
// covered the photo. Read off the scroll container (not the window) for the
// same reason the gallery is sticky rather than fixed — inside the Drawer the
// window never scrolls at all.
const scroller = ref<HTMLElement | null>(null)
const headerSolid = ref(false)
// Flip a little before the sheet's top edge reaches the bar, so the background
// is already there when the image disappears behind it rather than appearing a
// beat late.
const HEADER_SOLID_OFFSET_PX = 56
function onScroll() {
const el = scroller.value
if (!el) return
// The gallery's own height is the distance the sheet has to travel to cover
// it — measured rather than hard-coded, since the stage is sized in vh and
// clamped, so the number differs per screen.
const galleryHeight = el.querySelector('.pd__gallery')?.clientHeight ?? 0
headerSolid.value = el.scrollTop > Math.max(galleryHeight - HEADER_SOLID_OFFSET_PX, 0)
}
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)
const body = computed(() => productDetail.productRef.value?.description ?? '')
const bodyIsLong = computed(() => body.value.length > 120)
const categoryId = computed(() => productDetail.productRef.value?.category?.id ?? '')
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
})
// ── Gallery swipe ────────────────────────────────────────────
// The slides are stacked on one line and the track is translated to bring the
// active one on screen. The page is RTL, so the line runs right-to-left and
// slide N sits at +N × 100%: a drag towards the start of the line (negative dx
// in screen coordinates) advances to the next image.
//
// A plain transform slider rather than a carousel library — the app already
// pulls swiper in for the home rails, but this is a few lines of maths over
// three or four images and does not need one.
const dragX = ref(0)
const dragging = ref(false)
let startX = 0
// While dragging, the finger's travel is carried as a raw pixel delta on top of
// the settled percentage, so the image tracks the thumb instead of snapping.
const trackOffset = computed(() =>
dragging.value ? `calc(${imageIndex.value * 100}% + ${dragX.value}px)` : `${imageIndex.value * 100}%`,
)
// Past this much travel the swipe commits to the next image; below it the
// track springs back to where it started.
const SWIPE_COMMIT_PX = 48
function onTouchStart(e: TouchEvent) {
if (slides.value.length < 2) return
startX = e.touches[0].clientX
dragX.value = 0
dragging.value = true
}
function onTouchMove(e: TouchEvent) {
if (!dragging.value) return
dragX.value = e.touches[0].clientX - startX
}
function onTouchEnd() {
if (!dragging.value) return
const dx = dragX.value
dragging.value = false
dragX.value = 0
if (dx <= -SWIPE_COMMIT_PX && imageIndex.value < slides.value.length - 1) imageIndex.value++
else if (dx >= SWIPE_COMMIT_PX && imageIndex.value > 0) imageIndex.value--
}
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))
const increment = () => 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 = () => {
cart.addToCart(storeProduct.value)
notify.value = true
}
const toggleFav = (e: Event) => {
e.stopPropagation()
favorites.toggle(props.id)
}
// The action bar's secondary pill. Same handoff HomeView's CategoryGrid makes:
// seed the filter store, then hand over to search. The drawer is closed first,
// since on the home screen this component is mounted inside one and the route
// change alone would leave it open over the destination.
const goCategory = () => {
if (!categoryId.value) return
filters.query.cats = [categoryId.value]
emit('closeDrawer')
router.push({ name: 'search' })
}
onBeforeMount(async () => {
await productDetail.load()
})
</script>
<style scoped lang="less">
// The component owns its own scroll container rather than scrolling the page.
//
// This is what makes the fixed image work in BOTH mounts. On /product/:id the
// window scrolls, but inside the home screen's bottom Drawer the scroller is
// ant-design's own drawer body — a `position: fixed` image would escape that
// container and pin itself to the viewport, sitting over the drawer. Scrolling
// this element instead means the gallery is positioned against THIS box in
// both cases, so the behaviour is identical however the detail view is opened.
.pd {
position: relative;
height: 100vh;
overflow-y: auto;
overflow-x: hidden;
background: @canvas;
// Momentum scrolling on iOS, and stop a scroll at either end from chaining
// out to the page (or the drawer) behind.
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
/* ── Gallery ─────────────────────────────────────────────── */
// Stuck to the top of the scroll container as a backdrop: it never moves, and
// the sheet scrolls up over it. `sticky` rather than `fixed` so it stays inside
// this box — see the note on .pd above.
// Stuck under the header: the photo never moves, and the sheet scrolls up over
// it. `sticky` rather than `fixed` so it stays inside this box — see the note
// on .pd above.
//
// The header above it occupies real space at the top of the scroller, which
// would push the photo down; the negative margin pulls it back so the image
// starts at the very top with the bar floating over it.
.pd__gallery {
position: sticky;
top: 0;
z-index: 1;
// 60px = PageBar's 11px padding + the 38px button + 11px padding.
margin-top: calc(-60px - env(safe-area-inset-top, 0px));
}
/* ── Header ──────────────────────────────────────────────── */
// Stuck to the top of the scroll container, above both the gallery and the
// sheet, so it is the one element that never moves. At rest it is invisible
// chrome — only the floating buttons show; once `is-solid` is set it becomes a
// real bar.
// Solid state matches components/ui/PageBar.vue exactly — same fill, blur,
// hairline, padding and inner max-width — so the detail page's header is the
// same bar every other screen uses (cart, orders, newest…), not a lookalike.
// The only thing this one adds is the transparent rest state it fades in from.
.pd__header {
position: sticky;
top: 0;
z-index: 10;
padding-top: env(safe-area-inset-top, 0px);
// Transparent at rest: the buttons appear to float directly on the photo.
background: transparent;
border-bottom: 1px solid transparent;
transition: background 0.28s ease, border-color 0.28s ease, backdrop-filter 0.28s ease;
}
.pd__header.is-solid {
background: rgba(255, 255, 255, 0.88);
backdrop-filter: blur(14px);
border-bottom-color: @hairline;
}
.pd__headerTitle {
flex: 1;
min-width: 0;
margin: 0;
text-align: center;
// PageBar's `.bar__title`.
font-size: 14.5px;
font-weight: 800;
color: @ink;
letter-spacing: -0.1px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
// Only shown with the solid bar — over the photo the sheet already carries
// the title at full size.
opacity: 0;
transition: opacity 0.28s ease;
}
.is-solid .pd__headerTitle {
opacity: 1;
}
// PageBar's `.bar__inner`: same max-width, padding and gap.
.pd__controls {
max-width: 768px;
margin: 0 auto;
padding: 11px 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.pd__controlsEnd {
display: flex;
gap: 9px;
}
// The reference's floating white controls — squircles, not circles. Matching
// the sheet's corner family keeps them of a piece with the rest of the page.
// Over the photo: a floating white tile that lifts itself off the image. It
// morphs into PageBar's flat grey button once the header goes solid — the
// transition lists every property that differs between the two states so the
// change reads as one motion rather than a snap.
.pd__round {
position: relative;
width: 38px;
height: 38px;
display: grid;
place-items: center;
border: 0;
border-radius: 12px;
cursor: pointer;
text-decoration: none;
color: @ink;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(10px) saturate(140%);
box-shadow: 0 10px 26px -16px rgba(20, 22, 26, 0.7);
transition: transform 0.18s ease, color 0.28s ease, background 0.28s ease,
box-shadow 0.28s ease;
}
.pd__round:active {
transform: scale(0.92);
}
.pd__round.is-on {
color: #fff;
background: @wine;
}
// Inside the solid bar the buttons become PageBar's `.bar__btn`: a soft grey
// tile at 12px radius, no shadow and no blur — they no longer have to lift
// themselves off a photo. The favourite toggle keeps its filled state; that is
// state, not chrome.
.is-solid .pd__round {
color: @ink;
background: @hairlineSoft;
backdrop-filter: none;
box-shadow: none;
}
.is-solid .pd__round.is-on {
color: #fff;
background: @wine;
}
.pd__roundBadge {
position: absolute;
top: -5px;
inset-inline-start: -5px;
min-width: 18px;
height: 18px;
padding: 0 5px;
display: grid;
place-items: center;
border-radius: 999px;
font-size: 10px;
font-weight: 800;
color: #fff;
background: @wine;
}
.pd__stage {
position: relative;
// Taller than the cropped version needed: with `contain` the piece is fitted
// whole inside this box rather than filling it, so the box has to be roomy
// enough that the photo is not left small.
height: 54vh;
min-height: 320px;
max-height: 460px;
overflow: hidden;
background: @hairlineSoft;
// Horizontal drags are the gallery's; vertical ones still scroll the page.
touch-action: pan-y;
}
.pd__stage--empty {
display: grid;
place-items: center;
color: @inkFaint;
}
.pd__track {
display: flex;
height: 100%;
// Settles with the same long decelerate the home screen's reveal uses, so
// the two read as one system. Suppressed mid-drag so the image sits under
// the finger rather than lagging behind it.
transition: transform 0.5s cubic-bezier(0.32, 0.72, 0, 1);
will-change: transform;
}
.pd__track.is-dragging {
transition: none;
}
.pd__slide {
flex: 0 0 100%;
height: 100%;
// Each slide sits one full width further along the line; with the track
// translated by +index × 100% the active one lands on screen.
margin-inline-start: -100%;
}
.pd__slide:first-child {
margin-inline-start: 0;
}
.pd__art {
width: 100%;
height: 100%;
// `contain`, not `cover`: a piece of jewellery is the subject of its own
// photo, so cropping it to fill the frame cut the edges off. Every other
// card in the app (ProductCard, ProductPanel, CartItem) uses `contain` for
// the same reason — the whole piece has to be visible.
object-fit: contain;
// Breathing room so the piece does not touch the frame or sit under the
// floating controls at the top.
padding: 46px 20px 20px;
display: block;
// Otherwise a drag on the image starts the browser's own image drag and the
// touch sequence never reaches touchend.
-webkit-user-drag: none;
user-select: none;
}
// Vertical dot rail on the trailing edge, as in the reference.
.pd__rail {
position: absolute;
top: 50%;
inset-inline-end: 12px;
transform: translateY(-50%);
display: flex;
flex-direction: column;
gap: 6px;
padding: 7px 5px;
border-radius: 999px;
background: rgba(20, 22, 26, 0.3);
backdrop-filter: blur(6px);
}
.pd__railDot {
width: 6px;
height: 6px;
padding: 0;
border: 0;
border-radius: 999px;
cursor: pointer;
background: rgba(255, 255, 255, 0.5);
transition: height 0.25s cubic-bezier(0.32, 0.72, 0, 1), background 0.25s ease;
}
.pd__railDot.is-on {
height: 20px;
background: #fff;
}
/* ── Sheet ───────────────────────────────────────────────── */
// Rides over the stuck gallery. A higher stacking level than .pd__gallery is
// what lets it cover the image instead of scrolling behind it, and the bottom
// padding is what gives the sheet enough length to scroll at all — without it
// a short product would have nothing to scroll and the image would never be
// covered.
.pd__scroll {
position: relative;
z-index: 2;
}
.pd__sheet {
position: relative;
// Overlaps the image by 8px — just enough for the rounded top edge to sit ON
// the picture rather than below it, without covering the bottom of the
// piece. The radius stays 32px (ProductPanel's, the reel CTA's); only the
// pull-up is this small.
margin-top: -8px;
border-radius: 32px 32px 0 0;
background: @canvas;
padding: 8px 18px 8px;
box-shadow: 0 -18px 40px -30px rgba(20, 22, 26, 0.55);
// Tall enough to fill the screen once it has scrolled up under the header,
// so the end state is a full page rather than a sheet stopping short with a
// strip of photo left under it. Also guarantees the travel exists even for a
// product with almost no detail text.
//
// Subtracts the header and the action bar — the two things pinned outside
// this box — so filling the remainder is exactly one screen, with no extra
// scroll left over at the bottom.
min-height: calc(100vh - 60px - 72px - env(safe-area-inset-top, 0px));
}
.pd__grip {
width: 38px;
height: 4px;
margin: 0 auto 12px;
border-radius: 999px;
background: @hairline;
}
.pd__eyebrow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.pd__eyebrowText {
font-size: 11.5px;
font-weight: 600;
color: @inkFaint;
}
.pd__stock {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 11px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 700;
white-space: nowrap;
}
.pd__stockDot {
width: 5px;
height: 5px;
border-radius: 999px;
background: currentColor;
}
.pd__stock.is-in {
color: @green;
background: fade(@green, 8%);
}
.pd__stock.is-pre {
color: @wine;
background: @wineWash;
}
.pd__stock.is-out {
color: @inkMuted;
background: @hairlineSoft;
}
/* ── Head ────────────────────────────────────────────────── */
.pd__head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 14px;
padding-top: 10px;
}
.pd__headMain {
min-width: 0;
}
.pd__title {
margin: 0;
// Page title, one step above a section heading in the home screen's scale
// (.home__heading / .sec__title are 16px) — the largest type the design
// system uses for a heading, not a display size of its own.
font-size: 14px;
font-weight: 700;
line-height: 1.7;
color: @ink;
}
.pd__code {
margin: 5px 0 0;
direction: ltr;
text-align: end;
font-size: 11px;
font-weight: 700;
color: @inkFaint;
}
// Occupies the reference's price slot. Two real figures instead of an invented
// price — see the file header.
.pd__figures {
flex-shrink: 0;
display: flex;
align-items: flex-end;
gap: 8px;
}
.pd__figure {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 7px 11px;
// 13px is the radius CategoryGrid's and AccountStrip's small tiles use.
border-radius: 13px;
color: @ink;
background: @hairlineSoft;
}
.pd__figure--accent {
color: @wine;
background: @wineWash;
}
.pd__figureValue {
// A figure, so one step up from body text (13.5px is the heading size in
// this scale) — not the 17px display number it was.
font-size: 14px;
font-weight: 800;
line-height: 1.3;
color: inherit;
direction: ltr;
}
.pd__figureLabel {
font-size: 10px;
font-weight: 500;
color: @inkFaint;
}
.pd__figure--accent .pd__figureLabel {
color: fade(@wine, 65%);
}
/* ── Variant chips ───────────────────────────────────────── */
.pd__chips {
display: flex;
gap: 8px;
margin-top: 20px;
// Bleed to the screen edge so chips scroll past the sheet's gutter, the way
// CategoryGrid's rail does; the padding puts the gutter back inside.
margin-inline: -18px;
padding: 3px 18px;
overflow-x: auto;
}
.pd__chip {
flex: 0 0 auto;
min-width: 42px;
height: 34px;
padding: 0 14px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-family: inherit;
font-size: 12.5px;
font-weight: 700;
white-space: nowrap;
border-radius: 999px;
border: 1px solid @hairline;
color: @inkMuted;
background: @canvas;
transition: transform 0.18s ease, background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
}
.pd__chip:active {
transform: scale(0.95);
}
.pd__chip.is-on {
color: #fff;
background: @ink;
border-color: @ink;
}
/* ── Blocks ──────────────────────────────────────────────── */
.pd__block {
margin-top: 24px;
}
.pd__blockTitle {
margin: 0 0 11px;
font-size: 16px;
font-weight: 600;
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: 800;
color: @ink;
text-decoration: underline;
text-underline-offset: 4px;
}
.pd__specs {
margin: 0;
border-radius: 22px;
background: @hairlineSoft;
overflow: hidden;
}
.pd__spec {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 13px 16px;
border-bottom: 1px solid @hairline;
}
.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;
}
/* ── Action bar ──────────────────────────────────────────── */
// Pinned to the bottom of the scroll container, not the viewport. `sticky`
// with a negative-free bottom keeps it inside .pd — the same reason the
// gallery is sticky rather than fixed: in the Drawer a fixed bar would pin
// itself to the viewport and hang over whatever else is on screen.
.pd__bar {
position: sticky;
bottom: 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: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(18px) saturate(140%);
border-top: 1px solid @hairline;
}
// The reference's outlined left pill.
.pd__pill {
flex-shrink: 0;
max-width: 44%;
height: 48px;
padding: 0 15px;
display: inline-flex;
align-items: center;
gap: 7px;
cursor: pointer;
font-family: inherit;
font-size: 12.5px;
font-weight: 700;
border-radius: 999px;
border: 1px solid @hairline;
color: @ink;
background: @canvas;
transition: transform 0.18s ease, background 0.2s ease;
}
.pd__pill:active {
transform: scale(0.97);
}
// A long category name truncates rather than pushing the primary button out of
// the bar.
.pd__pillText {
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.pd__qty {
flex-shrink: 0;
height: 48px;
padding: 0 6px;
display: inline-flex;
align-items: center;
gap: 4px;
border-radius: 999px;
border: 1px solid @hairline;
background: @canvas;
}
.pd__qtyBtn {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border: 0;
border-radius: 50%;
cursor: pointer;
color: @ink;
background: @hairlineSoft;
transition: transform 0.18s ease;
}
.pd__qtyBtn:active {
transform: scale(0.9);
}
.pd__qtyValue {
min-width: 20px;
text-align: center;
font-size: 13px;
font-weight: 800;
color: @ink;
}
.pd__cta {
flex: 1;
min-width: 0;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 48px;
border: 0;
border-radius: 999px;
cursor: pointer;
font-family: inherit;
font-size: 13.5px;
font-weight: 800;
white-space: nowrap;
text-decoration: none;
color: #fff;
background: @ink;
box-shadow: 0 14px 30px -18px rgba(20, 22, 26, 0.9);
transition: transform 0.18s ease, opacity 0.18s ease;
}
.pd__cta:active {
transform: scale(0.985);
}
.pd__cta:disabled {
cursor: default;
opacity: 0.4;
box-shadow: none;
}
</style>