Files
Chengdong Zhang 9e3abf95ef feat(marketing): add threshold promotions (满减)
Shop-scoped threshold rules with bilingual names, integer
threshold/reduction (reduction < threshold), active windows and display
order. Checkout applies the best eligible rule per shop with currency
conversion; coupon/threshold/activity pricing are mutually exclusive per
shop order. Reduction persists in orders.discount_minor +
threshold_promotion_id and flows into payment totals; checkout
idempotency covers the threshold selection. Delete archives referenced
rules, hard-deletes unreferenced ones.

- migration 0026: threshold_promotions + orders.threshold_promotion_id
  + coupon/threshold exclusivity CHECK
- api: modules/promotion (CRUD, preview, checkout integration)
- shared: ThresholdPromotion types, shop CRUD + preview client methods,
  checkout threshold_by_shop
- shop-admin: promotions page + nav + en/zh locales
- mall: exclusive promotion radio on checkout, live preview wiring,
  threshold label on order detail
- tests: promotions.rs (scoping, validation, best-rule, exclusivity,
  activity-priced 409, totals, replay, archive-vs-delete)
- openspec: archived 2026-10-19-add-threshold-promotions; spec
  threshold-promotions created, order/frontend specs updated
2026-10-19 18:14:38 +08:00

214 lines
6.8 KiB
TypeScript

import { createApi } from "@vmall/shared";
import type { ApiClient } from "@vmall/shared";
import { createMockApi } from "~/mock/api";
/**
* Domains the live backend serves. Every other domain stays on the fixed-data
* adapter, so a domain can be migrated - or rolled back - by editing this list
* alone. See openspec/changes/replace-mock-api-wave-1/design.md.
*/
type LiveDomain =
| "auth"
| "account"
| "catalog"
| "currency"
| "content"
| "shops"
| "brands"
| "cart"
| "orders"
| "payments"
| "shipments"
| "invoices"
| "addresses"
| "coupons"
| "points"
| "flashSales"
| "groupBuying"
| "favorites"
| "aftersales"
| "reviews"
| "wallet"
| "membership"
| "messaging"
| "merchantOnboarding";
/**
* Explicit per-domain method picks rather than a string allowlist: indexing
* `ApiClient` by a union of method names would need an unsafe cast, and this
* keeps the compiler checking that every picked key exists.
*/
const LIVE_PICKS = {
auth: (a: ApiClient) => ({ register: a.register, login: a.login, me: a.me }),
account: (a: ApiClient) => ({ getAccountSummary: a.getAccountSummary }),
catalog: (a: ApiClient) => ({
listProducts: a.listProducts,
getProduct: a.getProduct,
listCategories: a.listCategories,
}),
currency: (a: ApiClient) => ({ listCurrencies: a.listCurrencies, convert: a.convert }),
content: (a: ApiClient) => ({ getHomeContent: a.getHomeContent }),
shops: (a: ApiClient) => ({ listShops: a.listShops, getShop: a.getShop }),
brands: (a: ApiClient) => ({ listBrands: a.listBrands }),
cart: (a: ApiClient) => ({
getCart: a.getCart,
addCartItem: a.addCartItem,
updateCartItem: a.updateCartItem,
removeCartItem: a.removeCartItem,
}),
orders: (a: ApiClient) => ({
checkout: a.checkout,
listMyOrders: a.listMyOrders,
getOrder: a.getOrder,
cancelOrder: a.cancelOrder,
quoteShipping: a.quoteShipping,
listShippingCompanies: a.listShippingCompanies,
}),
payments: (a: ApiClient) => ({
resolvePayment: a.resolvePayment,
getPayment: a.getPayment,
listPaymentMethods: a.listPaymentMethods,
createPaymentAttempt: a.createPaymentAttempt,
cancelPayment: a.cancelPayment,
}),
shipments: (a: ApiClient) => ({
confirmDelivered: a.confirmDelivered,
listMyShipments: a.listMyShipments,
}),
invoices: (a: ApiClient) => ({
requestInvoice: a.requestInvoice,
listMyInvoices: a.listMyInvoices,
}),
addresses: (a: ApiClient) => ({
listMyAddresses: a.listMyAddresses,
createAddress: a.createAddress,
updateAddress: a.updateAddress,
deleteAddress: a.deleteAddress,
setDefaultAddress: a.setDefaultAddress,
}),
coupons: (a: ApiClient) => ({
listShopCouponTemplates: a.listShopCouponTemplates,
listMyCoupons: a.listMyCoupons,
claimCoupon: a.claimCoupon,
// Threshold promotions share the shop-marketing domain with coupons.
previewCheckoutThresholds: a.previewCheckoutThresholds,
}),
points: (a: ApiClient) => ({
listPointsProducts: a.listPointsProducts,
listMyRedemptions: a.listMyRedemptions,
redeemPoints: a.redeemPoints,
}),
flashSales: (a: ApiClient) => ({ listFlashSales: a.listFlashSales }),
groupBuying: (a: ApiClient) => ({
listGroupBuyingActivities: a.listGroupBuyingActivities,
}),
favorites: (a: ApiClient) => ({
listFavorites: a.listFavorites,
addProductFavorite: a.addProductFavorite,
removeProductFavorite: a.removeProductFavorite,
addShopFavorite: a.addShopFavorite,
removeShopFavorite: a.removeShopFavorite,
}),
aftersales: (a: ApiClient) => ({
applyForAftersale: a.applyForAftersale,
listMyAftersales: a.listMyAftersales,
getAftersale: a.getAftersale,
cancelAftersale: a.cancelAftersale,
reopenAftersale: a.reopenAftersale,
submitAftersaleReturnTracking: a.submitAftersaleReturnTracking,
addAftersaleMessage: a.addAftersaleMessage,
}),
reviews: (a: ApiClient) => ({
listProductReviews: a.listProductReviews,
getProductReviewSummary: a.getProductReviewSummary,
listReviewableItems: a.listReviewableItems,
createReview: a.createReview,
}),
wallet: (a: ApiClient) => ({
getWallet: a.getWallet,
listWalletEntries: a.listWalletEntries,
rechargeWallet: a.rechargeWallet,
applyWithdrawal: a.applyWithdrawal,
listMyWithdrawals: a.listMyWithdrawals,
}),
membership: (a: ApiClient) => ({
getMembership: a.getMembership,
listGrowthLogs: a.listGrowthLogs,
}),
messaging: (a: ApiClient) => ({
listMessages: a.listMessages,
markMessageRead: a.markMessageRead,
markAllMessagesRead: a.markAllMessagesRead,
deleteMessage: a.deleteMessage,
getUnreadCount: a.getUnreadCount,
}),
merchantOnboarding: (a: ApiClient) => ({
submitMerchantApplication: a.submitMerchantApplication,
getMyMerchantApplications: a.getMyMerchantApplications,
}),
} satisfies Record<LiveDomain, (a: ApiClient) => Partial<ApiClient>>;
const KNOWN_DOMAINS = Object.keys(LIVE_PICKS) as LiveDomain[];
/** Fallback when runtimeConfig supplies no list; keep in step with nuxt.config.ts. */
const DEFAULT_LIVE_DOMAINS: LiveDomain[] = [
"catalog",
"currency",
"content",
"shops",
"brands",
"auth",
"account",
"cart",
"orders",
"payments",
"shipments",
"invoices",
"addresses",
"coupons",
"points",
"flashSales",
"groupBuying",
"favorites",
"aftersales",
"reviews",
"wallet",
"membership",
"messaging",
"merchantOnboarding",
];
export default defineNuxtPlugin(() => {
const config = useRuntimeConfig();
const mock = createMockApi();
const live = createApi({
baseUrl: config.public.apiBase as string,
getToken: () => (import.meta.client ? localStorage.getItem("vmall.token") : null),
});
const configured = config.public.liveDomains as string[] | undefined;
const liveDomains = (configured ?? DEFAULT_LIVE_DOMAINS).filter((domain): domain is LiveDomain =>
KNOWN_DOMAINS.includes(domain as LiveDomain),
);
// Cart, order and payment state cross-reference each other at runtime, so
// mixing fixed and live selection between them would split one checkout's
// state across two stores. Refuse a mixed selection at startup.
const transactional: LiveDomain[] = ["cart", "orders", "payments"];
const liveTransactional = transactional.filter((domain) => liveDomains.includes(domain));
if (liveTransactional.length !== 0 && liveTransactional.length !== transactional.length) {
throw new Error(
`vmall: cart/orders/payments must be all live or all fixed; got ${liveTransactional.join(", ")}`,
);
}
// The fixed-data adapter is the base object, so an unmigrated domain cannot
// regress and a live domain can be rolled back by removing one entry.
let api: ApiClient = mock;
for (const domain of liveDomains) {
api = { ...api, ...LIVE_PICKS[domain](live) };
}
return { provide: { api } };
});