feat(ui): adopt Tailwind v4 design system and archive change
- Add tailwindcss v4 + @tailwindcss/vite to mall, shop-admin, admin - Add @vmall/shared/theme.css tokens with html[data-accent] presets - Add @vmall/ui kit (VBtn/VBadge/VField/VInput/VCard/VPanel/VTable/VPage, VAccentSwatch, useAccent) as a Nuxt module - Convert all three apps to kit + utilities; delete ui.css/mall.css and every <style scoped>; consoles get accent presets, mall locked to red - Fix VCard boolean prop default (padding) and PDP/store stale useAsyncData keys on param navigation - Archive adopt-tailwind-design-system; new frontend-ui capability spec
This commit is contained in:
+27
-566
@@ -24,7 +24,10 @@ const routeId = computed(() => {
|
||||
// product that was just fetched, and chaining two useAsyncData calls left the
|
||||
// second handler running before the first had data.
|
||||
const { data: pageData } = await useAsyncData(
|
||||
"product-detail",
|
||||
// Keyed by route param: Nuxt remounts this page on param change, and a
|
||||
// static key would serve the previous product's cached entry without
|
||||
// re-running the handler.
|
||||
`product-detail-${routeId.value}`,
|
||||
async () => {
|
||||
const current = await $api.getProduct(routeId.value);
|
||||
// The rail uses live catalogue products so its links resolve; the store
|
||||
@@ -290,583 +293,41 @@ const detailImages = computed(() => product.value?.images ?? []);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="product-page section-bg">
|
||||
<div class="min-h-screen bg-bg pb-16 font-sans text-text">
|
||||
<UiBreadcrumb :items="breadcrumbItems" />
|
||||
<main v-if="detail" class="w1200">
|
||||
<section class="product-top mpanel">
|
||||
<div class="gallery">
|
||||
<div class="main-image">
|
||||
<img class="zoom-image" :src="currentImage" :alt="pick(detail.product.name, locale)" />
|
||||
</div>
|
||||
<div class="thumbs" role="list">
|
||||
<button
|
||||
v-for="(image, index) in detail.product.images.slice(0, 3)"
|
||||
:key="`${image}-${index}`"
|
||||
type="button"
|
||||
:class="{ active: galleryIndex === index }"
|
||||
:aria-label="`${t('product.detail')} ${index + 1}`"
|
||||
@click="galleryIndex = index"
|
||||
>
|
||||
<img :src="image" :alt="pick(detail.product.name, locale)" loading="lazy" />
|
||||
</button>
|
||||
</div>
|
||||
<main v-if="detail" class="mx-auto w-full max-w-mall px-4">
|
||||
<section class="grid overflow-hidden rounded-lg border border-border bg-surface shadow-sm lg:grid-cols-2">
|
||||
<div class="p-5">
|
||||
<div class="flex aspect-square items-center justify-center overflow-hidden bg-bg"><img class="h-full w-full object-contain transition-transform hover:scale-105" :src="currentImage" :alt="pick(detail.product.name, locale)" /></div>
|
||||
<div class="mt-3 flex gap-2" role="list"><button v-for="(image, index) in detail.product.images.slice(0, 3)" :key="`${image}-${index}`" type="button" class="h-16 w-16 overflow-hidden rounded-sm border border-border bg-surface p-0 hover:border-primary" :class="galleryIndex === index ? 'border-2 border-primary' : ''" :aria-label="`${t('product.detail')} ${index + 1}`" @click="galleryIndex = index"><img class="h-full w-full object-contain" :src="image" :alt="pick(detail.product.name, locale)" loading="lazy" /></button></div>
|
||||
</div>
|
||||
|
||||
<div class="summary">
|
||||
<div class="summary-heading">
|
||||
<div>
|
||||
<h1>{{ pick(detail.product.name, locale) }}</h1>
|
||||
<p class="subtitle">{{ pick(detail.product.description, locale) }}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="favorite"
|
||||
:class="{ active: favorite }"
|
||||
:disabled="favoriteLoading || favoriteBusy"
|
||||
:aria-label="favorite ? t('product.unfavorite') : t('product.favorite')"
|
||||
@click="toggleFavorite"
|
||||
>{{ favorite ? "♥" : "♡" }}</button>
|
||||
</div>
|
||||
<p v-if="favoriteError" class="favorite-error">{{ favoriteError }}</p>
|
||||
<div class="price-panel">
|
||||
<span class="price-label">{{ t("product.currentPrice") }}</span>
|
||||
<strong v-if="matchedSku" class="price"><PriceText :amount-minor="matchedSku.price_minor" :currency="matchedSku.currency" /></strong>
|
||||
<strong v-else class="price">{{ t("product.unavailable") }}</strong>
|
||||
<span v-if="matchedSku" class="market-label">{{ t("product.marketPrice") }} <s><PriceText :amount-minor="marketPrice" :currency="matchedSku.currency" /></s></span>
|
||||
</div>
|
||||
<div class="summary-meta">
|
||||
<span>{{ t("product.sold", { n: detail.product.sold_count }) }}</span>
|
||||
<span :class="{ soldout: stock <= 0 }">{{ stock > 0 ? t("product.stock", { n: stock }) : t("product.noStock") }}</span>
|
||||
</div>
|
||||
<div class="border-l border-border p-5 max-lg:border-l-0 max-lg:border-t">
|
||||
<div class="flex items-start justify-between gap-3"><div><h1 class="m-0 text-xl font-bold text-text">{{ pick(detail.product.name, locale) }}</h1><p class="mt-2 text-sm leading-relaxed text-muted">{{ pick(detail.product.description, locale) }}</p></div><VBtn size="sm" type="button" :disabled="favoriteLoading || favoriteBusy" :aria-label="favorite ? t('product.unfavorite') : t('product.favorite')" @click="toggleFavorite">{{ favorite ? "♥" : "♡" }}</VBtn></div>
|
||||
<p v-if="favoriteError" class="my-2 text-sm text-danger">{{ favoriteError }}</p>
|
||||
<div class="my-4 flex flex-wrap items-baseline gap-2 bg-primary-soft px-4 py-3"><span class="text-xs text-muted">{{ t("product.currentPrice") }}</span><strong v-if="matchedSku" class="text-2xl font-semibold text-primary"><PriceText :amount-minor="matchedSku.price_minor" :currency="matchedSku.currency" /></strong><strong v-else class="text-2xl font-semibold text-primary">{{ t("product.unavailable") }}</strong><span v-if="matchedSku" class="text-xs text-muted">{{ t("product.marketPrice") }} <s><PriceText :amount-minor="marketPrice" :currency="matchedSku.currency" /></s></span></div>
|
||||
<div class="mb-4 flex gap-5 text-xs text-muted"><span>{{ t("product.sold", { n: detail.product.sold_count }) }}</span><span :class="stock <= 0 ? 'text-danger' : ''">{{ stock > 0 ? t("product.stock", { n: stock }) : t("product.noStock") }}</span></div>
|
||||
|
||||
<div v-if="detail.coupons.length > 0" class="coupon-row">
|
||||
<span class="label">{{ t("product.coupons") }}</span>
|
||||
<div class="coupon-list">
|
||||
<span v-for="coupon in detail.coupons" :key="coupon.id" class="coupon">
|
||||
{{ pick(coupon.title, locale) }}
|
||||
<small>{{ t("product.expires", { date: coupon.ends_at.slice(0, 10) }) }}</small>
|
||||
<button
|
||||
type="button"
|
||||
class="coupon-claim"
|
||||
:disabled="claimingId === coupon.id || claimedIds.has(coupon.id)"
|
||||
@click="claim(coupon)"
|
||||
>
|
||||
{{ claimedIds.has(coupon.id) ? t("product.claimed") : t("product.claim") }}
|
||||
</button>
|
||||
</span>
|
||||
<span v-if="claimError" class="coupon-error">{{ claimError }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="detail.coupons.length > 0" class="mb-4 flex gap-3"><span class="w-16 shrink-0 text-sm text-muted">{{ t("product.coupons") }}</span><div class="flex flex-wrap gap-2"><span v-for="coupon in detail.coupons" :key="coupon.id" class="rounded-sm border border-primary/30 bg-primary-soft px-2 py-1 text-xs text-primary">{{ pick(coupon.title, locale) }} <small class="block text-[11px] text-muted">{{ t("product.expires", { date: coupon.ends_at.slice(0, 10) }) }}</small><VBtn class="mt-1" size="sm" type="button" :disabled="claimingId === coupon.id || claimedIds.has(coupon.id)" @click="claim(coupon)">{{ claimedIds.has(coupon.id) ? t("product.claimed") : t("product.claim") }}</VBtn></span><span v-if="claimError" class="text-sm text-danger">{{ claimError }}</span></div></div>
|
||||
|
||||
<div v-for="group in attributeGroups" :key="group.key" class="attribute-row">
|
||||
<span class="label">{{ group.key }}</span>
|
||||
<div class="attribute-values">
|
||||
<button
|
||||
v-for="value in group.values"
|
||||
:key="value"
|
||||
type="button"
|
||||
:class="{ active: selectedAttributes[group.key] === value }"
|
||||
@click="selectAttribute(group.key, value)"
|
||||
>{{ value }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="quantity-row">
|
||||
<span class="label">{{ t("product.quantity") }}</span>
|
||||
<UiQtyStepper v-model="quantity" :max="Math.max(1, stock)" />
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="button" class="primary-action" :disabled="!matchedSku || stock <= 0" @click="buyNow">{{ t("product.buyNow") }}</button>
|
||||
<button type="button" class="cart-action" :disabled="!matchedSku || stock <= 0" @click="addCart">{{ cartSuccess ? t("product.addedCart") : t("product.addCart") }}</button>
|
||||
</div>
|
||||
<p v-if="matchedSku && stock <= 0" class="stock-warning">{{ t("product.noStock") }}</p>
|
||||
<p v-else-if="!matchedSku" class="stock-warning">{{ t("product.unavailable") }}</p>
|
||||
<div v-for="group in attributeGroups" :key="group.key" class="mb-3 flex gap-3"><span class="w-16 shrink-0 pt-1 text-sm text-muted">{{ group.key }}</span><div class="flex flex-wrap gap-2"><button v-for="value in group.values" :key="value" type="button" class="rounded-md border border-border bg-surface px-3 py-1.5 text-sm text-text hover:border-primary hover:text-primary" :class="selectedAttributes[group.key] === value ? 'border-primary bg-primary-soft text-primary' : ''" @click="selectAttribute(group.key, value)">{{ value }}</button></div></div>
|
||||
<div class="mb-4 flex items-center gap-3"><span class="w-16 shrink-0 text-sm text-muted">{{ t("product.quantity") }}</span><UiQtyStepper v-model="quantity" :max="Math.max(1, stock)" /></div>
|
||||
<div class="flex flex-wrap gap-2"><VBtn variant="primary" type="button" :disabled="!matchedSku || stock <= 0" @click="buyNow">{{ t("product.buyNow") }}</VBtn><VBtn type="button" :disabled="!matchedSku || stock <= 0" @click="addCart">{{ cartSuccess ? t("product.addedCart") : t("product.addCart") }}</VBtn></div>
|
||||
<p v-if="matchedSku && stock <= 0" class="my-2 text-sm text-danger">{{ t("product.noStock") }}</p><p v-else-if="!matchedSku" class="my-2 text-sm text-danger">{{ t("product.unavailable") }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="below-grid">
|
||||
<aside class="left-rail">
|
||||
<div v-if="detail.store" class="store-card mpanel">
|
||||
<h2>{{ t("product.store") }}</h2>
|
||||
<div class="store-heading">
|
||||
<img v-if="detail.store.logo" :src="detail.store.logo" :alt="pick(detail.store.name, locale)" />
|
||||
<strong>{{ pick(detail.store.name, locale) }}</strong>
|
||||
</div>
|
||||
<dl>
|
||||
<div v-if="detail.store.company"><dt>{{ t("product.company") }}</dt><dd>{{ detail.store.company }}</dd></div>
|
||||
<div v-if="detail.store.region"><dt>{{ t("product.region") }}</dt><dd>{{ detail.store.region }}</dd></div>
|
||||
</dl>
|
||||
<template v-if="rateRows.length">
|
||||
<h3>{{ t("product.rates") }}</h3>
|
||||
<div v-for="row in rateRows" :key="row.key" class="rate-row">
|
||||
<span>{{ row.label }}</span><UiRatingStars :value="row.value" :size="13" />
|
||||
</div>
|
||||
</template>
|
||||
<div class="store-actions">
|
||||
<NuxtLink class="store-button" :to="`/stores/${detail.store.slug}`">{{ t("product.enterStore") }}</NuxtLink>
|
||||
<button type="button" class="store-button" disabled>{{ t("product.contact") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sales-rank mpanel">
|
||||
<h2>{{ t("product.salesRank") }}</h2>
|
||||
<NuxtLink v-for="item in detail.salesRank" :key="item.id" :to="`/goods/${item.slug}`" class="rank-item">
|
||||
<img :src="item.images[0]" :alt="pick(item.name, locale)" loading="lazy" />
|
||||
<span class="rank-name">{{ pick(item.name, locale) }}</span>
|
||||
<span v-if="lowestSku(item)" class="rank-price"><PriceText :amount-minor="lowestSku(item)!.price_minor" :currency="lowestSku(item)!.currency" /></span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<section class="mt-5 grid gap-5 lg:grid-cols-[260px_minmax(0,1fr)]">
|
||||
<aside class="space-y-5">
|
||||
<VCard v-if="detail.store">
|
||||
<h2 class="mb-3 border-l-[3px] border-primary pl-2 text-sm font-medium">{{ t("product.store") }}</h2><div class="flex items-center gap-2.5 border-b border-border pb-3"><img v-if="detail.store.logo" class="h-12 w-12 border border-border object-contain" :src="detail.store.logo" :alt="pick(detail.store.name, locale)" /><strong class="text-sm">{{ pick(detail.store.name, locale) }}</strong></div><dl class="my-3 text-xs text-muted"><div v-if="detail.store.company" class="my-2 flex justify-between gap-2"><dt>{{ t("product.company") }}</dt><dd class="m-0 text-right">{{ detail.store.company }}</dd></div><div v-if="detail.store.region" class="my-2 flex justify-between gap-2"><dt>{{ t("product.region") }}</dt><dd class="m-0 text-right">{{ detail.store.region }}</dd></div></dl><template v-if="rateRows.length"><h3 class="mb-2 text-xs font-medium">{{ t("product.rates") }}</h3><div v-for="row in rateRows" :key="row.key" class="flex items-center justify-between py-1 text-xs text-muted"><span>{{ row.label }}</span><UiRatingStars :value="row.value" :size="13" /></div></template><div class="mt-3 grid gap-2"><NuxtLink class="rounded-md border border-primary bg-primary px-3 py-2 text-center text-sm font-medium text-white no-underline hover:bg-primary-hover" :to="`/stores/${detail.store.slug}`">{{ t("product.enterStore") }}</NuxtLink><VBtn type="button" disabled>{{ t("product.contact") }}</VBtn></div>
|
||||
</VCard>
|
||||
<VCard><h2 class="mb-2 border-l-[3px] border-primary pl-2 text-sm font-medium">{{ t("product.salesRank") }}</h2><NuxtLink v-for="item in detail.salesRank" :key="item.id" :to="`/goods/${item.slug}`" class="grid grid-cols-[48px_minmax(0,1fr)] gap-2 border-t border-border py-2 text-xs text-text no-underline"><img class="h-12 w-12 object-contain" :src="item.images[0]" :alt="pick(item.name, locale)" loading="lazy" /><span class="min-w-0 self-center truncate">{{ pick(item.name, locale) }}<span v-if="lowestSku(item)" class="mt-1 block text-right text-primary"><PriceText :amount-minor="lowestSku(item)!.price_minor" :currency="lowestSku(item)!.currency" /></span></span></NuxtLink></VCard>
|
||||
</aside>
|
||||
|
||||
<section class="detail-panel mpanel">
|
||||
<UiTabs v-model="activeTab" :tabs="tabs">
|
||||
<template #default="{ active }">
|
||||
<div v-if="active === 'detail'" class="detail-content">
|
||||
<h2>{{ t("product.description") }}</h2>
|
||||
<p>{{ pick(detail.product.description, locale) }}</p>
|
||||
<img v-for="(image, index) in detailImages" :key="`${image}-detail-${index}`" :src="image" :alt="t('product.detail')" loading="lazy" />
|
||||
</div>
|
||||
<div v-else class="service-content">
|
||||
<h2>{{ t("product.tabsAfterSale") }}</h2>
|
||||
<p v-if="detail.store?.after_sale">{{ pick(detail.store.after_sale, locale) }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</UiTabs>
|
||||
</section>
|
||||
<VCard :padded="false" class="overflow-hidden"><UiTabs v-model="activeTab" :tabs="tabs"><template #default="{ active }"><div v-if="active === 'detail'" class="space-y-3 p-5"><h2 class="text-lg font-semibold">{{ t("product.description") }}</h2><p class="text-sm leading-relaxed text-text">{{ pick(detail.product.description, locale) }}</p><img v-for="(image, index) in detailImages" :key="`${image}-detail-${index}`" class="w-full object-contain" :src="image" :alt="t('product.detail')" loading="lazy" /></div><div v-else class="space-y-3 p-5"><h2 class="text-lg font-semibold">{{ t("product.tabsAfterSale") }}</h2><p v-if="detail.store?.after_sale" class="text-sm leading-relaxed text-text">{{ pick(detail.store.after_sale, locale) }}</p></div></template></UiTabs></VCard>
|
||||
</section>
|
||||
</main>
|
||||
<UiEmptyState v-else :text="t('product.productNotFound')" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<style scoped>
|
||||
.product-page {
|
||||
min-height: 700px;
|
||||
padding-bottom: 60px;
|
||||
}
|
||||
.product-top {
|
||||
display: grid;
|
||||
grid-template-columns: 450px 1fr;
|
||||
gap: 32px;
|
||||
min-height: 500px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--mall-line);
|
||||
padding: 24px;
|
||||
}
|
||||
.gallery {
|
||||
width: 400px;
|
||||
}
|
||||
.main-image {
|
||||
display: flex;
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
border: 1px solid var(--mall-line);
|
||||
}
|
||||
.zoom-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
transition: transform 220ms ease;
|
||||
}
|
||||
.main-image:hover .zoom-image {
|
||||
transform: scale(1.07);
|
||||
}
|
||||
.thumbs {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.thumbs button {
|
||||
width: 74px;
|
||||
height: 74px;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--mall-line);
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
.thumbs button.active {
|
||||
border-color: var(--mall-red);
|
||||
}
|
||||
.thumbs img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
.summary {
|
||||
min-width: 0;
|
||||
}
|
||||
.summary-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
color: var(--mall-ink);
|
||||
font-size: 22px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.subtitle {
|
||||
margin: 8px 0 18px;
|
||||
color: var(--mall-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.favorite {
|
||||
align-self: flex-start;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--mall-faint);
|
||||
cursor: pointer;
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
}
|
||||
.favorite.active {
|
||||
color: var(--mall-red);
|
||||
}
|
||||
.price-panel {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
background: #fff5f5;
|
||||
padding: 16px;
|
||||
}
|
||||
.price-label,
|
||||
.market-label,
|
||||
.label {
|
||||
color: var(--mall-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.price {
|
||||
color: var(--mall-red);
|
||||
font-size: 28px;
|
||||
}
|
||||
.market-label s {
|
||||
color: var(--mall-faint);
|
||||
}
|
||||
.summary-meta {
|
||||
display: flex;
|
||||
gap: 28px;
|
||||
border-bottom: 1px solid var(--mall-line);
|
||||
color: var(--mall-muted);
|
||||
font-size: 12px;
|
||||
padding: 13px 0;
|
||||
}
|
||||
.summary-meta .soldout,
|
||||
.stock-warning,
|
||||
.favorite-error {
|
||||
color: var(--mall-red);
|
||||
}
|
||||
.favorite-error {
|
||||
margin: 6px 0 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
.coupon-row,
|
||||
.attribute-row,
|
||||
.quantity-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 18px;
|
||||
border-bottom: 1px solid var(--mall-line);
|
||||
padding: 15px 0;
|
||||
}
|
||||
.coupon-row > .label,
|
||||
.attribute-row > .label,
|
||||
.quantity-row > .label {
|
||||
width: 58px;
|
||||
flex: 0 0 58px;
|
||||
line-height: 30px;
|
||||
}
|
||||
.coupon-list,
|
||||
.attribute-values {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.coupon {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
border: 1px solid var(--mall-red);
|
||||
color: var(--mall-red);
|
||||
font-size: 12px;
|
||||
padding: 5px 8px;
|
||||
}
|
||||
.coupon small {
|
||||
color: var(--mall-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
.coupon-claim {
|
||||
margin-top: 3px;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--mall-red);
|
||||
background: transparent;
|
||||
color: var(--mall-red);
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.coupon-claim:disabled {
|
||||
border-color: var(--mall-line-dark);
|
||||
color: var(--mall-faint);
|
||||
cursor: default;
|
||||
}
|
||||
.coupon-error {
|
||||
align-self: center;
|
||||
color: #b42318;
|
||||
font-size: 11px;
|
||||
}
|
||||
.attribute-values button {
|
||||
min-width: 66px;
|
||||
border: 1px solid var(--mall-line-dark);
|
||||
background: #fff;
|
||||
color: var(--mall-ink);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
.attribute-values button.active {
|
||||
border-color: var(--mall-red);
|
||||
color: var(--mall-red);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
.primary-action,
|
||||
.cart-action {
|
||||
min-width: 136px;
|
||||
border: 1px solid var(--mall-red);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
padding: 11px 22px;
|
||||
}
|
||||
.primary-action {
|
||||
background: #fff0f0;
|
||||
color: var(--mall-red);
|
||||
}
|
||||
.cart-action {
|
||||
background: var(--mall-red);
|
||||
color: #fff;
|
||||
}
|
||||
.primary-action:disabled,
|
||||
.cart-action:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
.stock-warning {
|
||||
margin: 10px 0 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
.below-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 260px 1fr;
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.left-rail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
.store-card,
|
||||
.sales-rank,
|
||||
.detail-panel {
|
||||
background: #fff;
|
||||
border: 1px solid var(--mall-line);
|
||||
padding: 18px;
|
||||
}
|
||||
.store-card h2,
|
||||
.sales-rank h2,
|
||||
.detail-content h2,
|
||||
.service-content h2 {
|
||||
border-left: 3px solid var(--mall-red);
|
||||
color: var(--mall-ink);
|
||||
font-size: 16px;
|
||||
margin: 0 0 16px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
.store-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.store-heading img {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
object-fit: contain;
|
||||
}
|
||||
dl {
|
||||
margin: 14px 0;
|
||||
}
|
||||
dl div {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
dt {
|
||||
color: var(--mall-muted);
|
||||
flex: 0 0 42px;
|
||||
}
|
||||
dd {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.store-card h3 {
|
||||
color: var(--mall-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
margin: 12px 0 8px;
|
||||
}
|
||||
.rate-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
color: var(--mall-muted);
|
||||
font-size: 12px;
|
||||
line-height: 25px;
|
||||
}
|
||||
.store-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.store-button {
|
||||
flex: 1;
|
||||
border: 1px solid var(--mall-line-dark);
|
||||
background: #fff;
|
||||
color: var(--mall-ink);
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
padding: 7px 4px;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
.store-button:first-child {
|
||||
border-color: var(--mall-red);
|
||||
color: var(--mall-red);
|
||||
}
|
||||
.store-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.rank-item {
|
||||
display: grid;
|
||||
grid-template-columns: 52px 1fr;
|
||||
gap: 5px 8px;
|
||||
border-bottom: 1px solid var(--mall-line);
|
||||
color: inherit;
|
||||
padding: 10px 0;
|
||||
text-decoration: none;
|
||||
}
|
||||
.rank-item:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.rank-item img {
|
||||
grid-row: 1 / span 2;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.rank-name {
|
||||
align-self: end;
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
max-height: 30px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.rank-price {
|
||||
align-self: start;
|
||||
color: var(--mall-red);
|
||||
font-size: 12px;
|
||||
}
|
||||
.detail-panel {
|
||||
min-height: 550px;
|
||||
}
|
||||
.detail-content p,
|
||||
.service-content p {
|
||||
color: var(--mall-muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
margin: 0 0 18px;
|
||||
}
|
||||
.detail-content > img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 500px;
|
||||
margin: 0 auto 14px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.review-summary {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid var(--mall-line);
|
||||
color: var(--mall-red);
|
||||
font-size: 13px;
|
||||
padding: 0 0 15px;
|
||||
}
|
||||
.review-summary span {
|
||||
color: var(--mall-muted);
|
||||
}
|
||||
.review {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid var(--mall-line);
|
||||
padding: 18px 0;
|
||||
}
|
||||
.review > img {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.review-body {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.review-body header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--mall-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.review-body header strong {
|
||||
color: var(--mall-ink);
|
||||
}
|
||||
.review-body time {
|
||||
margin-left: auto;
|
||||
}
|
||||
.review-body p {
|
||||
color: var(--mall-ink);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
margin: 9px 0;
|
||||
}
|
||||
.reply {
|
||||
background: #fafafa;
|
||||
color: var(--mall-muted);
|
||||
font-size: 12px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
.reply strong {
|
||||
color: var(--mall-red);
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.product-top,
|
||||
.below-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.gallery,
|
||||
.main-image {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user