370 lines
14 KiB
TypeScript
370 lines
14 KiB
TypeScript
"use client";
|
||||
|
|
|
|||
|
|
import { zodResolver } from "@hookform/resolvers/zod";
|
|||
|
|
import { AlertCircle, LoaderIcon, Paperclip, X } from "lucide-react";
|
|||
|
|
import { useRef, useState } from "react";
|
|||
|
|
import { Controller, useForm } from "react-hook-form";
|
|||
|
|
import { z } from "zod";
|
|||
|
|
|
|||
|
|
import { Button } from "@/components/ui/button";
|
|||
|
|
import {
|
|||
|
|
Field,
|
|||
|
|
FieldError,
|
|||
|
|
FieldGroup,
|
|||
|
|
FieldLabel,
|
|||
|
|
} from "@/components/ui/field";
|
|||
|
|
import { Input } from "@/components/ui/input";
|
|||
|
|
import {
|
|||
|
|
Select,
|
|||
|
|
SelectContent,
|
|||
|
|
SelectItem,
|
|||
|
|
SelectTrigger,
|
|||
|
|
SelectValue,
|
|||
|
|
} from "@/components/ui/select";
|
|||
|
|
import { Textarea } from "@/components/ui/textarea";
|
|||
|
|
import { cn } from "@/lib/utils";
|
|||
|
|
|
|||
|
|
const ticketSchema = z.object({
|
|||
|
|
email: z
|
|||
|
|
.string()
|
|||
|
|
.min(1, "ایمیل الزامی است")
|
|||
|
|
.email("لطفاً یک ایمیل معتبر وارد کنید"),
|
|||
|
|
category: z.string().min(1, "لطفاً یک دسته انتخاب کنید"),
|
|||
|
|
priority: z.string().min(1, "لطفاً یک اولویت انتخاب کنید"),
|
|||
|
|
subject: z.string().min(1, "موضوع الزامی است"),
|
|||
|
|
description: z.string().min(10, "توضیحات باید حداقل ۱۰ کاراکتر باشد"),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
type TicketFormData = z.infer<typeof ticketSchema>;
|
|||
|
|
|
|||
|
|
interface Category {
|
|||
|
|
value: string;
|
|||
|
|
label: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface Help4Props {
|
|||
|
|
title?: string;
|
|||
|
|
description?: string;
|
|||
|
|
categories?: Category[];
|
|||
|
|
className?: string;
|
|||
|
|
onSubmit?: (data: TicketFormData & { files: File[] }) => Promise<void>;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const Help4 = ({
|
|||
|
|
title = "ثبت تیکت پشتیبانی",
|
|||
|
|
description = "مشکل خود را شرح دهید تا در اسرع وقت پاسخگوییم.",
|
|||
|
|
categories = [
|
|||
|
|
{ value: "technical", label: "مشکل فنی" },
|
|||
|
|
{ value: "billing", label: "صورتحساب و پرداخت" },
|
|||
|
|
{ value: "account", label: "دسترسی به حساب" },
|
|||
|
|
{ value: "feature", label: "درخواست ویژگی" },
|
|||
|
|
{ value: "bug", label: "گزارش باگ" },
|
|||
|
|
{ value: "other", label: "سایر" },
|
|||
|
|
],
|
|||
|
|
className,
|
|||
|
|
onSubmit,
|
|||
|
|
}: Help4Props) => {
|
|||
|
|
const [files, setFiles] = useState<File[]>([]);
|
|||
|
|
const [isSubmitted, setIsSubmitted] = useState(false);
|
|||
|
|
const [showSuccess, setShowSuccess] = useState(false);
|
|||
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|||
|
|
|
|||
|
|
const form = useForm<TicketFormData>({
|
|||
|
|
resolver: zodResolver(ticketSchema),
|
|||
|
|
mode: "onSubmit",
|
|||
|
|
reValidateMode: "onSubmit",
|
|||
|
|
defaultValues: {
|
|||
|
|
email: "",
|
|||
|
|
category: "",
|
|||
|
|
priority: "",
|
|||
|
|
subject: "",
|
|||
|
|
description: "",
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|||
|
|
const selectedFiles = Array.from(e.target.files || []);
|
|||
|
|
const maxSize = 10 * 1024 * 1024; // 10MB
|
|||
|
|
const validFiles = selectedFiles.filter((file) => file.size <= maxSize);
|
|||
|
|
setFiles((prev) => [...prev, ...validFiles].slice(0, 5)); // Max 5 files
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const removeFile = (index: number) => {
|
|||
|
|
setFiles((prev) => prev.filter((_, i) => i !== index));
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleFormSubmit = async (data: TicketFormData) => {
|
|||
|
|
try {
|
|||
|
|
if (onSubmit) {
|
|||
|
|
await onSubmit({ ...data, files });
|
|||
|
|
} else {
|
|||
|
|
console.log("Ticket submitted:", { ...data, files });
|
|||
|
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|||
|
|
}
|
|||
|
|
setIsSubmitted(true);
|
|||
|
|
setShowSuccess(true);
|
|||
|
|
form.reset();
|
|||
|
|
setFiles([]);
|
|||
|
|
setTimeout(() => setShowSuccess(false), 4500);
|
|||
|
|
setTimeout(() => setIsSubmitted(false), 5000);
|
|||
|
|
} catch {
|
|||
|
|
form.setError("root", {
|
|||
|
|
message: "مشکلی پیش آمد. لطفاً دوباره تلاش کنید.",
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const formatFileSize = (bytes: number) => {
|
|||
|
|
if (bytes < 1024) return `${bytes.toLocaleString("fa-IR")} بایت`;
|
|||
|
|
if (bytes < 1024 * 1024)
|
|||
|
|
return `${(bytes / 1024).toLocaleString("fa-IR", { maximumFractionDigits: 1 })} کیلوبایت`;
|
|||
|
|
return `${(bytes / (1024 * 1024)).toLocaleString("fa-IR", { maximumFractionDigits: 1 })} مگابایت`;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<section className={cn("py-32", className)}>
|
|||
|
|
<div className="container">
|
|||
|
|
<div className="mx-auto max-w-2xl">
|
|||
|
|
<div className="mb-10 text-center">
|
|||
|
|
<h1 className="mb-4 text-xl font-medium tracking-tight md:text-2xll">
|
|||
|
|
{title}
|
|||
|
|
</h1>
|
|||
|
|
<p className="text-muted-foreground">{description}</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="rounded-xl border bg-background p-8">
|
|||
|
|
{isSubmitted && (
|
|||
|
|
<div
|
|||
|
|
className={cn(
|
|||
|
|
"mb-6 rounded-lg border border-green-500/20 bg-green-500/10 p-4 text-center transition-opacity duration-500",
|
|||
|
|
showSuccess ? "opacity-100" : "opacity-0",
|
|||
|
|
)}
|
|||
|
|
>
|
|||
|
|
<p className="text-sm font-medium text-green-600 dark:text-green-400">
|
|||
|
|
تیکت شما ثبت شد. ایمیل تأیید برایتان ارسال میشود.
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
<form onSubmit={form.handleSubmit(handleFormSubmit)}>
|
|||
|
|
<FieldGroup>
|
|||
|
|
<Controller
|
|||
|
|
control={form.control}
|
|||
|
|
name="email"
|
|||
|
|
render={({ field, fieldState }) => (
|
|||
|
|
<Field data-invalid={fieldState.invalid}>
|
|||
|
|
<FieldLabel htmlFor={field.name}>
|
|||
|
|
آدرس ایمیل{" "}
|
|||
|
|
<span className="text-destructive">*</span>
|
|||
|
|
</FieldLabel>
|
|||
|
|
<Input
|
|||
|
|
{...field}
|
|||
|
|
id={field.name}
|
|||
|
|
type="email"
|
|||
|
|
aria-invalid={fieldState.invalid}
|
|||
|
|
placeholder="you@example.com"
|
|||
|
|
/>
|
|||
|
|
{fieldState.invalid && (
|
|||
|
|
<FieldError errors={[fieldState.error]} />
|
|||
|
|
)}
|
|||
|
|
</Field>
|
|||
|
|
)}
|
|||
|
|
/>
|
|||
|
|
|
|||
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|||
|
|
<Controller
|
|||
|
|
control={form.control}
|
|||
|
|
name="category"
|
|||
|
|
render={({ field, fieldState }) => (
|
|||
|
|
<Field data-invalid={fieldState.invalid}>
|
|||
|
|
<FieldLabel htmlFor={field.name}>
|
|||
|
|
دستهبندی{" "}
|
|||
|
|
<span className="text-destructive">*</span>
|
|||
|
|
</FieldLabel>
|
|||
|
|
<Select
|
|||
|
|
onValueChange={field.onChange}
|
|||
|
|
defaultValue={field.value}
|
|||
|
|
>
|
|||
|
|
<SelectTrigger id={field.name}>
|
|||
|
|
<SelectValue placeholder="انتخاب دسته" />
|
|||
|
|
</SelectTrigger>
|
|||
|
|
<SelectContent>
|
|||
|
|
{categories.map((cat) => (
|
|||
|
|
<SelectItem key={cat.value} value={cat.value}>
|
|||
|
|
{cat.label}
|
|||
|
|
</SelectItem>
|
|||
|
|
))}
|
|||
|
|
</SelectContent>
|
|||
|
|
</Select>
|
|||
|
|
{fieldState.invalid && (
|
|||
|
|
<FieldError errors={[fieldState.error]} />
|
|||
|
|
)}
|
|||
|
|
</Field>
|
|||
|
|
)}
|
|||
|
|
/>
|
|||
|
|
|
|||
|
|
<Controller
|
|||
|
|
control={form.control}
|
|||
|
|
name="priority"
|
|||
|
|
render={({ field, fieldState }) => (
|
|||
|
|
<Field data-invalid={fieldState.invalid}>
|
|||
|
|
<FieldLabel htmlFor={field.name}>
|
|||
|
|
اولویت <span className="text-destructive">*</span>
|
|||
|
|
</FieldLabel>
|
|||
|
|
<Select
|
|||
|
|
onValueChange={field.onChange}
|
|||
|
|
defaultValue={field.value}
|
|||
|
|
>
|
|||
|
|
<SelectTrigger id={field.name}>
|
|||
|
|
<SelectValue placeholder="انتخاب اولویت" />
|
|||
|
|
</SelectTrigger>
|
|||
|
|
<SelectContent>
|
|||
|
|
<SelectItem value="low">کم</SelectItem>
|
|||
|
|
<SelectItem value="medium">متوسط</SelectItem>
|
|||
|
|
<SelectItem value="high">بالا</SelectItem>
|
|||
|
|
<SelectItem value="urgent">
|
|||
|
|
<span className="flex items-center gap-2">
|
|||
|
|
<AlertCircle className="size-4 text-destructive" />
|
|||
|
|
فوری
|
|||
|
|
</span>
|
|||
|
|
</SelectItem>
|
|||
|
|
</SelectContent>
|
|||
|
|
</Select>
|
|||
|
|
{fieldState.invalid && (
|
|||
|
|
<FieldError errors={[fieldState.error]} />
|
|||
|
|
)}
|
|||
|
|
</Field>
|
|||
|
|
)}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<Controller
|
|||
|
|
control={form.control}
|
|||
|
|
name="subject"
|
|||
|
|
render={({ field, fieldState }) => (
|
|||
|
|
<Field data-invalid={fieldState.invalid}>
|
|||
|
|
<FieldLabel htmlFor={field.name}>
|
|||
|
|
موضوع <span className="text-destructive">*</span>
|
|||
|
|
</FieldLabel>
|
|||
|
|
<Input
|
|||
|
|
{...field}
|
|||
|
|
id={field.name}
|
|||
|
|
aria-invalid={fieldState.invalid}
|
|||
|
|
placeholder="خلاصهای از مشکل خود"
|
|||
|
|
/>
|
|||
|
|
{fieldState.invalid && (
|
|||
|
|
<FieldError errors={[fieldState.error]} />
|
|||
|
|
)}
|
|||
|
|
</Field>
|
|||
|
|
)}
|
|||
|
|
/>
|
|||
|
|
|
|||
|
|
<Controller
|
|||
|
|
control={form.control}
|
|||
|
|
name="description"
|
|||
|
|
render={({ field, fieldState }) => (
|
|||
|
|
<Field data-invalid={fieldState.invalid}>
|
|||
|
|
<FieldLabel htmlFor={field.name}>
|
|||
|
|
توضیحات <span className="text-destructive">*</span>
|
|||
|
|
</FieldLabel>
|
|||
|
|
<Textarea
|
|||
|
|
{...field}
|
|||
|
|
id={field.name}
|
|||
|
|
aria-invalid={fieldState.invalid}
|
|||
|
|
placeholder="لطفاً تا حد امکان جزئیات را بنویسید..."
|
|||
|
|
rows={6}
|
|||
|
|
/>
|
|||
|
|
{fieldState.invalid && (
|
|||
|
|
<FieldError errors={[fieldState.error]} />
|
|||
|
|
)}
|
|||
|
|
</Field>
|
|||
|
|
)}
|
|||
|
|
/>
|
|||
|
|
|
|||
|
|
<div>
|
|||
|
|
<FieldLabel>پیوستها</FieldLabel>
|
|||
|
|
<p className="mb-3 text-xs text-muted-foreground">
|
|||
|
|
حداکثر ۵ فایل (هر کدام تا ۱۰ مگابایت). فرمتهای مجاز:
|
|||
|
|
تصویر، پیدیاف و فایل متنی.
|
|||
|
|
</p>
|
|||
|
|
<input
|
|||
|
|
ref={fileInputRef}
|
|||
|
|
type="file"
|
|||
|
|
multiple
|
|||
|
|
onChange={handleFileChange}
|
|||
|
|
accept="image/*,.pdf,.txt,.doc,.docx"
|
|||
|
|
className="hidden"
|
|||
|
|
/>
|
|||
|
|
<Button
|
|||
|
|
type="button"
|
|||
|
|
variant="outline"
|
|||
|
|
onClick={() => fileInputRef.current?.click()}
|
|||
|
|
disabled={files.length >= 5}
|
|||
|
|
>
|
|||
|
|
<Paperclip className="me-2 size-4" />
|
|||
|
|
پیوست فایل
|
|||
|
|
</Button>
|
|||
|
|
|
|||
|
|
{files.length > 0 && (
|
|||
|
|
<div className="mt-4 space-y-2">
|
|||
|
|
{files.map((file, index) => (
|
|||
|
|
<div
|
|||
|
|
key={index}
|
|||
|
|
className="flex items-center justify-between rounded-lg border bg-muted/50 px-3 py-2"
|
|||
|
|
>
|
|||
|
|
<div className="flex items-center gap-2 overflow-hidden">
|
|||
|
|
<Paperclip className="size-4 shrink-0 text-muted-foreground" />
|
|||
|
|
<span className="truncate text-sm">
|
|||
|
|
{file.name}
|
|||
|
|
</span>
|
|||
|
|
<span className="shrink-0 text-xs text-muted-foreground">
|
|||
|
|
({formatFileSize(file.size)})
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
<Button
|
|||
|
|
type="button"
|
|||
|
|
variant="ghost"
|
|||
|
|
size="sm"
|
|||
|
|
onClick={() => removeFile(index)}
|
|||
|
|
className="size-8 p-0"
|
|||
|
|
>
|
|||
|
|
<X className="size-4" />
|
|||
|
|
<span className="sr-only">حذف فایل</span>
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{form.formState.errors.root && (
|
|||
|
|
<p className="text-sm text-destructive">
|
|||
|
|
{form.formState.errors.root.message}
|
|||
|
|
</p>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
<Button
|
|||
|
|
size="lg"
|
|||
|
|
className="w-full"
|
|||
|
|
disabled={form.formState.isSubmitting}
|
|||
|
|
>
|
|||
|
|
{form.formState.isSubmitting ? (
|
|||
|
|
<>
|
|||
|
|
<LoaderIcon className="me-2 size-4 animate-spin" />
|
|||
|
|
در حال ارسال...
|
|||
|
|
</>
|
|||
|
|
) : (
|
|||
|
|
"ثبت تیکت"
|
|||
|
|
)}
|
|||
|
|
</Button>
|
|||
|
|
</FieldGroup>
|
|||
|
|
</form>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</section>
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export { Help4 };
|