feat: Redemption Code

This commit is contained in:
EUForest 2026-01-06 21:05:23 +08:00
parent 45b9b523b1
commit 2c4518cbab
6 changed files with 688 additions and 0 deletions

View File

@ -19,6 +19,7 @@
"Order Management": "Order Management", "Order Management": "Order Management",
"Payment Config": "Payment Config", "Payment Config": "Payment Config",
"Product Management": "Product Management", "Product Management": "Product Management",
"Redemption Management": "Redemption Management",
"Register": "Register", "Register": "Register",
"Reset Subscribe": "Reset Subscribe", "Reset Subscribe": "Reset Subscribe",
"Server Management": "Server Management", "Server Management": "Server Management",

View File

@ -19,6 +19,7 @@
"Order Management": "订单管理", "Order Management": "订单管理",
"Payment Config": "支付配置", "Payment Config": "支付配置",
"Product Management": "商品管理", "Product Management": "商品管理",
"Redemption Management": "兑换码管理",
"Register": "注册", "Register": "注册",
"Reset Subscribe": "重置订阅", "Reset Subscribe": "重置订阅",
"Server Management": "服务器管理", "Server Management": "服务器管理",

View File

@ -0,0 +1,6 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import Redemption from "@/sections/redemption";
export const Route = createLazyFileRoute("/dashboard/redemption/")({
component: Redemption,
});

View File

@ -0,0 +1,249 @@
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
import { Switch } from "@workspace/ui/components/switch";
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
import {
ProTable,
type ProTableActions,
} from "@workspace/ui/composed/pro-table/pro-table";
import {
batchDeleteRedemptionCode,
createRedemptionCode,
deleteRedemptionCode,
getRedemptionCodeList,
toggleRedemptionCodeStatus,
updateRedemptionCode,
} from "@workspace/ui/services/admin/redemption";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useSubscribe } from "@/stores/subscribe";
import RedemptionForm from "./redemption-form";
import RedemptionRecords from "./redemption-records";
export default function Redemption() {
const { t } = useTranslation("redemption");
const [loading, setLoading] = useState(false);
const [recordsOpen, setRecordsOpen] = useState(false);
const [selectedCodeId, setSelectedCodeId] = useState<number | null>(null);
const { subscribes } = useSubscribe();
const ref = useRef<ProTableActions>(null);
return (
<>
<ProTable<API.RedemptionCode, { subscribe_plan: number; unit_time: string; code: string }>
action={ref}
actions={{
render: (row) => [
<Button
key="records"
variant="outline"
size="sm"
onClick={() => {
setSelectedCodeId(row.id);
setRecordsOpen(true);
}}
>
{t("records", "Records")}
</Button>,
<RedemptionForm<API.UpdateRedemptionCodeRequest>
initialValues={row}
key="edit"
loading={loading}
onSubmit={async (values) => {
setLoading(true);
try {
await updateRedemptionCode({ ...values });
toast.success(t("updateSuccess", "Update Success"));
ref.current?.refresh();
setLoading(false);
return true;
} catch (_error) {
setLoading(false);
return false;
}
}}
title={t("editRedemptionCode", "Edit Redemption Code")}
trigger={t("edit", "Edit")}
/>,
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={t(
"deleteWarning",
"Once deleted, data cannot be recovered. Please proceed with caution."
)}
key="delete"
onConfirm={async () => {
await deleteRedemptionCode({ id: row.id });
toast.success(t("deleteSuccess", "Delete Success"));
ref.current?.refresh();
}}
title={t("confirmDelete", "Are you sure you want to delete?")}
trigger={
<Button variant="destructive">{t("delete", "Delete")}</Button>
}
/>,
],
batchRender: (rows) => [
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={t(
"deleteWarning",
"Once deleted, data cannot be recovered. Please proceed with caution."
)}
key="delete"
onConfirm={async () => {
await batchDeleteRedemptionCode({
ids: rows.map((item) => item.id),
});
toast.success(t("deleteSuccess", "Delete Success"));
ref.current?.reset();
}}
title={t("confirmDelete", "Are you sure you want to delete?")}
trigger={
<Button variant="destructive">{t("delete", "Delete")}</Button>
}
/>,
],
}}
columns={[
{
accessorKey: "code",
header: t("code", "Code"),
},
{
accessorKey: "subscribe_plan",
header: t("subscribePlan", "Subscribe Plan"),
cell: ({ row }) => {
const plan = subscribes?.find(
(s) => s.id === row.getValue("subscribe_plan")
);
return plan?.name || "--";
},
},
{
accessorKey: "unit_time",
header: t("unitTime", "Unit Time"),
cell: ({ row }) => {
const unitTime = row.getValue("unit_time") as string;
const unitTimeMap: Record<string, string> = {
day: t("form.day", "Day"),
month: t("form.month", "Month"),
quarter: t("form.quarter", "Quarter"),
half_year: t("form.halfYear", "Half Year"),
year: t("form.year", "Year"),
};
return unitTimeMap[unitTime] || unitTime;
},
},
{
accessorKey: "quantity",
header: t("duration", "Duration"),
cell: ({ row }) => `${row.original.quantity}`,
},
{
accessorKey: "total_count",
header: t("totalCount", "Total Count"),
cell: ({ row }) => (
<div className="flex flex-col">
<span>
{t("totalCount", "Total")}: {row.original.total_count}
</span>
<span>
{t("remainingCount", "Remaining")}:{" "}
{row.original.total_count - (row.original.used_count || 0)}
</span>
<span>
{t("usedCount", "Used")}: {row.original.used_count || 0}
</span>
</div>
),
},
{
accessorKey: "status",
header: t("status", "Status"),
cell: ({ row }) => (
<Switch
defaultChecked={row.getValue("status") === 1}
onCheckedChange={async (checked) => {
await toggleRedemptionCodeStatus({
id: row.original.id,
status: checked ? 1 : 0,
});
toast.success(
checked
? t("updateSuccess", "Update Success")
: t("updateSuccess", "Update Success")
);
ref.current?.refresh();
}}
/>
),
},
]}
header={{
toolbar: (
<RedemptionForm<API.CreateRedemptionCodeRequest>
loading={loading}
onSubmit={async (values) => {
setLoading(true);
try {
await createRedemptionCode(values);
toast.success(t("createSuccess", "Create Success"));
ref.current?.refresh();
setLoading(false);
return true;
} catch (_error) {
setLoading(false);
return false;
}
}}
title={t("createRedemptionCode", "Create Redemption Code")}
trigger={t("create", "Create")}
/>
),
}}
params={[
{
key: "subscribe_plan",
placeholder: t("subscribePlan", "Subscribe Plan"),
options: subscribes?.map((item) => ({
label: item.name!,
value: String(item.id),
})),
},
{
key: "unit_time",
placeholder: t("unitTime", "Unit Time"),
options: [
{ label: t("form.day", "Day"), value: "day" },
{ label: t("form.month", "Month"), value: "month" },
{ label: t("form.quarter", "Quarter"), value: "quarter" },
{ label: t("form.halfYear", "Half Year"), value: "half_year" },
{ label: t("form.year", "Year"), value: "year" },
],
},
{
key: "code",
},
]}
request={async (pagination, filters) => {
const { data } = await getRedemptionCodeList({
...pagination,
...filters,
});
return {
list: data.data?.list || [],
total: data.data?.total || 0,
};
}}
/>
<RedemptionRecords
codeId={selectedCodeId}
open={recordsOpen}
onOpenChange={setRecordsOpen}
/>
</>
);
}

View File

@ -0,0 +1,289 @@
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 {
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 { 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";
import { useSubscribe } from "@/stores/subscribe";
const formSchema = z.object({
id: z.number().optional(),
code: z.string().optional(),
batch_count: z.number().optional(),
total_count: z.number().min(1, "Total count is required"),
subscribe_plan: z.number().min(1, "Subscribe plan is required"),
unit_time: z.string().min(1, "Unit time is required"),
quantity: z.number().min(1, "Quantity is required"),
});
interface RedemptionFormProps<T> {
onSubmit: (data: T) => Promise<boolean> | boolean;
initialValues?: T;
loading?: boolean;
trigger: string;
title: string;
}
export default function RedemptionForm<T extends Record<string, any>>({
onSubmit,
initialValues,
loading,
trigger,
title,
}: RedemptionFormProps<T>) {
const { t } = useTranslation("redemption");
const [open, setOpen] = useState(false);
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
...initialValues,
} as any,
});
useEffect(() => {
form?.reset(initialValues);
}, [form, initialValues]);
async function handleSubmit(data: { [x: string]: any }) {
// When editing, merge the id from initialValues
const submitData = initialValues?.id
? { ...data, id: initialValues.id }
: data;
const bool = await onSubmit(submitData as T);
if (bool) setOpen(false);
}
const { subscribes } = useSubscribe();
const isEdit = !!initialValues?.id;
return (
<Sheet onOpenChange={setOpen} open={open}>
<SheetTrigger asChild>
<Button
onClick={() => {
form.reset();
setOpen(true);
}}
>
{trigger}
</Button>
</SheetTrigger>
<SheetContent className="w-[500px] max-w-full md:max-w-screen-md">
<SheetHeader>
<SheetTitle>{title}</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100vh-48px-36px-36px-env(safe-area-inset-top))]">
<Form {...form}>
<form
className="space-y-4 px-6 pt-4"
onSubmit={form.handleSubmit(handleSubmit)}
>
{isEdit && (
<FormField
control={form.control}
name="code"
render={({ field }) => (
<FormItem>
<FormLabel>{t("form.code", "Code")}</FormLabel>
<FormControl>
<EnhancedInput
disabled
onValueChange={(value) => {
form.setValue(field.name, value);
}}
placeholder={t("form.code", "Code")}
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{!isEdit && (
<FormField
control={form.control}
name="batch_count"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("form.batchCount", "Batch Count")}
</FormLabel>
<FormControl>
<EnhancedInput
min={1}
placeholder={t(
"form.batchCountPlaceholder",
"Batch Count"
)}
step={1}
type="number"
{...field}
onValueChange={(value) => {
form.setValue(field.name, value);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="subscribe_plan"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("form.subscribePlan", "Subscribe Plan")}
</FormLabel>
<FormControl>
<Combobox<number, false>
onChange={(value) => {
form.setValue(field.name, value);
}}
options={subscribes?.map((item) => ({
value: item.id!,
label: item.name!,
}))}
placeholder={t(
"form.selectPlan",
"Select Subscribe Plan"
)}
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="unit_time"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("form.unitTime", "Unit Time")}
</FormLabel>
<FormControl>
<Combobox<string, false>
onChange={(value) => {
form.setValue(field.name, value);
}}
options={[
{ value: "day", label: t("form.day", "Day") },
{ value: "month", label: t("form.month", "Month") },
{ value: "quarter", label: t("form.quarter", "Quarter") },
{ value: "half_year", label: t("form.halfYear", "Half Year") },
{ value: "year", label: t("form.year", "Year") },
]}
placeholder={t(
"form.selectUnitTime",
"Select Unit Time"
)}
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="quantity"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("form.duration", "Duration")}
</FormLabel>
<FormControl>
<EnhancedInput
min={1}
placeholder={t(
"form.durationPlaceholder",
"Duration"
)}
step={1}
type="number"
{...field}
onValueChange={(value) => {
form.setValue(field.name, value);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="total_count"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("form.totalCount", "Total Count")}
</FormLabel>
<FormControl>
<EnhancedInput
min={1}
placeholder={t(
"form.totalCountPlaceholder",
"Total Count"
)}
step={1}
type="number"
{...field}
onValueChange={(value) => {
form.setValue(field.name, value);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className="flex-row justify-end gap-2 pt-3">
<Button
disabled={loading}
onClick={() => {
setOpen(false);
}}
variant="outline"
>
{t("form.cancel", "Cancel")}
</Button>
<Button disabled={loading} onClick={form.handleSubmit(handleSubmit)}>
{loading && (
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
)}{" "}
{t("form.confirm", "Confirm")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}

View File

@ -0,0 +1,142 @@
import { Badge } from "@workspace/ui/components/badge";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@workspace/ui/components/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@workspace/ui/components/table";
import { getRedemptionRecordList } from "@workspace/ui/services/admin/redemption";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { formatDate } from "@/utils/common";
interface RedemptionRecordsProps {
codeId: number | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export default function RedemptionRecords({
codeId,
open,
onOpenChange,
}: RedemptionRecordsProps) {
const { t } = useTranslation("redemption");
const [loading, setLoading] = useState(false);
const [records, setRecords] = useState<API.RedemptionRecord[]>([]);
const [total, setTotal] = useState(0);
const [pagination, setPagination] = useState({ page: 1, size: 10 });
const fetchRecords = async () => {
if (!codeId) return;
setLoading(true);
try {
const { data } = await getRedemptionRecordList({
...pagination,
code_id: codeId,
});
setRecords(data.data?.list || []);
setTotal(data.data?.total || 0);
} catch (error) {
console.error("Failed to fetch redemption records:", error);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (open && codeId) {
fetchRecords();
}
}, [open, codeId, pagination]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{t("records", "Redemption Records")}
</DialogTitle>
</DialogHeader>
<div className="mt-4">
{loading ? (
<div className="flex justify-center py-8">
<span>{t("loading", "Loading...")}</span>
</div>
) : records.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{t("noRecords", "No records found")}
</div>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>{t("userId", "User ID")}</TableHead>
<TableHead>{t("subscribeId", "Subscribe ID")}</TableHead>
<TableHead>{t("unitTime", "Unit Time")}</TableHead>
<TableHead>{t("quantity", "Quantity")}</TableHead>
<TableHead>{t("redeemedAt", "Redeemed At")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{records.map((record) => (
<TableRow key={record.id}>
<TableCell>{record.id}</TableCell>
<TableCell>{record.user_id}</TableCell>
<TableCell>{record.subscribe_id}</TableCell>
<TableCell>{record.unit_time}</TableCell>
<TableCell>{record.quantity}</TableCell>
<TableCell>
{record.redeemed_at
? formatDate(record.redeemed_at)
: "--"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{total > pagination.size && (
<div className="flex justify-between items-center mt-4">
<span className="text-sm text-muted-foreground">
{t("total", "Total")}: {total}
</span>
<div className="flex gap-2">
<button
className="px-3 py-1 text-sm border rounded hover:bg-accent disabled:opacity-50"
disabled={pagination.page === 1}
onClick={() =>
setPagination((p) => ({ ...p, page: p.page - 1 }))
}
>
{t("previous", "Previous")}
</button>
<button
className="px-3 py-1 text-sm border rounded hover:bg-accent disabled:opacity-50"
disabled={pagination.page * pagination.size >= total}
onClick={() =>
setPagination((p) => ({ ...p, page: p.page + 1 }))
}
>
{t("next", "Next")}
</button>
</div>
</div>
)}
</>
)}
</div>
</DialogContent>
</Dialog>
);
}