From 23f51a1ef45521c9956da9ca836b178e0878bd3f Mon Sep 17 00:00:00 2001 From: shanshanzhong Date: Fri, 29 May 2026 04:15:40 -0700 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(#122):=20=E5=90=88=E5=B9=B6?= =?UTF-8?q?=E4=BF=83=E9=94=80=E4=BB=B7=E6=A1=A3=E4=BD=8D=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=E5=88=B0=E6=B5=8B=E8=AF=95=E7=8E=AF=E5=A2=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../public/assets/locales/en-US/promo.json | 5 + .../public/assets/locales/zh-CN/promo.json | 5 + apps/admin/src/sections/promo/index.tsx | 35 ++-- .../src/sections/promo/price-form.test.tsx | 152 ++++++++++++++++++ apps/admin/src/sections/promo/price-form.tsx | 100 ++++++++++-- apps/admin/src/sections/promo/utils.ts | 6 + .../assets/locales/en-US/subscribe.json | 4 + .../assets/locales/zh-CN/subscribe.json | 4 + apps/user/src/sections/purchasing/content.tsx | 10 +- .../src/sections/subscribe/promo-info.tsx | 33 ++++ apps/user/src/sections/subscribe/purchase.tsx | 9 +- packages/ui/src/services/admin/typings.d.ts | 26 ++- packages/ui/src/services/common/typings.d.ts | 7 + packages/ui/src/services/user/typings.d.ts | 7 + 14 files changed, 369 insertions(+), 34 deletions(-) create mode 100644 apps/admin/src/sections/promo/price-form.test.tsx create mode 100644 apps/user/src/sections/subscribe/promo-info.tsx diff --git a/apps/admin/public/assets/locales/en-US/promo.json b/apps/admin/public/assets/locales/en-US/promo.json index e13104d..97d3672 100644 --- a/apps/admin/public/assets/locales/en-US/promo.json +++ b/apps/admin/public/assets/locales/en-US/promo.json @@ -22,10 +22,13 @@ "inactiveMonthsPlaceholder": "Months without purchase", "name": "Name", "namePlaceholder": "Rule name", + "noDiscounts": "This subscribe has no discount quantities and cannot be configured.", + "promoPriceLessThanOriginalPrice": "Promo price must be lower than original price", "promoPriceLessThanUnitPrice": "Promo price must be lower than unit price", "selectEndTime": "Select time", "selectRule": "Select rule", "selectStartTime": "Select time", + "selectQuantity": "Select discount quantity", "selectSubscribe": "Select subscribe", "type": "Rule Type", "windowHours": "Window Hours", @@ -38,10 +41,12 @@ "name": "Name", "no": "No", "orderNo": "Order No.", + "originalPriceHint": "Original price", "params": "Params", "prices": "Prices", "priority": "Priority", "promoPrice": "Promo Price", + "quantity": "Quantity", "rule": "Rule", "ruleDetail": "Rule Detail", "rules": "Rules", diff --git a/apps/admin/public/assets/locales/zh-CN/promo.json b/apps/admin/public/assets/locales/zh-CN/promo.json index e95d07a..2509e47 100644 --- a/apps/admin/public/assets/locales/zh-CN/promo.json +++ b/apps/admin/public/assets/locales/zh-CN/promo.json @@ -22,10 +22,13 @@ "inactiveMonthsPlaceholder": "未购买的月数", "name": "名称", "namePlaceholder": "规则名称", + "noDiscounts": "该订阅没有 discount 档位,不能配置促销价。", + "promoPriceLessThanOriginalPrice": "促销价必须低于原价", "promoPriceLessThanUnitPrice": "促销价必须低于原价", "selectEndTime": "选择时间", "selectRule": "选择规则", "selectStartTime": "选择时间", + "selectQuantity": "选择 discount 档位", "selectSubscribe": "选择订阅", "type": "规则类型", "windowHours": "新用户窗口小时数", @@ -38,10 +41,12 @@ "name": "名称", "no": "否", "orderNo": "订单号", + "originalPriceHint": "原价", "params": "参数", "prices": "商品促销价", "priority": "优先级", "promoPrice": "促销价", + "quantity": "档位", "rule": "规则", "ruleDetail": "规则详情", "rules": "促销规则", diff --git a/apps/admin/src/sections/promo/index.tsx b/apps/admin/src/sections/promo/index.tsx index 71083f1..d55644e 100644 --- a/apps/admin/src/sections/promo/index.tsx +++ b/apps/admin/src/sections/promo/index.tsx @@ -46,7 +46,7 @@ type RuleFilters = { }; type PriceFilters = { - rule_id?: string; + promo_rule_id?: string; subscribe_id?: string; }; @@ -301,12 +301,13 @@ export default function PromoPage() { }} columns={[ { - accessorKey: "rule_id", + accessorKey: "promo_rule_id", header: t("rule", "Rule"), cell: ({ row }) => - row.original.rule_id - ? rules.find((rule) => rule.id === row.original.rule_id) - ?.name || `#${row.original.rule_id}` + row.original.promo_rule_id + ? rules.find( + (rule) => rule.id === row.original.promo_rule_id + )?.name || `#${row.original.promo_rule_id}` : "--", }, { @@ -316,6 +317,11 @@ export default function PromoPage() { row.original.subscribe_name || getSubscribeName(row.original.subscribe_id), }, + { + accessorKey: "quantity", + header: t("quantity", "Quantity"), + cell: ({ row }) => row.original.quantity || "--", + }, { accessorKey: "unit_price", header: t("unitPrice", "Unit Price"), @@ -361,7 +367,7 @@ export default function PromoPage() { }} params={[ { - key: "rule_id", + key: "promo_rule_id", placeholder: t("rule", "Rule"), options: rules.map((rule) => ({ label: rule.name, @@ -382,7 +388,7 @@ export default function PromoPage() { request={async (pagination, filters) => { const { data } = await getPromoPriceList({ ...pagination, - rule_id: parseOptionalNumber(filters.rule_id), + promo_rule_id: parseOptionalNumber(filters.promo_rule_id), subscribe_id: parseOptionalNumber(filters.subscribe_id), }); return { @@ -398,13 +404,13 @@ export default function PromoPage() { action={usageRef} columns={[ { - accessorKey: "rule_id", + accessorKey: "promo_rule_id", header: t("rule", "Rule"), cell: ({ row }) => row.original.rule_name || - rules.find((rule) => rule.id === row.original.rule_id) + rules.find((rule) => rule.id === row.original.promo_rule_id) ?.name || - `#${row.original.rule_id}`, + `#${row.original.promo_rule_id}`, }, { accessorKey: "user_id", @@ -418,6 +424,11 @@ export default function PromoPage() { row.original.subscribe_name || getSubscribeName(row.original.subscribe_id), }, + { + accessorKey: "quantity", + header: t("quantity", "Quantity"), + cell: ({ row }) => row.original.quantity || "--", + }, { accessorKey: "order_no", header: t("orderNo", "Order No."), @@ -447,7 +458,7 @@ export default function PromoPage() { } params={[ { - key: "rule_id", + key: "promo_rule_id", placeholder: t("rule", "Rule"), options: rules.map((rule) => ({ label: rule.name, @@ -470,7 +481,7 @@ export default function PromoPage() { request={async (pagination, filters) => { const { data } = await getPromoUsageList({ ...pagination, - rule_id: parseOptionalNumber(filters.rule_id), + promo_rule_id: parseOptionalNumber(filters.promo_rule_id), subscribe_id: parseOptionalNumber(filters.subscribe_id), user_id: parseOptionalNumber(filters.user_id), order_no: filters.order_no?.trim() || undefined, diff --git a/apps/admin/src/sections/promo/price-form.test.tsx b/apps/admin/src/sections/promo/price-form.test.tsx new file mode 100644 index 0000000..4350fc4 --- /dev/null +++ b/apps/admin/src/sections/promo/price-form.test.tsx @@ -0,0 +1,152 @@ +/** + * @vitest-environment jsdom + */ +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import PriceForm from "./price-form"; + +globalThis.ResizeObserver = class ResizeObserver { + observe() { + return; + } + unobserve() { + return; + } + disconnect() { + return; + } +}; +Element.prototype.scrollIntoView = function scrollIntoView() { + return; +}; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (_key: string, fallback: string): string => fallback, + }), +})); + +const rules: API.PromoRule[] = [ + { + id: 1, + name: "Campaign Rule", + type: "campaign", + params: {}, + priority: 1, + enabled: true, + }, +]; + +const subscribes: API.SubscribeItem[] = [ + { + id: 8, + name: "Monthly Plan", + unit_price: 1200, + unit_time: "Month", + sold: 0, + discount: [ + { + quantity: 3, + discount: 90, + }, + ], + }, +]; +const subscribesWithoutDiscounts: API.SubscribeItem[] = [ + { + id: 8, + name: "Monthly Plan", + unit_price: 1200, + unit_time: "Month", + sold: 0, + discount: [], + }, +]; + +async function selectCombobox(index: number, option: string) { + const combobox = screen.getAllByRole("combobox")[index]; + if (!combobox) throw new Error(`Combobox ${index} not found`); + fireEvent.click(combobox); + fireEvent.click(await screen.findByText(option)); +} + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("PriceForm", () => { + it("submits promo price with promo_rule_id and selected quantity item", async () => { + const onSubmit = vi.fn().mockResolvedValue(true); + render( + + ); + + fireEvent.click(screen.getByRole("button", { name: "Create Promo Price" })); + await selectCombobox(0, "Campaign Rule"); + await selectCombobox(1, "Monthly Plan"); + await selectCombobox(2, "3 x Month"); + fireEvent.change(screen.getByPlaceholderText("Enter price"), { + target: { value: "20" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Confirm" })); + + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith({ + promo_rule_id: 1, + items: [{ subscribe_id: 8, quantity: 3, promo_price: 2000 }], + }) + ); + }); + + it("blocks submit when subscribe has no discount quantities", async () => { + const onSubmit = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByRole("button", { name: "Create Promo Price" })); + await selectCombobox(1, "Monthly Plan"); + + expect( + screen.getByText( + "This subscribe has no discount quantities and cannot be configured." + ) + ).not.toBeNull(); + expect(screen.getByRole("button", { name: "Confirm" })).toHaveProperty( + "disabled", + true + ); + }); + + it("keeps the form open and shows an error when promo price reaches original price", async () => { + const onSubmit = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByRole("button", { name: "Create Promo Price" })); + await selectCombobox(0, "Campaign Rule"); + await selectCombobox(1, "Monthly Plan"); + await selectCombobox(2, "3 x Month"); + fireEvent.change(screen.getByPlaceholderText("Enter price"), { + target: { value: "36" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Confirm" })); + + expect( + await screen.findByText("Promo price must be lower than original price") + ).not.toBeNull(); + expect(onSubmit).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/admin/src/sections/promo/price-form.tsx b/apps/admin/src/sections/promo/price-form.tsx index 012d728..c678285 100644 --- a/apps/admin/src/sections/promo/price-form.tsx +++ b/apps/admin/src/sections/promo/price-form.tsx @@ -23,11 +23,16 @@ import { useState } from "react"; import { useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { z } from "zod"; -import { getSubscribeUnitPrice } from "./utils"; +import { + formatCurrency, + getSubscribeDiscounts, + getSubscribeUnitPrice, +} from "./utils"; const priceSchema = z.object({ - rule_id: z.number().min(1), + promo_rule_id: z.number().min(1), subscribe_id: z.number().min(1), + quantity: z.number().min(1), promo_price: z.number().min(1), }); @@ -51,26 +56,42 @@ export default function PriceForm({ const form = useForm({ resolver: zodResolver(priceSchema), defaultValues: { - rule_id: 0, + promo_rule_id: 0, subscribe_id: 0, + quantity: 0, promo_price: 0, }, }); const subscribeId = form.watch("subscribe_id"); + const quantity = form.watch("quantity"); const subscribe = subscribes.find((item) => item.id === subscribeId); + const discounts = getSubscribeDiscounts(subscribe); const unitPrice = getSubscribeUnitPrice(subscribe); + const originalPrice = unitPrice * quantity; + const hasSelectedSubscribe = subscribeId > 0; + const hasDiscounts = discounts.length > 0; async function handleSubmit(values: PriceFormValues) { - if (unitPrice > 0 && values.promo_price >= unitPrice) { + const maxPrice = unitPrice * values.quantity; + if (maxPrice > 0 && values.promo_price >= maxPrice) { form.setError("promo_price", { message: t( - "form.promoPriceLessThanUnitPrice", - "Promo price must be lower than unit price" + "form.promoPriceLessThanOriginalPrice", + "Promo price must be lower than original price" ), }); return; } - const success = await onSubmit(values); + const success = await onSubmit({ + promo_rule_id: values.promo_rule_id, + items: [ + { + subscribe_id: values.subscribe_id, + quantity: values.quantity, + promo_price: values.promo_price, + }, + ], + }); if (success) setOpen(false); } @@ -79,7 +100,12 @@ export default function PriceForm({ diff --git a/apps/admin/src/sections/promo/utils.ts b/apps/admin/src/sections/promo/utils.ts index bd8a6b7..ea10b06 100644 --- a/apps/admin/src/sections/promo/utils.ts +++ b/apps/admin/src/sections/promo/utils.ts @@ -20,3 +20,9 @@ export function parseOptionalNumber(value?: string) { export function getSubscribeUnitPrice(subscribe?: API.SubscribeItem) { return typeof subscribe?.unit_price === "number" ? subscribe.unit_price : 0; } + +export function getSubscribeDiscounts(subscribe?: API.SubscribeItem) { + return (subscribe?.discount || []).filter( + (item) => typeof item.quantity === "number" && item.quantity > 0 + ); +} diff --git a/apps/user/public/assets/locales/en-US/subscribe.json b/apps/user/public/assets/locales/en-US/subscribe.json index bee2b6d..77526c0 100644 --- a/apps/user/public/assets/locales/en-US/subscribe.json +++ b/apps/user/public/assets/locales/en-US/subscribe.json @@ -37,6 +37,10 @@ "passwordHint": "If you do not enter a password, we will automatically generate one and send it to your email.", "paymentMethod": "Payment Method", "productDescription": "productDescription", + "promo": { + "available": "Promo Available", + "validUntil": "Valid until" + }, "purchaseDuration": "Purchase Duration", "recharge": "Recharge", "rechargeAmount": "Recharge Amount", diff --git a/apps/user/public/assets/locales/zh-CN/subscribe.json b/apps/user/public/assets/locales/zh-CN/subscribe.json index 0c94342..ece8ba7 100644 --- a/apps/user/public/assets/locales/zh-CN/subscribe.json +++ b/apps/user/public/assets/locales/zh-CN/subscribe.json @@ -37,6 +37,10 @@ "passwordHint": "如果您不输入密码,我们将自动生成一个并发送到您的邮箱。", "paymentMethod": "支付方式", "productDescription": "商品描述", + "promo": { + "available": "促销可用", + "validUntil": "有效期至" + }, "purchaseDuration": "购买时长", "recharge": "充值", "rechargeAmount": "充值金额", diff --git a/apps/user/src/sections/purchasing/content.tsx b/apps/user/src/sections/purchasing/content.tsx index cef21c1..759abf5 100644 --- a/apps/user/src/sections/purchasing/content.tsx +++ b/apps/user/src/sections/purchasing/content.tsx @@ -17,6 +17,7 @@ import CouponInput from "@/sections/subscribe/coupon-input"; import { SubscribeDetail } from "@/sections/subscribe/detail"; import DurationSelector from "@/sections/subscribe/duration-selector"; import PaymentMethods from "@/sections/subscribe/payment-methods"; +import { PromoInfo } from "@/sections/subscribe/promo-info"; import { useGlobalStore } from "@/stores/global"; export default function Content({ @@ -49,6 +50,9 @@ export default function Content({ valid: false, message: "", }); + const selectedDiscount = subscription?.discount?.find( + (item) => item.quantity === params.quantity + ); const { data: order } = useQuery({ enabled: !!subscription?.id && !!params.payment, @@ -103,7 +107,10 @@ export default function Content({ navigate({ to: "/purchasing/order", search: { order_no } }); } } catch (error) { - console.log(error); + setIsEmailValid((prev) => ({ + ...prev, + message: error instanceof Error ? error.message : prev.message, + })); } }); }, [params, navigate]); @@ -285,6 +292,7 @@ export default function Content({ show_original_price: subscription?.show_original_price, }} /> + diff --git a/apps/user/src/sections/subscribe/promo-info.tsx b/apps/user/src/sections/subscribe/promo-info.tsx new file mode 100644 index 0000000..d6f439e --- /dev/null +++ b/apps/user/src/sections/subscribe/promo-info.tsx @@ -0,0 +1,33 @@ +import { Badge } from "@workspace/ui/components/badge"; +import { useTranslation } from "react-i18next"; +import { Display } from "@/components/display"; + +interface PromoInfoProps { + discount?: API.SubscribeDiscount; +} + +export function PromoInfo({ discount }: Readonly) { + const { t } = useTranslation("subscribe"); + const promo = discount?.promo; + + if (!promo) return null; + + return ( +
+
+ + {promo.rule_name || t("promo.available", "Promo Available")} + + {promo.end_time && ( + + {t("promo.validUntil", "Valid until")}:{" "} + {new Date(promo.end_time * 1000).toLocaleString()} + + )} +
+ + + +
+ ); +} diff --git a/apps/user/src/sections/subscribe/purchase.tsx b/apps/user/src/sections/subscribe/purchase.tsx index af89b68..1b2d9f6 100644 --- a/apps/user/src/sections/subscribe/purchase.tsx +++ b/apps/user/src/sections/subscribe/purchase.tsx @@ -18,6 +18,7 @@ import { useTranslation } from "react-i18next"; import CouponInput from "@/sections/subscribe/coupon-input"; import DurationSelector from "@/sections/subscribe/duration-selector"; import PaymentMethods from "@/sections/subscribe/payment-methods"; +import { PromoInfo } from "@/sections/subscribe/promo-info"; import { useGlobalStore } from "@/stores/global"; import { SubscribeBilling } from "./billing"; import { SubscribeDetail } from "./detail"; @@ -41,7 +42,12 @@ export default function Purchase({ coupon: "", }); const [loading, startTransition] = useTransition(); - const lastSuccessOrderRef = useRef(null); + const lastSuccessOrderRef = useRef( + undefined + ); + const selectedDiscount = subscribe?.discount?.find( + (item) => item.quantity === params.quantity + ); const { data: order } = useQuery({ enabled: !!subscribe?.id, @@ -140,6 +146,7 @@ export default function Purchase({ show_original_price: subscribe?.show_original_price, }} /> +
diff --git a/packages/ui/src/services/admin/typings.d.ts b/packages/ui/src/services/admin/typings.d.ts index 602434f..90e4e26 100644 --- a/packages/ui/src/services/admin/typings.d.ts +++ b/packages/ui/src/services/admin/typings.d.ts @@ -307,27 +307,34 @@ declare namespace API { type PromoPrice = { id: number; - rule_id: number; + promo_rule_id: number; subscribe_id: number; subscribe_name?: string; + quantity: number; unit_price: number; promo_price: number; created_at?: number; updated_at?: number; }; - type CreatePromoPriceRequest = { - rule_id: number; + type CreatePromoPriceItem = { subscribe_id: number; + quantity: number; promo_price: number; }; + type CreatePromoPriceRequest = { + promo_rule_id: number; + items: CreatePromoPriceItem[]; + }; + type PromoUsage = { id: number; - rule_id: number; + promo_rule_id: number; rule_name?: string; subscribe_id: number; subscribe_name?: string; + quantity: number; user_id: number; order_no: string; unit_price: number; @@ -1020,7 +1027,7 @@ declare namespace API { type GetPromoPriceListParams = { page: number; size: number; - rule_id?: number; + promo_rule_id?: number; subscribe_id?: number; }; @@ -1036,7 +1043,7 @@ declare namespace API { type GetPromoUsageListParams = { page: number; size: number; - rule_id?: number; + promo_rule_id?: number; subscribe_id?: number; user_id?: number; order_no?: string; @@ -2219,6 +2226,13 @@ declare namespace API { quantity: number; discount: number; map_apple?: string; + promo?: SubscribeDiscountPromo; + }; + + type SubscribeDiscountPromo = { + rule_name?: string; + promo_price: number; + end_time?: number; }; type SubscribeGroup = { diff --git a/packages/ui/src/services/common/typings.d.ts b/packages/ui/src/services/common/typings.d.ts index 099059b..e4cce72 100644 --- a/packages/ui/src/services/common/typings.d.ts +++ b/packages/ui/src/services/common/typings.d.ts @@ -910,6 +910,13 @@ declare namespace API { quantity: number; discount: number; map_apple?: string; + promo?: SubscribeDiscountPromo; + }; + + type SubscribeDiscountPromo = { + rule_name?: string; + promo_price: number; + end_time?: number; }; type SubscribeGroup = { diff --git a/packages/ui/src/services/user/typings.d.ts b/packages/ui/src/services/user/typings.d.ts index 75a94ec..cfcc498 100644 --- a/packages/ui/src/services/user/typings.d.ts +++ b/packages/ui/src/services/user/typings.d.ts @@ -1019,6 +1019,13 @@ declare namespace API { quantity: number; discount: number; map_apple?: string; + promo?: SubscribeDiscountPromo; + }; + + type SubscribeDiscountPromo = { + rule_name?: string; + promo_price: number; + end_time?: number; }; type SubscribeGroup = {