thirukalyanamweb/src/feature/StepperForm.jsx
2026-03-02 17:02:47 +05:30

814 lines
24 KiB
JavaScript

import React, { useState, useEffect } from "react";
import { ChevronLeft } from "lucide-react";
import { useDispatch, useSelector } from "react-redux";
import {
updatePersonalDetails,
updateEducationalDetails,
updateFamilyDetails,
updateLifestyleDetails,
updatePartnerPreferences,
submitForm,
} from "../redux/registrationFormSlice";
import PersonalDetailsForm from "./PersonalDetailsForm";
import EducationalDetailsForm from "./EducationalDetailsForm";
import FamilyDetailsForm from "./FamilyDetailsForm";
import LifestyleDetailsForm from "./LifestyleDetailsForm";
import PartnerPreferencesForm from "./PartnerPreferencesForm";
import PreviewScreen from "./PreviewScreen";
import { useLocation } from "react-router-dom";
import {
useRegisterStep1,
useRegisterStep2,
useRegisterStep3,
useRegisterStep4,
useRegisterStep5,
} from "../hooks/useRegister";
import { setAccessToken } from "../api/axiosInstance";
import toast from "react-hot-toast";
const STEP_FIELD_ORDER = {
1: [
"name",
"gender",
"mobileNumber",
"dob",
"height",
"weight",
"maritalStatus",
"religion",
"profileFor",
"caste",
"subCaste",
"gothram",
"raasi",
"star",
"email",
"password",
"confirmPassword",
"state",
"city",
"pincode",
"profiles",
],
2: [
"fieldOfStudy",
"qualification",
"collegeName",
"occupation",
"organization",
"employeeType",
"income",
"workLocation",
],
3: [
"fatherName",
"fatherOccupation",
"motherName",
"motherOccupation",
"brotherCount",
"sisterCount",
"familyStatus",
"nativePlace",
],
4: ["diets", "hobbies", "dob", "tob", "placeOfBirth"],
5: [
"ageRange",
"castes",
"subCastes",
"occupations",
"educations",
"hobbies",
"annualIncome",
"states",
"districts",
],
};
const STEP1_SERVER_FIELD_MAP = {
name: "name",
mobile: "mobileNumber",
mobile_number: "mobileNumber",
mobileNumber: "mobileNumber",
phone: "mobileNumber",
email: "email",
gender: "gender",
dob: "dob",
height: "height",
weight: "weight",
marital_status: "maritalStatus",
maritalStatus: "maritalStatus",
religion: "religion",
profile_for: "profileFor",
profileFor: "profileFor",
caste: "caste",
sub_caste: "subCaste",
subCaste: "subCaste",
gothram: "gothram",
raasi: "raasi",
star: "star",
state: "state",
district: "city",
city: "city",
pincode: "pincode",
password: "password",
confirm_password: "confirmPassword",
confirmPassword: "confirmPassword",
profiles: "profiles",
profile_images: "profiles",
};
const Stepper = ({ currentStep, onStepClick }) => {
const steps = [
{ num: 1, label: "Personal" },
{ num: 2, label: "Educational" },
{ num: 3, label: "Family" },
{ num: 4, label: "Lifestyle" },
{ num: 5, label: "Partner" },
{ num: 6, label: "Preview" },
];
return (
<div className="flex items-center justify-between px-4 py-6">
{steps.map((step, index) => (
<React.Fragment key={step.num}>
<div
className="flex flex-col items-center cursor-pointer"
onClick={() => onStepClick(step.num)}
>
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold ${
currentStep >= step.num
? "bg-red-600 text-white"
: "bg-gray-300 text-gray-600"
}`}
>
{step.num}
</div>
</div>
{index < steps.length - 1 && (
<div
className={`flex-1 h-0.5 mx-1 ${
currentStep > step.num ? "bg-red-600" : "bg-gray-300"
}`}
/>
)}
</React.Fragment>
))}
</div>
);
};
const StepperForm = () => {
const dispatch = useDispatch();
const location = useLocation();
const personalDetails = useSelector(
(state) => state.registerform.personalDetails
);
const educationalDetails = useSelector(
(state) => state.registerform.educationalDetails
);
const familyDetails = useSelector((state) => state.registerform.familyDetails);
const lifestyleDetails = useSelector(
(state) => state.registerform.lifestyleDetails
);
const partnerPreferences = useSelector(
(state) => state.registerform.partnerPreferences
);
const initialStep = location.state?.step || 1;
const [currentStep, setCurrentStep] = useState(initialStep);
const [errors, setErrors] = useState({});
const registerStep1 = useRegisterStep1();
const registerStep2 = useRegisterStep2();
const registerStep3 = useRegisterStep3();
const registerStep4 = useRegisterStep4();
const registerStep5 = useRegisterStep5();
const normalizeStep1Field = (key) => {
if (!key) return key;
const trimmed = String(key).trim();
if (!trimmed) return trimmed;
// Map backend profile_images errors (e.g., profile_images.0) to the profiles field
if (trimmed.startsWith("profile_images")) {
return "profiles";
}
return (
STEP1_SERVER_FIELD_MAP[trimmed] ||
STEP1_SERVER_FIELD_MAP[trimmed.toLowerCase()] ||
trimmed
);
};
const coerceErrorMessage = (value) => {
if (Array.isArray(value)) {
return value.filter(Boolean).join(" ");
}
if (typeof value === "string") return value;
if (value && typeof value === "object") {
return (
value.message ||
value.msg ||
value.error ||
value.detail ||
""
);
}
if (value === null || value === undefined) return "";
return String(value);
};
const mapServerErrors = (error) => {
const data = error?.response?.data ?? error?.data ?? error;
if (!data) return {};
const payload = data.errors ?? data.error ?? data.data ?? data;
if (typeof payload === "string") {
return { _form: payload };
}
if (Array.isArray(payload)) {
const out = {};
payload.forEach((item) => {
if (!item) return;
if (typeof item === "string") {
out._form = out._form ? `${out._form} ${item}` : item;
return;
}
const key = item.field || item.name || item.param || item.key;
const message =
item.message || item.msg || item.error || item.detail || "";
if (key) {
out[normalizeStep1Field(key)] =
String(message || "Invalid value");
}
});
return out;
}
if (typeof payload === "object") {
const out = {};
Object.entries(payload).forEach(([key, value]) => {
if (
(key === "message" || key === "error" || key === "detail") &&
typeof value === "string"
) {
out._form = out._form ? `${out._form} ${value}` : value;
return;
}
const normalizedKey = normalizeStep1Field(key);
const message = coerceErrorMessage(value);
if (!normalizedKey) return;
out[normalizedKey] = message || "Invalid value";
});
return out;
}
return {};
};
const focusFirstError = (errorMap, fieldOrder = []) => {
if (!errorMap || Object.keys(errorMap).length === 0) return;
const order = fieldOrder.filter((key) => errorMap[key]);
const firstKey =
order[0] ||
Object.keys(errorMap).find((key) => key !== "_form");
if (!firstKey) return;
setTimeout(() => {
const byAria = document.querySelector(
`[aria-labelledby~="${firstKey}-label"]`
);
if (byAria && typeof byAria.focus === "function") {
byAria.focus();
return;
}
const byName = document.querySelector(`[name="${firstKey}"]`);
if (byName && typeof byName.focus === "function") {
byName.focus();
return;
}
const byId = document.getElementById(firstKey);
if (byId && typeof byId.focus === "function") {
byId.focus();
}
}, 0);
};
const clearFieldErrors = (fields) => {
if (!fields) return;
const list = Array.isArray(fields) ? fields : [fields];
setErrors((prev) => {
if (!prev || Object.keys(prev).length === 0) return prev;
let changed = false;
const next = { ...prev };
list.forEach((field) => {
if (field in next) {
delete next[field];
changed = true;
}
});
if (next._form) {
delete next._form;
changed = true;
}
return changed ? next : prev;
});
};
useEffect(() => {
// in case user comes again with a different step
if (location.state?.step) {
setCurrentStep(location.state.step);
window.scrollTo(0, 0);
}
}, [location.state?.step]);
const validateStep = (step) => {
const newErrors = {};
if (step === 1) {
const required = [
"name",
"mobileNumber",
"gender",
"dob",
"maritalStatus",
"profileFor",
"caste",
"email",
"password",
"confirmPassword",
"state",
"city",
"pincode",
];
required.forEach((field) => {
if (!personalDetails[field]) {
newErrors[field] = "This field is required";
}
});
if (
personalDetails.email &&
!/\S+@\S+\.\S+/.test(personalDetails.email)
) {
newErrors.email = "Invalid email format";
}
if (
personalDetails.mobileNumber &&
personalDetails.mobileNumber.length !== 10
) {
newErrors.mobileNumber = "Mobile number must be 10 digits";
}
if (personalDetails.height && Number(personalDetails.height) > 10) {
newErrors.height = "Height must be 10 or less";
}
if (personalDetails.weight && Number(personalDetails.weight) > 300) {
newErrors.weight = "Weight must be 300 or less";
}
if (
personalDetails.password &&
personalDetails.confirmPassword &&
personalDetails.password !== personalDetails.confirmPassword
) {
newErrors.confirmPassword = "Passwords do not match";
}
} else if (step === 2) {
const required = [
"qualification",
"fieldOfStudy",
"occupation",
"organization",
"employeeType",
"income",
];
required.forEach((field) => {
if (!educationalDetails[field]) {
newErrors[field] = "This field is required";
}
});
} else if (step === 3) {
const required = [
"fatherName",
"motherName",
"familyStatus",
];
required.forEach((field) => {
if (!familyDetails[field]) {
newErrors[field] = "This field is required";
}
});
} else if (step === 4) {
const required = ["diets", "hobbies", "dob", "tob"];
required.forEach((field) => {
const value = lifestyleDetails[field];
if (Array.isArray(value)) {
if (value.length === 0) newErrors[field] = "This field is required";
} else if (!value) {
newErrors[field] = "This field is required";
}
});
} else if (step === 5) {
const required = [
"ageRange",
"castes",
"subCastes",
"occupations",
"educations",
"hobbies",
"annualIncome",
"states",
"districts",
];
required.forEach((field) => {
const value = partnerPreferences[field];
if (Array.isArray(value)) {
if (value.length === 0) {
newErrors[field] = "This field is required";
}
return;
}
if (!value) {
newErrors[field] = "This field is required";
}
});
}
setErrors(newErrors);
if (Object.keys(newErrors).length > 0) {
focusFirstError(newErrors, STEP_FIELD_ORDER[step] || []);
}
return Object.keys(newErrors).length === 0;
};
const buildRegisterStep1Payload = async () => {
const formData = new FormData();
formData.append("name", personalDetails.name);
formData.append("mobile", personalDetails.mobileNumber);
formData.append("email", personalDetails.email);
formData.append("pincode", personalDetails.pincode);
formData.append("gender", personalDetails.gender);
formData.append("dob", personalDetails.dob);
formData.append("height", personalDetails.height || "");
formData.append("weight", personalDetails.weight || "");
formData.append("marital_status", personalDetails.maritalStatus);
formData.append("religion", personalDetails.religion);
formData.append("profile_for", personalDetails.profileFor || "");
formData.append("caste", personalDetails.caste);
formData.append("sub_caste", personalDetails.subCaste || "");
formData.append("gothram", personalDetails.gothram || "");
formData.append("raasi", personalDetails.raasi || "");
formData.append("star", personalDetails.star || "");
formData.append("state", personalDetails.state);
formData.append("district", personalDetails.city);
formData.append("password", personalDetails.password || "");
formData.append("fcm_token", localStorage.getItem("fcm_token") || "");
if (personalDetails.profiles && Array.isArray(personalDetails.profiles)) {
for (const [index, item] of personalDetails.profiles.entries()) {
// If the file object is intact (e.g., just added), use it directly.
if (item.file && (item.file instanceof Blob || (typeof item.file === 'object' && item.file !== null && item.file.name))) {
formData.append(`profile_images[${index}]`, item.file);
}
// If the file object was lost due to Redux serialization,
// try to recover it from the blob URL preview.
else if (
item.preview &&
typeof item.preview === "string"
) {
try {
const response = await fetch(item.preview);
const blob = await response.blob();
const fileName = item.name || `profile_image_${index}.jpg`;
const fileType = item.type || "image/jpeg";
const recoveredFile = new File([blob], fileName, { type: fileType });
formData.append(`profile_images[${index}]`, recoveredFile);
} catch (e) {
console.error(`Could not recover file from blob URL: ${item.preview}`, e);
}
}
}
}
return formData;
};
const buildRegisterStep2Payload = () => {
const formData = new FormData();
formData.append("college_name", educationalDetails.collegeName || "");
formData.append("study_field", educationalDetails.fieldOfStudy || "");
formData.append("education", educationalDetails.qualification || "");
formData.append("occupation", educationalDetails.occupation || "");
formData.append("company_name", educationalDetails.organization || "");
formData.append("employee_type", educationalDetails.employeeType || "");
formData.append("annual_income", educationalDetails.income || "");
formData.append("work_location", educationalDetails.workLocation || "");
return formData;
};
const buildRegisterStep3Payload = () => {
const formData = new FormData();
formData.append("father_name", familyDetails.fatherName);
formData.append("father_occupation", familyDetails.fatherOccupation || "");
formData.append("mother_name", familyDetails.motherName);
formData.append("mother_occupation", familyDetails.motherOccupation || "");
formData.append("family_status", familyDetails.familyStatus);
formData.append("native_place", familyDetails.nativePlace || "");
formData.append("brother_count", familyDetails.brotherCount || 0);
formData.append("sister_count", familyDetails.sisterCount || 0);
(familyDetails.brothers || []).forEach((brother, index) => {
formData.append(`brothers[${index}][name]`, brother?.name || "");
formData.append(`brothers[${index}][occupation]`, brother?.occupation || "");
formData.append(`brothers[${index}][marital_status]`, brother?.maritalStatus || "");
formData.append(`brothers[${index}][have_childrens]`, brother?.haveChildrens ?? "");
});
(familyDetails.sisters || []).forEach((sister, index) => {
formData.append(`sisters[${index}][name]`, sister?.name || "");
formData.append(`sisters[${index}][occupation]`, sister?.occupation || "");
formData.append(`sisters[${index}][marital_status]`, sister?.maritalStatus || "");
formData.append(`sisters[${index}][have_childrens]`, sister?.haveChildrens ?? "");
});
return formData;
};
const buildRegisterStep4Payload = () => {
const formData = new FormData();
formData.append("dob", lifestyleDetails.dob || "");
formData.append("tob", lifestyleDetails.tob || "");
formData.append("place_of_birth", lifestyleDetails.placeOfBirth || "");
(lifestyleDetails.diets || []).forEach((id, index) => {
formData.append(`diets[${index}]`, id);
});
(lifestyleDetails.hobbies || []).forEach((id, index) => {
formData.append(`hobbies[${index}]`, id);
});
const graha = lifestyleDetails.graha || {};
Object.keys(graha).forEach((house) => {
const values = graha[house] || [];
values.forEach((value, index) => {
formData.append(`graha_${house}[${index}]`, value);
});
});
const amsam = lifestyleDetails.amsam || {};
Object.keys(amsam).forEach((house) => {
const values = amsam[house] || [];
values.forEach((value, index) => {
formData.append(`amsam_${house}[${index}]`, value);
});
});
return formData;
};
const buildRegisterStep5Payload = () => {
const formData = new FormData();
formData.append("age_range", partnerPreferences.ageRange || "");
formData.append("annual_income", partnerPreferences.annualIncome || "");
(partnerPreferences.castes || []).forEach((id, index) => {
formData.append(`castes[${index}]`, id);
});
(partnerPreferences.subCastes || []).forEach((id, index) => {
formData.append(`sub_castes[${index}]`, id);
});
(partnerPreferences.occupations || []).forEach((id, index) => {
formData.append(`occupations[${index}]`, id);
});
(partnerPreferences.educations || []).forEach((id, index) => {
formData.append(`educations[${index}]`, id);
});
(partnerPreferences.hobbies || []).forEach((id, index) => {
formData.append(`hobbies[${index}]`, id);
});
(partnerPreferences.states || []).forEach((id, index) => {
formData.append(`states[${index}]`, id);
});
(partnerPreferences.districts || []).forEach((id, index) => {
formData.append(`districts[${index}]`, id);
});
return formData;
};
const extractAccessToken = (res) =>
res?.access_token ||
res?.accessToken ||
res?.token ||
res?.data?.access_token ||
res?.data?.accessToken ||
res?.data?.token ||
res?.result?.access_token ||
res?.result?.accessToken ||
res?.result?.token ||
null;
const handleStepSubmit = async () => {
const isValid = validateStep(currentStep);
if (!isValid) return;
try {
if (currentStep === 1) {
const payload = await buildRegisterStep1Payload();
const res = await registerStep1.mutateAsync(payload);
const token = extractAccessToken(res);
if (token) {
setAccessToken(token);
}
} else if (currentStep === 2) {
const payload = buildRegisterStep2Payload();
await registerStep2.mutateAsync(payload);
} else if (currentStep === 3) {
const payload = buildRegisterStep3Payload();
await registerStep3.mutateAsync(payload);
} else if (currentStep === 4) {
const payload = buildRegisterStep4Payload();
await registerStep4.mutateAsync(payload);
} else if (currentStep === 5) {
const payload = buildRegisterStep5Payload();
await registerStep5.mutateAsync(payload);
}
setErrors({});
setCurrentStep((prev) => Math.min(prev + 1, 6));
window.scrollTo(0, 0);
} catch (e) {
const serverErrors = mapServerErrors(e);
let handled = false;
if (Object.keys(serverErrors).length > 0) {
const hasFieldErrors = Object.keys(serverErrors).some(
(key) => key !== "_form"
);
if (hasFieldErrors) {
setErrors(serverErrors);
focusFirstError(serverErrors, STEP_FIELD_ORDER[currentStep]);
// For step 1, we know mapping is correct so field errors are sufficient feedback
if (currentStep === 1) handled = true;
}
if (serverErrors._form) {
toast.error(serverErrors._form, { position: "top-right" });
handled = true;
}
}
if (!handled) {
const msg = e?.response?.data?.message || e?.message || "Failed to submit step. Please try again.";
toast.error(msg, { position: "top-right" });
}
}
};
const handleSkip = () => {
setErrors({});
setCurrentStep((prev) => Math.min(prev + 1, 6));
window.scrollTo(0, 0);
};
const handleStepClick = (step) => {
setCurrentStep(step);
setErrors({});
window.scrollTo(0, 0);
};
const handleEdit = (step) => {
setCurrentStep(step);
setErrors({});
window.scrollTo(0, 0);
};
const handleFinalSubmit = async () => {
for (let i = 1; i <= 5; i++) {
const ok = validateStep(i);
if (!ok) {
setCurrentStep(i);
return;
}
}
try {
// final combined API call - replace with your final API
await new Promise((resolve) => setTimeout(resolve, 500));
toast.success("Form submitted successfully!", { position: "top-right" });
} catch (e) {
toast.error("Failed to submit form.", { position: "top-right" });
}
};
const renderStepContent = () => {
switch (currentStep) {
case 1:
return (
<PersonalDetailsForm
onSubmitStep={handleStepSubmit}
errors={errors}
onFieldChange={clearFieldErrors}
/>
);
case 2:
return (
<EducationalDetailsForm
onSubmitStep={handleStepSubmit}
onSkipStep={handleSkip}
errors={errors}
onFieldChange={clearFieldErrors}
/>
);
case 3:
return (
<FamilyDetailsForm
onSubmitStep={handleStepSubmit}
onSkipStep={handleSkip}
errors={errors}
onFieldChange={clearFieldErrors}
/>
);
case 4:
return (
<LifestyleDetailsForm
onSubmitStep={handleStepSubmit}
onSkipStep={handleSkip}
errors={errors}
onFieldChange={clearFieldErrors}
/>
);
case 5:
return (
<PartnerPreferencesForm
onSubmitStep={handleStepSubmit}
onSkipStep={handleSkip}
errors={errors}
onFieldChange={clearFieldErrors}
/>
);
case 6:
return <PreviewScreen onEdit={handleEdit} onSubmit={handleFinalSubmit} />;
default:
return null;
}
};
const getTitle = () => {
const titles = {
1: "Personal Details",
2: "Educational & Professional Details",
3: "Family Details",
4: "Lifestyle & Habits",
5: "Partner Preferences",
6: "Details Preview",
};
return titles[currentStep] || "";
};
return (
<div className="">
<div className="max-w-[1400px] mx-auto bg-white ">
{/* Header */}
<div className="my-4 rounded-[10px] py-4 bg-[#e3ffed] w-full max-w-[1200px] mx-auto">
<Stepper currentStep={currentStep} onStepClick={handleStepClick} />
<div className="flex items-center p-4 justify-center">
{currentStep > 1 && currentStep < 6 && (
<button
onClick={() => setCurrentStep((prev) => prev - 1)}
className="mr-3"
>
<ChevronLeft size={24} />
</button>
)}
<h1 className="text-[24px] font-semibold text-center uppercase bg-[#fff2f2] py-2 px-3 rounded-5">{getTitle()}</h1>
</div>
</div>
{/* Content */}
<div className="pb-6">{renderStepContent()}</div>
</div>
</div>
);
};
export default StepperForm;