Files
vmall/apps/mall/pages/integral.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

303 lines
7.0 KiB
Vue

<script setup lang="ts">
import { t as pick } from "@vmall/shared";
import { INTEGRAL_PRODUCTS, USER_STATS } from "~/mock/data";
const { locale, t } = useI18n();
const session = useSessionStore();
const redeemed = ref<Set<string>>(new Set());
const breadcrumb = computed(() => [
{ label: t("stores.breadcrumbHome"), to: "/" },
{ label: t("marketing.integralBreadcrumb") },
]);
function redeem(id: string): void {
redeemed.value = new Set(redeemed.value).add(id);
}
function isRedeemed(id: string): boolean {
return redeemed.value.has(id);
}
onMounted(() => session.hydrate());
</script>
<template>
<div class="integral-page">
<UiBreadcrumb :items="breadcrumb" />
<section class="w1200 marketing-section">
<img class="marketing-banner" src="/mock/integral-banner.svg" :alt="t('marketing.integralBannerAlt')" />
<div class="benefits-row">
<section class="points-account">
<template v-if="session.isLoggedIn">
<img src="/mock/avatar.svg" :alt="session.user?.display_name ?? t('marketing.pointsBalance')" />
<div>
<p class="account-name">{{ session.user?.display_name }}</p>
<p class="points-label">{{ t("marketing.pointsBalance") }}</p>
<strong class="account-points">{{ USER_STATS.points }}</strong>
</div>
</template>
<template v-else>
<div class="login-copy">
<h2>{{ t("marketing.pointsBalance") }}</h2>
<p>{{ t("marketing.loginPrompt") }}</p>
</div>
<div class="account-actions">
<NuxtLink to="/login" class="mbtn red">{{ t("marketing.login") }}</NuxtLink>
<NuxtLink to="/register" class="mbtn">{{ t("marketing.register") }}</NuxtLink>
</div>
</template>
</section>
<div class="value-card">
<span class="value-mark">01</span>
<strong>{{ t("marketing.customize") }}</strong>
</div>
<div class="value-card">
<span class="value-mark">02</span>
<strong>{{ t("marketing.shipping") }}</strong>
</div>
<div class="value-card">
<span class="value-mark">03</span>
<strong>{{ t("marketing.support") }}</strong>
</div>
</div>
<header class="section-heading">
<h1>{{ t("marketing.recommendation") }}</h1>
</header>
<div v-if="INTEGRAL_PRODUCTS.length" class="integral-grid">
<article v-for="item in INTEGRAL_PRODUCTS" :key="item.id" class="integral-card hover-lift">
<div class="product-image">
<img :src="item.image" :alt="pick(item.name, locale)" loading="lazy" />
</div>
<div class="product-body">
<h2>{{ pick(item.name, locale) }}</h2>
<div class="points-line">
<strong>{{ t("marketing.points", { n: item.points }) }}</strong>
<s><PriceText :amount-minor="item.marketPriceMinor" :currency="item.currency" /></s>
</div>
<p class="stock-line">{{ t("marketing.stock", { n: item.stock }) }}</p>
<button type="button" class="mbtn red redeem-button" :disabled="isRedeemed(item.id)" @click="redeem(item.id)">
{{ isRedeemed(item.id) ? t("marketing.redeemed") : t("marketing.redeem") }}
</button>
<p v-if="isRedeemed(item.id)" class="mock-note" aria-live="polite">{{ t("marketing.mockNotice") }}</p>
</div>
</article>
</div>
<UiEmptyState v-else :text="t('marketing.noIntegralProducts')" />
</section>
</div>
</template>
<style scoped>
.integral-page {
padding-bottom: 60px;
}
.marketing-section {
background: #fff;
}
.marketing-banner {
display: block;
width: 100%;
height: 350px;
object-fit: cover;
}
.benefits-row {
display: grid;
grid-template-columns: 1.4fr repeat(3, 1fr);
gap: 12px;
margin: 20px 0;
}
.points-account,
.value-card {
min-height: 106px;
border: 1px solid var(--mall-line);
background: #fff;
}
.points-account {
display: flex;
align-items: center;
gap: 13px;
padding: 14px 18px;
}
.points-account > img {
width: 64px;
height: 64px;
border-radius: 50%;
object-fit: cover;
}
.account-name,
.points-label,
.login-copy h2,
.login-copy p {
margin: 0;
}
.account-name {
font-size: 14px;
font-weight: 700;
}
.points-label {
margin-top: 7px;
color: var(--mall-muted);
font-size: 12px;
}
.account-points {
display: block;
margin-top: 2px;
color: var(--mall-red);
font-size: 20px;
}
.login-copy {
min-width: 0;
flex: 1;
}
.login-copy h2 {
font-size: 16px;
font-weight: 500;
}
.login-copy p {
margin-top: 7px;
color: var(--mall-faint);
font-size: 12px;
}
.account-actions {
display: flex;
flex-direction: column;
gap: 6px;
}
.account-actions .mbtn {
min-width: 74px;
padding: 4px 8px;
text-align: center;
}
.value-card {
display: flex;
align-items: center;
gap: 10px;
padding: 0 13px;
}
.value-mark {
color: var(--mall-red);
font-size: 18px;
font-weight: 700;
}
.value-card strong {
font-size: 13px;
font-weight: 500;
}
.section-heading {
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;
}
.integral-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
margin-top: 20px;
}
.integral-card {
min-width: 0;
border: 1px solid var(--mall-line);
background: #fff;
}
.product-image {
display: flex;
height: 205px;
align-items: center;
justify-content: center;
padding: 14px;
}
.product-image img {
width: 100%;
height: 100%;
object-fit: contain;
}
.product-body {
padding: 0 14px 14px;
}
.product-body h2 {
height: 37px;
overflow: hidden;
margin: 0;
color: var(--mall-ink);
font-size: 13px;
font-weight: 500;
line-height: 1.45;
}
.points-line {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
margin-top: 10px;
}
.points-line strong {
color: var(--mall-red);
font-size: 17px;
}
.points-line s {
color: var(--mall-faint);
font-size: 12px;
}
.stock-line {
margin: 8px 0 11px;
color: var(--mall-faint);
font-size: 12px;
}
.redeem-button {
display: block;
width: 100%;
text-align: center;
}
.mock-note {
margin: 8px 0 0;
color: var(--mall-green);
font-size: 12px;
text-align: center;
}
@media (max-width: 1240px) {
.w1200 {
width: calc(100% - 32px);
}
}
@media (max-width: 900px) {
.benefits-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.points-account {
grid-column: span 2;
}
.integral-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.marketing-banner {
height: 220px;
}
.benefits-row {
grid-template-columns: 1fr;
}
.points-account {
grid-column: auto;
}
.integral-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 420px) {
.integral-grid {
grid-template-columns: 1fr;
}
}
</style>