Files
tm-landing/app/_inquires/form.tsx
T
AmirReza Jamali 8518c2c140 refactor(inquiries): Update form validation and error handling
- Change required fields in inquiries form to enforce mandatory input
- Enhance API error handler to provide user-friendly messages without exposing raw server text
- Improve test cases for API error handling to ensure accurate message mapping and validation
- Refactor error messages for clarity and consistency across the application
2026-04-13 08:37:22 +03:30

223 lines
6.0 KiB
TypeScript

"use client";
import { handleApiError } from "@/lib/apiErrorHandler";
import { sanitizeText } from "@/lib/sanitize";
import { createFieldValidation } from "@/lib/validation";
import api from "@/service/api";
import { CmsContact } from "@/types/TCms";
import HCaptcha from "@hcaptcha/react-hcaptcha";
import { useMemo, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "react-toastify";
import InputGroup from "../_components/InputGroup";
import Loading from "../_components/Loading";
type TInquiresForm = {
[key: string]: string;
};
type InquiresFormProps = {
columnsPerRow?: 1 | 2 | 3 | 4;
buttonText?: string;
contactData?: CmsContact;
};
const InquiresForm = ({
columnsPerRow = 2,
buttonText,
contactData,
}: InquiresFormProps) => {
const [isLoading, setIsLoading] = useState(false);
const [hcaptchaToken, setHcaptchaToken] = useState<string | null>(null);
const captchaRef = useRef<HCaptcha>(null);
const {
register,
handleSubmit,
formState: { errors },
reset,
setError,
} = useForm<TInquiresForm>();
const defaultFields = useMemo(
() => [
{
name: "full_name",
label: "Full Name",
placeholder: "",
required: true,
},
{
name: "company_name",
label: "Company Name",
placeholder: "",
required: true,
},
{
name: "work_email",
label: "Work Email",
placeholder: "",
required: true,
},
{
name: "phone_number",
label: "Phone Number",
placeholder: "",
required: true,
},
],
[],
);
const fields = contactData?.fields || defaultFields;
const formFieldNames = useMemo(() => fields.map((f) => f.name), [fields]);
const onCaptchaVerify = (token: string) => {
setHcaptchaToken(token);
};
const onCaptchaExpire = () => {
setHcaptchaToken(null);
};
const onSubmit = async (data: TInquiresForm) => {
if (!hcaptchaToken) {
toast.error("Please complete the captcha verification");
return;
}
setIsLoading(true);
try {
const response = (await api.post("inquiries", { ...data })).data;
toast.success(response.message);
reset();
} catch (error) {
handleApiError(error, setError, toast.error, formFieldNames);
} finally {
setIsLoading(false);
}
};
const gridColsClass = {
1: "lg:grid-cols-1",
2: "lg:grid-cols-2",
3: "lg:grid-cols-3",
4: "lg:grid-cols-4",
}[columnsPerRow];
const colSpanClass = {
1: "lg:col-span-1",
2: "lg:col-span-2",
3: "lg:col-span-3",
4: "lg:col-span-4",
}[columnsPerRow];
const submitText =
sanitizeText(contactData?.submit_button_text) || buttonText || "Submit";
/**
* Creates validation rules based on field type with XSS protection
*/
const getFieldValidation = (field: {
name: string;
label: string;
required?: boolean;
}) => {
const isEmailField =
field.name.includes("email") || field.name === "work_email";
const isPhoneField =
field.name.includes("phone") || field.name === "phone_number";
const isStrictTextField =
field.name === "full_name" || field.name === "company_name";
if (isPhoneField) {
return createFieldValidation(field.label, field.required, {
pattern: {
value: /^[+\d\s()-]+$/,
message:
"Please enter a valid phone number (digits, +, spaces, parentheses, and dashes only)",
},
minLength: field.required
? { value: 10, message: "Phone number must be at least 10 digits" }
: undefined,
maxLength: {
value: 20,
message: "Phone number must not exceed 20 characters",
},
});
}
if (isEmailField) {
return createFieldValidation(field.label, field.required, {
pattern: {
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: "Please enter a valid email address",
},
});
}
if (isStrictTextField) {
return createFieldValidation(field.label, field.required, {
minLength: field.required
? {
value: 2,
message: `${field.label} must be at least 2 characters`,
}
: undefined,
maxLength: {
value: 200,
message: `${field.label} must not exceed 200 characters`,
},
pattern: {
value: /^[\p{L}\p{M}\p{N}\s'.,()&/_-]+$/u,
message: `${field.label} may only include letters, numbers, spaces, and common punctuation (no HTML or code)`,
},
});
}
return createFieldValidation(field.label, field.required, {
maxLength: {
value: 200,
message: `${field.label} must not exceed 200 characters`,
},
});
};
return (
<form
className={`w-full px-4 lg:px-16 py-10 grid ${gridColsClass} gap-4`}
onSubmit={handleSubmit(onSubmit)}>
{fields.map((field) => {
const inputType =
field.name.includes("email") || field.name === "work_email"
? "email"
: "text";
return (
<InputGroup
key={field.name}
id={field.name}
label={sanitizeText(field.label)}
placeholder={sanitizeText(field.placeholder)}
register={register}
type={inputType}
error={errors[field.name]?.message}
validation={getFieldValidation(field)}
/>
);
})}
<div className={`${colSpanClass}`}>
<HCaptcha
sitekey={process.env.NEXT_PUBLIC_HCAPTCHA_SITE_KEY || ""}
onVerify={onCaptchaVerify}
onExpire={onCaptchaExpire}
ref={captchaRef}
/>
</div>
<div className={`${colSpanClass} flex justify-end`}>
<button className="bg-(--primary) cursor-pointer w-fit text-white rounded-full py-4 px-6">
{isLoading ? <Loading /> : submitText}
</button>
</div>
</form>
);
};
export default InquiresForm;