Browse Source

feat: implement marriage onboarding progress tracking with new types, helpers, and UI components

master
sina_sajjadi 3 weeks ago
parent
commit
28d8e61fb3
  1. 2
      public/openapi.json
  2. 55
      src/app/questions-list/page.tsx
  3. 89
      src/components/questions/progress-helper.ts
  4. 4
      src/components/questions/question-answer-storage.tsx
  5. 15
      src/components/questions/required-steps-card.tsx
  6. 8
      src/data/section-slug-map.ts
  7. 1
      src/hooks/marriage/types.ts
  8. 6
      src/i18n/locales/en/questions.json
  9. 6
      src/i18n/locales/fa/questions.json

2
public/openapi.json
File diff suppressed because it is too large
View File

55
src/app/questions-list/page.tsx

@ -13,8 +13,11 @@ import { PageBackground } from "@/components/utils/page-background";
import { import {
getQuestionListItems, getQuestionListItems,
isQuestionListItemVisibleForProfile, isQuestionListItemVisibleForProfile,
isQuestionRequiredForProfile,
isQuestionVisibleForProfile,
type QuestionListItem, type QuestionListItem,
} from "@/data/question-data"; } from "@/data/question-data";
import { hasQuestionAnswerValue } from "@/components/questions/question-answer-storage";
import { useStartMarriageMatchMutation } from "@/hooks/marriage/use-match-start"; import { useStartMarriageMatchMutation } from "@/hooks/marriage/use-match-start";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { useMarriageSectionsQuery } from "@/hooks/marriage/use-sections"; import { useMarriageSectionsQuery } from "@/hooks/marriage/use-sections";
@ -22,12 +25,15 @@ import { localizePath } from "@/i18n/config";
import { useI18n } from "@/i18n/provider"; import { useI18n } from "@/i18n/provider";
import { toFrontendSlug } from "@/data/section-slug-map"; import { toFrontendSlug } from "@/data/section-slug-map";
import SectionsRequest from "./sections-request"; import SectionsRequest from "./sections-request";
import { getStoredAge, getLocalSectionProgress } from "@/components/questions/progress-helper";
export default function QuestionsListPage() { export default function QuestionsListPage() {
const { dictionary: t, locale } = useI18n(); const { dictionary: t, locale } = useI18n();
const router = useRouter(); const router = useRouter();
const { data: profile } = useMarriageProfileQuery(); const { data: profile } = useMarriageProfileQuery();
const { data: sections } = useMarriageSectionsQuery();
const { data: sections } = useMarriageSectionsQuery({
refetchOnMount: "always",
});
const startMatchMutation = useStartMarriageMatchMutation({ const startMatchMutation = useStartMarriageMatchMutation({
onSuccess: () => { onSuccess: () => {
router.push(localizePath("/finding-match", locale)); router.push(localizePath("/finding-match", locale));
@ -45,6 +51,28 @@ export default function QuestionsListPage() {
), ),
[locale, profile?.gender], [locale, profile?.gender],
); );
const sectionProgressBySlug = useMemo(() => {
const progressBySlug = new Map<string, number>();
const age = getStoredAge();
sections?.forEach((section) => {
const frontendSlug = toFrontendSlug(section.slug);
const progress = Math.max(0, Math.min(100, Math.round(section.completion_percent)));
progressBySlug.set(frontendSlug, progress);
progressBySlug.set(section.slug, progress);
});
questionListItems.forEach((item) => {
const localProgress = getLocalSectionProgress(item, profile, age);
if (localProgress !== null) {
progressBySlug.set(item.slug, localProgress);
}
});
return progressBySlug;
}, [sections, questionListItems, profile]);
const allRequiredSectionsCompleted = useMemo(() => { const allRequiredSectionsCompleted = useMemo(() => {
if (!sections?.length) { if (!sections?.length) {
return false; return false;
@ -52,8 +80,13 @@ export default function QuestionsListPage() {
return sections return sections
.filter((section) => section.is_required) .filter((section) => section.is_required)
.every((section) => section.completion_percent >= 100);
}, [sections]);
.every((section) => {
const frontendSlug = toFrontendSlug(section.slug);
const progress = sectionProgressBySlug.get(frontendSlug) ?? Math.max(0, Math.min(100, Math.round(section.completion_percent)));
return progress >= 100;
});
}, [sections, sectionProgressBySlug]);
const profileStatus = profile?.status; const profileStatus = profile?.status;
const isProfileSuspended = profileStatus === "suspended"; const isProfileSuspended = profileStatus === "suspended";
const canStartMatch = const canStartMatch =
@ -67,22 +100,10 @@ export default function QuestionsListPage() {
} }
return sections.some( return sections.some(
(section) => !section.is_required && section.completion_percent < 100,
(section) => !section.is_required && (sectionProgressBySlug.get(toFrontendSlug(section.slug)) ?? section.completion_percent) < 100,
); );
}, [sections]);
}, [sections, sectionProgressBySlug]);
const sectionProgressBySlug = useMemo(() => {
const progressBySlug = new Map<string, number>();
sections?.forEach((section) => {
const frontendSlug = toFrontendSlug(section.slug);
const progress = Math.max(0, Math.min(100, Math.round(section.completion_percent)));
progressBySlug.set(frontendSlug, progress);
progressBySlug.set(section.slug, progress);
});
return progressBySlug;
}, [sections]);
const handleStartMatch = () => { const handleStartMatch = () => {
if (!canStartMatch) { if (!canStartMatch) {
return; return;

89
src/components/questions/progress-helper.ts

@ -0,0 +1,89 @@
import {
type QuestionListItem,
isQuestionVisibleForProfile,
isQuestionRequiredForProfile,
} from "@/data/question-data";
import { hasQuestionAnswerValue } from "./question-answer-storage";
export function getStoredAge(): number | null {
try {
if (typeof window === "undefined") return null;
const rawValue = window.localStorage.getItem("marriage:sections:personal_info:answers");
if (!rawValue) return null;
const storedAnswers = JSON.parse(rawValue);
const ageField = storedAnswers.fields?.find((f: any) => f.label === "Age");
if (ageField && ageField.value !== undefined && ageField.value !== null) {
const num = Number(ageField.value);
if (Number.isFinite(num)) return num;
}
const dobField = storedAnswers.fields?.find((f: any) => f.label === "Date of Birth");
if (dobField && dobField.value) {
const dob = new Date(dobField.value);
if (!Number.isNaN(dob.getTime())) {
const today = new Date();
let age = today.getFullYear() - dob.getFullYear();
const m = today.getMonth() - dob.getMonth();
if (m < 0 || (m === 0 && today.getDate() < dob.getDate())) {
age--;
}
return age >= 0 ? age : null;
}
}
} catch (e) { }
return null;
}
export function getLocalSectionProgress(item: QuestionListItem, profile: any, age: number | null): number | null {
try {
if (typeof window === "undefined") return null;
const storageKey = `marriage:sections:${item.slug}:answers`;
const rawValue = window.localStorage.getItem(storageKey);
if (!rawValue) {
return null;
}
const storedValue = JSON.parse(rawValue);
if (!storedValue || !Array.isArray(storedValue.fields) || storedValue.fields.length === 0) {
return null;
}
const profileContext = {
age,
gender: profile?.gender,
};
const visibleQuestions = item.questions
.filter((question) => isQuestionVisibleForProfile(question, profileContext))
.map((question) => ({
...question,
required: isQuestionRequiredForProfile(question, profileContext),
}));
const requiredQuestions = visibleQuestions.filter((q) => q.required);
if (requiredQuestions.length === 0) {
if (visibleQuestions.length === 0) {
return 100;
}
const answeredCount = visibleQuestions.filter((q, index) => {
const slugifyTitle = (title: string) => title.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
const fieldKey = `q${index + 1}_${slugifyTitle(q.title)}`;
const field = storedValue.fields.find((f: any) => f.key === fieldKey || f.label === q.title);
return field && hasQuestionAnswerValue(field.value);
}).length;
return Math.round((answeredCount / visibleQuestions.length) * 100);
}
const answeredRequiredCount = requiredQuestions.filter((q) => {
const originalIndex = item.questions.findIndex((origQ) => origQ.title === q.title);
if (originalIndex === -1) return false;
const slugifyTitle = (title: string) => title.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
const fieldKey = `q${originalIndex + 1}_${slugifyTitle(q.title)}`;
const field = storedValue.fields.find((f: any) => f.key === fieldKey || f.label === q.title);
return field && hasQuestionAnswerValue(field.value);
}).length;
return Math.round((answeredRequiredCount / requiredQuestions.length) * 100);
} catch (e) {
return null;
}
}

4
src/components/questions/question-answer-storage.tsx

@ -187,6 +187,7 @@ function createPayload(
return { return {
current_step: getCurrentStep(fields), current_step: getCurrentStep(fields),
total_steps: questions.length,
fields, fields,
}; };
} }
@ -259,7 +260,8 @@ function writeStoredAnswers(
} }
function getKeepalivePatchUrl(slug: string) { function getKeepalivePatchUrl(slug: string) {
return getApiRequestUrl(`/api/marriage/sections/${pathParam(slug)}/data/`);
const backendSlug = toBackendSlug(slug);
return getApiRequestUrl(`/api/marriage/sections/${pathParam(backendSlug)}/data/`);
} }
export function QuestionAnswersProvider({ export function QuestionAnswersProvider({

15
src/components/questions/required-steps-card.tsx

@ -10,6 +10,7 @@ import { useMarriageSectionsQuery } from "@/hooks/marriage/use-sections";
import { useI18n } from "@/i18n/provider"; import { useI18n } from "@/i18n/provider";
import { useMemo } from "react"; import { useMemo } from "react";
import { toFrontendSlug } from "@/data/section-slug-map"; import { toFrontendSlug } from "@/data/section-slug-map";
import { getStoredAge, getLocalSectionProgress } from "@/components/questions/progress-helper";
type RequiredStep = { type RequiredStep = {
slug: string; slug: string;
@ -58,19 +59,23 @@ export default function RequiredStepsCard() {
.map((item) => item.slug) .map((item) => item.slug)
); );
const age = getStoredAge();
return sections return sections
.map((section) => { .map((section) => {
const frontendSlug = toFrontendSlug(section.slug); const frontendSlug = toFrontendSlug(section.slug);
const progress = Math.max(
0,
Math.min(100, Math.round(section.completion_percent)),
);
// Check if the frontend equivalent of this section is required // Check if the frontend equivalent of this section is required
const frontendItem = getQuestionListItems(locale).find( const frontendItem = getQuestionListItems(locale).find(
(item) => item.slug === frontendSlug, (item) => item.slug === frontendSlug,
); );
const localProgress = frontendItem ? getLocalSectionProgress(frontendItem, profile, age) : null;
const progress = localProgress !== null ? localProgress : Math.max(
0,
Math.min(100, Math.round(section.completion_percent)),
);
return { return {
slug: frontendSlug, slug: frontendSlug,
required: frontendItem ? Boolean(frontendItem.required) : section.is_required, required: frontendItem ? Boolean(frontendItem.required) : section.is_required,
@ -78,7 +83,7 @@ export default function RequiredStepsCard() {
}; };
}) })
.filter((step) => visibleSlugs.has(step.slug)); .filter((step) => visibleSlugs.has(step.slug));
}, [sections, fallbackRequiredSteps, locale, profile?.gender]);
}, [sections, fallbackRequiredSteps, locale, profile]);
const { completed, total } = getRequiredStepStats(steps); const { completed, total } = getRequiredStepStats(steps);
const completion = total > 0 ? Math.round((completed / total) * 100) : 0; const completion = total > 0 ? Math.round((completed / total) * 100) : 0;

8
src/data/section-slug-map.ts

@ -4,9 +4,11 @@
* single slug, so these two naming systems don't always match 1-to-1. * single slug, so these two naming systems don't always match 1-to-1.
*/ */
export const BACKEND_TO_FRONTEND_SLUG_MAP: Record<string, string> = { export const BACKEND_TO_FRONTEND_SLUG_MAP: Record<string, string> = {
contact: "contact_residence_family_communication",
career_and_education: "education_career_economic_status",
expactancy_and_equality: "appearance_health_activity",
personal_identity: "personal_info",
contact_residence: "contact_residence_family_communication",
appearance_health: "appearance_health_activity",
education_career: "education_career_economic_status",
family_background: "family_background",
marital_history: "marital_history_children", marital_history: "marital_history_children",
beliefs_lifestyle: "beliefs_lifestyle_boundaries", beliefs_lifestyle: "beliefs_lifestyle_boundaries",
spouse_criteria: "future_spouse_criteria", spouse_criteria: "future_spouse_criteria",

1
src/hooks/marriage/types.ts

@ -128,6 +128,7 @@ export type MarriageSectionData = {
export type UpdateMarriageSectionDataPayload = { export type UpdateMarriageSectionDataPayload = {
current_step: number; current_step: number;
total_steps?: number;
fields: MarriageField[]; fields: MarriageField[];
}; };

6
src/i18n/locales/en/questions.json

@ -920,7 +920,7 @@
{ {
"title": "Previous Marriage Duration", "title": "Previous Marriage Duration",
"type": "text", "type": "text",
"required": true,
"required": false,
"tooltip": "Specify the duration of your previous marriage or engagement.", "tooltip": "Specify the duration of your previous marriage or engagement.",
"logic": { "logic": {
"dependsOn": { "dependsOn": {
@ -986,7 +986,7 @@
{ {
"title": "Number of Children", "title": "Number of Children",
"type": "number", "type": "number",
"required": true,
"required": false,
"tooltip": "Specify the number of children you have.", "tooltip": "Specify the number of children you have.",
"logic": { "logic": {
"dependsOn": { "dependsOn": {
@ -2086,7 +2086,7 @@
}, },
{ {
"title": "Confirmation of Document and Information Accuracy", "title": "Confirmation of Document and Information Accuracy",
"type": "checkbox",
"type": "radio",
"required": true, "required": true,
"tooltip": "Confirm that your entered information matches your uploaded documents.", "tooltip": "Confirm that your entered information matches your uploaded documents.",
"extras": { "extras": {

6
src/i18n/locales/fa/questions.json

@ -920,7 +920,7 @@
{ {
"title": "مدت ازدواج یا عقد قبلی", "title": "مدت ازدواج یا عقد قبلی",
"type": "text", "type": "text",
"required": true,
"required": false,
"tooltip": "مدت زمان ازدواج یا دوران عقد قبلی خود را مشخص کنید.", "tooltip": "مدت زمان ازدواج یا دوران عقد قبلی خود را مشخص کنید.",
"logic": { "logic": {
"dependsOn": { "dependsOn": {
@ -986,7 +986,7 @@
{ {
"title": "تعداد فرزندان", "title": "تعداد فرزندان",
"type": "number", "type": "number",
"required": true,
"required": false,
"tooltip": "تعداد فرزندان خود را وارد کنید.", "tooltip": "تعداد فرزندان خود را وارد کنید.",
"logic": { "logic": {
"dependsOn": { "dependsOn": {
@ -2086,7 +2086,7 @@
}, },
{ {
"title": "تأیید تطابق مدارک و اطلاعات", "title": "تأیید تطابق مدارک و اطلاعات",
"type": "checkbox",
"type": "radio",
"required": true, "required": true,
"tooltip": "تأیید مطابقت اطلاعات وارد شده با مدارک شناسایی.", "tooltip": "تأیید مطابقت اطلاعات وارد شده با مدارک شناسایی.",
"extras": { "extras": {

Loading…
Cancel
Save