Files
vmall/apps/mall/pages/collective.vue
T
Chengdong Zhang 21e99bb52b feat(mall): classic B2B2C PC storefront with fixed mock API layer
- Mock adapter implementing @vmall/shared ApiClient (localStorage-persisted
  cart/orders/invoices), runtime switch via mockApi flag (default on)
- Fixed bilingual mock catalog: 3-level categories, 24 products w/ SKUs,
  stores, brands, banners, floors, seckill/collective/integral, comments,
  coupons, addresses, seeded orders/shipments/invoices
- B2B2C mall shell: top bar, logo/search/cart header, dark nav + category
  mega-menu, value-prop footer, back-to-top; 1200px grid, #ca151e theme
- UI primitives replacing element-plus: carousel, pagination, breadcrumb,
  qty stepper, rating stars, modal, tabs, step bar, product card
- Pages: home floors, search (filters/sort/paging), goods detail (SKU
  picker, store rail, review tabs), cart -> checkout -> pay -> success,
  auth pages, user center (dashboard/orders/addresses/favorites/coupons/
  invoices), stores, seckill, collective, integral
- i18n split into per-domain locale modules (en+zh)
- OpenSpec change mall-pc-storefront-replica archived; all specs green
2026-09-17 19:13:29 +08:00

172 lines
4.2 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.
<script setup lang="ts">
import { t as pick } from "@vmall/shared";
import { collectiveProducts, lowestSku } from "~/mock/data";
const { locale, t } = useI18n();
const products = collectiveProducts();
const breadcrumb = computed(() => [
{ label: t("stores.breadcrumbHome"), to: "/" },
{ label: t("marketing.collectiveBreadcrumb") },
]);
function currencyOf(product: (typeof products)[number]["product"]): string {
return lowestSku(product)?.currency ?? "USD";
}
function priceMinorOf(product: (typeof products)[number]["product"]): number {
return lowestSku(product)?.price_minor ?? 0;
}
</script>
<template>
<div class="collective-page">
<UiBreadcrumb :items="breadcrumb" />
<section class="w1200 marketing-section">
<img class="marketing-banner" src="/mock/collective-banner.svg" :alt="t('marketing.collectiveBannerAlt')" />
<header class="section-heading">
<h1>{{ t("marketing.collectiveTitle") }}</h1>
</header>
<div v-if="products.length" class="collective-grid">
<article v-for="item in products" :key="item.product.id" class="collective-card hover-lift">
<NuxtLink :to="`/goods/${item.product.slug}`" class="product-image">
<img :src="item.product.images[0]" :alt="pick(item.product.name, locale)" loading="lazy" />
</NuxtLink>
<div class="product-body">
<NuxtLink :to="`/goods/${item.product.slug}`" class="product-name">{{ pick(item.product.name, locale) }}</NuxtLink>
<div class="deal-meta">
<span class="deal-price"><PriceText :amount-minor="priceMinorOf(item.product)" :currency="currencyOf(item.product)" /></span>
<span class="group-tag">{{ t("marketing.peopleGroup", { n: item.need }) }}</span>
</div>
<p class="joined">{{ t("marketing.peopleJoined", { n: item.joined }) }}</p>
<NuxtLink :to="`/goods/${item.product.slug}`" class="deal-link">{{ t("marketing.joinGroup") }} <span aria-hidden="true"></span></NuxtLink>
</div>
</article>
</div>
<UiEmptyState v-else :text="t('marketing.noCollectiveProducts')" />
</section>
</div>
</template>
<style scoped>
.collective-page {
padding-bottom: 60px;
}
.marketing-section {
background: #fff;
}
.marketing-banner {
display: block;
width: 100%;
height: 350px;
object-fit: cover;
}
.section-heading {
margin-top: 20px;
border: 1px solid var(--mall-line);
background: #fafafa;
padding: 14px 18px;
}
.section-heading h1 {
margin: 0;
border-left: 3px solid var(--mall-red);
padding-left: 10px;
font-size: 17px;
font-weight: 500;
}
.collective-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 15px;
margin-top: 20px;
}
.collective-card {
min-width: 0;
border: 1px solid var(--mall-line);
background: #fff;
}
.product-image {
display: flex;
height: 180px;
align-items: center;
justify-content: center;
padding: 12px;
}
.product-image img {
width: 100%;
height: 100%;
object-fit: contain;
}
.product-body {
padding: 0 12px 13px;
}
.product-name {
display: block;
height: 36px;
overflow: hidden;
color: var(--mall-ink);
font-size: 13px;
line-height: 1.4;
text-decoration: none;
}
.product-name:hover {
color: var(--mall-red);
}
.deal-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 5px;
margin-top: 11px;
}
.deal-price {
color: var(--mall-red);
font-size: 17px;
font-weight: 700;
}
.group-tag {
flex: 0 0 auto;
border: 1px solid #f2b9bc;
background: #fff4f4;
padding: 2px 5px;
color: var(--mall-red);
font-size: 11px;
}
.joined {
margin: 7px 0 10px;
color: var(--mall-faint);
font-size: 12px;
}
.deal-link {
display: block;
border-top: 1px solid var(--mall-line);
padding-top: 9px;
color: var(--mall-red);
font-size: 12px;
text-decoration: none;
}
.deal-link span {
float: right;
font-size: 16px;
line-height: 11px;
}
@media (max-width: 1240px) {
.w1200 {
width: calc(100% - 32px);
}
}
@media (max-width: 900px) {
.collective-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 600px) {
.marketing-banner {
height: 220px;
}
.collective-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>