e63d2d3156
- Implement real-time validation with `isValid` state in Contact Us and Inquiries forms - Add `trigger` function to validate forms before submission - Disable submit button based on form validity and loading state - Improve user experience by preventing submission of invalid forms
232 lines
6.2 KiB
TypeScript
232 lines
6.2 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, isValid },
|
|
reset,
|
|
setError,
|
|
trigger,
|
|
} = useForm<TInquiresForm>({ mode: "onChange" });
|
|
|
|
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) => {
|
|
const isFormValid = await trigger();
|
|
if (!isFormValid) {
|
|
return;
|
|
}
|
|
|
|
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
|
|
type="submit"
|
|
disabled={!isValid || isLoading}
|
|
className="bg-(--primary) cursor-pointer w-fit text-white rounded-full py-4 px-6 disabled:opacity-60 disabled:cursor-not-allowed">
|
|
{isLoading ? <Loading /> : submitText}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
);
|
|
};
|
|
|
|
export default InquiresForm;
|