diff --git a/apps/admin/dist.zip b/apps/admin/dist.zip index 2523f97..f6bac1c 100644 Binary files a/apps/admin/dist.zip and b/apps/admin/dist.zip differ diff --git a/apps/admin/public/assets/locales/en-US/lottery.json b/apps/admin/public/assets/locales/en-US/lottery.json new file mode 100644 index 0000000..5cdb158 --- /dev/null +++ b/apps/admin/public/assets/locales/en-US/lottery.json @@ -0,0 +1,181 @@ +{ + "activityTime": "Activity Time", + "cancel": "Cancel", + "confirm": "Confirm", + "confirmPause": "Are you sure you want to pause this activity?", + "confirmPublish": "Are you sure you want to publish this activity?", + "create": "Create", + "createActivity": "Create Activity", + "createSuccess": "Create Success", + "detail": { + "prizesTab": "Prizes", + "rulesTab": "Rules" + }, + "edit": "Edit", + "editActivity": "Edit Activity", + "form": { + "cancel": "Cancel", + "confirm": "Confirm", + "description": "Description", + "descriptionPlaceholder": "Activity description (Markdown supported)", + "endAt": "End Time", + "endAtAfterStartAt": "End time must be later than start time", + "endAtRequired": "End time is required", + "gridSize": "Grid Size", + "selectEndAt": "Select time", + "selectGridSize": "Select grid size", + "selectStartAt": "Select time", + "startAt": "Start Time", + "startAtRequired": "Start time is required", + "title": "Title", + "titlePlaceholder": "Activity title", + "titleRequired": "Title is required" + }, + "grant": { + "amount": "Amount", + "description": "Manually grant draw chances to a user for activity: {{title}}", + "sourceRef": "Source Reference", + "sourceRefHint": "Idempotency key: the same reference only grants once. Repeated submissions will not grant again.", + "sourceRefPlaceholder": "grant-20260709-abc123", + "success": "Chances granted", + "title": "Grant Chances", + "userId": "User ID", + "userIdHint": "Enter the numeric user ID. You can find it in the user management list.", + "userIdPlaceholder": "e.g. 87433" + }, + "grantChances": "Grant Chances", + "gridSize": "Grid Size", + "id": "ID", + "manage": "Manage", + "pause": "Pause", + "pauseSuccess": "Pause Success", + "pauseWarning": "Users will not be able to draw while the activity is paused.", + "prize": { + "addPrize": "Add Prize", + "amountPrecision": "Amount supports at most 2 decimal places", + "amountRequired": "Amount must be greater than 0", + "amountUnitHint": "Entered in CNY (yuan), stored as cents; up to 2 decimal places", + "amountYuan": "Commission Amount (CNY)", + "cancel": "Cancel", + "centerSlotHint": "Usually the draw button", + "confirm": "Confirm", + "confirmDelete": "Delete", + "createSuccess": "Prize created", + "createTitle": "Add Prize", + "daysSuffix": "days", + "delete": "Delete", + "deleteDescription": "This prize will be removed from the grid. This action cannot be undone.", + "deleteSuccess": "Prize deleted", + "deleteTitle": "Delete this prize?", + "durationDays": "Duration (days)", + "durationDaysPlaceholder": "e.g. 1", + "durationDaysRequired": "Duration days must be a positive integer", + "editTitle": "Edit Prize", + "fallbackBadge": "Fallback", + "fallbackIgnoresWeight": "Fallback prize ignores weight", + "iconUrl": "Icon URL", + "iconUrlInvalid": "Must be a valid URL", + "isFallback": "Fallback Prize", + "isFallbackHint": "Awarded when limited prizes are sold out", + "name": "Prize Name", + "namePlaceholder": "e.g. 1-Day Pass", + "nameRequired": "Name is required", + "noFallbackHint": "Consider configuring a fallback prize so draws never fail when limited prizes run out.", + "noFallbackTitle": "No fallback prize", + "noRandomPrizes": "No prizes participate in the random draw yet", + "noneHint": "No config needed. Consider enabling fallback so users always land on this prize when limited prizes run out.", + "probabilityPreview": "Probability Preview", + "selectType": "Select type", + "slotLabel": "Slot", + "soldOutBadge": "Sold Out", + "stage2Hint": "Available in Stage 2", + "stockShort": "Stock", + "totalStock": "Total Stock", + "totalStockPlaceholder": "e.g. 100", + "totalStockRequired": "Total stock must be a positive integer", + "type": "Prize Type", + "typeCommission": "Commission", + "typeCoupon": "Coupon", + "typeCrypto": "Crypto", + "typeManualOther": "Manual (Other)", + "typeNone": "No Prize (Thanks)", + "typePhysical": "Physical", + "typePoints": "Points", + "typeVpnDuration": "VPN Duration", + "unlimitedStock": "Unlimited Stock", + "updateSuccess": "Prize updated", + "weight": "Weight", + "weightHint": "0 = displayed but excluded from random draw", + "weightInteger": "Weight must be an integer", + "weightMin": "Weight must be at least 0", + "weightShort": "Weight", + "yuanSuffix": "CNY" + }, + "publish": "Publish", + "publishSuccess": "Publish Success", + "publishWarning": "Once published, the activity becomes visible to users.", + "rules": { + "addCondition": "Add Condition", + "addSource": "Add Source", + "amount": "Amount", + "cannotSimplify": "This rule tree is too complex for simple mode. Please edit it in Advanced mode.", + "capsBytesExceeded": "Size {{value}} bytes exceeds limit {{limit}} bytes", + "capsDepthExceeded": "Depth {{value}} exceeds limit {{limit}}", + "capsExceeded": "Rule tree exceeds limits", + "capsNodesExceeded": "Node count {{value}} exceeds limit {{limit}}", + "chanceSources": "Chance Sources", + "chanceSourcesDescription": "Configure how users earn draw chances. Each source can only appear once.", + "conditionType": { + "has_subscription": "Has Subscription", + "invite_count": "Invite Count", + "register_days": "Register Days", + "subscription_type": "Subscription Type", + "total_recharge": "Total Recharge", + "user_tag": "User Tag" + }, + "dailyLimit": "Daily limit (optional)", + "duplicateSource": "Duplicate source", + "eligibility": "Eligibility", + "jsonInvalid": "Invalid JSON: {{error}}", + "jsonMustBeObject": "Rule tree must be a JSON object", + "jsonPlaceholder": "Rule tree JSON, e.g. {\"op\":\"AND\",\"children\":[...]}. Leave empty for no requirement.", + "noConditions": "No conditions. Everyone is eligible to join.", + "op": "Combine Conditions With", + "opAnd": "AND", + "opOr": "OR", + "param": { + "min": "Min Count", + "minDaysRemaining": "Min Days Remaining", + "minUsdt": "Min USDT", + "planIds": "Plan IDs", + "planIdsPlaceholder": "Comma separated, e.g. 1,2,3", + "tags": "Tags", + "tagsPlaceholder": "Comma separated, e.g. vip,beta", + "windowDays": "Window Days (optional)" + }, + "save": "Save Rules", + "saveFailed": "Failed to save rules", + "saveSuccess": "Rules saved", + "selectSource": "Select source", + "simpleMode": "Simple", + "advancedMode": "Advanced (JSON)", + "source": { + "dailySignin": "Daily Sign-in", + "inviteSuccess": "Invite Success", + "manualGrant": "Manual Grant", + "newSubscription": "New Subscription" + }, + "stage1Warning": "Stage 1 notice", + "stage1WarningDescription": "The rule context is not wired up in the current version (Stage 1). Configuring eligibility will prevent users from joining the lottery. It is recommended to keep it empty." + }, + "status": { + "draft": "Draft", + "ended": "Ended", + "paused": "Paused", + "running": "Running" + }, + "statusAll": "All", + "statusLabel": "Status", + "title": "Title", + "updateSuccess": "Update Success" +} diff --git a/apps/admin/public/assets/locales/en-US/menu.json b/apps/admin/public/assets/locales/en-US/menu.json index b478ab3..bd97a1a 100644 --- a/apps/admin/public/assets/locales/en-US/menu.json +++ b/apps/admin/public/assets/locales/en-US/menu.json @@ -14,6 +14,7 @@ "Invite Management": "Invite Management", "Login": "Login", "Logs & Analytics": "Logs & Analytics", + "Lottery Management": "Lottery Management", "Maintenance": "Maintenance", "Marketing Management": "Marketing Management", "Mobile": "Mobile", diff --git a/apps/admin/public/assets/locales/zh-CN/lottery.json b/apps/admin/public/assets/locales/zh-CN/lottery.json new file mode 100644 index 0000000..a44b324 --- /dev/null +++ b/apps/admin/public/assets/locales/zh-CN/lottery.json @@ -0,0 +1,181 @@ +{ + "activityTime": "活动时间", + "cancel": "取消", + "confirm": "确认", + "confirmPause": "确定要暂停该活动吗?", + "confirmPublish": "确定要上架该活动吗?", + "create": "创建", + "createActivity": "创建活动", + "createSuccess": "创建成功", + "detail": { + "prizesTab": "奖品", + "rulesTab": "规则" + }, + "edit": "编辑", + "editActivity": "编辑活动", + "form": { + "cancel": "取消", + "confirm": "确认", + "description": "活动描述", + "descriptionPlaceholder": "活动描述(支持 Markdown)", + "endAt": "结束时间", + "endAtAfterStartAt": "结束时间必须晚于开始时间", + "endAtRequired": "请选择结束时间", + "gridSize": "格子数", + "selectEndAt": "选择时间", + "selectGridSize": "选择格子数", + "selectStartAt": "选择时间", + "startAt": "开始时间", + "startAtRequired": "请选择开始时间", + "title": "标题", + "titlePlaceholder": "活动标题", + "titleRequired": "请输入活动标题" + }, + "grant": { + "amount": "发放数量", + "description": "为用户手动发放活动「{{title}}」的抽奖次数", + "sourceRef": "来源引用号", + "sourceRefHint": "幂等键:相同的引用号只会发放一次,重复提交不会重复发放。", + "sourceRefPlaceholder": "grant-20260709-abc123", + "success": "次数发放成功", + "title": "发放次数", + "userId": "用户 ID", + "userIdHint": "请输入用户的数字 ID,可在用户管理列表中查看。", + "userIdPlaceholder": "例如:87433" + }, + "grantChances": "发放次数", + "gridSize": "格子数", + "id": "ID", + "manage": "管理", + "pause": "暂停", + "pauseSuccess": "暂停成功", + "pauseWarning": "活动暂停期间,用户将无法抽奖。", + "prize": { + "addPrize": "添加奖品", + "amountPrecision": "金额最多支持 2 位小数", + "amountRequired": "金额必须大于 0", + "amountUnitHint": "以元为单位输入,按分存储;最多支持 2 位小数", + "amountYuan": "佣金金额(元)", + "cancel": "取消", + "centerSlotHint": "通常为抽奖按钮", + "confirm": "确认", + "confirmDelete": "删除", + "createSuccess": "奖品创建成功", + "createTitle": "添加奖品", + "daysSuffix": "天", + "delete": "删除", + "deleteDescription": "该奖品将从九宫格中移除,此操作不可撤销。", + "deleteSuccess": "奖品删除成功", + "deleteTitle": "确定删除该奖品吗?", + "durationDays": "时长(天)", + "durationDaysPlaceholder": "例如:1", + "durationDaysRequired": "时长天数必须为正整数", + "editTitle": "编辑奖品", + "fallbackBadge": "保底", + "fallbackIgnoresWeight": "保底奖品不参与权重计算", + "iconUrl": "图标 URL", + "iconUrlInvalid": "请输入有效的 URL", + "isFallback": "保底奖品", + "isFallbackHint": "限量奖品抽完后发放该奖品", + "name": "奖品名称", + "namePlaceholder": "例如:1 天时长卡", + "nameRequired": "请输入奖品名称", + "noFallbackHint": "建议配置一个保底奖品,避免限量奖品抽完后抽奖失败。", + "noFallbackTitle": "未配置保底奖品", + "noRandomPrizes": "暂无参与随机抽取的奖品", + "noneHint": "无需额外配置。建议开启保底,限量奖品抽完后用户将始终抽中该奖品。", + "probabilityPreview": "概率预览", + "selectType": "选择类型", + "slotLabel": "格子", + "soldOutBadge": "已抽完", + "stage2Hint": "Stage 2 开放", + "stockShort": "库存", + "totalStock": "总库存", + "totalStockPlaceholder": "例如:100", + "totalStockRequired": "总库存必须为正整数", + "type": "奖品类型", + "typeCommission": "佣金", + "typeCoupon": "优惠券", + "typeCrypto": "加密货币", + "typeManualOther": "人工发放(其他)", + "typeNone": "谢谢参与", + "typePhysical": "实物奖品", + "typePoints": "积分", + "typeVpnDuration": "免费时长", + "unlimitedStock": "无限库存", + "updateSuccess": "奖品更新成功", + "weight": "权重", + "weightHint": "权重为 0 时仅展示,不参与随机抽取", + "weightInteger": "权重必须为整数", + "weightMin": "权重不能小于 0", + "weightShort": "权重", + "yuanSuffix": "元" + }, + "publish": "上架", + "publishSuccess": "上架成功", + "publishWarning": "上架后活动将对用户可见。", + "rules": { + "addCondition": "添加条件", + "addSource": "添加来源", + "amount": "次数", + "cannotSimplify": "规则树结构较复杂,无法用简单模式编辑,请在高级(JSON)模式下修改", + "capsBytesExceeded": "大小 {{value}} 字节超过上限 {{limit}} 字节", + "capsDepthExceeded": "深度 {{value}} 超过上限 {{limit}}", + "capsExceeded": "规则树超出限制", + "capsNodesExceeded": "节点数 {{value}} 超过上限 {{limit}}", + "chanceSources": "次数来源", + "chanceSourcesDescription": "配置用户获取抽奖次数的方式,每种来源只能配置一次。", + "conditionType": { + "has_subscription": "持有订阅", + "invite_count": "邀请人数", + "register_days": "注册天数", + "subscription_type": "订阅套餐类型", + "total_recharge": "累计充值", + "user_tag": "用户标签" + }, + "dailyLimit": "每日上限(可选)", + "duplicateSource": "来源不能重复", + "eligibility": "参与门槛", + "jsonInvalid": "JSON 无效:{{error}}", + "jsonMustBeObject": "规则树必须是 JSON 对象", + "jsonPlaceholder": "规则树 JSON,例如 {\"op\":\"AND\",\"children\":[...]},留空表示无门槛。", + "noConditions": "暂无条件,所有用户均可参与。", + "op": "条件组合方式", + "opAnd": "且(AND)", + "opOr": "或(OR)", + "param": { + "min": "最小数量", + "minDaysRemaining": "剩余天数下限", + "minUsdt": "最低 USDT 金额", + "planIds": "套餐 ID", + "planIdsPlaceholder": "逗号分隔,例如 1,2,3", + "tags": "标签", + "tagsPlaceholder": "逗号分隔,例如 vip,beta", + "windowDays": "时间窗口天数(可选)" + }, + "save": "保存规则", + "saveFailed": "规则保存失败", + "saveSuccess": "规则保存成功", + "selectSource": "选择来源", + "simpleMode": "简单模式", + "advancedMode": "高级模式(JSON)", + "source": { + "dailySignin": "每日签到", + "inviteSuccess": "邀请成功", + "manualGrant": "手动发放", + "newSubscription": "新订阅" + }, + "stage1Warning": "Stage 1 提示", + "stage1WarningDescription": "当前版本(Stage 1)规则上下文尚未接线,配置门槛将导致用户无法参与抽奖,建议保持为空。" + }, + "status": { + "draft": "草稿", + "ended": "已结束", + "paused": "已暂停", + "running": "进行中" + }, + "statusAll": "全部", + "statusLabel": "状态", + "title": "标题", + "updateSuccess": "更新成功" +} diff --git a/apps/admin/public/assets/locales/zh-CN/menu.json b/apps/admin/public/assets/locales/zh-CN/menu.json index 9e06313..e222104 100644 --- a/apps/admin/public/assets/locales/zh-CN/menu.json +++ b/apps/admin/public/assets/locales/zh-CN/menu.json @@ -14,6 +14,7 @@ "Invite Management": "邀请管理", "Login": "登录", "Logs & Analytics": "日志与分析", + "Lottery Management": "抽奖管理", "Maintenance": "维护", "Marketing Management": "营销管理", "Mobile": "短信", diff --git a/apps/admin/src/layout/navs.ts b/apps/admin/src/layout/navs.ts index cbf7b12..255dc64 100644 --- a/apps/admin/src/layout/navs.ts +++ b/apps/admin/src/layout/navs.ts @@ -81,6 +81,11 @@ export function useNavs() { url: "/dashboard/marketing", icon: "flat-color-icons:bullish", }, + { + title: t("Lottery Management", "Lottery Management"), + url: "/dashboard/lottery", + icon: "flat-color-icons:ratings", + }, { title: t("Announcement Management", "Announcement Management"), url: "/dashboard/announcement", diff --git a/apps/admin/src/routeTree.gen.ts b/apps/admin/src/routeTree.gen.ts index d352d91..4ff8668 100644 --- a/apps/admin/src/routeTree.gen.ts +++ b/apps/admin/src/routeTree.gen.ts @@ -47,6 +47,9 @@ const DashboardOrderIndexLazyRouteImport = const DashboardMarketingIndexLazyRouteImport = createFileRoute( '/dashboard/marketing/', )() +const DashboardLotteryIndexLazyRouteImport = createFileRoute( + '/dashboard/lottery/', +)() const DashboardInviteManagementIndexLazyRouteImport = createFileRoute( '/dashboard/invite-management/', )() @@ -226,6 +229,14 @@ const DashboardMarketingIndexLazyRoute = } as any).lazy(() => import('./routes/dashboard/marketing/index.lazy').then((d) => d.Route), ) +const DashboardLotteryIndexLazyRoute = + DashboardLotteryIndexLazyRouteImport.update({ + id: '/lottery/', + path: '/lottery/', + getParentRoute: () => DashboardRouteLazyRoute, + } as any).lazy(() => + import('./routes/dashboard/lottery/index.lazy').then((d) => d.Route), + ) const DashboardInviteManagementIndexLazyRoute = DashboardInviteManagementIndexLazyRouteImport.update({ id: '/invite-management/', @@ -411,6 +422,7 @@ export interface FileRoutesByFullPath { '/dashboard/family': typeof DashboardFamilyIndexLazyRoute '/dashboard/group': typeof DashboardGroupIndexLazyRoute '/dashboard/invite-management': typeof DashboardInviteManagementIndexLazyRoute + '/dashboard/lottery': typeof DashboardLotteryIndexLazyRoute '/dashboard/marketing': typeof DashboardMarketingIndexLazyRoute '/dashboard/order': typeof DashboardOrderIndexLazyRoute '/dashboard/payment': typeof DashboardPaymentIndexLazyRoute @@ -448,6 +460,7 @@ export interface FileRoutesByTo { '/dashboard/family': typeof DashboardFamilyIndexLazyRoute '/dashboard/group': typeof DashboardGroupIndexLazyRoute '/dashboard/invite-management': typeof DashboardInviteManagementIndexLazyRoute + '/dashboard/lottery': typeof DashboardLotteryIndexLazyRoute '/dashboard/marketing': typeof DashboardMarketingIndexLazyRoute '/dashboard/order': typeof DashboardOrderIndexLazyRoute '/dashboard/payment': typeof DashboardPaymentIndexLazyRoute @@ -488,6 +501,7 @@ export interface FileRoutesById { '/dashboard/family/': typeof DashboardFamilyIndexLazyRoute '/dashboard/group/': typeof DashboardGroupIndexLazyRoute '/dashboard/invite-management/': typeof DashboardInviteManagementIndexLazyRoute + '/dashboard/lottery/': typeof DashboardLotteryIndexLazyRoute '/dashboard/marketing/': typeof DashboardMarketingIndexLazyRoute '/dashboard/order/': typeof DashboardOrderIndexLazyRoute '/dashboard/payment/': typeof DashboardPaymentIndexLazyRoute @@ -529,6 +543,7 @@ export interface FileRouteTypes { | '/dashboard/family' | '/dashboard/group' | '/dashboard/invite-management' + | '/dashboard/lottery' | '/dashboard/marketing' | '/dashboard/order' | '/dashboard/payment' @@ -566,6 +581,7 @@ export interface FileRouteTypes { | '/dashboard/family' | '/dashboard/group' | '/dashboard/invite-management' + | '/dashboard/lottery' | '/dashboard/marketing' | '/dashboard/order' | '/dashboard/payment' @@ -605,6 +621,7 @@ export interface FileRouteTypes { | '/dashboard/family/' | '/dashboard/group/' | '/dashboard/invite-management/' + | '/dashboard/lottery/' | '/dashboard/marketing/' | '/dashboard/order/' | '/dashboard/payment/' @@ -744,6 +761,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DashboardMarketingIndexLazyRouteImport parentRoute: typeof DashboardRouteLazyRoute } + '/dashboard/lottery/': { + id: '/dashboard/lottery/' + path: '/lottery' + fullPath: '/dashboard/lottery' + preLoaderRoute: typeof DashboardLotteryIndexLazyRouteImport + parentRoute: typeof DashboardRouteLazyRoute + } '/dashboard/invite-management/': { id: '/dashboard/invite-management/' path: '/invite-management' @@ -926,6 +950,7 @@ interface DashboardRouteLazyRouteChildren { DashboardFamilyIndexLazyRoute: typeof DashboardFamilyIndexLazyRoute DashboardGroupIndexLazyRoute: typeof DashboardGroupIndexLazyRoute DashboardInviteManagementIndexLazyRoute: typeof DashboardInviteManagementIndexLazyRoute + DashboardLotteryIndexLazyRoute: typeof DashboardLotteryIndexLazyRoute DashboardMarketingIndexLazyRoute: typeof DashboardMarketingIndexLazyRoute DashboardOrderIndexLazyRoute: typeof DashboardOrderIndexLazyRoute DashboardPaymentIndexLazyRoute: typeof DashboardPaymentIndexLazyRoute @@ -964,6 +989,7 @@ const DashboardRouteLazyRouteChildren: DashboardRouteLazyRouteChildren = { DashboardGroupIndexLazyRoute: DashboardGroupIndexLazyRoute, DashboardInviteManagementIndexLazyRoute: DashboardInviteManagementIndexLazyRoute, + DashboardLotteryIndexLazyRoute: DashboardLotteryIndexLazyRoute, DashboardMarketingIndexLazyRoute: DashboardMarketingIndexLazyRoute, DashboardOrderIndexLazyRoute: DashboardOrderIndexLazyRoute, DashboardPaymentIndexLazyRoute: DashboardPaymentIndexLazyRoute, diff --git a/apps/admin/src/routes/dashboard/lottery/index.lazy.tsx b/apps/admin/src/routes/dashboard/lottery/index.lazy.tsx new file mode 100644 index 0000000..e9f1530 --- /dev/null +++ b/apps/admin/src/routes/dashboard/lottery/index.lazy.tsx @@ -0,0 +1,6 @@ +import { createLazyFileRoute } from "@tanstack/react-router"; +import Lottery from "@/sections/lottery"; + +export const Route = createLazyFileRoute("/dashboard/lottery/")({ + component: Lottery, +}); diff --git a/apps/admin/src/sections/lottery/activity-detail.tsx b/apps/admin/src/sections/lottery/activity-detail.tsx new file mode 100644 index 0000000..e2630fd --- /dev/null +++ b/apps/admin/src/sections/lottery/activity-detail.tsx @@ -0,0 +1,119 @@ +import { Badge } from "@workspace/ui/components/badge"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, +} from "@workspace/ui/components/sheet"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@workspace/ui/components/tabs"; +import { getLotteryActivityDetail } from "@workspace/ui/services/admin/lottery"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import PrizeGrid from "./prize-grid"; +import RuleEditor from "./rule-editor"; + +interface ActivityDetailProps { + activity: API.LotteryActivity | null; + open: boolean; + onOpenChange: (open: boolean) => void; + onChanged?: () => void; +} + +type TranslateFn = (key: string, defaultValue: string) => string; + +const STATUS_BADGE_VARIANTS: Record< + API.LotteryActivityStatus, + "default" | "secondary" | "destructive" | "outline" +> = { + draft: "secondary", + running: "default", + paused: "outline", + ended: "destructive", +}; + +function statusLabel( + status: API.LotteryActivityStatus, + t: TranslateFn +): string { + const labels: Record = { + draft: t("status.draft", "Draft"), + running: t("status.running", "Running"), + paused: t("status.paused", "Paused"), + ended: t("status.ended", "Ended"), + }; + return labels[status] ?? status; +} + +export default function ActivityDetail({ + activity, + open, + onOpenChange, + onChanged, +}: ActivityDetailProps) { + const { t } = useTranslation("lottery"); + // 抽屉打开时拉取详情作为数据源,避免列表行数据不完整时被全量 PUT 覆盖 + const [detail, setDetail] = useState(null); + const activityId = activity?.id; + + useEffect(() => { + setDetail(null); + if (!(open && activityId)) return; + let cancelled = false; + getLotteryActivityDetail({ id: activityId }) + .then(({ data }) => { + if (!cancelled && data.data) setDetail(data.data); + }) + .catch(() => { + // Request layer already surfaces the error toast; fall back to prop data + }); + return () => { + cancelled = true; + }; + }, [open, activityId]); + + const current = detail ?? activity; + + return ( + + + {current && ( + <> + + + {current.title} + + {statusLabel(current.status, t)} + + + +
+ + + + {t("detail.prizesTab", "Prizes")} + + + {t("detail.rulesTab", "Rules")} + + + + + + + + + +
+ + )} +
+
+ ); +} diff --git a/apps/admin/src/sections/lottery/activity-form.tsx b/apps/admin/src/sections/lottery/activity-form.tsx new file mode 100644 index 0000000..3348337 --- /dev/null +++ b/apps/admin/src/sections/lottery/activity-form.tsx @@ -0,0 +1,286 @@ +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 { ScrollArea } from "@workspace/ui/components/scroll-area"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@workspace/ui/components/select"; +import { + Sheet, + SheetContent, + SheetFooter, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@workspace/ui/components/sheet"; +import { Textarea } from "@workspace/ui/components/textarea"; +import { DatePicker } from "@workspace/ui/composed/date-picker"; +import { EnhancedInput } from "@workspace/ui/composed/enhanced-input"; +import { Icon } from "@workspace/ui/composed/icon"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { z } from "zod"; + +const GRID_SIZE_OPTIONS = [9] as const; + +const getFormSchema = (t: (key: string, defaultValue: string) => string) => + z + .object({ + title: z.string().min(1, t("form.titleRequired", "Title is required")), + description: z.string().optional(), + // DatePicker works in milliseconds; converted to Unix seconds on submit + start_at: z + .number() + .min(1, t("form.startAtRequired", "Start time is required")), + end_at: z + .number() + .min(1, t("form.endAtRequired", "End time is required")), + grid_size: z.number(), + }) + .superRefine((data, ctx) => { + if ( + data.start_at > 0 && + data.end_at > 0 && + data.end_at <= data.start_at + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t( + "form.endAtAfterStartAt", + "End time must be later than start time" + ), + path: ["end_at"], + }); + } + }); + +type ActivityFormValues = z.infer>; + +interface ActivityFormProps { + trigger: React.ReactNode; + title: string; + loading?: boolean; + initialValues?: Partial; + onSubmit: (values: API.CreateLotteryActivityRequest) => Promise; +} + +function toFormValues( + activity?: Partial +): ActivityFormValues { + return { + title: activity?.title ?? "", + description: activity?.description ?? "", + start_at: activity?.start_at ? activity.start_at * 1000 : 0, + end_at: activity?.end_at ? activity.end_at * 1000 : 0, + grid_size: activity?.grid_size ?? GRID_SIZE_OPTIONS[0], + }; +} + +export default function ActivityForm({ + trigger, + title, + loading, + initialValues, + onSubmit, +}: ActivityFormProps) { + const { t } = useTranslation("lottery"); + const formSchema = getFormSchema(t); + + const [open, setOpen] = useState(false); + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: toFormValues(initialValues), + }); + + useEffect(() => { + form.reset(toFormValues(initialValues)); + }, [form, initialValues]); + + async function handleSubmit(values: ActivityFormValues) { + const success = await onSubmit({ + title: values.title.trim(), + description: values.description ?? "", + start_at: Math.floor(values.start_at / 1000), + end_at: Math.floor(values.end_at / 1000), + grid_size: values.grid_size, + eligibility: initialValues?.eligibility ?? {}, + chance_sources: initialValues?.chance_sources ?? [], + unmet_action: "block", + }); + if (success) { + setOpen(false); + form.reset(toFormValues(initialValues)); + } + } + + return ( + + + + + + + {title} + + +
+ + ( + + {t("form.title", "Title")} + + field.onChange(value)} + placeholder={t( + "form.titlePlaceholder", + "Activity title" + )} + value={field.value} + /> + + + + )} + /> + ( + + + {t("form.description", "Description")} + + +