298 lines
10 KiB
Vue
298 lines
10 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 signInThenReturn = useSignInRedirect();
|
|
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 signInThenReturn();
|
|
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="bg-bg text-text min-h-screen pb-14 font-sans">
|
|
<UiBreadcrumb :items="breadcrumb" />
|
|
<section class="max-w-mall bg-surface mx-auto px-4">
|
|
<img
|
|
class="block h-[350px] w-full object-cover"
|
|
src="/mock/integral-banner.svg"
|
|
:alt="t('marketing.integralBannerAlt')"
|
|
/>
|
|
<div class="my-5 grid gap-3 md:grid-cols-[1.4fr_repeat(3,1fr)]">
|
|
<section
|
|
class="border-border bg-surface flex min-h-[106px] items-center gap-3 border px-4 py-3"
|
|
>
|
|
<template v-if="session.isLoggedIn">
|
|
<img
|
|
class="h-14 w-14 rounded-full object-cover"
|
|
src="/mock/avatar.svg"
|
|
:alt="session.user?.display_name ?? t('marketing.pointsBalance')"
|
|
/>
|
|
<div>
|
|
<p class="m-0 text-sm">{{ session.user?.display_name }}</p>
|
|
<p class="text-muted mt-1 mb-1 text-xs">{{ t("marketing.pointsBalance") }}</p>
|
|
<strong class="text-primary text-xl">{{
|
|
stats ? stats.points : t("common.loading")
|
|
}}</strong>
|
|
</div>
|
|
</template>
|
|
<template v-else>
|
|
<div class="flex-1">
|
|
<h2 class="m-0 text-base">{{ t("marketing.pointsBalance") }}</h2>
|
|
<p class="text-muted my-1 text-sm">{{ t("marketing.loginPrompt") }}</p>
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<NuxtLink
|
|
class="border-primary bg-primary hover:bg-primary-hover inline-flex items-center justify-center rounded-md border px-3 py-1.5 text-sm text-white"
|
|
to="/login"
|
|
>{{ t("marketing.login") }}</NuxtLink
|
|
><NuxtLink
|
|
class="border-border bg-surface hover:bg-bg inline-flex items-center justify-center rounded-md border px-3 py-1.5 text-sm"
|
|
to="/register"
|
|
>{{ t("marketing.register") }}</NuxtLink
|
|
>
|
|
</div>
|
|
</template>
|
|
</section>
|
|
<div
|
|
v-for="(label, index) in [
|
|
t('marketing.customize'),
|
|
t('marketing.shipping'),
|
|
t('marketing.support'),
|
|
]"
|
|
:key="label"
|
|
class="border-border bg-surface flex min-h-[106px] items-center gap-3 border px-4"
|
|
>
|
|
<span class="text-primary text-2xl">0{{ index + 1 }}</span
|
|
><strong>{{ label }}</strong>
|
|
</div>
|
|
</div>
|
|
|
|
<header class="border-border bg-bg border px-4 py-3.5">
|
|
<h1 class="border-primary m-0 border-l-[3px] pl-2.5 text-[17px] font-medium">
|
|
{{ t("marketing.recommendation") }}
|
|
</h1>
|
|
</header>
|
|
<div v-if="session.isLoggedIn" class="my-4 flex items-center gap-3">
|
|
<label class="flex flex-1 flex-wrap items-center gap-2 text-sm"
|
|
><span>{{ t("marketing.addressLabel") }}</span
|
|
><select
|
|
v-if="addresses.length"
|
|
v-model="selectedAddressId"
|
|
class="border-border bg-surface text-text focus:border-primary focus:outline-primary/30 rounded-md border px-3 py-2 text-sm focus:outline-2"
|
|
>
|
|
<option v-for="address in addresses" :key="address.id" :value="address.id">
|
|
{{ address.recipient }} · {{ address.city }} {{ address.line1 }}
|
|
</option></select
|
|
><span v-else class="text-muted"
|
|
>{{ t("marketing.noAddress") }}
|
|
<NuxtLink class="text-primary" to="/user/addresses">{{
|
|
t("marketing.addAddress")
|
|
}}</NuxtLink></span
|
|
></label
|
|
>
|
|
</div>
|
|
<p
|
|
v-if="error"
|
|
class="border-danger/30 bg-danger/10 text-danger my-2 border px-3.5 py-2.5"
|
|
role="alert"
|
|
>
|
|
{{ error }}
|
|
</p>
|
|
<p
|
|
v-if="notice"
|
|
class="border-success/30 bg-success/10 text-success my-2 border px-3.5 py-2.5"
|
|
aria-live="polite"
|
|
>
|
|
{{ notice }}
|
|
</p>
|
|
<div v-if="loading" class="text-muted py-6">{{ t("common.loading") }}</div>
|
|
<div
|
|
v-else-if="products.length"
|
|
class="grid [grid-template-columns:repeat(auto-fill,minmax(220px,1fr))] gap-4 py-5"
|
|
>
|
|
<VCard
|
|
v-for="item in products"
|
|
:key="item.id"
|
|
:padded="false"
|
|
class="min-w-0 overflow-hidden"
|
|
>
|
|
<div class="bg-bg flex h-52 items-center justify-center">
|
|
<img
|
|
class="h-full w-full object-contain"
|
|
:src="item.image || '/mock/product-9.svg'"
|
|
:alt="pick(item.name, locale)"
|
|
loading="lazy"
|
|
/>
|
|
</div>
|
|
<div class="p-3.5">
|
|
<h2 class="m-0 line-clamp-2 text-base font-medium">{{ pick(item.name, locale) }}</h2>
|
|
<div class="text-primary mt-2 text-lg font-semibold">
|
|
{{ t("marketing.points", { n: item.points_price }) }}
|
|
</div>
|
|
<p class="text-muted my-2 text-sm">{{ t("marketing.stock", { n: item.stock }) }}</p>
|
|
<div class="mb-3 flex items-center gap-2 text-sm">
|
|
<span>{{ t("marketing.qty") }}</span
|
|
><VInput
|
|
v-model.number="quantities[item.id]"
|
|
class="w-20"
|
|
type="number"
|
|
min="1"
|
|
:max="item.stock"
|
|
/>
|
|
</div>
|
|
<VBtn
|
|
class="w-full"
|
|
variant="primary"
|
|
type="button"
|
|
:disabled="item.stock <= 0 || redeemingId === item.id"
|
|
@click="redeem(item)"
|
|
>{{ redeemingId === item.id ? t("common.loading") : t("marketing.redeem") }}</VBtn
|
|
>
|
|
</div>
|
|
</VCard>
|
|
</div>
|
|
<UiEmptyState v-else :text="t('marketing.noIntegralProducts')" />
|
|
|
|
<template v-if="session.isLoggedIn && redemptions.length">
|
|
<header class="border-border bg-bg mt-5 border px-4 py-3.5">
|
|
<h1 class="border-primary m-0 border-l-[3px] pl-2.5 text-[17px] font-medium">
|
|
{{ t("marketing.redemptionHistory") }}
|
|
</h1>
|
|
</header>
|
|
<VTable>
|
|
<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>
|
|
</VTable>
|
|
</template>
|
|
</section>
|
|
</div>
|
|
</template>
|