diff --git a/apps/admin/public/assets/locales/en-US/menu.json b/apps/admin/public/assets/locales/en-US/menu.json index b0d651b..b478ab3 100644 --- a/apps/admin/public/assets/locales/en-US/menu.json +++ b/apps/admin/public/assets/locales/en-US/menu.json @@ -21,6 +21,7 @@ "Order Management": "Order Management", "Payment Config": "Payment Config", "Product Management": "Product Management", + "Promo Management": "Promo Management", "Redemption Management": "Redemption Management", "Register": "Register", "Reset Subscribe": "Reset Subscribe", diff --git a/apps/admin/public/assets/locales/en-US/promo.json b/apps/admin/public/assets/locales/en-US/promo.json new file mode 100644 index 0000000..e13104d --- /dev/null +++ b/apps/admin/public/assets/locales/en-US/promo.json @@ -0,0 +1,65 @@ +{ + "cancel": "Cancel", + "confirm": "Confirm", + "confirmDelete": "Are you sure you want to delete?", + "createPrice": "Create Promo Price", + "createRule": "Create Rule", + "createSuccess": "Create Success", + "delete": "Delete", + "deleteSuccess": "Delete Success", + "deleteWarning": "Once deleted, data cannot be recovered. Please proceed with caution.", + "detail": "Detail", + "edit": "Edit", + "editRule": "Edit Rule", + "emptyPrices": "No promo prices", + "emptyRules": "No promo rules", + "emptyUsage": "No promo usage records", + "enabled": "Enabled", + "endTime": "End Time", + "form": { + "enterPromoPrice": "Enter price", + "inactiveMonths": "Inactive Months", + "inactiveMonthsPlaceholder": "Months without purchase", + "name": "Name", + "namePlaceholder": "Rule name", + "promoPriceLessThanUnitPrice": "Promo price must be lower than unit price", + "selectEndTime": "Select time", + "selectRule": "Select rule", + "selectStartTime": "Select time", + "selectSubscribe": "Select subscribe", + "type": "Rule Type", + "windowHours": "Window Hours", + "windowHoursPlaceholder": "Hours after registration" + }, + "loadErrorDescription": "Refresh the table or try again later.", + "loadPricesError": "Failed to load promo prices", + "loadRulesError": "Failed to load promo rules", + "loadUsageError": "Failed to load promo usage", + "name": "Name", + "no": "No", + "orderNo": "Order No.", + "params": "Params", + "prices": "Prices", + "priority": "Priority", + "promoPrice": "Promo Price", + "rule": "Rule", + "ruleDetail": "Rule Detail", + "rules": "Rules", + "searchRule": "Rule name", + "startTime": "Start Time", + "subscribe": "Subscribe", + "type": "Type", + "types": { + "campaign": "Campaign", + "inactive_user": "Inactive User", + "new_user": "New User" + }, + "unitPrice": "Unit Price", + "unitPriceHint": "Unit price", + "updateSuccess": "Update Success", + "usage": "Usage", + "usedAt": "Used At", + "userId": "User ID", + "validityPeriod": "Validity Period", + "yes": "Yes" +} diff --git a/apps/admin/public/assets/locales/zh-CN/menu.json b/apps/admin/public/assets/locales/zh-CN/menu.json index 4d7048a..9e06313 100644 --- a/apps/admin/public/assets/locales/zh-CN/menu.json +++ b/apps/admin/public/assets/locales/zh-CN/menu.json @@ -21,6 +21,7 @@ "Order Management": "订单管理", "Payment Config": "支付配置", "Product Management": "商品管理", + "Promo Management": "促销管理", "Redemption Management": "兑换码管理", "Register": "注册", "Reset Subscribe": "重置订阅", diff --git a/apps/admin/public/assets/locales/zh-CN/promo.json b/apps/admin/public/assets/locales/zh-CN/promo.json new file mode 100644 index 0000000..e95d07a --- /dev/null +++ b/apps/admin/public/assets/locales/zh-CN/promo.json @@ -0,0 +1,65 @@ +{ + "cancel": "取消", + "confirm": "确认", + "confirmDelete": "确定要删除吗?", + "createPrice": "创建促销价", + "createRule": "创建规则", + "createSuccess": "创建成功", + "delete": "删除", + "deleteSuccess": "删除成功", + "deleteWarning": "删除后数据无法恢复,请谨慎操作。", + "detail": "详情", + "edit": "编辑", + "editRule": "编辑规则", + "emptyPrices": "暂无商品促销价", + "emptyRules": "暂无促销规则", + "emptyUsage": "暂无促销使用记录", + "enabled": "启用", + "endTime": "结束时间", + "form": { + "enterPromoPrice": "输入价格", + "inactiveMonths": "未活跃月数", + "inactiveMonthsPlaceholder": "未购买的月数", + "name": "名称", + "namePlaceholder": "规则名称", + "promoPriceLessThanUnitPrice": "促销价必须低于原价", + "selectEndTime": "选择时间", + "selectRule": "选择规则", + "selectStartTime": "选择时间", + "selectSubscribe": "选择订阅", + "type": "规则类型", + "windowHours": "新用户窗口小时数", + "windowHoursPlaceholder": "注册后的小时数" + }, + "loadErrorDescription": "请刷新表格或稍后重试。", + "loadPricesError": "促销价加载失败", + "loadRulesError": "促销规则加载失败", + "loadUsageError": "促销使用记录加载失败", + "name": "名称", + "no": "否", + "orderNo": "订单号", + "params": "参数", + "prices": "商品促销价", + "priority": "优先级", + "promoPrice": "促销价", + "rule": "规则", + "ruleDetail": "规则详情", + "rules": "促销规则", + "searchRule": "规则名称", + "startTime": "开始时间", + "subscribe": "订阅", + "type": "类型", + "types": { + "campaign": "活动", + "inactive_user": "回归用户", + "new_user": "新用户" + }, + "unitPrice": "原价", + "unitPriceHint": "原价", + "updateSuccess": "更新成功", + "usage": "使用记录", + "usedAt": "使用时间", + "userId": "用户 ID", + "validityPeriod": "有效期", + "yes": "是" +} diff --git a/apps/admin/src/layout/navs.ts b/apps/admin/src/layout/navs.ts index 098bfe0..cbf7b12 100644 --- a/apps/admin/src/layout/navs.ts +++ b/apps/admin/src/layout/navs.ts @@ -49,6 +49,11 @@ export function useNavs() { url: "/dashboard/product", icon: "flat-color-icons:shop", }, + { + title: t("Promo Management", "Promo Management"), + url: "/dashboard/promo", + icon: "flat-color-icons:briefcase", + }, ], }, diff --git a/apps/admin/src/main.tsx b/apps/admin/src/main.tsx index 6a9224a..feaee70 100644 --- a/apps/admin/src/main.tsx +++ b/apps/admin/src/main.tsx @@ -42,6 +42,7 @@ initializeI18n({ "nodes", "order", "payment", + "promo", "product", "servers", "subscribe", diff --git a/apps/admin/src/routeTree.gen.ts b/apps/admin/src/routeTree.gen.ts index da5d6cb..d352d91 100644 --- a/apps/admin/src/routeTree.gen.ts +++ b/apps/admin/src/routeTree.gen.ts @@ -34,6 +34,8 @@ const DashboardSubscribeIndexLazyRouteImport = createFileRoute( const DashboardRedemptionIndexLazyRouteImport = createFileRoute( '/dashboard/redemption/', )() +const DashboardPromoIndexLazyRouteImport = + createFileRoute('/dashboard/promo/')() const DashboardProductIndexLazyRouteImport = createFileRoute( '/dashboard/product/', )() @@ -186,6 +188,13 @@ const DashboardRedemptionIndexLazyRoute = } as any).lazy(() => import('./routes/dashboard/redemption/index.lazy').then((d) => d.Route), ) +const DashboardPromoIndexLazyRoute = DashboardPromoIndexLazyRouteImport.update({ + id: '/promo/', + path: '/promo/', + getParentRoute: () => DashboardRouteLazyRoute, +} as any).lazy(() => + import('./routes/dashboard/promo/index.lazy').then((d) => d.Route), +) const DashboardProductIndexLazyRoute = DashboardProductIndexLazyRouteImport.update({ id: '/product/', @@ -406,6 +415,7 @@ export interface FileRoutesByFullPath { '/dashboard/order': typeof DashboardOrderIndexLazyRoute '/dashboard/payment': typeof DashboardPaymentIndexLazyRoute '/dashboard/product': typeof DashboardProductIndexLazyRoute + '/dashboard/promo': typeof DashboardPromoIndexLazyRoute '/dashboard/redemption': typeof DashboardRedemptionIndexLazyRoute '/dashboard/subscribe': typeof DashboardSubscribeIndexLazyRoute '/dashboard/system': typeof DashboardSystemIndexLazyRoute @@ -442,6 +452,7 @@ export interface FileRoutesByTo { '/dashboard/order': typeof DashboardOrderIndexLazyRoute '/dashboard/payment': typeof DashboardPaymentIndexLazyRoute '/dashboard/product': typeof DashboardProductIndexLazyRoute + '/dashboard/promo': typeof DashboardPromoIndexLazyRoute '/dashboard/redemption': typeof DashboardRedemptionIndexLazyRoute '/dashboard/subscribe': typeof DashboardSubscribeIndexLazyRoute '/dashboard/system': typeof DashboardSystemIndexLazyRoute @@ -481,6 +492,7 @@ export interface FileRoutesById { '/dashboard/order/': typeof DashboardOrderIndexLazyRoute '/dashboard/payment/': typeof DashboardPaymentIndexLazyRoute '/dashboard/product/': typeof DashboardProductIndexLazyRoute + '/dashboard/promo/': typeof DashboardPromoIndexLazyRoute '/dashboard/redemption/': typeof DashboardRedemptionIndexLazyRoute '/dashboard/subscribe/': typeof DashboardSubscribeIndexLazyRoute '/dashboard/system/': typeof DashboardSystemIndexLazyRoute @@ -521,6 +533,7 @@ export interface FileRouteTypes { | '/dashboard/order' | '/dashboard/payment' | '/dashboard/product' + | '/dashboard/promo' | '/dashboard/redemption' | '/dashboard/subscribe' | '/dashboard/system' @@ -557,6 +570,7 @@ export interface FileRouteTypes { | '/dashboard/order' | '/dashboard/payment' | '/dashboard/product' + | '/dashboard/promo' | '/dashboard/redemption' | '/dashboard/subscribe' | '/dashboard/system' @@ -595,6 +609,7 @@ export interface FileRouteTypes { | '/dashboard/order/' | '/dashboard/payment/' | '/dashboard/product/' + | '/dashboard/promo/' | '/dashboard/redemption/' | '/dashboard/subscribe/' | '/dashboard/system/' @@ -694,6 +709,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DashboardRedemptionIndexLazyRouteImport parentRoute: typeof DashboardRouteLazyRoute } + '/dashboard/promo/': { + id: '/dashboard/promo/' + path: '/promo' + fullPath: '/dashboard/promo' + preLoaderRoute: typeof DashboardPromoIndexLazyRouteImport + parentRoute: typeof DashboardRouteLazyRoute + } '/dashboard/product/': { id: '/dashboard/product/' path: '/product' @@ -908,6 +930,7 @@ interface DashboardRouteLazyRouteChildren { DashboardOrderIndexLazyRoute: typeof DashboardOrderIndexLazyRoute DashboardPaymentIndexLazyRoute: typeof DashboardPaymentIndexLazyRoute DashboardProductIndexLazyRoute: typeof DashboardProductIndexLazyRoute + DashboardPromoIndexLazyRoute: typeof DashboardPromoIndexLazyRoute DashboardRedemptionIndexLazyRoute: typeof DashboardRedemptionIndexLazyRoute DashboardSubscribeIndexLazyRoute: typeof DashboardSubscribeIndexLazyRoute DashboardSystemIndexLazyRoute: typeof DashboardSystemIndexLazyRoute @@ -945,6 +968,7 @@ const DashboardRouteLazyRouteChildren: DashboardRouteLazyRouteChildren = { DashboardOrderIndexLazyRoute: DashboardOrderIndexLazyRoute, DashboardPaymentIndexLazyRoute: DashboardPaymentIndexLazyRoute, DashboardProductIndexLazyRoute: DashboardProductIndexLazyRoute, + DashboardPromoIndexLazyRoute: DashboardPromoIndexLazyRoute, DashboardRedemptionIndexLazyRoute: DashboardRedemptionIndexLazyRoute, DashboardSubscribeIndexLazyRoute: DashboardSubscribeIndexLazyRoute, DashboardSystemIndexLazyRoute: DashboardSystemIndexLazyRoute, diff --git a/apps/admin/src/routes/dashboard/promo/index.lazy.tsx b/apps/admin/src/routes/dashboard/promo/index.lazy.tsx new file mode 100644 index 0000000..3893fb1 --- /dev/null +++ b/apps/admin/src/routes/dashboard/promo/index.lazy.tsx @@ -0,0 +1,6 @@ +import { createLazyFileRoute } from "@tanstack/react-router"; +import PromoPage from "@/sections/promo"; + +export const Route = createLazyFileRoute("/dashboard/promo/")({ + component: PromoPage, +}); diff --git a/apps/admin/src/sections/promo/index.test.tsx b/apps/admin/src/sections/promo/index.test.tsx new file mode 100644 index 0000000..6fc5b1b --- /dev/null +++ b/apps/admin/src/sections/promo/index.test.tsx @@ -0,0 +1,155 @@ +/** + * @vitest-environment jsdom + */ +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { getPromoRuleList } from "@workspace/ui/services/admin/promo"; +import type { AxiosResponse } from "axios"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import PromoPage from "."; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (_key: string, fallback: string): string => fallback, + }), +})); + +vi.mock("@/stores/subscribe", () => ({ + useSubscribe: () => ({ + subscribes: [ + { + id: 8, + name: "Monthly Plan", + unit_price: 1200, + sold: 0, + }, + ], + getSubscribeName: (id?: number) => (id === 8 ? "Monthly Plan" : "--"), + }), +})); + +vi.mock("@/utils/common", () => ({ + formatDate: (timestamp: number) => `date-${timestamp}`, +})); + +vi.mock("@workspace/ui/services/admin/promo", () => ({ + createPromoPrice: vi.fn(), + createPromoRule: vi.fn(), + deletePromoPrice: vi.fn(), + deletePromoRule: vi.fn(), + getPromoPriceList: vi.fn(), + getPromoRuleList: vi.fn(), + getPromoUsageList: vi.fn(), + updatePromoRule: vi.fn(), +})); + +const mockedGetPromoRuleList = vi.mocked(getPromoRuleList); + +function createRuleListResponse( + data: API.GetPromoRuleListResponse +): AxiosResponse { + return { + data: { + code: 200, + data, + }, + status: 200, + statusText: "OK", + headers: {}, + config: { + headers: {} as AxiosResponse["config"]["headers"], + }, + }; +} + +beforeEach(() => { + localStorage.setItem("timezone", "UTC"); +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("PromoPage", () => { + it("renders promo rules with type, priority, status, and validity period", async () => { + mockedGetPromoRuleList.mockResolvedValue( + createRuleListResponse({ + total: 1, + list: [ + { + id: 1, + name: "Return User Price", + type: "inactive_user", + params: { inactive_months: 3 }, + priority: 5, + enabled: true, + start_time: 1_716_800_000, + end_time: 1_716_900_000, + }, + ], + }) + ); + + render(); + + await waitFor(() => + expect(mockedGetPromoRuleList).toHaveBeenCalledWith({ + page: 1, + size: 200, + search: undefined, + type: undefined, + enabled: undefined, + }) + ); + expect(await screen.findByText("Return User Price")).not.toBeNull(); + expect(screen.getByText("inactive_user")).not.toBeNull(); + expect(screen.getByText("5")).not.toBeNull(); + expect(screen.getByText("date-1716800000")).not.toBeNull(); + expect(screen.getByText("date-1716900000")).not.toBeNull(); + }); + + it("shows the empty state when there are no promo rules", async () => { + mockedGetPromoRuleList.mockResolvedValue( + createRuleListResponse({ + total: 0, + list: [], + }) + ); + + render(); + + expect(await screen.findByText("No promo rules")).not.toBeNull(); + }); + + it("shows a loading state while promo rules are being fetched", () => { + let resolveRequest: + | (( + value: AxiosResponse< + API.Response & { data?: API.GetPromoRuleListResponse } + > + ) => void) + | undefined; + mockedGetPromoRuleList.mockReturnValue( + new Promise((resolve) => { + resolveRequest = resolve; + }) + ); + + render(); + + expect(screen.getByRole("status", { name: "Loading data" })).not.toBeNull(); + expect(resolveRequest).toBeTypeOf("function"); + }); + + it("shows an error state when promo rules fail to load", async () => { + mockedGetPromoRuleList.mockRejectedValue(new Error("network failed")); + + render(); + + await waitFor(() => expect(mockedGetPromoRuleList).toHaveBeenCalled()); + expect( + await screen.findByText("Failed to load promo rules") + ).not.toBeNull(); + expect(screen.queryByText("No promo rules")).toBeNull(); + }); +}); diff --git a/apps/admin/src/sections/promo/index.tsx b/apps/admin/src/sections/promo/index.tsx new file mode 100644 index 0000000..71083f1 --- /dev/null +++ b/apps/admin/src/sections/promo/index.tsx @@ -0,0 +1,493 @@ +import { + Alert, + AlertDescription, + AlertTitle, +} from "@workspace/ui/components/alert"; +import { Badge } from "@workspace/ui/components/badge"; +import { Button } from "@workspace/ui/components/button"; +import { Switch } from "@workspace/ui/components/switch"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@workspace/ui/components/tabs"; +import { ConfirmButton } from "@workspace/ui/composed/confirm-button"; +import Empty from "@workspace/ui/composed/empty"; +import { + ProTable, + type ProTableActions, +} from "@workspace/ui/composed/pro-table/pro-table"; +import { + createPromoPrice, + createPromoRule, + deletePromoPrice, + deletePromoRule, + getPromoPriceList, + getPromoRuleList, + getPromoUsageList, + updatePromoRule, +} from "@workspace/ui/services/admin/promo"; +import { CircleAlert } from "lucide-react"; +import { useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { useSubscribe } from "@/stores/subscribe"; +import { formatDate } from "@/utils/common"; +import PriceForm from "./price-form"; +import RuleDetail from "./rule-detail"; +import RuleForm from "./rule-form"; +import { formatCurrency, parseOptionalNumber, promoRuleTypes } from "./utils"; + +type RuleFilters = { + search?: string; + type?: API.PromoRuleType; + enabled?: string; +}; + +type PriceFilters = { + rule_id?: string; + subscribe_id?: string; +}; + +type UsageFilters = PriceFilters & { + user_id?: string; + order_no?: string; +}; + +function ErrorState({ + title, + description, +}: { + title: string; + description: string; +}) { + return ( + + + ); +} + +export default function PromoPage() { + const { t } = useTranslation("promo"); + const { subscribes, getSubscribeName } = useSubscribe(); + const [loading, setLoading] = useState(false); + const [rules, setRules] = useState([]); + const [detailRule, setDetailRule] = useState(); + const [detailOpen, setDetailOpen] = useState(false); + const ruleRef = useRef(null); + const priceRef = useRef(null); + const usageRef = useRef(null); + + return ( + <> + + + {t("rules", "Rules")} + {t("prices", "Prices")} + {t("usage", "Usage")} + + + + + action={ruleRef} + actions={{ + render: (row) => [ + , + { + setLoading(true); + try { + await updatePromoRule({ id: row.id }, values); + toast.success(t("updateSuccess", "Update Success")); + ruleRef.current?.refresh(); + setLoading(false); + return true; + } catch (_error) { + setLoading(false); + return false; + } + }} + title={t("editRule", "Edit Rule")} + trigger={t("edit", "Edit")} + />, + { + await deletePromoRule({ id: row.id }); + toast.success(t("deleteSuccess", "Delete Success")); + ruleRef.current?.refresh(); + }} + title={t("confirmDelete", "Are you sure you want to delete?")} + trigger={ + + } + />, + ], + }} + columns={[ + { + accessorKey: "name", + header: t("name", "Name"), + }, + { + accessorKey: "type", + header: t("type", "Type"), + cell: ({ row }) => ( + + {t(`types.${row.original.type}`, row.original.type)} + + ), + }, + { + accessorKey: "priority", + header: t("priority", "Priority"), + }, + { + accessorKey: "enabled", + header: t("enabled", "Enabled"), + cell: ({ row }) => ( + { + await updatePromoRule( + { id: row.original.id }, + { ...row.original, enabled: checked } + ); + toast.success(t("updateSuccess", "Update Success")); + ruleRef.current?.refresh(); + }} + /> + ), + }, + { + accessorKey: "start_time", + header: t("startTime", "Start Time"), + cell: ({ row }) => + row.original.start_time + ? formatDate(row.original.start_time) + : "--", + }, + { + accessorKey: "end_time", + header: t("endTime", "End Time"), + cell: ({ row }) => + row.original.end_time + ? formatDate(row.original.end_time) + : "--", + }, + ]} + empty={} + error={ + + } + header={{ + toolbar: ( + { + setLoading(true); + try { + await createPromoRule(values); + toast.success(t("createSuccess", "Create Success")); + ruleRef.current?.refresh(); + setLoading(false); + return true; + } catch (_error) { + setLoading(false); + return false; + } + }} + title={t("createRule", "Create Rule")} + trigger={t("createRule", "Create Rule")} + /> + ), + }} + params={[ + { key: "search", placeholder: t("searchRule", "Rule name") }, + { + key: "type", + placeholder: t("type", "Type"), + options: promoRuleTypes.map((type) => ({ + label: t(`types.${type}`, type), + value: type, + })), + }, + { + key: "enabled", + placeholder: t("enabled", "Enabled"), + options: [ + { label: t("yes", "Yes"), value: "true" }, + { label: t("no", "No"), value: "false" }, + ], + }, + ]} + request={async (pagination, filters) => { + const { data } = await getPromoRuleList({ + ...pagination, + search: filters.search?.trim() || undefined, + type: filters.type, + enabled: + filters.enabled === undefined || filters.enabled === "" + ? undefined + : filters.enabled === "true", + }); + const list = data.data?.list || []; + setRules(list); + return { + list, + total: data.data?.total || 0, + }; + }} + /> + + + + + action={priceRef} + actions={{ + render: (row) => [ + { + await deletePromoPrice({ id: row.id }); + toast.success(t("deleteSuccess", "Delete Success")); + priceRef.current?.refresh(); + }} + title={t("confirmDelete", "Are you sure you want to delete?")} + trigger={ + + } + />, + ], + }} + columns={[ + { + accessorKey: "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}` + : "--", + }, + { + accessorKey: "subscribe_id", + header: t("subscribe", "Subscribe"), + cell: ({ row }) => + row.original.subscribe_name || + getSubscribeName(row.original.subscribe_id), + }, + { + accessorKey: "unit_price", + header: t("unitPrice", "Unit Price"), + cell: ({ row }) => formatCurrency(row.original.unit_price), + }, + { + accessorKey: "promo_price", + header: t("promoPrice", "Promo Price"), + cell: ({ row }) => formatCurrency(row.original.promo_price), + }, + ]} + empty={} + error={ + + } + header={{ + toolbar: ( + { + setLoading(true); + try { + await createPromoPrice(values); + toast.success(t("createSuccess", "Create Success")); + priceRef.current?.refresh(); + setLoading(false); + return true; + } catch (_error) { + setLoading(false); + return false; + } + }} + rules={rules} + subscribes={subscribes} + /> + ), + }} + params={[ + { + key: "rule_id", + placeholder: t("rule", "Rule"), + options: rules.map((rule) => ({ + label: rule.name, + value: String(rule.id), + })), + }, + { + key: "subscribe_id", + placeholder: t("subscribe", "Subscribe"), + options: subscribes + .filter((item) => typeof item.id === "number") + .map((item) => ({ + label: item.name || `#${item.id}`, + value: String(item.id), + })), + }, + ]} + request={async (pagination, filters) => { + const { data } = await getPromoPriceList({ + ...pagination, + rule_id: parseOptionalNumber(filters.rule_id), + subscribe_id: parseOptionalNumber(filters.subscribe_id), + }); + return { + list: data.data?.list || [], + total: data.data?.total || 0, + }; + }} + /> + + + + + action={usageRef} + columns={[ + { + accessorKey: "rule_id", + header: t("rule", "Rule"), + cell: ({ row }) => + row.original.rule_name || + rules.find((rule) => rule.id === row.original.rule_id) + ?.name || + `#${row.original.rule_id}`, + }, + { + accessorKey: "user_id", + header: t("userId", "User ID"), + cell: ({ row }) => `#${row.original.user_id}`, + }, + { + accessorKey: "subscribe_id", + header: t("subscribe", "Subscribe"), + cell: ({ row }) => + row.original.subscribe_name || + getSubscribeName(row.original.subscribe_id), + }, + { + accessorKey: "order_no", + header: t("orderNo", "Order No."), + }, + { + accessorKey: "promo_price", + header: t("promoPrice", "Promo Price"), + cell: ({ row }) => formatCurrency(row.original.promo_price), + }, + { + accessorKey: "used_at", + header: t("usedAt", "Used At"), + cell: ({ row }) => formatDate(row.original.used_at), + }, + ]} + empty={ + + } + error={ + + } + params={[ + { + key: "rule_id", + placeholder: t("rule", "Rule"), + options: rules.map((rule) => ({ + label: rule.name, + value: String(rule.id), + })), + }, + { + key: "subscribe_id", + placeholder: t("subscribe", "Subscribe"), + options: subscribes + .filter((item) => typeof item.id === "number") + .map((item) => ({ + label: item.name || `#${item.id}`, + value: String(item.id), + })), + }, + { key: "user_id", placeholder: t("userId", "User ID") }, + { key: "order_no", placeholder: t("orderNo", "Order No.") }, + ]} + request={async (pagination, filters) => { + const { data } = await getPromoUsageList({ + ...pagination, + rule_id: parseOptionalNumber(filters.rule_id), + subscribe_id: parseOptionalNumber(filters.subscribe_id), + user_id: parseOptionalNumber(filters.user_id), + order_no: filters.order_no?.trim() || undefined, + }); + return { + list: data.data?.list || [], + total: data.data?.total || 0, + }; + }} + /> + + + + + ); +} diff --git a/apps/admin/src/sections/promo/price-form.tsx b/apps/admin/src/sections/promo/price-form.tsx new file mode 100644 index 0000000..012d728 --- /dev/null +++ b/apps/admin/src/sections/promo/price-form.tsx @@ -0,0 +1,188 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { Button } from "@workspace/ui/components/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@workspace/ui/components/form"; +import { + Sheet, + SheetContent, + SheetFooter, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@workspace/ui/components/sheet"; +import { Combobox } from "@workspace/ui/composed/combobox"; +import { EnhancedInput } from "@workspace/ui/composed/enhanced-input"; +import { unitConversion } from "@workspace/ui/utils/unit-conversions"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { z } from "zod"; +import { getSubscribeUnitPrice } from "./utils"; + +const priceSchema = z.object({ + rule_id: z.number().min(1), + subscribe_id: z.number().min(1), + promo_price: z.number().min(1), +}); + +type PriceFormValues = z.infer; + +interface PriceFormProps { + loading?: boolean; + rules: API.PromoRule[]; + subscribes: API.SubscribeItem[]; + onSubmit: (data: API.CreatePromoPriceRequest) => Promise | boolean; +} + +export default function PriceForm({ + loading, + rules, + subscribes, + onSubmit, +}: PriceFormProps) { + const { t } = useTranslation("promo"); + const [open, setOpen] = useState(false); + const form = useForm({ + resolver: zodResolver(priceSchema), + defaultValues: { + rule_id: 0, + subscribe_id: 0, + promo_price: 0, + }, + }); + const subscribeId = form.watch("subscribe_id"); + const subscribe = subscribes.find((item) => item.id === subscribeId); + const unitPrice = getSubscribeUnitPrice(subscribe); + + async function handleSubmit(values: PriceFormValues) { + if (unitPrice > 0 && values.promo_price >= unitPrice) { + form.setError("promo_price", { + message: t( + "form.promoPriceLessThanUnitPrice", + "Promo price must be lower than unit price" + ), + }); + return; + } + const success = await onSubmit(values); + if (success) setOpen(false); + } + + return ( + + + + + + + {t("createPrice", "Create Promo Price")} + +
+ + ( + + {t("rule", "Rule")} + + + onChange={field.onChange} + options={rules.map((rule) => ({ + label: rule.name, + value: rule.id, + }))} + placeholder={t("form.selectRule", "Select rule")} + value={field.value} + /> + + + + )} + /> + ( + + {t("subscribe", "Subscribe")} + + + onChange={field.onChange} + options={subscribes + .filter((item) => typeof item.id === "number") + .map((item) => ({ + label: item.name || `#${item.id}`, + value: item.id as number, + }))} + placeholder={t( + "form.selectSubscribe", + "Select subscribe" + )} + value={field.value} + /> + + + + )} + /> + ( + + {t("promoPrice", "Promo Price")} + + + unitConversion("centsToDollars", value) + } + formatOutput={(value) => + unitConversion("dollarsToCents", value) + } + min={0.01} + onValueChange={(value) => field.onChange(value)} + placeholder={t("form.enterPromoPrice", "Enter price")} + type="number" + value={field.value} + /> + + {unitPrice > 0 && ( +

+ {t("unitPriceHint", "Unit price")}:{" "} + {unitConversion("centsToDollars", unitPrice)} +

+ )} + +
+ )} + /> + + + + + +
+
+ ); +} diff --git a/apps/admin/src/sections/promo/rule-detail.tsx b/apps/admin/src/sections/promo/rule-detail.tsx new file mode 100644 index 0000000..262147e --- /dev/null +++ b/apps/admin/src/sections/promo/rule-detail.tsx @@ -0,0 +1,84 @@ +import { Badge } from "@workspace/ui/components/badge"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, +} from "@workspace/ui/components/sheet"; +import { + Table, + TableBody, + TableCell, + TableRow, +} from "@workspace/ui/components/table"; +import { useTranslation } from "react-i18next"; +import { formatDate } from "@/utils/common"; + +interface RuleDetailProps { + open: boolean; + rule?: API.PromoRule; + onOpenChange: (open: boolean) => void; +} + +export default function RuleDetail({ + open, + rule, + onOpenChange, +}: RuleDetailProps) { + const { t } = useTranslation("promo"); + + return ( + + + + + {rule?.name || t("ruleDetail", "Rule Detail")} + + + {rule && ( +
+ + + + {t("type", "Type")} + + + {t(`types.${rule.type}`, rule.type)} + + + + + {t("priority", "Priority")} + {rule.priority} + + + {t("enabled", "Enabled")} + + {rule.enabled ? t("yes", "Yes") : t("no", "No")} + + + + {t("params", "Params")} + + + {JSON.stringify(rule.params || {}, null, 2)} + + + + + + {t("validityPeriod", "Validity Period")} + + + {rule.start_time ? formatDate(rule.start_time) : "--"} -{" "} + {rule.end_time ? formatDate(rule.end_time) : "--"} + + + +
+
+ )} +
+
+ ); +} diff --git a/apps/admin/src/sections/promo/rule-form.tsx b/apps/admin/src/sections/promo/rule-form.tsx new file mode 100644 index 0000000..23404d1 --- /dev/null +++ b/apps/admin/src/sections/promo/rule-form.tsx @@ -0,0 +1,353 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { Button } from "@workspace/ui/components/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@workspace/ui/components/form"; +import { + RadioGroup, + RadioGroupItem, +} from "@workspace/ui/components/radio-group"; +import { ScrollArea } from "@workspace/ui/components/scroll-area"; +import { + Sheet, + SheetContent, + SheetFooter, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@workspace/ui/components/sheet"; +import { Switch } from "@workspace/ui/components/switch"; +import { DatePicker } from "@workspace/ui/composed/date-picker"; +import { EnhancedInput } from "@workspace/ui/composed/enhanced-input"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { z } from "zod"; +import { promoRuleTypes } from "./utils"; + +const ruleSchema = z + .object({ + name: z.string().min(1), + type: z.enum(["new_user", "inactive_user", "campaign"]), + inactive_months: z.number().optional(), + window_hours: z.number().optional(), + priority: z.number().min(0), + enabled: z.boolean(), + start_time: z.number().optional(), + end_time: z.number().optional(), + }) + .superRefine((data, ctx) => { + if ( + data.start_time !== undefined && + data.end_time !== undefined && + data.start_time >= data.end_time + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "End time must be later than start time", + path: ["end_time"], + }); + } + if ( + data.type === "inactive_user" && + (!data.inactive_months || data.inactive_months <= 0) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Inactive months must be greater than 0", + path: ["inactive_months"], + }); + } + if ( + data.type === "new_user" && + (!data.window_hours || data.window_hours <= 0) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Window hours must be greater than 0", + path: ["window_hours"], + }); + } + }); + +type RuleFormValues = z.infer; + +interface RuleFormProps { + initialValues?: API.PromoRule; + loading?: boolean; + onSubmit: (data: API.CreatePromoRuleRequest) => Promise | boolean; + title: string; + trigger: string; +} + +function toFormValues(rule?: API.PromoRule): RuleFormValues { + return { + name: rule?.name ?? "", + type: rule?.type ?? "inactive_user", + inactive_months: rule?.params?.inactive_months, + window_hours: rule?.params?.window_hours, + priority: rule?.priority ?? 0, + enabled: rule?.enabled ?? false, + start_time: rule?.start_time, + end_time: rule?.end_time, + }; +} + +function toRequest(values: RuleFormValues): API.CreatePromoRuleRequest { + const params: API.PromoRuleParams = + values.type === "inactive_user" + ? { inactive_months: values.inactive_months } + : values.type === "new_user" + ? { window_hours: values.window_hours } + : {}; + + return { + name: values.name.trim(), + type: values.type, + params, + priority: values.priority, + enabled: values.enabled, + start_time: values.start_time || undefined, + end_time: values.end_time || undefined, + }; +} + +export default function RuleForm({ + initialValues, + loading, + onSubmit, + title, + trigger, +}: RuleFormProps) { + const { t } = useTranslation("promo"); + const [open, setOpen] = useState(false); + const form = useForm({ + resolver: zodResolver(ruleSchema), + defaultValues: toFormValues(initialValues), + }); + const type = form.watch("type"); + + useEffect(() => { + form.reset(toFormValues(initialValues)); + }, [form, initialValues]); + + async function handleSubmit(values: RuleFormValues) { + const success = await onSubmit(toRequest(values)); + if (success) setOpen(false); + } + + return ( + + + + + + + {title} + + +
+ + ( + + {t("form.name", "Name")} + + field.onChange(value)} + placeholder={t("form.namePlaceholder", "Rule name")} + value={field.value} + /> + + + + )} + /> + ( + + {t("form.type", "Rule Type")} + + + field.onChange(value as API.PromoRuleType) + } + value={field.value} + > + {promoRuleTypes.map((item) => ( + + + + + + {t(`types.${item}`, item)} + + + ))} + + + + + )} + /> + {type === "inactive_user" && ( + ( + + + {t("form.inactiveMonths", "Inactive Months")} + + + field.onChange(value)} + placeholder={t( + "form.inactiveMonthsPlaceholder", + "Months without purchase" + )} + type="number" + value={field.value} + /> + + + + )} + /> + )} + {type === "new_user" && ( + ( + + + {t("form.windowHours", "Window Hours")} + + + field.onChange(value)} + placeholder={t( + "form.windowHoursPlaceholder", + "Hours after registration" + )} + type="number" + value={field.value} + /> + + + + )} + /> + )} + ( + + {t("priority", "Priority")} + + field.onChange(value)} + placeholder="0" + type="number" + value={field.value} + /> + + + + )} + /> +
+ ( + + {t("startTime", "Start Time")} + + field.onChange(value)} + placeholder={t("form.selectStartTime", "Select time")} + value={field.value} + /> + + + + )} + /> + ( + + {t("endTime", "End Time")} + + field.onChange(value)} + placeholder={t("form.selectEndTime", "Select time")} + value={field.value} + /> + + + + )} + /> +
+ ( + +
+ {t("enabled", "Enabled")} +
+ + + +
+ )} + /> + + +
+ + + +
+
+ ); +} diff --git a/apps/admin/src/sections/promo/utils.ts b/apps/admin/src/sections/promo/utils.ts new file mode 100644 index 0000000..bd8a6b7 --- /dev/null +++ b/apps/admin/src/sections/promo/utils.ts @@ -0,0 +1,22 @@ +import { unitConversion } from "@workspace/ui/utils/unit-conversions"; + +export const promoRuleTypes: API.PromoRuleType[] = [ + "inactive_user", + "new_user", + "campaign", +]; + +export function formatCurrency(value?: number) { + if (typeof value !== "number") return "--"; + return `$${unitConversion("centsToDollars", value)}`; +} + +export function parseOptionalNumber(value?: string) { + if (!value) return; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +export function getSubscribeUnitPrice(subscribe?: API.SubscribeItem) { + return typeof subscribe?.unit_price === "number" ? subscribe.unit_price : 0; +} diff --git a/apps/admin/src/sections/user/user-subscription/index.tsx b/apps/admin/src/sections/user/user-subscription/index.tsx index 36793e0..caa5c46 100644 --- a/apps/admin/src/sections/user/user-subscription/index.tsx +++ b/apps/admin/src/sections/user/user-subscription/index.tsx @@ -278,6 +278,17 @@ export default function UserSubscription({ userId }: { userId: number }) { /> ), }, + { + accessorKey: "traffic_limit", + header: t("userTrafficLimit", "User Traffic Limit"), + cell: ({ row }) => ( + + ), + }, { id: "remaining_traffic", header: t("remainingTraffic", "Remaining Traffic"), @@ -296,15 +307,19 @@ export default function UserSubscription({ userId }: { userId: number }) { accessorKey: "speed_limit", header: t("speedLimit", "Speed Limit"), cell: ({ row }) => { - const speed = row.original?.subscribe?.speed_limit; - return ; + const speed = + row.original.speed_limit || + row.original?.subscribe?.speed_limit; + return ; }, }, { accessorKey: "device_limit", header: t("deviceLimit", "Device Limit"), cell: ({ row }) => { - const limit = row.original?.subscribe?.device_limit; + const limit = + row.original.device_limit || + row.original?.subscribe?.device_limit; return ; }, }, diff --git a/apps/admin/src/sections/user/user-subscription/subscription-detail.tsx b/apps/admin/src/sections/user/user-subscription/subscription-detail.tsx index 51a2085..cc10c2f 100644 --- a/apps/admin/src/sections/user/user-subscription/subscription-detail.tsx +++ b/apps/admin/src/sections/user/user-subscription/subscription-detail.tsx @@ -20,6 +20,7 @@ import { AlertTriangle, Gauge } from "lucide-react"; import { type ReactNode, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; +import { Display } from "@/components/display"; import { IpLink } from "@/components/ip-link"; import { formatDate } from "@/utils/common"; @@ -36,10 +37,21 @@ function SpeedLimitCard({ subscriptionId }: { subscriptionId: number }) { if (!detail || detail.status !== 1) return null; const baseSpeed = detail.subscribe?.speed_limit ?? 0; + const userSpeed = detail.speed_limit ?? 0; + const userTraffic = detail.traffic_limit ?? 0; + const userDeviceLimit = detail.device_limit ?? 0; const effectiveSpeed = detail.effective_speed ?? 0; const isThrottled = detail.is_throttled; - if (baseSpeed === 0 && !isThrottled) return null; + if ( + baseSpeed === 0 && + userSpeed === 0 && + userTraffic === 0 && + userDeviceLimit === 0 && + !isThrottled + ) { + return null; + } return (
)} + {(userSpeed > 0 || userTraffic > 0 || userDeviceLimit > 0) && ( +
+
+
+ {t("userSpeedLimit", "User Speed Limit")} +
+
+ +
+
+
+
+ {t("userTrafficLimit", "User Traffic Limit")} +
+
+ +
+
+
+
+ {t("userDeviceLimit", "User Device Limit")} +
+
+ +
+
+
+ )}
); @@ -156,10 +196,9 @@ export function SubscriptionDetail({ header: t("enable", "Enable"), cell: ({ row }) => ( { - console.log("Switch:", checked); - }} + disabled /> ), }, diff --git a/apps/admin/src/sections/user/user-subscription/subscription-form.tsx b/apps/admin/src/sections/user/user-subscription/subscription-form.tsx index 37d1f93..e3fb344 100644 --- a/apps/admin/src/sections/user/user-subscription/subscription-form.tsx +++ b/apps/admin/src/sections/user/user-subscription/subscription-form.tsx @@ -33,20 +33,23 @@ interface Props { title: string; loading?: boolean; initialData?: API.UserSubscribe; - onSubmit: (values: any) => Promise; + onSubmit: (values: SubscriptionFormValues) => Promise; } const formSchema = z.object({ - subscribe_id: z.number().optional(), - traffic: z.number().optional(), + subscribe_id: z.number().default(0), + traffic: z.number().default(0), speed_limit: z.number().optional(), + traffic_limit: z.number().optional(), device_limit: z.number().optional(), - expired_at: z.number().nullish().optional(), - upload: z.number().optional(), - download: z.number().optional(), + expired_at: z.number().default(0), + upload: z.number().default(0), + download: z.number().default(0), id: z.number().optional(), }); +export type SubscriptionFormValues = z.infer; + export function SubscriptionForm({ trigger, title, @@ -62,6 +65,9 @@ export function SubscriptionForm({ defaultValues: { subscribe_id: initialData?.subscribe_id || 0, traffic: initialData?.traffic || 0, + traffic_limit: initialData?.traffic_limit || 0, + speed_limit: initialData?.speed_limit || 0, + device_limit: initialData?.device_limit || 0, upload: initialData?.upload || 0, download: initialData?.download || 0, expired_at: initialData?.expire_time || 0, @@ -69,7 +75,7 @@ export function SubscriptionForm({ }, }); - const handleSubmit = async (values: any) => { + const handleSubmit = async (values: SubscriptionFormValues) => { const success = await onSubmit(values); if (success) { setOpen(false); @@ -154,6 +160,89 @@ export function SubscriptionForm({ )} /> + ( + + + {t("userTrafficLimit", "User Traffic Limit")} + + + + unitConversion("bytesToGb", value) + } + formatOutput={(value) => + unitConversion("gbToBytes", value) + } + onValueChange={(value) => { + form.setValue(field.name, value as number); + }} + suffix="GB" + /> + + + + )} + /> + ( + + + {t("userSpeedLimit", "User Speed Limit")} + + + { + form.setValue(field.name, value as number); + }} + suffix="Mbps" + /> + + + + )} + /> + ( + + + {t("userDeviceLimit", "User Device Limit")} + + + { + form.setValue(field.name, value as number); + }} + /> + + + + )} + /> +) { + return request( + `${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/promo/rule`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + data: body, + ...(options || {}), + } + ); +} + +/** Get promo rule list GET /v1/admin/promo/rule/list */ +export async function getPromoRuleList( + params: API.GetPromoRuleListParams, + options?: Record +) { + return request( + `${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/promo/rule/list`, + { + method: "GET", + params: { + ...params, + }, + ...(options || {}), + } + ); +} + +/** Get promo rule detail GET /v1/admin/promo/rule/:id */ +export async function getPromoRuleDetail( + params: API.GetPromoRuleDetailParams, + options?: Record +) { + return request( + `${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/promo/rule/${params.id}`, + { + method: "GET", + ...(options || {}), + } + ); +} + +/** Update promo rule PUT /v1/admin/promo/rule/:id */ +export async function updatePromoRule( + params: API.UpdatePromoRuleParams, + body: API.UpdatePromoRuleRequest, + options?: Record +) { + return request( + `${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/promo/rule/${params.id}`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + data: body, + ...(options || {}), + } + ); +} + +/** Delete promo rule DELETE /v1/admin/promo/rule/:id */ +export async function deletePromoRule( + params: API.DeletePromoRuleParams, + options?: Record +) { + return request( + `${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/promo/rule/${params.id}`, + { + method: "DELETE", + ...(options || {}), + } + ); +} + +/** Create promo price POST /v1/admin/promo/price */ +export async function createPromoPrice( + body: API.CreatePromoPriceRequest, + options?: Record +) { + return request( + `${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/promo/price`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + data: body, + ...(options || {}), + } + ); +} + +/** Get promo price list GET /v1/admin/promo/price/list */ +export async function getPromoPriceList( + params: API.GetPromoPriceListParams, + options?: Record +) { + return request( + `${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/promo/price/list`, + { + method: "GET", + params: { + ...params, + }, + ...(options || {}), + } + ); +} + +/** Delete promo price DELETE /v1/admin/promo/price/:id */ +export async function deletePromoPrice( + params: API.DeletePromoPriceParams, + options?: Record +) { + return request( + `${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/promo/price/${params.id}`, + { + method: "DELETE", + ...(options || {}), + } + ); +} + +/** Get promo usage list GET /v1/admin/promo/usage/list */ +export async function getPromoUsageList( + params: API.GetPromoUsageListParams, + options?: Record +) { + return request( + `${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/promo/usage/list`, + { + method: "GET", + params: { + ...params, + }, + ...(options || {}), + } + ); +} diff --git a/packages/ui/src/services/admin/typings.d.ts b/packages/ui/src/services/admin/typings.d.ts index 75e90a4..b93e9d6 100644 --- a/packages/ui/src/services/admin/typings.d.ts +++ b/packages/ui/src/services/admin/typings.d.ts @@ -272,6 +272,69 @@ declare namespace API { enabled: boolean; }; + type PromoRuleType = "new_user" | "inactive_user" | "campaign"; + + type PromoRuleParams = { + inactive_months?: number; + window_hours?: number; + [key: string]: unknown; + }; + + type PromoRule = { + id: number; + name: string; + type: PromoRuleType; + params: PromoRuleParams; + priority: number; + enabled: boolean; + start_time?: number; + end_time?: number; + created_at?: number; + updated_at?: number; + }; + + type CreatePromoRuleRequest = { + name: string; + type: PromoRuleType; + params: PromoRuleParams; + priority: number; + enabled: boolean; + start_time?: number; + end_time?: number; + }; + + type UpdatePromoRuleRequest = CreatePromoRuleRequest; + + type PromoPrice = { + id: number; + rule_id: number; + subscribe_id: number; + subscribe_name?: string; + unit_price: number; + promo_price: number; + created_at?: number; + updated_at?: number; + }; + + type CreatePromoPriceRequest = { + rule_id: number; + subscribe_id: number; + promo_price: number; + }; + + type PromoUsage = { + id: number; + rule_id: number; + rule_name?: string; + subscribe_id: number; + subscribe_name?: string; + user_id: number; + order_no: string; + unit_price: number; + promo_price: number; + used_at: number; + }; + type CreateOrderRequest = { user_id: number; type: number; @@ -398,6 +461,11 @@ declare namespace API { expired_at: number; traffic: number; subscribe_id: number; + speed_limit?: number; + traffic_limit?: number; + device_limit?: number; + upload?: number; + download?: number; }; type CurrencyConfig = { @@ -926,6 +994,61 @@ declare namespace API { list: Coupon[]; }; + type GetPromoRuleListParams = { + page: number; + size: number; + type?: PromoRuleType; + enabled?: boolean; + search?: string; + }; + + type GetPromoRuleListResponse = { + total: number; + list: PromoRule[]; + }; + + type GetPromoRuleDetailParams = { + id: number; + }; + + type UpdatePromoRuleParams = { + id: number; + }; + + type DeletePromoRuleParams = { + id: number; + }; + + type GetPromoPriceListParams = { + page: number; + size: number; + rule_id?: number; + subscribe_id?: number; + }; + + type GetPromoPriceListResponse = { + total: number; + list: PromoPrice[]; + }; + + type DeletePromoPriceParams = { + id: number; + }; + + type GetPromoUsageListParams = { + page: number; + size: number; + rule_id?: number; + subscribe_id?: number; + user_id?: number; + order_no?: string; + }; + + type GetPromoUsageListResponse = { + total: number; + list: PromoUsage[]; + }; + type GetDetailRequest = { id: number; }; @@ -2428,12 +2551,16 @@ declare namespace API { }; type UpdateUserSubscribeRequest = { + user_id?: number; user_subscribe_id: number; subscribe_id: number; traffic: number; expired_at: number; upload: number; download: number; + speed_limit?: number; + traffic_limit?: number; + device_limit?: number; }; type User = { @@ -2522,6 +2649,9 @@ declare namespace API { finished_at: number; reset_time: number; traffic: number; + traffic_limit?: number; + speed_limit?: number; + device_limit?: number; download: number; upload: number; token: string; @@ -2544,6 +2674,9 @@ declare namespace API { expire_time: number; reset_time: number; traffic: number; + traffic_limit?: number; + speed_limit?: number; + device_limit?: number; download: number; upload: number; token: string;