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.
506 lines
12 KiB
Vue
506 lines
12 KiB
Vue
<script setup lang="ts">
|
|
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 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) {
|
|
stats.value = null;
|
|
return;
|
|
}
|
|
try {
|
|
stats.value = await $api.getAccountSummary();
|
|
} catch {
|
|
stats.value = null;
|
|
}
|
|
}
|
|
|
|
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 = [];
|
|
}
|
|
}
|
|
|
|
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 = [];
|
|
}
|
|
}
|
|
|
|
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();
|
|
await Promise.all([loadCatalog(), loadStats(), loadAccountData()]);
|
|
loading.value = false;
|
|
});
|
|
</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">{{ stats ? stats.points : t("common.loading") }}</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="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 || '/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_price }) }}</strong>
|
|
</div>
|
|
<p class="stock-line">{{ t("marketing.stock", { n: item.stock }) }}</p>
|
|
<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>
|
|
</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>
|
|
|
|
<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;
|
|
}
|
|
.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));
|
|
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>
|