feat: add withdrawal management pages
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
@@ -9,29 +10,146 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@workspace/ui/components/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@workspace/ui/components/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@workspace/ui/components/form";
|
||||
import { Input } from "@workspace/ui/components/input";
|
||||
import { Textarea } from "@workspace/ui/components/textarea";
|
||||
import type { ProListActions } from "@workspace/ui/composed/pro-list/pro-list";
|
||||
import { ProList } from "@workspace/ui/composed/pro-list/pro-list";
|
||||
import {
|
||||
commissionWithdraw,
|
||||
queryUserAffiliate,
|
||||
queryUserAffiliateList,
|
||||
queryWithdrawalLog,
|
||||
} from "@workspace/ui/services/user/user";
|
||||
import { formatDate } from "@workspace/ui/utils/formatting";
|
||||
import { unitConversion } from "@workspace/ui/utils/unit-conversions";
|
||||
import { Copy } from "lucide-react";
|
||||
import { useRef, useState, useTransition } from "react";
|
||||
import { CopyToClipboard } from "react-copy-to-clipboard";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Display } from "@/components/display";
|
||||
import { useGlobalStore } from "@/stores/global";
|
||||
|
||||
function getWithdrawalStatusMeta(
|
||||
status: number,
|
||||
t: ReturnType<typeof useTranslation<"affiliate">>["t"]
|
||||
) {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return {
|
||||
label: t("withdrawal.status.approved", "Approved"),
|
||||
className: "bg-emerald-500/10 text-emerald-600",
|
||||
};
|
||||
case 2:
|
||||
return {
|
||||
label: t("withdrawal.status.rejected", "Rejected"),
|
||||
className: "bg-destructive/10 text-destructive",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
label: t("withdrawal.status.pending", "Pending"),
|
||||
className: "bg-amber-500/10 text-amber-600",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default function Affiliate() {
|
||||
const { t } = useTranslation("affiliate");
|
||||
const { user, common } = useGlobalStore();
|
||||
const { data } = useQuery({
|
||||
const { user, common, getUserInfo } = useGlobalStore();
|
||||
const [withdrawalOpen, setWithdrawalOpen] = useState(false);
|
||||
const [submitting, startTransition] = useTransition();
|
||||
const withdrawalListActionRef = useRef<ProListActions | undefined>(undefined);
|
||||
|
||||
const affiliateQuery = useQuery({
|
||||
queryKey: ["queryUserAffiliate"],
|
||||
queryFn: async () => {
|
||||
const response = await queryUserAffiliate();
|
||||
return response.data.data;
|
||||
},
|
||||
});
|
||||
const availableCommission = Number(
|
||||
user?.commission ?? affiliateQuery.data?.total_commission ?? 0
|
||||
);
|
||||
const withdrawalFormSchema = z.object({
|
||||
amount: z
|
||||
.number()
|
||||
.int(t("withdrawal.validation.amountInvalid", "Amount is invalid"))
|
||||
.positive(
|
||||
t(
|
||||
"withdrawal.validation.amountPositive",
|
||||
"Amount must be greater than 0"
|
||||
)
|
||||
)
|
||||
.max(
|
||||
availableCommission,
|
||||
t(
|
||||
"withdrawal.validation.amountExceeded",
|
||||
"Amount cannot exceed available commission"
|
||||
)
|
||||
),
|
||||
content: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(
|
||||
1,
|
||||
t(
|
||||
"withdrawal.validation.contentRequired",
|
||||
"Withdrawal info is required"
|
||||
)
|
||||
)
|
||||
.max(
|
||||
1000,
|
||||
t("withdrawal.validation.contentTooLong", "Withdrawal info is too long")
|
||||
),
|
||||
});
|
||||
const withdrawalForm = useForm<z.infer<typeof withdrawalFormSchema>>({
|
||||
resolver: zodResolver(withdrawalFormSchema),
|
||||
defaultValues: {
|
||||
amount: 0,
|
||||
content: "",
|
||||
},
|
||||
});
|
||||
|
||||
const submitWithdrawal = (values: z.infer<typeof withdrawalFormSchema>) => {
|
||||
startTransition(async () => {
|
||||
await commissionWithdraw({
|
||||
amount: values.amount,
|
||||
content: values.content.trim(),
|
||||
});
|
||||
toast.success(
|
||||
t("withdrawal.submitSuccess", "Withdrawal request submitted")
|
||||
);
|
||||
await Promise.all([
|
||||
affiliateQuery.refetch(),
|
||||
getUserInfo(),
|
||||
withdrawalListActionRef.current?.refresh(),
|
||||
]);
|
||||
setWithdrawalOpen(false);
|
||||
withdrawalForm.reset({
|
||||
amount: 0,
|
||||
content: "",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -43,15 +161,146 @@ export default function Affiliate() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="font-bold text-3xl">
|
||||
<Display type="currency" value={data?.total_commission} />
|
||||
</span>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
({t("commissionRate", "Commission Rate")}:{" "}
|
||||
{user?.referral_percentage || common?.invite?.referral_percentage}
|
||||
%)
|
||||
</span>
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="font-bold text-3xl">
|
||||
<Display
|
||||
type="currency"
|
||||
value={affiliateQuery.data?.total_commission}
|
||||
/>
|
||||
</span>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
({t("commissionRate", "Commission Rate")}:{" "}
|
||||
{user?.referral_percentage ||
|
||||
common?.invite?.referral_percentage}
|
||||
%)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-2 md:items-end">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{t(
|
||||
"withdrawal.availableCommission",
|
||||
"Available for withdrawal"
|
||||
)}
|
||||
</div>
|
||||
<div className="font-semibold text-xl">
|
||||
<Display type="currency" value={availableCommission} />
|
||||
</div>
|
||||
<Dialog onOpenChange={setWithdrawalOpen} open={withdrawalOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
{t("withdrawal.apply", "Apply for Withdrawal")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("withdrawal.applyTitle", "Apply for Withdrawal")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
"withdrawal.applyDescription",
|
||||
"Enter the withdrawal amount and payout details."
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...withdrawalForm}>
|
||||
<form
|
||||
className="space-y-4"
|
||||
id="withdrawal-form"
|
||||
onSubmit={withdrawalForm.handleSubmit(submitWithdrawal)}
|
||||
>
|
||||
<FormField
|
||||
control={withdrawalForm.control}
|
||||
name="amount"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("withdrawal.amount", "Amount")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
inputMode="decimal"
|
||||
min="0"
|
||||
onChange={(event) => {
|
||||
const cents = unitConversion(
|
||||
"dollarsToCents",
|
||||
Number(event.target.value || 0)
|
||||
);
|
||||
field.onChange(Number(cents) || 0);
|
||||
}}
|
||||
placeholder={t(
|
||||
"withdrawal.amountPlaceholder",
|
||||
"Enter withdrawal amount"
|
||||
)}
|
||||
step="0.01"
|
||||
type="number"
|
||||
value={
|
||||
field.value
|
||||
? unitConversion(
|
||||
"centsToDollars",
|
||||
Number(field.value)
|
||||
)
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("withdrawal.amountHint", "Available")}:{" "}
|
||||
<Display
|
||||
type="currency"
|
||||
value={availableCommission}
|
||||
/>
|
||||
</p>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={withdrawalForm.control}
|
||||
name="content"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("withdrawal.content", "Payout Information")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={t(
|
||||
"withdrawal.contentPlaceholder",
|
||||
"Bank account, wallet address, recipient name, or other payout details"
|
||||
)}
|
||||
rows={4}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
disabled={submitting}
|
||||
onClick={() => setWithdrawalOpen(false)}
|
||||
variant="outline"
|
||||
>
|
||||
{t("withdrawal.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={submitting || availableCommission <= 0}
|
||||
form="withdrawal-form"
|
||||
type="submit"
|
||||
>
|
||||
{submitting
|
||||
? t("withdrawal.submitting", "Submitting...")
|
||||
: t("withdrawal.submit", "Submit Withdrawal")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -82,6 +331,67 @@ export default function Affiliate() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ProList<API.WithdrawalLog, Record<string, unknown>>
|
||||
action={withdrawalListActionRef}
|
||||
header={{
|
||||
title: t("withdrawal.records", "Withdrawal Records"),
|
||||
}}
|
||||
renderItem={(item) => {
|
||||
const statusMeta = getWithdrawalStatusMeta(item.status, t);
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<CardContent className="space-y-3 p-4 text-sm">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="font-semibold text-lg">
|
||||
<Display type="currency" value={item.amount} />
|
||||
</div>
|
||||
<span
|
||||
className={`inline-flex rounded-full px-3 py-1 font-medium text-xs ${statusMeta.className}`}
|
||||
>
|
||||
{statusMeta.label}
|
||||
</span>
|
||||
</div>
|
||||
<ul className="grid gap-3 md:grid-cols-2">
|
||||
<li className="flex flex-col gap-1">
|
||||
<span className="text-muted-foreground">
|
||||
{t("withdrawal.record.content", "Payout Information")}
|
||||
</span>
|
||||
<span className="whitespace-pre-wrap break-words">
|
||||
{item.content}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex flex-col gap-1">
|
||||
<span className="text-muted-foreground">
|
||||
{t("withdrawal.record.createdAt", "Created At")}
|
||||
</span>
|
||||
<time>{formatDate(item.created_at)}</time>
|
||||
</li>
|
||||
{item.reason ? (
|
||||
<li className="flex flex-col gap-1 md:col-span-2">
|
||||
<span className="text-muted-foreground">
|
||||
{t("withdrawal.record.reason", "Reject Reason")}
|
||||
</span>
|
||||
<span className="whitespace-pre-wrap break-words text-destructive">
|
||||
{item.reason}
|
||||
</span>
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}}
|
||||
request={async (pagination) => {
|
||||
const response = await queryWithdrawalLog({
|
||||
page: pagination.page,
|
||||
size: pagination.size,
|
||||
});
|
||||
return {
|
||||
list: response.data.data?.list || [],
|
||||
total: response.data.data?.total || 0,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
<ProList<API.UserAffiliate, Record<string, unknown>>
|
||||
header={{
|
||||
title: t("inviteRecords", "Invite Records"),
|
||||
|
||||
Reference in New Issue
Block a user