feat(mall): claim and redeem shop coupons from the storefront
The product page loads a shop's claimable coupons through the shared client and claims them in place; the buyer coupon list reads owned snapshots with their claimed/redeemed/expired state. Checkout offers each shop order at most one owned coupon and submits only the choice; the pay page and order detail render the server-persisted discount and reduced total, never a client figure. Coupon fixtures leave the pages; the fixed-data adapter still serves the coupon domain as the rollback path. The demo seed creates two deterministic templates and claims them for the demo customer.
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { t as pick } from "@vmall/shared";
|
||||
import { ApiError } from "@vmall/shared";
|
||||
import type { Category, Product, Sku } from "@vmall/shared";
|
||||
import { MOCK_COUPONS } from "~/mock/data";
|
||||
import type { Category, CouponTemplate, Product, Sku } from "@vmall/shared";
|
||||
import { lowestSku } from "~/utils/product";
|
||||
import { useCartStore } from "~/stores/cart";
|
||||
import { useSessionStore } from "~/stores/session";
|
||||
|
||||
type AttributeGroup = { key: string; values: string[] };
|
||||
|
||||
@@ -13,6 +13,7 @@ const router = useRouter();
|
||||
const { locale, t } = useI18n();
|
||||
const { $api } = useNuxtApp();
|
||||
const cart = useCartStore();
|
||||
const session = useSessionStore();
|
||||
|
||||
const routeId = computed(() => {
|
||||
const value = route.params.id;
|
||||
@@ -33,9 +34,15 @@ const { data: pageData } = await useAsyncData(
|
||||
shop_id: current.shop_id,
|
||||
per_page: 6,
|
||||
});
|
||||
// Claimable coupons are live; the store card stays local display content.
|
||||
// A coupon failure must not blank the product page.
|
||||
const coupons = await $api
|
||||
.listShopCouponTemplates(current.shop_id)
|
||||
.catch(() => [] as CouponTemplate[]);
|
||||
return {
|
||||
product: current,
|
||||
related: siblings.items.filter((item) => item.id !== current.id).slice(0, 5),
|
||||
coupons,
|
||||
};
|
||||
},
|
||||
{ watch: [routeId], default: () => null },
|
||||
@@ -54,11 +61,32 @@ const detail = computed(() => {
|
||||
product: current,
|
||||
// Null when the shop has no public profile; the card is then not rendered.
|
||||
store: shops.value.find((shop) => shop.id === current.shop_id) ?? null,
|
||||
coupons: MOCK_COUPONS,
|
||||
coupons: pageData.value?.coupons ?? [],
|
||||
salesRank: related.value,
|
||||
};
|
||||
});
|
||||
|
||||
const claimingId = ref("");
|
||||
const claimError = ref("");
|
||||
const claimedIds = ref<Set<string>>(new Set());
|
||||
|
||||
async function claim(coupon: CouponTemplate): Promise<void> {
|
||||
if (!session.isLoggedIn) {
|
||||
await navigateTo("/login");
|
||||
return;
|
||||
}
|
||||
claimingId.value = coupon.id;
|
||||
claimError.value = "";
|
||||
try {
|
||||
const owned = await $api.claimCoupon(coupon.id);
|
||||
claimedIds.value = new Set(claimedIds.value).add(owned.template_id ?? coupon.id);
|
||||
} catch {
|
||||
claimError.value = t("product.claimFailed");
|
||||
} finally {
|
||||
claimingId.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
const store = computed(() => detail.value?.store ?? null);
|
||||
const selectedAttributes = reactive<Record<string, string>>({});
|
||||
const quantity = ref(1);
|
||||
@@ -243,13 +271,22 @@ const detailImages = computed(() => product.value?.images ?? []);
|
||||
<span :class="{ soldout: stock <= 0 }">{{ stock > 0 ? t("product.stock", { n: stock }) : t("product.noStock") }}</span>
|
||||
</div>
|
||||
|
||||
<div class="coupon-row">
|
||||
<div v-if="detail.coupons.length > 0" class="coupon-row">
|
||||
<span class="label">{{ t("product.coupons") }}</span>
|
||||
<div class="coupon-list">
|
||||
<span v-for="coupon in detail.coupons" :key="coupon.id" class="coupon">
|
||||
{{ pick(coupon.title, locale) }}
|
||||
<small>{{ t("product.expires", { date: coupon.expiresAt }) }}</small>
|
||||
<small>{{ t("product.expires", { date: coupon.ends_at.slice(0, 10) }) }}</small>
|
||||
<button
|
||||
type="button"
|
||||
class="coupon-claim"
|
||||
:disabled="claimingId === coupon.id || claimedIds.has(coupon.id)"
|
||||
@click="claim(coupon)"
|
||||
>
|
||||
{{ claimedIds.has(coupon.id) ? t("product.claimed") : t("product.claim") }}
|
||||
</button>
|
||||
</span>
|
||||
<span v-if="claimError" class="coupon-error">{{ claimError }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -490,6 +527,26 @@ h1 {
|
||||
color: var(--mall-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
.coupon-claim {
|
||||
margin-top: 3px;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--mall-red);
|
||||
background: transparent;
|
||||
color: var(--mall-red);
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.coupon-claim:disabled {
|
||||
border-color: var(--mall-line-dark);
|
||||
color: var(--mall-faint);
|
||||
cursor: default;
|
||||
}
|
||||
.coupon-error {
|
||||
align-self: center;
|
||||
color: #b42318;
|
||||
font-size: 11px;
|
||||
}
|
||||
.attribute-values button {
|
||||
min-width: 66px;
|
||||
border: 1px solid var(--mall-line-dark);
|
||||
|
||||
Reference in New Issue
Block a user