diff --git a/apps/admin/public/assets/locales/en-US/dashboard.json b/apps/admin/public/assets/locales/en-US/dashboard.json index 8256029..2e80d11 100644 --- a/apps/admin/public/assets/locales/en-US/dashboard.json +++ b/apps/admin/public/assets/locales/en-US/dashboard.json @@ -29,5 +29,6 @@ "users": "Users", "userTitle": "User Statistics", "userTraffic": "User Traffic", + "withdrawalManagement": "Withdrawal Management", "yesterday": "Yesterday" } 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..97d3672 --- /dev/null +++ b/apps/admin/public/assets/locales/en-US/promo.json @@ -0,0 +1,70 @@ +{ + "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", + "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", + "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.", + "originalPriceHint": "Original price", + "params": "Params", + "prices": "Prices", + "priority": "Priority", + "promoPrice": "Promo Price", + "quantity": "Quantity", + "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/en-US/user.json b/apps/admin/public/assets/locales/en-US/user.json index 7e3f9da..4ba0f85 100644 --- a/apps/admin/public/assets/locales/en-US/user.json +++ b/apps/admin/public/assets/locales/en-US/user.json @@ -100,10 +100,12 @@ "manager": "Administrator", "memberCount": "Member Count", "more": "More", + "neverLoggedIn": "Never logged in", "normal": "Normal", "next": "Next", "noInvitedUsers": "No invited users yet", "notifySettingsTitle": "Notify Settings", + "inactive": "Inactive", "offline": "Offline", "online": "Online", "onlineDevices": "Online Devices", @@ -189,6 +191,7 @@ "groupUpdated": "Group updated successfully", "upload": "Upload", "uploadTraffic": "Upload Traffic", + "userStatus": "User Status", "userAgent": "User Agent", "userEmail": "Email", "userEmailPlaceholder": "Enter email", @@ -198,6 +201,7 @@ "userName": "Username", "userProfile": "User Profile", "userGroup": "User Group", + "active": "Active", "verified": "Verified", "viewDeviceGroup": "View Device Group", "viewOwner": "View Owner", diff --git a/apps/admin/public/assets/locales/zh-CN/dashboard.json b/apps/admin/public/assets/locales/zh-CN/dashboard.json index 7e5c4e0..bfbbce7 100644 --- a/apps/admin/public/assets/locales/zh-CN/dashboard.json +++ b/apps/admin/public/assets/locales/zh-CN/dashboard.json @@ -29,5 +29,6 @@ "users": "用户", "userTitle": "用户统计", "userTraffic": "用户流量", + "withdrawalManagement": "提现管理", "yesterday": "昨日" } 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..2509e47 --- /dev/null +++ b/apps/admin/public/assets/locales/zh-CN/promo.json @@ -0,0 +1,70 @@ +{ + "cancel": "取消", + "confirm": "确认", + "confirmDelete": "确定要删除吗?", + "createPrice": "创建促销价", + "createRule": "创建规则", + "createSuccess": "创建成功", + "delete": "删除", + "deleteSuccess": "删除成功", + "deleteWarning": "删除后数据无法恢复,请谨慎操作。", + "detail": "详情", + "edit": "编辑", + "editRule": "编辑规则", + "emptyPrices": "暂无商品促销价", + "emptyRules": "暂无促销规则", + "emptyUsage": "暂无促销使用记录", + "enabled": "启用", + "endTime": "结束时间", + "form": { + "enterPromoPrice": "输入价格", + "inactiveMonths": "未活跃月数", + "inactiveMonthsPlaceholder": "未购买的月数", + "name": "名称", + "namePlaceholder": "规则名称", + "noDiscounts": "该订阅没有 discount 档位,不能配置促销价。", + "promoPriceLessThanOriginalPrice": "促销价必须低于原价", + "promoPriceLessThanUnitPrice": "促销价必须低于原价", + "selectEndTime": "选择时间", + "selectRule": "选择规则", + "selectStartTime": "选择时间", + "selectQuantity": "选择 discount 档位", + "selectSubscribe": "选择订阅", + "type": "规则类型", + "windowHours": "新用户窗口小时数", + "windowHoursPlaceholder": "注册后的小时数" + }, + "loadErrorDescription": "请刷新表格或稍后重试。", + "loadPricesError": "促销价加载失败", + "loadRulesError": "促销规则加载失败", + "loadUsageError": "促销使用记录加载失败", + "name": "名称", + "no": "否", + "orderNo": "订单号", + "originalPriceHint": "原价", + "params": "参数", + "prices": "商品促销价", + "priority": "优先级", + "promoPrice": "促销价", + "quantity": "档位", + "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/public/assets/locales/zh-CN/user.json b/apps/admin/public/assets/locales/zh-CN/user.json index 33c0e64..612e003 100644 --- a/apps/admin/public/assets/locales/zh-CN/user.json +++ b/apps/admin/public/assets/locales/zh-CN/user.json @@ -101,10 +101,12 @@ "manager": "管理员", "memberCount": "成员数量", "more": "更多", + "neverLoggedIn": "从未登录", "normal": "正常", "next": "下一页", "noInvitedUsers": "暂无邀请用户", "notifySettingsTitle": "通知设置", + "inactive": "未活跃", "offline": "离线", "online": "在线", "onlineDevices": "在线设备", @@ -190,6 +192,7 @@ "groupUpdated": "分组更新成功", "upload": "上传", "uploadTraffic": "上传流量", + "userStatus": "用户状态", "userAgent": "用户代理", "userEmail": "邮箱", "userEmailPlaceholder": "输入邮箱", @@ -199,6 +202,7 @@ "userName": "用户名", "userProfile": "用户资料", "userGroup": "用户分组", + "active": "活跃", "verified": "已验证", "viewDeviceGroup": "查看设备组", "viewOwner": "查看所有者", 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/dashboard/components/statistics.tsx b/apps/admin/src/sections/dashboard/components/statistics.tsx index db3b069..1009c41 100644 --- a/apps/admin/src/sections/dashboard/components/statistics.tsx +++ b/apps/admin/src/sections/dashboard/components/statistics.tsx @@ -241,11 +241,11 @@ export default function Statistics() { iconBg: "bg-green-100 dark:bg-green-900/30", }, { - title: t("pendingTickets", "Pending Tickets"), + title: t("withdrawalManagement", "提现管理"), value: TicketTotal || 0, subtitle: t("pending", "Pending"), icon: "uil:clipboard-notes", - href: "/dashboard/ticket", + href: "/dashboard/withdrawal", color: "text-red-600 dark:text-red-400", iconBg: "bg-red-100 dark:bg-red-900/30", }, 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..d55644e --- /dev/null +++ b/apps/admin/src/sections/promo/index.tsx @@ -0,0 +1,504 @@ +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 = { + promo_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: "promo_rule_id", + header: t("rule", "Rule"), + cell: ({ row }) => + row.original.promo_rule_id + ? rules.find( + (rule) => rule.id === row.original.promo_rule_id + )?.name || `#${row.original.promo_rule_id}` + : "--", + }, + { + accessorKey: "subscribe_id", + header: t("subscribe", "Subscribe"), + cell: ({ row }) => + 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"), + 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: "promo_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, + promo_rule_id: parseOptionalNumber(filters.promo_rule_id), + subscribe_id: parseOptionalNumber(filters.subscribe_id), + }); + return { + list: data.data?.list || [], + total: data.data?.total || 0, + }; + }} + /> + + + + + action={usageRef} + columns={[ + { + accessorKey: "promo_rule_id", + header: t("rule", "Rule"), + cell: ({ row }) => + row.original.rule_name || + rules.find((rule) => rule.id === row.original.promo_rule_id) + ?.name || + `#${row.original.promo_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: "quantity", + header: t("quantity", "Quantity"), + cell: ({ row }) => row.original.quantity || "--", + }, + { + 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: "promo_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, + 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, + }); + return { + list: data.data?.list || [], + total: data.data?.total || 0, + }; + }} + /> + + + + + ); +} 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 new file mode 100644 index 0000000..c678285 --- /dev/null +++ b/apps/admin/src/sections/promo/price-form.tsx @@ -0,0 +1,260 @@ +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 { + formatCurrency, + getSubscribeDiscounts, + getSubscribeUnitPrice, +} from "./utils"; + +const priceSchema = z.object({ + promo_rule_id: z.number().min(1), + subscribe_id: z.number().min(1), + quantity: 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: { + 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) { + const maxPrice = unitPrice * values.quantity; + if (maxPrice > 0 && values.promo_price >= maxPrice) { + form.setError("promo_price", { + message: t( + "form.promoPriceLessThanOriginalPrice", + "Promo price must be lower than original price" + ), + }); + return; + } + 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); + } + + 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={(value) => { + field.onChange(value); + form.setValue("quantity", 0, { + shouldDirty: true, + shouldValidate: true, + }); + form.clearErrors(["quantity", "promo_price"]); + }} + 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("quantity", "Quantity")} + + + onChange={field.onChange} + options={discounts.map((item) => ({ + label: `${item.quantity} x ${t( + subscribe?.unit_time || "Month", + subscribe?.unit_time || "Month" + )}`, + value: item.quantity, + }))} + placeholder={t( + "form.selectQuantity", + "Select discount quantity" + )} + value={field.value} + /> + + {hasSelectedSubscribe && !hasDiscounts && ( +

+ {t( + "form.noDiscounts", + "This subscribe has no discount quantities and cannot be configured." + )} +

+ )} + +
+ )} + /> + ( + + {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} + /> + + {originalPrice > 0 && ( +

+ {t("originalPriceHint", "Original price")}:{" "} + {formatCurrency(originalPrice)} +

+ )} + +
+ )} + /> + + + + + +
+
+ ); +} 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..ea10b06 --- /dev/null +++ b/apps/admin/src/sections/promo/utils.ts @@ -0,0 +1,28 @@ +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; +} + +export function getSubscribeDiscounts(subscribe?: API.SubscribeItem) { + return (subscribe?.discount || []).filter( + (item) => typeof item.quantity === "number" && item.quantity > 0 + ); +} diff --git a/apps/admin/src/sections/user/index.tsx b/apps/admin/src/sections/user/index.tsx index 74b9998..2117b19 100644 --- a/apps/admin/src/sections/user/index.tsx +++ b/apps/admin/src/sections/user/index.tsx @@ -1,34 +1,25 @@ import { useQuery } from "@tanstack/react-query"; import { Link, useSearch } from "@tanstack/react-router"; -import { Badge } from "@workspace/ui/components/badge"; import { Button } from "@workspace/ui/components/button"; import { Dialog, DialogContent, DialogHeader, DialogTitle, - DialogTrigger, } from "@workspace/ui/components/dialog"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from "@workspace/ui/components/dropdown-menu"; -import { Input } from "@workspace/ui/components/input"; import { Popover, PopoverContent, PopoverTrigger, } from "@workspace/ui/components/popover"; import { ScrollArea } from "@workspace/ui/components/scroll-area"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@workspace/ui/components/select"; import { Sheet, SheetContent, @@ -43,7 +34,12 @@ import { TabsList, TabsTrigger, } from "@workspace/ui/components/tabs"; -import { Combobox } from "@workspace/ui/composed/combobox"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@workspace/ui/components/tooltip"; // import { ConfirmButton } from "@workspace/ui/composed/confirm-button"; import { ProTable, @@ -60,9 +56,8 @@ import { getUserList, updateUserBasicInfo, } from "@workspace/ui/services/admin/user"; -import { parseDeviceType } from "@workspace/ui/utils/device"; import { FilePenLine } from "lucide-react"; -import { useCallback, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { Display } from "@/components/display"; @@ -73,18 +68,16 @@ import { RemarkForm } from "./remark-form"; import { buildUserBasicInfoPayload } from "./user-basic-info-payload"; import { UserDetail } from "./user-detail"; import UserForm from "./user-form"; +import { UserIdentifier } from "./user-identifier"; import { UserInviteStatsSheet } from "./user-invite-stats-sheet"; import { AuthMethodsForm } from "./user-profile/auth-methods-form"; import { BasicInfoForm } from "./user-profile/basic-info-form"; import { NotifySettingsForm } from "./user-profile/notify-settings-form"; +import { UserSearchBar } from "./user-search-bar"; import UserSubscription from "./user-subscription"; // import EditUserGroupDialog from "./edit-user-group-dialog"; -type UserDeviceWithDeviceNo = API.UserDevice & { - device_no?: string; -}; - export default function User() { const { t } = useTranslation("user"); const [loading, setLoading] = useState(false); @@ -98,6 +91,14 @@ export default function User() { value: sp.search || sp.user_id || "", }); + useEffect(() => { + searchRef.current = { + type: sp.user_id ? "user_id" : "email", + value: sp.search || sp.user_id || "", + }; + ref.current?.refresh(); + }, [sp.search, sp.user_id]); + const handleSearch = useCallback((type: string, value: string) => { searchRef.current = { type, value }; ref.current?.refresh(); @@ -151,7 +152,6 @@ export default function User() { onChanged={() => ref.current?.refresh()} userId={row.id} />, - , /* + + {t("orderList", "Order List")} @@ -262,62 +264,43 @@ export default function User() { id: "auth_methods", accessorKey: "auth_methods", header: "设备码/邮箱", - cell: ({ row }) => { - const method = row.original.auth_methods?.[0]; - const identifier = method?.auth_identifier || ""; - const isDevice = method?.auth_type === "device"; - const firstDevice = row.original.user_devices?.[0] as - | UserDeviceWithDeviceNo - | undefined; - const deviceNo = firstDevice?.device_no; - const deviceType = parseDeviceType(firstDevice?.user_agent || ""); - const display = isDevice ? deviceNo || identifier : identifier; - return ( -
- {/* - {method?.auth_type} - */} - {deviceType && ( - - {deviceType} - - )} - {display} - - -
- {row.original?.remark ? `(${row.original.remark})` : ""} - -
-
- - { - const { data } = await getUserDetail({ - id: row.original.id, - }); - const user = data.data; + cell: ({ row }) => ( +
+ + + +
+ {row.original?.remark ? `(${row.original.remark})` : ""} + +
+
+ + { + const { data } = await getUserDetail({ + id: row.original.id, + }); + const user = data.data; - if (!user) { - throw new Error("User detail not found"); - } + if (!user) { + throw new Error("User detail not found"); + } - await updateUserBasicInfo( - buildUserBasicInfoPayload(user, remark) - ); - toast.success(t("updateSuccess")); - ref.current?.refresh(); - }} - /> - -
-
- ); - }, + await updateUserBasicInfo( + buildUserBasicInfoPayload(user, remark) + ); + toast.success(t("updateSuccess")); + ref.current?.refresh(); + }} + /> +
+
+
+ ), }, /* { id: "balance", @@ -355,6 +338,48 @@ export default function User() { header: t("referer", "Referer"), cell: ({ row }) => , }, + { + id: "last_login_time", + accessorKey: "last_login_time", + header: t("userStatus", "用户状态"), + cell: ({ row }) => { + const lastLoginTime = ( + row.original as API.User & { + last_login_time?: number; + } + ).last_login_time; + const isOnline = Boolean(lastLoginTime); + const dotClassName = isOnline ? "bg-green-500" : "bg-gray-400"; + const statusLabel = isOnline + ? t("active", "Active") + : t("inactive", "Inactive"); + + return ( + + + +
+
+
+ + {isOnline + ? formatDate(lastLoginTime) + : t("neverLoggedIn", "Never logged in")} + +
+
+ ); + }, + }, { id: "created_at", accessorKey: "created_at", @@ -523,6 +548,25 @@ function InviteStatsMenuItem({ userId }: { userId: number }) { ); } +function PreviewNodesMenuItem({ userId }: { userId: number }) { + const { t } = useTranslation("user"); + const [open, setOpen] = useState(false); + + return ( + <> + { + e.preventDefault(); + setOpen(true); + }} + > + {t("previewNodes", "Preview Nodes")} + + + + ); +} + function DeviceGroupSheet({ userId, onChanged, @@ -551,92 +595,16 @@ function DeviceGroupSheet({ ); } -function UserSearchBar({ - initialType, - initialValue, - onSearch, - subscribes, +function PreviewNodesDialog({ + userId, + open, + onOpenChange, }: { - initialType: string; - initialValue: string; - onSearch: (type: string, value: string) => void; - subscribes?: API.SubscribeItem[]; + userId: number; + open: boolean; + onOpenChange: (open: boolean) => void; }) { const { t } = useTranslation("user"); - const [searchType, setSearchType] = useState(initialType); - const [searchValue, setSearchValue] = useState(initialValue); - - return ( -
- - {searchType === "subscribe_id" ? ( - { - setSearchValue(value); - onSearch("subscribe_id", value); - }} - options={subscribes?.map((item) => ({ - label: item.name!, - value: String(item.id!), - }))} - placeholder={t("subscription", "Subscription")} - value={searchValue} - /> - ) : ( - <> - setSearchValue(e.target.value)} - onKeyDown={(e) => - e.key === "Enter" && onSearch(searchType, searchValue) - } - placeholder={t("searchInputPlaceholder", "Enter search term")} - value={searchValue} - /> - - {searchValue && ( - - )} - - )} -
- ); -} - -function PreviewNodesDialog({ userId }: { userId: number }) { - const { t } = useTranslation("user"); - const [open, setOpen] = useState(false); const { data: previewData, isLoading } = useQuery({ enabled: open, queryKey: ["previewUserNodes", userId], @@ -647,10 +615,7 @@ function PreviewNodesDialog({ userId }: { userId: number }) { }); return ( - - - - + diff --git a/apps/admin/src/sections/user/user-detail.tsx b/apps/admin/src/sections/user/user-detail.tsx index 7f530ca..03baeb3 100644 --- a/apps/admin/src/sections/user/user-detail.tsx +++ b/apps/admin/src/sections/user/user-detail.tsx @@ -16,6 +16,7 @@ import { formatBytes } from "@workspace/ui/utils/formatting"; import { useTranslation } from "react-i18next"; import { Display } from "@/components/display"; import { formatDate } from "@/utils/common"; +import { UserIdentifier } from "./user-identifier"; // import EditUserGroupDialog from "./edit-user-group-dialog"; // import { getUserGroupList } from "@workspace/ui/services/admin/group"; @@ -229,26 +230,13 @@ export function UserDetail({ id }: { id: number }) { if (!id) return "--"; - const firstMethod = data?.auth_methods[0]; - const isDevice = firstMethod?.auth_type === "device"; - const deviceNo = (data as any)?.user_devices?.[0]?.device_no; - const rawIdentifier = firstMethod?.auth_identifier || ""; - const identifier = isDevice ? deviceNo || rawIdentifier : rawIdentifier; - return ( + {searchValue && ( + + )} + + )} + + ); +} diff --git a/apps/user/public/assets/locales/en-US/subscribe.json b/apps/user/public/assets/locales/en-US/subscribe.json index bee2b6d..812832f 100644 --- a/apps/user/public/assets/locales/en-US/subscribe.json +++ b/apps/user/public/assets/locales/en-US/subscribe.json @@ -9,6 +9,7 @@ "originalPrice": "Original Price (Monthly)", "price": "Price", "productDiscount": "Product Discount", + "promoDiscount": "Promo Discount", "total": "Total" }, "buy": "Buy", @@ -37,6 +38,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..d0375bb 100644 --- a/apps/user/public/assets/locales/zh-CN/subscribe.json +++ b/apps/user/public/assets/locales/zh-CN/subscribe.json @@ -9,6 +9,7 @@ "originalPrice": "原价(按月)", "price": "价格", "productDiscount": "商品折扣", + "promoDiscount": "促销优惠", "total": "总价" }, "buy": "购买", @@ -37,6 +38,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/billing.tsx b/apps/user/src/sections/subscribe/billing.tsx index c24db2e..ec016a4 100644 --- a/apps/user/src/sections/subscribe/billing.tsx +++ b/apps/user/src/sections/subscribe/billing.tsx @@ -10,6 +10,7 @@ interface SubscribeBillingProps { unit_price: number; unit_time: string; subscribe_discount: number; + promo_discount: number; show_original_price?: boolean; } >; @@ -66,6 +67,14 @@ export function SubscribeBilling({ order }: Readonly) { +
  • + + {t("billing.promoDiscount", "Promo Discount")} + + + + +
  • {t("billing.couponDiscount", "Coupon Discount")} 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..55f88c0 --- /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.expires_at && ( + + {t("promo.validUntil", "Valid until")}:{" "} + {new Date(promo.expires_at * 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/index.ts b/packages/ui/src/services/admin/index.ts index 53ec429..ebb010f 100644 --- a/packages/ui/src/services/admin/index.ts +++ b/packages/ui/src/services/admin/index.ts @@ -15,6 +15,7 @@ import * as log from "./log"; import * as marketing from "./marketing"; import * as order from "./order"; import * as payment from "./payment"; +import * as promo from "./promo"; import * as redemption from "./redemption"; import * as server from "./server"; import * as subscribe from "./subscribe"; @@ -37,6 +38,7 @@ export default { marketing, order, payment, + promo, redemption, server, subscribe, diff --git a/packages/ui/src/services/admin/promo.ts b/packages/ui/src/services/admin/promo.ts new file mode 100644 index 0000000..398f010 --- /dev/null +++ b/packages/ui/src/services/admin/promo.ts @@ -0,0 +1,150 @@ +/* eslint-disable */ +import request from "@workspace/ui/lib/request"; + +/** Create promo rule POST /v1/admin/promo/rule */ +export async function createPromoRule( + body: API.CreatePromoRuleRequest, + options?: Record +) { + 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 3638077..90e4e26 100644 --- a/packages/ui/src/services/admin/typings.d.ts +++ b/packages/ui/src/services/admin/typings.d.ts @@ -272,6 +272,76 @@ 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; + 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 CreatePromoPriceItem = { + subscribe_id: number; + quantity: number; + promo_price: number; + }; + + type CreatePromoPriceRequest = { + promo_rule_id: number; + items: CreatePromoPriceItem[]; + }; + + type PromoUsage = { + 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; + promo_price: number; + used_at: number; + }; + type CreateOrderRequest = { user_id: number; type: number; @@ -929,6 +999,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; + promo_rule_id?: number; + subscribe_id?: number; + }; + + type GetPromoPriceListResponse = { + total: number; + list: PromoPrice[]; + }; + + type DeletePromoPriceParams = { + id: number; + }; + + type GetPromoUsageListParams = { + page: number; + size: number; + promo_rule_id?: number; + subscribe_id?: number; + user_id?: number; + order_no?: string; + }; + + type GetPromoUsageListResponse = { + total: number; + list: PromoUsage[]; + }; + type GetDetailRequest = { id: number; }; @@ -2101,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..9a5e1be 100644 --- a/packages/ui/src/services/user/typings.d.ts +++ b/packages/ui/src/services/user/typings.d.ts @@ -548,6 +548,7 @@ declare namespace API { price: number; amount: number; discount: number; + promo_discount: number; gift_amount: number; coupon: string; coupon_discount: number; @@ -1019,6 +1020,14 @@ declare namespace API { quantity: number; discount: number; map_apple?: string; + promo?: SubscribeDiscountPromo; + }; + + type SubscribeDiscountPromo = { + rule_name?: string; + rule_type?: string; + promo_price: number; + expires_at?: number; }; type SubscribeGroup = {