The product page loads a shop's claimable coupons through the shared client and claims them in place; the buyer coupon list reads owned snapshots with their claimed/redeemed/expired state. Checkout offers each shop order at most one owned coupon and submits only the choice; the pay page and order detail render the server-persisted discount and reduced total, never a client figure. Coupon fixtures leave the pages; the fixed-data adapter still serves the coupon domain as the rollback path. The demo seed creates two deterministic templates and claims them for the demo customer.
800 lines
22 KiB
Vue
800 lines
22 KiB
Vue
<script setup lang="ts">
|
|
import { t as pick } from "@vmall/shared";
|
|
import { ApiError } from "@vmall/shared";
|
|
import type { Category, CouponTemplate, Product, Sku } from "@vmall/shared";
|
|
import { lowestSku } from "~/utils/product";
|
|
import { useCartStore } from "~/stores/cart";
|
|
import { useSessionStore } from "~/stores/session";
|
|
|
|
type AttributeGroup = { key: string; values: string[] };
|
|
|
|
const route = useRoute();
|
|
const router = useRouter();
|
|
const { locale, t } = useI18n();
|
|
const { $api } = useNuxtApp();
|
|
const cart = useCartStore();
|
|
const session = useSessionStore();
|
|
|
|
const routeId = computed(() => {
|
|
const value = route.params.id;
|
|
return Array.isArray(value) ? value[0] ?? "" : value ?? "";
|
|
});
|
|
// One request key for the whole page: the ranking rail must be derived from the
|
|
// 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",
|
|
async () => {
|
|
const current = await $api.getProduct(routeId.value);
|
|
// The rail uses live catalogue products so its links resolve; the store
|
|
// card, comments and coupons stay local display-only content (non-goals).
|
|
// Store best sellers, ranking the shop's own products. Ranking by category
|
|
// would empty the rail for any product alone in its leaf category.
|
|
const siblings = await $api.listProducts({
|
|
shop_id: current.shop_id,
|
|
per_page: 6,
|
|
});
|
|
// Claimable coupons are live; the store card stays local display content.
|
|
// A coupon failure must not blank the product page.
|
|
const coupons = await $api
|
|
.listShopCouponTemplates(current.shop_id)
|
|
.catch(() => [] as CouponTemplate[]);
|
|
return {
|
|
product: current,
|
|
related: siblings.items.filter((item) => item.id !== current.id).slice(0, 5),
|
|
coupons,
|
|
};
|
|
},
|
|
{ watch: [routeId], default: () => null },
|
|
);
|
|
|
|
const product = computed(() => pageData.value?.product ?? null);
|
|
const related = computed<Product[]>(() => pageData.value?.related ?? []);
|
|
|
|
// Shared with the store directory and the order surfaces.
|
|
const { data: shops } = await useAsyncData("shops", () => $api.listShops(), { default: () => [] });
|
|
|
|
const detail = computed(() => {
|
|
const current = product.value;
|
|
if (!current) return null;
|
|
return {
|
|
product: current,
|
|
// Null when the shop has no public profile; the card is then not rendered.
|
|
store: shops.value.find((shop) => shop.id === current.shop_id) ?? null,
|
|
coupons: pageData.value?.coupons ?? [],
|
|
salesRank: related.value,
|
|
};
|
|
});
|
|
|
|
const claimingId = ref("");
|
|
const claimError = ref("");
|
|
const claimedIds = ref<Set<string>>(new Set());
|
|
|
|
async function claim(coupon: CouponTemplate): Promise<void> {
|
|
if (!session.isLoggedIn) {
|
|
await navigateTo("/login");
|
|
return;
|
|
}
|
|
claimingId.value = coupon.id;
|
|
claimError.value = "";
|
|
try {
|
|
const owned = await $api.claimCoupon(coupon.id);
|
|
claimedIds.value = new Set(claimedIds.value).add(owned.template_id ?? coupon.id);
|
|
} catch {
|
|
claimError.value = t("product.claimFailed");
|
|
} finally {
|
|
claimingId.value = "";
|
|
}
|
|
}
|
|
|
|
const store = computed(() => detail.value?.store ?? null);
|
|
const selectedAttributes = reactive<Record<string, string>>({});
|
|
const quantity = ref(1);
|
|
const galleryIndex = ref(0);
|
|
const favorite = ref(false);
|
|
const cartSuccess = ref(false);
|
|
const activeTab = ref("detail");
|
|
|
|
const attributeGroups = computed<AttributeGroup[]>(() => {
|
|
const valuesByKey = new Map<string, Set<string>>();
|
|
for (const sku of product.value?.skus ?? []) {
|
|
if (!sku.active) continue;
|
|
for (const [key, value] of Object.entries(sku.attributes)) {
|
|
const values = valuesByKey.get(key) ?? new Set<string>();
|
|
values.add(value);
|
|
valuesByKey.set(key, values);
|
|
}
|
|
}
|
|
return [...valuesByKey.entries()].map(([key, values]) => ({ key, values: [...values] }));
|
|
});
|
|
|
|
watch(
|
|
detail,
|
|
(current) => {
|
|
for (const key of Object.keys(selectedAttributes)) delete selectedAttributes[key];
|
|
const initial = current ? (current.product.skus?.find((sku) => sku.active) ?? lowestSku(current.product)) : null;
|
|
if (initial) Object.assign(selectedAttributes, initial.attributes);
|
|
quantity.value = 1;
|
|
galleryIndex.value = 0;
|
|
favorite.value = false;
|
|
cartSuccess.value = false;
|
|
activeTab.value = "detail";
|
|
},
|
|
{ immediate: true },
|
|
);
|
|
|
|
const matchedSku = computed<Sku | null>(() => {
|
|
const skus = product.value?.skus ?? [];
|
|
const keys = attributeGroups.value.map((group) => group.key);
|
|
if (!keys.every((key) => selectedAttributes[key])) return null;
|
|
return skus.find(
|
|
(sku) => sku.active && keys.every((key) => sku.attributes[key] === selectedAttributes[key]),
|
|
) ?? null;
|
|
});
|
|
|
|
|
|
const stock = computed(() => matchedSku.value?.stock ?? 0);
|
|
const marketPrice = computed(() => {
|
|
const amount = matchedSku.value?.price_minor;
|
|
return amount === undefined ? 0 : Math.round((amount * 115) / 100);
|
|
});
|
|
const currentImage = computed(() => product.value?.images[galleryIndex.value] ?? product.value?.images[0] ?? "/mock/product-1.svg");
|
|
|
|
// Shared with the shell's category menu, so the tree is not refetched.
|
|
const { data: categories } = await useAsyncData("shell-categories", () => $api.listCategories());
|
|
const categoryById = (id: string): Category | undefined =>
|
|
(categories.value ?? []).find((category) => category.id === id);
|
|
const categoryPath = computed(() => {
|
|
const path: Category[] = [];
|
|
let current = product.value?.category_id ? categoryById(product.value.category_id) : undefined;
|
|
while (current) {
|
|
path.unshift(current);
|
|
current = current.parent_id ? categoryById(current.parent_id) : undefined;
|
|
}
|
|
return path;
|
|
});
|
|
|
|
const breadcrumbItems = computed(() => [
|
|
{ label: t("product.home"), to: "/" },
|
|
...categoryPath.value.map((category) => ({
|
|
label: pick(category.name, locale.value),
|
|
to: `/search?category=${category.id}`,
|
|
})),
|
|
{ label: product.value ? pick(product.value.name, locale.value) : t("product.productNotFound") },
|
|
]);
|
|
|
|
const selectAttribute = (key: string, value: string): void => {
|
|
selectedAttributes[key] = value;
|
|
quantity.value = 1;
|
|
cartSuccess.value = false;
|
|
};
|
|
|
|
const addToCart = async (): Promise<boolean> => {
|
|
if (!matchedSku.value || stock.value <= 0) return false;
|
|
try {
|
|
await $api.addCartItem(matchedSku.value.id, quantity.value);
|
|
} catch (error) {
|
|
// A live cart needs a token; send a signed-out shopper to sign in and back.
|
|
if (error instanceof ApiError && error.status === 401) {
|
|
await router.push(`/login?redirect=${encodeURIComponent(route.fullPath)}`);
|
|
return false;
|
|
}
|
|
throw error;
|
|
}
|
|
await cart.refresh();
|
|
return true;
|
|
};
|
|
|
|
const buyNow = async (): Promise<void> => {
|
|
if (await addToCart()) await router.push("/checkout");
|
|
};
|
|
|
|
const addCart = async (): Promise<void> => {
|
|
if (await addToCart()) {
|
|
cartSuccess.value = true;
|
|
window.setTimeout(() => {
|
|
cartSuccess.value = false;
|
|
}, 1800);
|
|
}
|
|
};
|
|
|
|
// No reviews tab: there is no reviews capability, and the mall will not present
|
|
// invented reviewers and ratings as fact. See the wave-6 design.
|
|
const tabs = computed(() => [
|
|
{ key: "detail", label: t("product.tabsDetail") },
|
|
{ key: "service", label: t("product.tabsAfterSale") },
|
|
]);
|
|
|
|
/** Only the scores the platform actually set; nothing is defaulted. */
|
|
const rateRows = computed(() => {
|
|
const profile = store.value;
|
|
if (!profile) return [];
|
|
const rows = [
|
|
{ key: "overall", value: profile.score_rating, label: t("product.overall") },
|
|
{ key: "service", value: profile.score_service, label: t("product.service") },
|
|
{ key: "delivery", value: profile.score_speed, label: t("product.delivery") },
|
|
{ key: "agree", value: profile.score_agreement, label: t("product.quality") },
|
|
];
|
|
return rows.filter(
|
|
(row): row is { key: string; value: number; label: string } => typeof row.value === "number",
|
|
);
|
|
});
|
|
|
|
const detailImages = computed(() => product.value?.images ?? []);
|
|
</script>
|
|
|
|
<template>
|
|
<div class="product-page section-bg">
|
|
<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>
|
|
</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 }"
|
|
:aria-label="favorite ? t('product.unfavorite') : t('product.favorite')"
|
|
@click="favorite = !favorite"
|
|
>{{ favorite ? "♥" : "♡" }}</button>
|
|
</div>
|
|
<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 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-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>
|
|
</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>
|
|
</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>
|
|
</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 {
|
|
color: var(--mall-red);
|
|
}
|
|
.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>
|