feat(mall): live points mall with address-aware redemption
The points page loads the published catalog and the customer's live points through the shared client, redeems with a chosen saved address, refreshes the balance, and lists the customer's redemption history with its fulfilment state. The INTEGRAL_PRODUCTS and USER_STATS imports leave the page; the fixed-data adapter still serves the points domain as the rollback path.
This commit is contained in:
@@ -37,6 +37,24 @@ export default {
|
||||
login: "Sign in",
|
||||
register: "Create account",
|
||||
noIntegralProducts: "No rewards available right now.",
|
||||
addressLabel: "Ship to",
|
||||
noAddress: "No saved address.",
|
||||
addAddress: "Add one in your account",
|
||||
addressRequired: "Add a shipping address before redeeming.",
|
||||
qty: "Qty",
|
||||
redeemSuccess: "Redeemed as {no}.",
|
||||
redeemConflict: "Unable to redeem: not enough points or stock.",
|
||||
redeemFailed: "Unable to redeem right now.",
|
||||
redemptionHistory: "My redemptions",
|
||||
orderNo: "Order no.",
|
||||
reward: "Reward",
|
||||
pointsCost: "Points",
|
||||
createdAt: "Date",
|
||||
redemptionStatus: {
|
||||
pending_fulfillment: "Pending",
|
||||
fulfilled: "Fulfilled",
|
||||
cancelled: "Cancelled",
|
||||
},
|
||||
},
|
||||
},
|
||||
zh: {
|
||||
@@ -77,6 +95,24 @@ export default {
|
||||
login: "登录",
|
||||
register: "注册",
|
||||
noIntegralProducts: "当前暂无积分商品。",
|
||||
addressLabel: "收货地址",
|
||||
noAddress: "暂无收货地址。",
|
||||
addAddress: "前往账户添加",
|
||||
addressRequired: "请先添加收货地址。",
|
||||
qty: "数量",
|
||||
redeemSuccess: "兑换成功,订单号 {no}。",
|
||||
redeemConflict: "兑换失败:积分或库存不足。",
|
||||
redeemFailed: "兑换失败,请稍后重试。",
|
||||
redemptionHistory: "我的兑换",
|
||||
orderNo: "订单号",
|
||||
reward: "兑换商品",
|
||||
pointsCost: "积分",
|
||||
createdAt: "日期",
|
||||
redemptionStatus: {
|
||||
pending_fulfillment: "待发货",
|
||||
fulfilled: "已发货",
|
||||
cancelled: "已取消",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
+208
-25
@@ -1,15 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import type { AccountSummary } from "@vmall/shared";
|
||||
import { t as pick } from "@vmall/shared";
|
||||
// The catalog stays on fixtures until the points-mall change lands; the points
|
||||
// balance itself is live.
|
||||
import { INTEGRAL_PRODUCTS } from "~/mock/data";
|
||||
import type {
|
||||
AccountSummary,
|
||||
Address,
|
||||
AddressBookEntry,
|
||||
IntegralOrder,
|
||||
IntegralProduct,
|
||||
} from "@vmall/shared";
|
||||
import { ApiError, t as pick } from "@vmall/shared";
|
||||
|
||||
const { locale, t } = useI18n();
|
||||
const { $api } = useNuxtApp();
|
||||
const session = useSessionStore();
|
||||
const stats = ref<AccountSummary | null>(null);
|
||||
const redeemed = ref<Set<string>>(new Set());
|
||||
const products = ref<IntegralProduct[]>([]);
|
||||
const redemptions = ref<IntegralOrder[]>([]);
|
||||
const addresses = ref<AddressBookEntry[]>([]);
|
||||
const selectedAddressId = ref("");
|
||||
const quantities = reactive<Record<string, number>>({});
|
||||
const redeemingId = ref("");
|
||||
const error = ref("");
|
||||
const notice = ref("");
|
||||
const loading = ref(true);
|
||||
|
||||
const breadcrumb = computed(() => [
|
||||
{ label: t("stores.breadcrumbHome"), to: "/" },
|
||||
{ label: t("marketing.integralBreadcrumb") },
|
||||
]);
|
||||
|
||||
function quantityFor(id: string): number {
|
||||
return quantities[id] ?? 1;
|
||||
}
|
||||
|
||||
async function loadStats(): Promise<void> {
|
||||
if (!session.isLoggedIn) {
|
||||
@@ -23,22 +43,81 @@ async function loadStats(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
async function loadCatalog(): Promise<void> {
|
||||
try {
|
||||
// Published products only; the server hides the rest.
|
||||
products.value = await $api.listPointsProducts();
|
||||
for (const product of products.value) quantities[product.id] ??= 1;
|
||||
} catch {
|
||||
products.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
function isRedeemed(id: string): boolean {
|
||||
return redeemed.value.has(id);
|
||||
async function loadAccountData(): Promise<void> {
|
||||
if (!session.isLoggedIn) return;
|
||||
try {
|
||||
const [orders, list] = await Promise.all([
|
||||
$api.listMyRedemptions(),
|
||||
$api.listMyAddresses(),
|
||||
]);
|
||||
redemptions.value = orders;
|
||||
addresses.value = list;
|
||||
selectedAddressId.value =
|
||||
list.find((address) => address.is_default)?.id ?? list[0]?.id ?? "";
|
||||
} catch {
|
||||
redemptions.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
function addressForRedemption(): Address | null {
|
||||
const saved = addresses.value.find((a) => a.id === selectedAddressId.value);
|
||||
if (!saved) return null;
|
||||
return {
|
||||
recipient: saved.recipient,
|
||||
phone: saved.phone,
|
||||
country: saved.country,
|
||||
region: saved.region,
|
||||
city: saved.city,
|
||||
line1: saved.line1,
|
||||
postal_code: saved.postal_code,
|
||||
};
|
||||
}
|
||||
|
||||
async function redeem(product: IntegralProduct): Promise<void> {
|
||||
if (!session.isLoggedIn) {
|
||||
await navigateTo("/login");
|
||||
return;
|
||||
}
|
||||
const address = addressForRedemption();
|
||||
if (!address) {
|
||||
error.value = t("marketing.addressRequired");
|
||||
return;
|
||||
}
|
||||
redeemingId.value = product.id;
|
||||
error.value = "";
|
||||
notice.value = "";
|
||||
try {
|
||||
const order = await $api.redeemPoints({
|
||||
product_id: product.id,
|
||||
qty: quantityFor(product.id),
|
||||
shipping_address: address,
|
||||
});
|
||||
notice.value = t("marketing.redeemSuccess", { no: order.order_no });
|
||||
await Promise.all([loadStats(), loadCatalog(), loadAccountData()]);
|
||||
} catch (err: unknown) {
|
||||
error.value =
|
||||
err instanceof ApiError && err.status === 409
|
||||
? t("marketing.redeemConflict")
|
||||
: t("marketing.redeemFailed");
|
||||
} finally {
|
||||
redeemingId.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
session.hydrate();
|
||||
void loadStats();
|
||||
await Promise.all([loadCatalog(), loadStats(), loadAccountData()]);
|
||||
loading.value = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -86,26 +165,86 @@ onMounted(() => {
|
||||
<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 v-if="session.isLoggedIn" class="redeem-bar">
|
||||
<label class="redeem-address">
|
||||
<span>{{ t("marketing.addressLabel") }}</span>
|
||||
<select v-if="addresses.length" v-model="selectedAddressId" class="minput">
|
||||
<option v-for="address in addresses" :key="address.id" :value="address.id">
|
||||
{{ address.recipient }} · {{ address.city }} {{ address.line1 }}
|
||||
</option>
|
||||
</select>
|
||||
<span v-else class="muted">
|
||||
{{ t("marketing.noAddress") }}
|
||||
<NuxtLink to="/user/addresses">{{ t("marketing.addAddress") }}</NuxtLink>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="error" class="redeem-error" role="alert">{{ error }}</p>
|
||||
<p v-if="notice" class="redeem-notice" aria-live="polite">{{ notice }}</p>
|
||||
|
||||
<div v-if="loading" class="muted">{{ t("common.loading") }}</div>
|
||||
<div v-else-if="products.length" class="integral-grid">
|
||||
<article v-for="item in products" :key="item.id" class="integral-card hover-lift">
|
||||
<div class="product-image">
|
||||
<img :src="item.image" :alt="pick(item.name, locale)" loading="lazy" />
|
||||
<img :src="item.image || '/mock/product-9.svg'" :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>
|
||||
<strong>{{ t("marketing.points", { n: item.points_price }) }}</strong>
|
||||
</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") }}
|
||||
<label class="qty-line">
|
||||
<span>{{ t("marketing.qty") }}</span>
|
||||
<input
|
||||
v-model.number="quantities[item.id]"
|
||||
class="minput qty-input"
|
||||
type="number"
|
||||
min="1"
|
||||
:max="item.stock"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="mbtn red redeem-button"
|
||||
:disabled="item.stock <= 0 || redeemingId === item.id"
|
||||
@click="redeem(item)"
|
||||
>
|
||||
{{ redeemingId === item.id ? t("common.loading") : 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')" />
|
||||
|
||||
<template v-if="session.isLoggedIn && redemptions.length">
|
||||
<header class="section-heading history-heading">
|
||||
<h1>{{ t("marketing.redemptionHistory") }}</h1>
|
||||
</header>
|
||||
<table class="mtable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t("marketing.orderNo") }}</th>
|
||||
<th>{{ t("marketing.reward") }}</th>
|
||||
<th>{{ t("marketing.qty") }}</th>
|
||||
<th>{{ t("marketing.pointsCost") }}</th>
|
||||
<th>{{ t("common.status") }}</th>
|
||||
<th>{{ t("marketing.createdAt") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="order in redemptions" :key="order.id">
|
||||
<td>{{ order.order_no }}</td>
|
||||
<td>{{ order.items.map((line) => pick(line.name, locale)).join(", ") }}</td>
|
||||
<td>{{ order.items.reduce((n, line) => n + line.qty, 0) }}</td>
|
||||
<td>{{ order.total_points }}</td>
|
||||
<td>{{ t(`marketing.redemptionStatus.${order.status}`) }}</td>
|
||||
<td>{{ order.created_at.slice(0, 10) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -218,6 +357,50 @@ onMounted(() => {
|
||||
font-size: 17px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.redeem-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 18px;
|
||||
border: 1px solid var(--mall-line);
|
||||
border-top: none;
|
||||
background: #fffafa;
|
||||
}
|
||||
.redeem-address {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
color: var(--mall-muted);
|
||||
}
|
||||
.redeem-error {
|
||||
margin: 12px 0 0;
|
||||
padding: 10px 14px;
|
||||
color: #b42318;
|
||||
background: #fff1f0;
|
||||
border: 1px solid #ffd6d2;
|
||||
}
|
||||
.redeem-notice {
|
||||
margin: 12px 0 0;
|
||||
padding: 10px 14px;
|
||||
color: #067647;
|
||||
background: #ecfdf3;
|
||||
border: 1px solid #abefc6;
|
||||
}
|
||||
.qty-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 8px 0 11px;
|
||||
color: var(--mall-faint);
|
||||
font-size: 12px;
|
||||
}
|
||||
.qty-input {
|
||||
width: 72px;
|
||||
}
|
||||
.history-heading {
|
||||
margin-top: 26px;
|
||||
}
|
||||
.integral-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
|
||||
Reference in New Issue
Block a user