159 lines
4.2 KiB
TypeScript
159 lines
4.2 KiB
TypeScript
'use client';
|
||
|
||
import { useState } from 'react';
|
||
import { useForm } from 'react-hook-form';
|
||
import { zodResolver } from '@hookform/resolvers/zod';
|
||
import { z } from 'zod';
|
||
import { publicApi } from '@/lib/services';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Input } from '@/components/ui/input';
|
||
import { Label } from '@/components/ui/label';
|
||
import { Textarea } from '@/components/ui/textarea';
|
||
|
||
const ERROR_INPUT_CLASS = 'border-red-500 focus-visible:ring-red-500';
|
||
|
||
const schema = z.object({
|
||
name: z
|
||
.string()
|
||
.trim()
|
||
.min(1, '请输入姓名')
|
||
.max(50, '姓名不能超过 50 个字'),
|
||
phone: z
|
||
.string()
|
||
.trim()
|
||
.min(1, '请输入手机号')
|
||
.regex(/^1[3-9]\d{9}$/, '请输入正确的手机号(11 位)'),
|
||
email: z
|
||
.string()
|
||
.trim()
|
||
.max(254, '邮箱长度不能超过 254')
|
||
.email('邮箱格式不正确')
|
||
.optional()
|
||
.or(z.literal('')),
|
||
content: z
|
||
.string()
|
||
.trim()
|
||
.min(5, '留言内容至少 5 个字')
|
||
.max(2000, '留言内容不能超过 2000 个字'),
|
||
});
|
||
|
||
type FormData = z.infer<typeof schema>;
|
||
|
||
export function ContactForm() {
|
||
const {
|
||
register,
|
||
handleSubmit,
|
||
reset,
|
||
formState: { errors, isSubmitting },
|
||
} = useForm<FormData>({
|
||
resolver: zodResolver(schema),
|
||
mode: 'onTouched',
|
||
reValidateMode: 'onChange',
|
||
defaultValues: { name: '', phone: '', email: '', content: '' },
|
||
});
|
||
const [serverError, setServerError] = useState<string | null>(null);
|
||
const [success, setSuccess] = useState(false);
|
||
|
||
const onSubmit = async (data: FormData) => {
|
||
setServerError(null);
|
||
setSuccess(false);
|
||
try {
|
||
await publicApi.submitMessage({
|
||
name: data.name,
|
||
phone: data.phone,
|
||
email: data.email || undefined,
|
||
content: data.content,
|
||
});
|
||
setSuccess(true);
|
||
reset();
|
||
} catch (e) {
|
||
setServerError((e as Error).message);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<form
|
||
onSubmit={(e) => {
|
||
e.preventDefault();
|
||
void handleSubmit(onSubmit)(e);
|
||
}}
|
||
className="space-y-4"
|
||
>
|
||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||
<Field label="姓名" error={errors.name?.message} required>
|
||
<Input
|
||
placeholder="请输入您的姓名"
|
||
autoComplete="name"
|
||
maxLength={50}
|
||
className={errors.name ? ERROR_INPUT_CLASS : ''}
|
||
{...register('name')}
|
||
/>
|
||
</Field>
|
||
<Field label="手机号" error={errors.phone?.message} required>
|
||
<Input
|
||
placeholder="请输入 11 位手机号"
|
||
inputMode="numeric"
|
||
autoComplete="tel"
|
||
maxLength={11}
|
||
className={errors.phone ? ERROR_INPUT_CLASS : ''}
|
||
{...register('phone')}
|
||
/>
|
||
</Field>
|
||
</div>
|
||
<Field label="邮箱(可选)" error={errors.email?.message}>
|
||
<Input
|
||
placeholder="example@company.com"
|
||
inputMode="email"
|
||
autoComplete="email"
|
||
maxLength={254}
|
||
className={errors.email ? ERROR_INPUT_CLASS : ''}
|
||
{...register('email')}
|
||
/>
|
||
</Field>
|
||
<Field label="留言内容" error={errors.content?.message} required>
|
||
<Textarea
|
||
rows={5}
|
||
placeholder="请简要描述您的需求(至少 5 个字)"
|
||
maxLength={2000}
|
||
className={errors.content ? ERROR_INPUT_CLASS : ''}
|
||
{...register('content')}
|
||
/>
|
||
</Field>
|
||
|
||
{serverError && <p className="text-sm text-red-600">{serverError}</p>}
|
||
{success && (
|
||
<p className="text-sm text-green-600">
|
||
✓ 留言已提交,我们会尽快与您联系!
|
||
</p>
|
||
)}
|
||
|
||
<Button type="submit" disabled={isSubmitting}>
|
||
{isSubmitting ? '提交中…' : '提交留言'}
|
||
</Button>
|
||
</form>
|
||
);
|
||
}
|
||
|
||
function Field({
|
||
label,
|
||
required,
|
||
error,
|
||
children,
|
||
}: {
|
||
label: string;
|
||
required?: boolean;
|
||
error?: string;
|
||
children: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<div className="space-y-1.5">
|
||
<Label>
|
||
{label}
|
||
{required && <span className="ml-1 text-red-500">*</span>}
|
||
</Label>
|
||
{children}
|
||
{error && <p className="text-xs text-red-600">{error}</p>}
|
||
</div>
|
||
);
|
||
}
|