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, } from "@workspace/ui/components/sheet"; import { Switch } from "@workspace/ui/components/switch"; import { ConfirmButton } from "@workspace/ui/composed/confirm-button"; import { EnhancedInput, type EnhancedInputProps, } from "@workspace/ui/composed/enhanced-input"; import { Icon } from "@workspace/ui/composed/icon"; import { createLotteryPrize, deleteLotteryPrize, updateLotteryPrize, } from "@workspace/ui/services/admin/lottery"; import { getSubscribeList } from "@workspace/ui/services/admin/subscribe"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { z } from "zod"; // 概率单位约定:weight 1000 = 1%(支持 3 位小数概率,总权重 100000 = 100%)。 const WEIGHT_PER_PERCENT = 1000; // numberOutput 把 text 输入转成数字(空/非法→undefined)。数字输入统一用 type="text" // + 该 formatOutput,避开 EnhancedInput 在 type="number" 下 min/max 实时钳制导致的 // “输入 0 / 改中间位跳变”问题;提交仍是数字,后端收 int。 const numberOutput = (v: string | number): number | undefined => { const s = String(v ?? "").trim(); if (s === "") { return; } const n = Number(s); return Number.isNaN(n) ? undefined : n; }; type TranslateFn = (key: string, defaultValue: string) => string; const PRIZE_TYPE_OPTIONS: { value: API.LotteryPrizeType; labelKey: string; labelDefault: string; }[] = [ { value: "vpn_duration", labelKey: "prize.typeVpnDuration", labelDefault: "VPN Duration", }, { value: "crypto", labelKey: "prize.typeCrypto", labelDefault: "Crypto", }, { value: "none", labelKey: "prize.typeNone", labelDefault: "No Prize (Thanks)", }, ]; const getPrizeSchema = (t: TranslateFn) => z .object({ type: z.enum(["vpn_duration", "crypto", "none"]), name: z.string().min(1, t("prize.nameRequired", "Name is required")), icon_url: z .string() .url(t("prize.iconUrlInvalid", "Must be a valid URL")) .or(z.literal("")) .optional(), weight: z .number() .min(0, t("prize.weightMin", "Probability must be at least 0")) .max(100, t("prize.weightMax", "Probability cannot exceed 100")), unlimited_stock: z.boolean(), total_stock: z.number().optional(), is_fallback: z.boolean(), duration_days: z.number().optional(), subscribe_id: z.number().optional(), }) .superRefine((data, ctx) => { const issue = (path: string, message: string) => { ctx.addIssue({ code: z.ZodIssueCode.custom, message, path: [path] }); }; const isPositiveInt = (value?: number) => !!value && value > 0 && Number.isInteger(value); if (data.type === "vpn_duration" && !isPositiveInt(data.duration_days)) { issue( "duration_days", t( "prize.durationDaysRequired", "Duration days must be a positive integer" ) ); } if (!(data.unlimited_stock || isPositiveInt(data.total_stock))) { issue( "total_stock", t( "prize.totalStockRequired", "Total stock must be a positive integer" ) ); } }); type PrizeFormValues = z.infer>; interface PrizeFormProps { activityId: number; slot: number; initialValues?: API.LotteryPrize; open: boolean; onOpenChange: (open: boolean) => void; onSuccess: () => void; } function toFormValues(prize?: API.LotteryPrize): PrizeFormValues { const config = prize?.config ?? {}; return { type: (prize?.type as PrizeFormValues["type"]) ?? "vpn_duration", name: prize?.name ?? "", icon_url: prize?.icon_url ?? "", weight: (prize?.weight ?? 0) / WEIGHT_PER_PERCENT, unlimited_stock: prize ? prize.total_stock === null : true, total_stock: prize?.total_stock ?? undefined, is_fallback: prize?.is_fallback ?? false, duration_days: config.duration_days as number | undefined, subscribe_id: config.subscribe_id as number | undefined, }; } function toConfig(values: PrizeFormValues): Record { if (values.type === "vpn_duration") { const config: Record = { duration_days: values.duration_days }; // 可选:无活跃订阅时按该套餐新建订阅发放时长。0/未选则沿用旧行为(跳过发放)。 if (values.subscribe_id && values.subscribe_id > 0) { config.subscribe_id = values.subscribe_id; } return config; } // crypto(人工发放) / none 无需额外配置:中奖后凭截图联系客服线下发放。 return {}; } export default function PrizeForm({ activityId, slot, initialValues, open, onOpenChange, onSuccess, }: PrizeFormProps) { const { t } = useTranslation("lottery"); const [loading, setLoading] = useState(false); const [plans, setPlans] = useState([]); const form = useForm({ resolver: zodResolver(getPrizeSchema(t)), defaultValues: toFormValues(initialValues), }); const type = form.watch("type"); const unlimitedStock = form.watch("unlimited_stock"); const isFallback = form.watch("is_fallback"); const isEdit = !!initialValues?.id; const targetSlot = initialValues?.slot ?? slot; useEffect(() => { if (open) form.reset(toFormValues(initialValues)); }, [form, initialValues, open]); // vpn_duration 需要一个可选的“新建订阅套餐”下拉,仅在面板打开且类型匹配时拉取。 useEffect(() => { if (!(open && type === "vpn_duration") || plans.length > 0) return; getSubscribeList({ page: 1, size: 100 }) .then(({ data }) => setPlans(data?.data?.list ?? [])) .catch(() => { // Request layer already surfaces the error toast }); }, [open, type, plans.length]); async function handleSubmit(values: PrizeFormValues) { setLoading(true); try { const body: API.CreateLotteryPrizeRequest = { activity_id: activityId, slot: targetSlot, type: values.type, name: values.name.trim(), icon_url: values.icon_url?.trim() ?? "", config: toConfig(values), weight: values.is_fallback ? 0 : Math.round(values.weight * WEIGHT_PER_PERCENT), total_stock: values.unlimited_stock ? null : (values.total_stock ?? null), is_fallback: values.is_fallback, }; if (isEdit) { await updateLotteryPrize({ ...body, id: initialValues!.id }); toast.success(t("prize.updateSuccess", "Prize updated")); } else { await createLotteryPrize(body); toast.success(t("prize.createSuccess", "Prize created")); } onSuccess(); onOpenChange(false); } catch { // Request layer already surfaces the error toast } finally { setLoading(false); } } async function handleDelete() { if (!initialValues?.id) return; setLoading(true); try { await deleteLotteryPrize({ id: initialValues.id }); toast.success(t("prize.deleteSuccess", "Prize deleted")); onSuccess(); onOpenChange(false); } catch { // Request layer already surfaces the error toast } finally { setLoading(false); } } const renderInput = ( name: "name" | "icon_url" | "weight" | "total_stock" | "duration_days", label: string, inputProps: Partial>, description?: string ) => ( ( {label} form.setValue(name, value)} value={field.value} {...inputProps} /> {description && (

{description}

)}
)} /> ); const renderSwitch = ( name: "unlimited_stock" | "is_fallback", label: string, description?: string ) => ( (
{label} {description && (

{description}

)}
form.setValue(name, checked)} />
)} /> ); return ( {isEdit ? t("prize.editTitle", "Edit Prize") : t("prize.createTitle", "Add Prize")} {" · "} {t("prize.slotLabel", "Slot")} {targetSlot}
( {t("prize.type", "Prize Type")} )} /> {renderInput("name", t("prize.name", "Prize Name"), { placeholder: t("prize.namePlaceholder", "e.g. 1-Day Pass"), })} {renderInput("icon_url", t("prize.iconUrl", "Icon URL"), { placeholder: "https://", })} {type === "vpn_duration" && renderInput( "duration_days", t("prize.durationDays", "Duration (days)"), { type: "text", inputMode: "numeric", formatOutput: numberOutput, placeholder: t("prize.durationDaysPlaceholder", "e.g. 1"), suffix: t("prize.daysSuffix", "days"), } )} {type === "vpn_duration" && ( ( {t("prize.subscribePlan", "New-subscription plan")}

{t( "prize.subscribePlanHint", "When the user has no active subscription, create one on this plan and grant the duration. Leave empty to skip granting." )}

)} /> )} {type === "none" && (

{t( "prize.noneHint", "No config needed. Consider enabling fallback so users always land on this prize when limited prizes run out." )}

)} {type === "crypto" && (

{t( "prize.manualPrizeHint", "Manual prize: no extra config. When won, the user contacts customer service with a screenshot to redeem — no in-app claim form." )}

)} {renderInput( "weight", t("prize.weight", "Win probability (%)"), { type: "text", inputMode: "decimal", formatOutput: numberOutput, suffix: "%", disabled: isFallback, }, isFallback ? t( "prize.fallbackIgnoresWeight", "Fallback prize ignores probability (never randomly drawn)" ) : t( "prize.weightHint", "Percent chance in the grid. All drawable prizes must total 100%. 0 = shown but never drawn." ) )} {renderSwitch( "unlimited_stock", t("prize.unlimitedStock", "Unlimited Stock") )} {!unlimitedStock && renderInput( "total_stock", t("prize.totalStock", "Total Stock"), { type: "text", inputMode: "numeric", formatOutput: numberOutput, placeholder: t("prize.totalStockPlaceholder", "e.g. 100"), } )} {renderSwitch( "is_fallback", t("prize.isFallback", "Fallback Prize"), t( "prize.isFallbackHint", "Awarded when limited prizes are sold out" ) )}
{isEdit && ( {t("prize.delete", "Delete")} } /> )}
); }