Browse Source

feat(marriage): implement form overview API and optimize question loading

Refactor the marriage profile and assessment flow to use a new overview
endpoint, reducing the need to fetch full question schemas for the
initial list view.

- Implement `getFormOverview` and `useFormOverviewQuery` to fetch
  high-level section metadata.
- Update `QuestionDetailClient` and `QuestionsListPage` to utilize
  the overview data for rendering progress and section lists.
- Refactor `schema-adapter` to support mapping overview sections to
  frontend `QuestionListItem` objects.
- Optimize proxy performance by adding TTFB metrics and improving
  encoding support.
- Improve UI/UX with better overscroll behavior and viewport settings.
- Add unit and integration tests for the new schema adapter and
  required steps component.
Dev
mortezaei 1 week ago
parent
commit
79e7564ffb
  1. 64
      src/app/api/proxy/route.ts
  2. 14
      src/app/globals.css
  3. 2
      src/app/layout.tsx
  4. 216
      src/app/questions-list/[slug]/question-detail-client.test.tsx
  5. 117
      src/app/questions-list/[slug]/question-detail-client.tsx
  6. 260
      src/app/questions-list/page.tsx
  7. 17
      src/app/questions-list/sections-request.tsx
  8. 84
      src/components/Componentes/question-answer-storage.tsx
  9. 38
      src/components/Componentes/question-card.tsx
  10. 31
      src/components/Componentes/required-steps-card.test.tsx
  11. 86
      src/components/Componentes/required-steps-card.tsx
  12. 206
      src/components/Componentes/schema-question-flow.integration.test.tsx
  13. 4
      src/hooks/marriage/query-keys.ts
  14. 87
      src/hooks/marriage/use-form-schema.ts
  15. 127
      src/hooks/marriage/use-section-data.ts
  16. 1
      src/lib/marriage-profile-contract.ts
  17. 39
      src/lib/schema-adapter-overview.test.ts
  18. 66
      src/lib/schema-adapter.ts

64
src/app/api/proxy/route.ts

@ -33,10 +33,9 @@ const RESPONSE_HEADERS_TO_DROP = [
];
const MAX_LOG_BODY_LENGTH = 10_000;
const shouldLogProxy =
process.env.LOG_API_PROXY === "true" ||
(process.env.LOG_API_PROXY !== "false" &&
process.env.NODE_ENV !== "production");
const shouldLogProxy = process.env.LOG_API_PROXY === "true";
const shouldLogProxyTiming =
process.env.LOG_API_PROXY_TIMING === "true" || shouldLogProxy;
class ProxyError extends Error {
constructor(
@ -131,7 +130,11 @@ function getRequestHeaders(request: NextRequest, targetUrl: URL) {
getCookieValue(cookieHeader, "token") ??
getCookieValue(cookieHeader, "auth_token");
if (cookieToken && cookieToken !== "NO_TOKEN" && cookieToken.trim() !== "") {
if (
cookieToken &&
cookieToken !== "NO_TOKEN" &&
cookieToken.trim() !== ""
) {
headers.set("authorization", `Token ${cookieToken}`);
}
}
@ -154,7 +157,7 @@ function getRequestHeaders(request: NextRequest, targetUrl: URL) {
candidate !== null && isLocale(candidate),
) ?? "en";
headers.set("accept-encoding", "identity");
headers.set("accept-encoding", "gzip, br");
headers.set("accept-language", lang);
headers.set("x-user-language", lang);
headers.set("http_x_user_language", lang);
@ -268,9 +271,9 @@ function logProxyRequest(
function logProxyResponse(
upstreamResponse: Response,
responseBody: ArrayBuffer,
metrics: Record<string, number>,
) {
if (!shouldLogProxy) {
if (!shouldLogProxyTiming) {
return;
}
@ -278,19 +281,7 @@ function logProxyResponse(
status: upstreamResponse.status,
statusText: upstreamResponse.statusText,
headers: headersToObject(upstreamResponse.headers),
body: getBodyLogValue(
responseBody,
upstreamResponse.headers.get("content-type") ?? undefined,
),
response: {
status: upstreamResponse.status,
statusText: upstreamResponse.statusText,
headers: headersToObject(upstreamResponse.headers),
body: getBodyLogValue(
responseBody,
upstreamResponse.headers.get("content-type") ?? undefined,
),
},
metrics,
});
}
@ -300,6 +291,7 @@ function writeProxyLog(label: string, value: unknown) {
async function proxyRequest(request: NextRequest) {
try {
const proxyStartedAt = performance.now();
const targetUrl = getTargetUrl(request);
const requestBody =
request.method === "GET" || request.method === "HEAD"
@ -309,15 +301,14 @@ async function proxyRequest(request: NextRequest) {
logProxyRequest(request, targetUrl, requestHeaders, requestBody);
const upstreamStartedAt = performance.now();
const upstreamResponse = await fetch(targetUrl, {
method: request.method,
headers: requestHeaders,
body: requestBody,
cache: "no-store",
});
const responseBody = await upstreamResponse.arrayBuffer();
logProxyResponse(upstreamResponse, responseBody);
const upstreamTtfb = performance.now() - upstreamStartedAt;
const responseHeaders = getResponseHeaders(upstreamResponse.headers);
@ -328,6 +319,31 @@ async function proxyRequest(request: NextRequest) {
);
responseHeaders.set("Pragma", "no-cache");
responseHeaders.set("Expires", "0");
responseHeaders.set(
"Server-Timing",
`upstream-ttfb;dur=${upstreamTtfb.toFixed(1)}`,
);
responseHeaders.set("X-Proxy-Upstream-TTFB-Ms", upstreamTtfb.toFixed(1));
let responseBytes = 0;
const responseBody =
upstreamResponse.body?.pipeThrough(
new TransformStream<Uint8Array, Uint8Array>({
transform(chunk, controller) {
responseBytes += chunk.byteLength;
controller.enqueue(chunk);
},
flush() {
const total = performance.now() - proxyStartedAt;
logProxyResponse(upstreamResponse, {
upstreamTtfb: Math.round(upstreamTtfb),
upstreamBody: Math.round(total - upstreamTtfb),
proxyTotal: Math.round(total),
responseBytes,
});
},
}),
) ?? null;
return new Response(responseBody, {
status: upstreamResponse.status,

14
src/app/globals.css

@ -145,16 +145,22 @@
}
html {
min-height: 100%;
height: 100%;
overscroll-behavior: none;
overscroll-behavior-y: none;
}
body {
min-height: 100vh;
min-height: 100%;
margin: 0;
display: flex;
justify-content: center;
color: var(--foreground);
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
overscroll-behavior: none;
overscroll-behavior-y: none;
-webkit-overflow-scrolling: touch;
touch-action: pan-y;
}
html:lang(ar) body,
@ -180,7 +186,7 @@ html:lang(ar) body,
.app-shell {
width: 100%;
min-height: 100vh;
min-height: 100%;
padding-inline: 17px;
padding-bottom: var(--safe-bottom, 0px);
box-sizing: border-box;
@ -189,6 +195,8 @@ html:lang(ar) body,
background-position: top;
background-repeat: no-repeat;
background-size: cover;
overscroll-behavior: none;
overscroll-behavior-y: none;
}
html[data-web-bootstrap="pending"] .app-shell {

2
src/app/layout.tsx

@ -44,6 +44,8 @@ export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: false,
viewportFit: "cover",
themeColor: "#ffffff",
};

216
src/app/questions-list/[slug]/question-detail-client.test.tsx

@ -1,11 +1,23 @@
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
import {
render,
screen,
fireEvent,
waitFor,
cleanup,
} from "@testing-library/react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import QuestionDetailClient from "./question-detail-client";
import { useCattellQuestionsQuery } from "@/hooks/marriage/use-cattell";
import { useGlasserQuestionsQuery } from "@/hooks/marriage/use-glasser";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { useFormSchemaQuery } from "@/hooks/marriage/use-form-schema";
import { convertSchemaToFrontendItems } from "@/lib/schema-adapter";
import {
useFormOverviewQuery,
useFormSectionQuery,
} from "@/hooks/marriage/use-form-schema";
import {
convertOverviewToFrontendItems,
mapBackendSectionToFrontend,
} from "@/lib/schema-adapter";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("next/navigation", () => ({
@ -13,7 +25,10 @@ vi.mock("next/navigation", () => ({
}));
vi.mock("@/translations/provider", () => ({
useI18n: vi.fn(() => ({ locale: "en", dictionary: new Proxy({}, { get: (_, key) => key }) })),
useI18n: vi.fn(() => ({
locale: "en",
dictionary: new Proxy({}, { get: (_, key) => key }),
})),
}));
vi.mock("@/hooks/marriage/use-cattell", () => ({
@ -31,7 +46,8 @@ vi.mock("@/hooks/marriage/use-profile-main", () => ({
}));
vi.mock("@/hooks/marriage/use-form-schema", () => ({
useFormSchemaQuery: vi.fn(),
useFormOverviewQuery: vi.fn(),
useFormSectionQuery: vi.fn(),
}));
vi.mock("@/hooks/marriage/use-habcoin-payment", () => ({
@ -39,7 +55,8 @@ vi.mock("@/hooks/marriage/use-habcoin-payment", () => ({
}));
vi.mock("@/lib/schema-adapter", () => ({
convertSchemaToFrontendItems: vi.fn(),
convertOverviewToFrontendItems: vi.fn(),
mapBackendSectionToFrontend: vi.fn(),
}));
describe("QuestionDetailClient Validation", () => {
@ -57,21 +74,25 @@ describe("QuestionDetailClient Validation", () => {
data: { age: 30, gender: "male" },
isLoading: false,
});
(useFormSchemaQuery as any).mockReturnValue({
data: {},
(useFormOverviewQuery as any).mockReturnValue({
data: { version: 1 },
isLoading: false,
});
(useFormSectionQuery as any).mockReturnValue({
data: undefined,
isLoading: false,
});
});
const setupTest = (slug: string, cattellData: any, glasserData: any) => {
// Mock schema adapter to return an item for the requested slug
(convertSchemaToFrontendItems as any).mockReturnValue([
(convertOverviewToFrontendItems as any).mockReturnValue([
{
slug: slug,
title: "Test",
questions: []
}
questions: [],
},
]);
(useCattellQuestionsQuery as any).mockReturnValue({
@ -107,9 +128,9 @@ describe("QuestionDetailClient Validation", () => {
questionsListHref="/questions-list"
title="Test"
/>
</QueryClientProvider>
</QueryClientProvider>,
);
// Intro screen might render first; if Start is available, click it to mount questions flow
const startButton = screen.queryByText("Start");
if (startButton) {
@ -118,20 +139,24 @@ describe("QuestionDetailClient Validation", () => {
};
it("should render correctly when Cattell API data is completely valid", () => {
setupTest("personality_test", {
questions: [
{
question_number: 1,
text: "Valid Question Cattell",
options: [
{ id: "opt_a", label: "Opt1", value: "A" },
{ id: "opt_b", label: "Opt2", value: "B" },
{ id: "opt_c", label: "Opt3", value: "C" },
],
}
]
}, null);
setupTest(
"personality_test",
{
questions: [
{
question_number: 1,
text: "Valid Question Cattell",
options: [
{ id: "opt_a", label: "Opt1", value: "A" },
{ id: "opt_b", label: "Opt2", value: "B" },
{ id: "opt_c", label: "Opt3", value: "C" },
],
},
],
},
null,
);
// Retry UI should NOT be present
expect(screen.queryByText("Retry")).toBeNull();
// Question text should be visible
@ -140,25 +165,33 @@ describe("QuestionDetailClient Validation", () => {
it("should render Retry UI when Cattell API data is empty", () => {
setupTest("personality_test", { questions: [] }, null);
expect(screen.getAllByText("No questions found for this test.")).toBeDefined();
expect(
screen.getAllByText("No questions found for this test."),
).toBeDefined();
expect(screen.getAllByText("Retry")).toBeDefined();
});
it("should render Retry UI when Cattell options are invalid (schema failure) and trigger refetch on Retry", async () => {
setupTest("personality_test", {
questions: [
{
question_number: 1,
text: "Invalid Question",
options: [{ label: "Opt1", value: "A" }], // Invalid schema
}
]
}, null);
expect(screen.getAllByText("No questions found for this test.")).toBeDefined();
setupTest(
"personality_test",
{
questions: [
{
question_number: 1,
text: "Invalid Question",
options: [{ label: "Opt1", value: "A" }], // Invalid schema
},
],
},
null,
);
expect(
screen.getAllByText("No questions found for this test."),
).toBeDefined();
const retryBtn = screen.getAllByText("Retry")[0];
fireEvent.click(retryBtn);
await waitFor(() => {
expect(mockCattellRefetch).toHaveBeenCalled();
@ -179,10 +212,10 @@ describe("QuestionDetailClient Validation", () => {
{ id: "o4", label: "O4", value: 4 },
{ id: "o5", label: "O5", value: 5 },
],
}
]
},
],
});
expect(screen.queryByText("Retry")).toBeNull();
expect(screen.getByText("Valid Question Glasser")).toBeDefined();
});
@ -200,12 +233,14 @@ describe("QuestionDetailClient Validation", () => {
{ label: "O3", value: 3 },
{ label: "O4", value: 4 },
],
}
]
},
],
});
expect(screen.getAllByText("No questions found for this test.")).toBeDefined();
expect(
screen.getAllByText("No questions found for this test."),
).toBeDefined();
const retryBtn = screen.getAllByText("Retry")[0];
fireEvent.click(retryBtn);
await waitFor(() => {
expect(mockGlasserRefetch).toHaveBeenCalled();
@ -213,43 +248,58 @@ describe("QuestionDetailClient Validation", () => {
});
it("should render profile questions using ID-based data flow", () => {
(convertSchemaToFrontendItems as any).mockReturnValue([
const profileItem = {
slug: "profile_test",
title: "Profile Form",
questions: [
{
id: "q_123",
title: "Dynamic ID Question",
type: "text",
order: 1,
required: true,
isVisible: true,
private: false,
description: "",
tooltip: "",
extras: {},
options: [],
},
{
id: "q_456",
title: "Another ID Question",
type: "radio",
order: 2,
required: false,
isVisible: true,
private: false,
description: "",
tooltip: "",
extras: {},
options: [
{ id: "opt_1", label: "Yes", value: "yes", order: 1 },
{ id: "opt_2", label: "No", value: "no", order: 2 },
],
},
],
};
(convertOverviewToFrontendItems as any).mockReturnValue([
{
slug: "profile_test",
title: "Profile Form",
questions: [
{
id: "q_123",
title: "Dynamic ID Question",
type: "text",
order: 1,
required: true,
isVisible: true,
private: false,
description: "",
tooltip: "",
extras: {},
options: []
},
{
id: "q_456",
title: "Another ID Question",
type: "radio",
order: 2,
required: false,
isVisible: true,
private: false,
description: "",
tooltip: "",
extras: {},
options: [
{ id: "opt_1", label: "Yes", value: "yes", order: 1 },
{ id: "opt_2", label: "No", value: "no", order: 2 }
]
}
]
}
questions: [],
},
]);
(useFormSectionQuery as any).mockReturnValue({
data: {
version: 1,
section: { cards: [] },
answers: {},
section_progress: { completion_percent: 0 },
},
isLoading: false,
});
(mapBackendSectionToFrontend as any).mockReturnValue(profileItem);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
@ -266,7 +316,7 @@ describe("QuestionDetailClient Validation", () => {
questionsListHref="/questions-list"
title="Profile Test"
/>
</QueryClientProvider>
</QueryClientProvider>,
);
// Profile questions render directly, no start button

117
src/app/questions-list/[slug]/question-detail-client.tsx

@ -19,15 +19,26 @@ import TestQuestionsFlow, {
type TestQuestion,
} from "@/components/Componentes/test-questions-flow";
import {
getGlasserQuestions,
useGlasserQuestionsQuery,
useSubmitGlasserAssessmentMutation,
} from "@/hooks/marriage/use-glasser";
import {
getCattellQuestions,
useCattellQuestionsQuery,
useSubmitCattellAssessmentMutation,
} from "@/hooks/marriage/use-cattell";
import { useFormSchemaQuery } from "@/hooks/marriage/use-form-schema";
import { convertSchemaToFrontendItems, type QuestionField } from "@/lib/schema-adapter";
import { useQueryClient } from "@tanstack/react-query";
import { marriageQueryKeys } from "@/hooks/marriage/query-keys";
import {
useFormOverviewQuery,
useFormSectionQuery,
} from "@/hooks/marriage/use-form-schema";
import {
convertOverviewToFrontendItems,
mapBackendSectionToFrontend,
type QuestionField,
} from "@/lib/schema-adapter";
import { defaultLocale, type Locale } from "@/translations/config";
import { useI18n } from "@/translations/provider";
@ -102,7 +113,9 @@ function QuestionFlowWrapper({
let isAnswered = hasAnswer;
if (hasAnswer) {
const isEmailQuestion = question.type === "email" || question.validation?.format === "email";
const isEmailQuestion =
question.type === "email" ||
question.validation?.format === "email";
if (isEmailQuestion) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
isAnswered = emailRegex.test(String(answer).trim());
@ -126,10 +139,7 @@ function QuestionFlowWrapper({
data-question-disabled="false"
data-question-answered={String(isAnswered)}
>
<QuestionRenderer
question={question}
dobQuestion={dobQuestion}
/>
<QuestionRenderer question={question} dobQuestion={dobQuestion} />
</div>
);
})}
@ -151,6 +161,7 @@ export default function QuestionDetailClient({
const { dictionary: t } = useI18n();
const [isTestStarted, setIsTestStarted] = useState(false);
const [hasTestProgress, setHasTestProgress] = useState(false);
const queryClient = useQueryClient();
useEffect(() => {
if (typeof window !== "undefined") {
@ -174,12 +185,34 @@ export default function QuestionDetailClient({
}
}, [itemSlug, isTestStarted]);
const { data: schema, isLoading: isSchemaLoading } = useFormSchemaQuery("profile", locale);
const items = useMemo(() => convertSchemaToFrontendItems(schema, locale), [schema, locale]);
const item = items.find((i) => i.slug === itemSlug);
const isCattellSlug = itemSlug === "personality_test";
const isGlasserSlug = itemSlug === "glasser_5_needs_test";
const isAssessment = isCattellSlug || isGlasserSlug;
const { data: overview, isLoading: isOverviewLoading } = useFormOverviewQuery(
"profile",
locale,
);
const { data: sectionResponse, isLoading: isSectionLoading } =
useFormSectionQuery(
"profile",
itemSlug,
locale,
Boolean(itemSlug) && !isAssessment,
);
const items = useMemo(
() => convertOverviewToFrontendItems(overview),
[overview],
);
const overviewItem = items.find((candidate) => candidate.slug === itemSlug);
const item = useMemo(() => {
if (!overviewItem || isAssessment || !sectionResponse) return overviewItem;
return mapBackendSectionToFrontend(
sectionResponse.section,
sectionResponse.section_progress.completion_percent,
);
}, [isAssessment, overviewItem, sectionResponse]);
const isSchemaLoading =
isOverviewLoading || (!isAssessment && isSectionLoading);
const cattellQuery = useCattellQuestionsQuery(locale, {
enabled: isCattellSlug && isTestStarted,
@ -193,15 +226,39 @@ export default function QuestionDetailClient({
});
const submitGlasserMutation = useSubmitGlasserAssessmentMutation();
useEffect(() => {
if (!isAssessment || isTestStarted) return;
if (isCattellSlug) {
void queryClient.prefetchQuery({
queryKey: marriageQueryKeys.cattellQuestions(locale),
queryFn: () => getCattellQuestions(locale),
staleTime: 30 * 1000,
});
} else if (isGlasserSlug) {
void queryClient.prefetchQuery({
queryKey: marriageQueryKeys.glasserQuestions(locale),
queryFn: () => getGlasserQuestions(locale),
staleTime: 30 * 1000,
});
}
}, [
isAssessment,
isCattellSlug,
isGlasserSlug,
isTestStarted,
locale,
queryClient,
]);
const cattellTestQuestions: TestQuestion[] = useMemo(() => {
const questionsList = cattellQuery.data?.questions || [];
// Strict schema validation for Cattell
const isValidCattell = (q: any) =>
q.question_number &&
q.text &&
q.options &&
q.options.length === 3 &&
const isValidCattell = (q: any) =>
q.question_number &&
q.text &&
q.options &&
q.options.length === 3 &&
q.options.every((o: any) => o.id && o.label && o.value);
if (questionsList.length > 0 && !questionsList.every(isValidCattell)) {
@ -218,14 +275,21 @@ export default function QuestionDetailClient({
const glasserTestQuestions: TestQuestion[] = useMemo(() => {
const questionsList = glasserQuery.data?.questions || [];
// Strict schema validation for Glasser
const isValidGlasser = (q: any) =>
q.question_number &&
q.text &&
q.options &&
q.options.length === 5 &&
q.options.every((o: any) => o.id && o.label && typeof o.value === 'number' && o.value >= 1 && o.value <= 5);
const isValidGlasser = (q: any) =>
q.question_number &&
q.text &&
q.options &&
q.options.length === 5 &&
q.options.every(
(o: any) =>
o.id &&
o.label &&
typeof o.value === "number" &&
o.value >= 1 &&
o.value <= 5,
);
if (questionsList.length > 0 && !questionsList.every(isValidGlasser)) {
console.error("Invalid Glasser API response schema");
@ -572,7 +636,8 @@ export default function QuestionDetailClient({
}
const dobQuestion = visibleQuestions.find(
(question) => question.ui_config?.isDob === true || question.type === "date",
(question) =>
question.ui_config?.isDob === true || question.type === "date",
);
return (
@ -583,7 +648,7 @@ export default function QuestionDetailClient({
slug={item.slug}
questions={visibleQuestions}
locale={locale}
schemaVersion={schema?.version ?? 1}
schemaVersion={sectionResponse?.version ?? overview?.version ?? 1}
>
<main className="-mx-[17px] flex h-svh flex-col overflow-hidden bg-[#F7F1F0]">
<StickyHeader sticky={false} className="shrink-0">

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

@ -2,7 +2,7 @@
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { IoClose } from "react-icons/io5";
import {
getSubmitPath,
@ -15,14 +15,16 @@ import InformationSheet from "@/components/Componentes/information-sheet";
import NavigationButton from "@/components/Componentes/navigation-button";
import { getQuestionAnswersStorageKey } from "@/components/Componentes/question-answer-storage";
import { FixToTheEnd } from "@/components/Componentes/fix-to-the-end";
import { LoadingSkeleton } from "@/components/Componentes/loading-skeleton";
import { PageBackground } from "@/components/Componentes/page-background";
import ErrorToast from "@/components/Componentes/error-toast";
import { useQueryClient } from "@tanstack/react-query";
import { useStartMarriageMatchMutation } from "@/hooks/marriage/use-match-start";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { useFormSchemaQuery } from "@/hooks/marriage/use-form-schema";
import { convertSchemaToFrontendItems } from "@/lib/schema-adapter";
import {
getFormSection,
useFormOverviewQuery,
} from "@/hooks/marriage/use-form-schema";
import { convertOverviewToFrontendItems } from "@/lib/schema-adapter";
import type { QuestionListItem } from "@/lib/schema-adapter";
import { useCloseServiceOnBack } from "@/hooks/use-close-service-on-back";
import {
@ -33,6 +35,7 @@ import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider";
import SectionsRequest from "./sections-request";
import { getAssessmentLocalProgress } from "@/lib/assessment-progress";
import { REQUIRED_PROFILE_SECTION_COUNT } from "@/lib/marriage-profile-contract";
export default function QuestionsListPage() {
useCloseServiceOnBack();
@ -41,8 +44,10 @@ export default function QuestionsListPage() {
const queryClient = useQueryClient();
const { data: profile, isLoading: isProfileLoading } =
useMarriageProfileQuery();
const { data: schema, isLoading: isSchemaLoading } =
useFormSchemaQuery("profile", locale);
const { data: overview, isLoading: isSchemaLoading } = useFormOverviewQuery(
"profile",
locale,
);
const isSectionsLoading = false;
@ -76,10 +81,12 @@ export default function QuestionsListPage() {
const [selectedSection, setSelectedSection] =
useState<QuestionListItem | null>(null);
const questionListItems = useMemo(
() => convertSchemaToFrontendItems(schema, locale),
[schema, locale],
() => convertOverviewToFrontendItems(overview),
[overview],
);
const [localAssessmentProgress, setLocalAssessmentProgress] = useState<Map<string, number>>(new Map());
const [localAssessmentProgress, setLocalAssessmentProgress] = useState<
Map<string, number>
>(new Map());
useEffect(() => {
const next = new Map<string, number>();
@ -94,20 +101,26 @@ export default function QuestionsListPage() {
}
}
setLocalAssessmentProgress(next);
}, [schema]);
}, [overview]);
const sectionProgressBySlug = useMemo(() => {
const progressBySlug = new Map<string, number>();
if (schema?.progress?.sections_progress) {
Object.entries(schema.progress.sections_progress).forEach(([slug, prog]) => {
progressBySlug.set(slug, Math.max(0, Math.min(100, Math.round(prog.completion_percent))));
});
if (overview?.progress?.sections_progress) {
Object.entries(overview.progress.sections_progress).forEach(
([slug, prog]) => {
progressBySlug.set(
slug,
Math.max(0, Math.min(100, Math.round(prog.completion_percent))),
);
},
);
}
localAssessmentProgress.forEach((progress, slug) => {
if ((progressBySlug.get(slug) ?? 0) < 100) progressBySlug.set(slug, progress);
if ((progressBySlug.get(slug) ?? 0) < 100)
progressBySlug.set(slug, progress);
});
// Add fallback for combined section from the schema adapter which attaches it to questionListItems directly
questionListItems.forEach((item) => {
if (!progressBySlug.has(item.slug)) {
@ -116,15 +129,56 @@ export default function QuestionsListPage() {
});
return progressBySlug;
}, [schema, questionListItems, localAssessmentProgress]);
}, [overview, questionListItems, localAssessmentProgress]);
const requiredQuestionListItems = useMemo(
() => questionListItems.filter((item) => Boolean(item.required)),
[questionListItems],
);
const completedRequiredSections = useMemo(
() =>
requiredQuestionListItems.filter(
(item) => (sectionProgressBySlug.get(item.slug) ?? 0) >= 100,
).length,
[requiredQuestionListItems, sectionProgressBySlug],
);
const [displayedRequiredSections, setDisplayedRequiredSections] = useState(
() => completedRequiredSections,
);
useEffect(() => {
if (displayedRequiredSections === completedRequiredSections) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
setDisplayedRequiredSections(completedRequiredSections);
return;
}
const direction = completedRequiredSections > displayedRequiredSections ? 1 : -1;
const distance = Math.abs(completedRequiredSections - displayedRequiredSections);
const interval = window.setInterval(() => {
setDisplayedRequiredSections((current) => {
const next = current + direction;
if (next === completedRequiredSections) window.clearInterval(interval);
return next;
});
}, Math.max(90, Math.floor(500 / distance)));
return () => window.clearInterval(interval);
}, [completedRequiredSections, displayedRequiredSections]);
const hasValidRequiredContract =
requiredQuestionListItems.length === REQUIRED_PROFILE_SECTION_COUNT;
useEffect(() => {
if (overview && !hasValidRequiredContract) {
console.error("Required section contract mismatch", {
expected: REQUIRED_PROFILE_SECTION_COUNT,
actual: requiredQuestionListItems.length,
});
}
}, [hasValidRequiredContract, overview, requiredQuestionListItems.length]);
const allRequiredSectionsCompleted = useMemo(() => {
if (requiredQuestionListItems.length === 0) {
if (!hasValidRequiredContract) {
return false;
}
@ -132,7 +186,7 @@ export default function QuestionsListPage() {
const progress = sectionProgressBySlug.get(item.slug) ?? 0;
return progress >= 100;
});
}, [requiredQuestionListItems, sectionProgressBySlug]);
}, [hasValidRequiredContract, requiredQuestionListItems, sectionProgressBySlug]);
const profileStatus = profile?.status;
const isProfileSuspended = profileStatus === "suspended";
@ -145,19 +199,20 @@ export default function QuestionsListPage() {
const [toastMessage, setToastMessage] = useState<string | null>(null);
const syncPendingAnswers = useCallback(async () => {
if (!schema) return;
if (!overview) return;
const { updateMarriageSectionData } = await import(
"@/hooks/marriage/use-section-data"
);
let currentVersion = schema.version;
let currentVersion = overview.version;
for (const item of questionListItems) {
const storageKey = getQuestionAnswersStorageKey(item.slug);
const rawValue = window.localStorage.getItem(storageKey);
if (!rawValue) continue;
const storedValue = JSON.parse(rawValue);
if (!storedValue.pending_sync || !Array.isArray(storedValue.fields)) continue;
if (!storedValue.pending_sync || !Array.isArray(storedValue.fields))
continue;
const pendingKeys = new Set<string>(
Array.isArray(storedValue.pending_keys)
? storedValue.pending_keys
@ -170,11 +225,7 @@ export default function QuestionsListPage() {
current_step: storedValue.current_step,
fields: [field],
});
queryClient.setQueryData(
["marriage", "form-schema", "profile", locale],
result.schema,
);
currentVersion = result.schema.version;
currentVersion = result.version;
pendingKeys.delete(field.key);
storedValue.pending_keys = [...pendingKeys];
window.localStorage.setItem(storageKey, JSON.stringify(storedValue));
@ -182,7 +233,109 @@ export default function QuestionsListPage() {
storedValue.pending_sync = false;
window.localStorage.setItem(storageKey, JSON.stringify(storedValue));
}
}, [locale, queryClient, questionListItems, schema]);
}, [overview, questionListItems]);
const prefetchSection = useCallback(
(item: QuestionListItem) => {
const href = localizePath(`/questions-list/${item.slug}`, locale);
router.prefetch(href);
if (
item.slug === "personality_test" ||
item.slug === "glasser_5_needs_test"
)
return;
void queryClient.prefetchQuery({
queryKey: ["marriage", "form-section", "profile", item.slug, locale],
queryFn: () => getFormSection("profile", item.slug, locale),
staleTime: 30 * 1000,
});
},
[locale, queryClient, router],
);
const viewportPrefetchChain = useRef(Promise.resolve());
const viewportPrefetchSlugs = useRef(new Set<string>());
const enqueueViewportPrefetch = useCallback(
(item: QuestionListItem) => {
if (
item.slug === "personality_test" ||
item.slug === "glasser_5_needs_test" ||
viewportPrefetchSlugs.current.has(item.slug)
) {
return;
}
viewportPrefetchSlugs.current.add(item.slug);
viewportPrefetchChain.current = viewportPrefetchChain.current
.catch(() => undefined)
.then(() =>
queryClient.fetchQuery({
queryKey: [
"marriage",
"form-section",
"profile",
item.slug,
locale,
],
queryFn: () => getFormSection("profile", item.slug, locale),
staleTime: 30 * 1000,
}),
)
.then(() => undefined);
},
[locale, queryClient],
);
const prefetchQueueStarted = useRef(false);
useEffect(() => {
if (prefetchQueueStarted.current || !overview) return;
const likelySections = questionListItems
.filter(
(item) =>
item.required &&
(sectionProgressBySlug.get(item.slug) ?? item.progress) < 100,
)
.slice(0, 2);
if (likelySections.length === 0) return;
prefetchQueueStarted.current = true;
let cancelled = false;
const runQueue = async () => {
for (const item of likelySections) {
if (cancelled) return;
try {
await queryClient.fetchQuery({
queryKey: [
"marriage",
"form-section",
"profile",
item.slug,
locale,
],
queryFn: () => getFormSection("profile", item.slug, locale),
staleTime: 30 * 1000,
});
} catch {
// A later interaction or navigation can retry without blocking the list.
}
}
};
const schedule = () => void runQueue();
const idleWindow = window as Window & {
requestIdleCallback?: (
callback: () => void,
options?: { timeout: number },
) => number;
cancelIdleCallback?: (handle: number) => void;
};
const isIdleScheduled = Boolean(idleWindow.requestIdleCallback);
const handle = idleWindow.requestIdleCallback
? idleWindow.requestIdleCallback(schedule, { timeout: 750 })
: globalThis.setTimeout(schedule, 150);
return () => {
cancelled = true;
if (isIdleScheduled) idleWindow.cancelIdleCallback?.(handle as number);
else globalThis.clearTimeout(handle);
};
}, [locale, overview, queryClient, questionListItems, sectionProgressBySlug]);
useEffect(() => {
void syncPendingAnswers().catch(() => setIsSyncError(true));
@ -289,37 +442,38 @@ export default function QuestionsListPage() {
</header>
<div className="relative mt-4 space-y-5">
{/* Required Steps Card Skeleton (Hosseinieh Card Style) */}
<div className="rounded-[15px] bg-[#40506A] p-4 text-white shadow-[0_18px_34px_rgba(38,52,73,0.16)] flex items-center justify-between gap-5">
<div className="min-w-0 flex-1 space-y-2.5">
<div className="flex items-center gap-3">
<span className="size-6 shrink-0 rounded-full shimmer-white-bg" />
<span className="h-4 w-32 rounded-md shimmer-white-bg block" />
</div>
<div className="space-y-1.5 pt-0.5">
<span className="h-3 w-4/5 rounded-md shimmer-white-bg block" />
<span className="h-3 w-3/5 rounded-md shimmer-white-bg block" />
</div>
</div>
<div className="size-[60px] shrink-0 rounded-full shimmer-white-bg flex items-center justify-center p-1.5">
<div className="size-full rounded-full bg-[#40506A]" />
</div>
</div>
<RequiredStepsCard
completed={displayedRequiredSections}
total={REQUIRED_PROFILE_SECTION_COUNT}
/>
{/* Section Card Skeletons (solid blocks like the Meet/checkup
AppShimmer loading one sweep band runs across each card) */}
<div className="space-y-3">
{Array.from({ length: 6 }).map((_, idx) => (
<div
key={idx}
className="shimmer-bg h-[84px] rounded-[20px]"
/>
<div key={idx} className="shimmer-bg h-[84px] rounded-[20px]" />
))}
</div>
</div>
<FixToTheEnd>
<LoadingSkeleton className="h-[52px] w-full rounded-[14px]" />
<Button aria-label={t["Find Matches"]} disabled>
<span className="flex items-center justify-center gap-2.5">
<Image
src="/assets/images/noun-wedding-rings-6540466 1.svg"
alt=""
aria-hidden="true"
width={28}
height={28}
className="shrink-0"
loading="eager"
fetchPriority="high"
/>
<span className="leading-none font-semibold">
{t["Submit"]}
</span>
</span>
</Button>
</FixToTheEnd>
</main>
</>
@ -395,7 +549,7 @@ export default function QuestionsListPage() {
className="text-left"
/>
) : null}
<SectionsRequest />
<SectionsRequest sections={overview?.sections} />
<PageBackground disabled />
<main
@ -436,8 +590,8 @@ export default function QuestionsListPage() {
<div className="relative">
<div className="mt-4">
<RequiredStepsCard
items={questionListItems}
progressBySlug={sectionProgressBySlug}
completed={displayedRequiredSections}
total={REQUIRED_PROFILE_SECTION_COUNT}
/>
</div>
@ -448,6 +602,8 @@ export default function QuestionsListPage() {
item={item}
progress={sectionProgressBySlug.get(item.slug) ?? null}
onInfoClick={(section) => setSelectedSection(section)}
onNearViewport={enqueueViewportPrefetch}
onPrefetch={prefetchSection}
/>
))}
</section>

17
src/app/questions-list/sections-request.tsx

@ -4,8 +4,7 @@ import { useEffect, useMemo, useState } from "react";
import { IoClose } from "react-icons/io5";
import Button from "@/components/Componentes/button";
import InformationSheet from "@/components/Componentes/information-sheet";
import { useMarriageSectionsQuery } from "@/hooks/marriage/use-sections";
import { useI18n } from "@/translations/provider";
import type { FormOverviewSection } from "@/hooks/marriage/use-form-schema";
const bookingTerms = [
"All provided information is held in strict confidence.",
@ -24,9 +23,11 @@ const FIRST_ENTRY_TERMS = [
'Do you operate based on superficial behavioral adaptations, or are you aware of the deep "source traits" that fundamentally control your decision-making processes?',
] as const;
export default function SectionsRequest() {
const { locale } = useI18n();
const { data: sections, isSuccess } = useMarriageSectionsQuery(locale);
export default function SectionsRequest({
sections,
}: {
sections: FormOverviewSection[] | undefined;
}) {
const [hasSeenSheet, setHasSeenSheet] = useState(true);
const hasNoProgression = useMemo(() => {
@ -35,11 +36,13 @@ export default function SectionsRequest() {
}
return sections.every(
(section) => section.current_step <= 0 && section.completion_percent <= 0,
(section) =>
section.progress.current_step <= 0 &&
section.progress.completion_percent <= 0,
);
}, [sections]);
const isOpen = isSuccess && hasNoProgression && !hasSeenSheet;
const isOpen = Boolean(sections) && hasNoProgression && !hasSeenSheet;
useEffect(() => {
try {

84
src/components/Componentes/question-answer-storage.tsx

@ -47,16 +47,11 @@ type FlushAnswersOptions = {
type QuestionAnswersContextValue = {
flushAnswers: (options?: FlushAnswersOptions) => Promise<void>;
getAnswerValue: (
question: QuestionField,
) => MarriageFieldValue | undefined;
getAnswerValue: (question: QuestionField) => MarriageFieldValue | undefined;
hasPendingSync: boolean;
isSaving: boolean;
isLoading: boolean;
setAnswerValue: (
question: QuestionField,
value: MarriageFieldValue,
) => void;
setAnswerValue: (question: QuestionField, value: MarriageFieldValue) => void;
backendFields: MarriageField[];
};
@ -122,14 +117,12 @@ function isMarriagePhoneFieldValue(
);
}
function createQuestionField(
question: QuestionField,
value: MarriageFieldValue,
): MarriageField {
let option_id = undefined;
if (question.options && Array.isArray(question.options)) {
if (question.type === "checkbox" && Array.isArray(value)) {
option_id = value;
@ -142,7 +135,7 @@ function createQuestionField(
}
const key = question.id;
return {
key,
label: question.title,
@ -201,9 +194,7 @@ function createPayload(
backendFields?: MarriageField[],
): UpdateMarriageSectionDataPayload {
const fields = getOrderedFields(answers, questions, backendFields);
const targetQuestions = questions.filter(
(q) => q.required && q.isVisible,
);
const targetQuestions = questions.filter((q) => q.required && q.isVisible);
return {
current_step: getCurrentStep(fields, questions, backendFields),
@ -249,7 +240,9 @@ function readStoredAnswers(storageKey: string, slug: string) {
storedValue.slug === slug &&
(storedValue.pending_sync ?? fields.length > 0),
pendingKeys: Array.isArray(storedValue.pending_keys)
? storedValue.pending_keys.filter((key): key is string => typeof key === "string")
? storedValue.pending_keys.filter(
(key): key is string => typeof key === "string",
)
: fields.map((field) => field.key),
};
} catch {
@ -295,9 +288,7 @@ function writeStoredAnswers(
}
function getKeepalivePatchUrl(slug: string) {
return getApiRequestUrl(
`/api/marriage/forms/profile/answers/`,
);
return getApiRequestUrl(`/api/marriage/forms/profile/answers/`);
}
function getCsrfToken() {
@ -318,7 +309,7 @@ export function QuestionAnswersProvider({
const [answers, setAnswers] = useState<QuestionAnswersByKey>({});
const [hasPendingSync, setHasPendingSync] = useState(false);
const { isPending: isSaving, mutateAsync } =
useUpdateMarriageSectionDataMutation(slug);
useUpdateMarriageSectionDataMutation(slug, locale);
const answersRef = useRef<QuestionAnswersByKey>({});
const hasPendingSyncRef = useRef(false);
const answersRevisionRef = useRef(0);
@ -342,8 +333,18 @@ export function QuestionAnswersProvider({
slugRef.current = slug;
questionsRef.current = questions;
backendFieldsRef.current = serverSectionData?.data || [];
schemaVersionRef.current = Math.max(schemaVersionRef.current, schemaVersion);
}, [answers, hasPendingSync, slug, questions, schemaVersion, serverSectionData?.data]);
schemaVersionRef.current = Math.max(
schemaVersionRef.current,
schemaVersion,
);
}, [
answers,
hasPendingSync,
slug,
questions,
schemaVersion,
serverSectionData?.data,
]);
useEffect(() => {
storageKeyRef.current = storageKey;
@ -377,7 +378,7 @@ export function QuestionAnswersProvider({
const nextKeys = Object.keys(finalAnswers);
if (prevKeys.length === nextKeys.length) {
const isSame = prevKeys.every(
(k) => prev[k]?.value === finalAnswers[k]?.value
(k) => prev[k]?.value === finalAnswers[k]?.value,
);
if (isSame) return prev;
}
@ -416,10 +417,7 @@ export function QuestionAnswersProvider({
);
const setAnswerValue = useCallback(
(
question: QuestionField,
value: MarriageFieldValue,
) => {
(question: QuestionField, value: MarriageFieldValue) => {
if (!canEdit) {
return;
}
@ -481,11 +479,19 @@ export function QuestionAnswersProvider({
return;
}
const fullPayload = createPayload(answersRef.current, questionsRef.current, backendFieldsRef.current);
const nextDirtyKey = fullPayload.fields.find((field) => dirtyKeysRef.current.has(field.key))?.key;
const fullPayload = createPayload(
answersRef.current,
questionsRef.current,
backendFieldsRef.current,
);
const nextDirtyKey = fullPayload.fields.find((field) =>
dirtyKeysRef.current.has(field.key),
)?.key;
const payload = {
...fullPayload,
fields: nextDirtyKey ? fullPayload.fields.filter((field) => field.key === nextDirtyKey) : [],
fields: nextDirtyKey
? fullPayload.fields.filter((field) => field.key === nextDirtyKey)
: [],
version: schemaVersionRef.current,
};
const revision = answersRevisionRef.current;
@ -495,8 +501,8 @@ export function QuestionAnswersProvider({
}
const request = mutateAsync(payload).then((result) => {
if (result?.schema?.version) {
schemaVersionRef.current = result.schema.version;
if (result?.version) {
schemaVersionRef.current = result.version;
}
});
flushPromiseRef.current = request;
@ -558,8 +564,14 @@ export function QuestionAnswersProvider({
return;
}
const fullPayload = createPayload(answersRef.current, questionsRef.current, backendFieldsRef.current);
const pendingFields = fullPayload.fields.filter((field) => dirtyKeysRef.current.has(field.key));
const fullPayload = createPayload(
answersRef.current,
questionsRef.current,
backendFieldsRef.current,
);
const pendingFields = fullPayload.fields.filter((field) =>
dirtyKeysRef.current.has(field.key),
);
const payload = { ...fullPayload, fields: pendingFields.slice(0, 1) };
const revision = answersRevisionRef.current;
@ -590,7 +602,7 @@ export function QuestionAnswersProvider({
credentials: "include",
headers,
keepalive: true,
method: "PUT",
method: "PATCH",
})
.then((response) => {
if (!response.ok) {
@ -679,9 +691,7 @@ export function useQuestionAnswers() {
return context;
}
export function useQuestionAnswer(
question: QuestionField,
) {
export function useQuestionAnswer(question: QuestionField) {
const context = useContext(QuestionAnswersContext);
return {

38
src/components/Componentes/question-card.tsx

@ -1,4 +1,5 @@
import Link from "next/link";
import { useEffect, useRef } from "react";
import { IoInformation } from "react-icons/io5";
import type { QuestionCardIcon, QuestionListItem } from "@/lib/schema-adapter";
import { localizePath } from "@/translations/config";
@ -9,6 +10,8 @@ type QuestionCardProps = {
item: QuestionListItem;
progress?: number | null;
onInfoClick?: (item: QuestionListItem) => void;
onNearViewport?: (item: QuestionListItem) => void;
onPrefetch?: (item: QuestionListItem) => void;
};
const RADIUS = 8;
@ -29,6 +32,8 @@ export function QuestionCard({
item,
progress = item.progress,
onInfoClick,
onNearViewport,
onPrefetch,
}: QuestionCardProps) {
const { dictionary: t, locale } = useI18n();
const hasProgress = typeof progress === "number" && Number.isFinite(progress);
@ -37,14 +42,41 @@ export function QuestionCard({
: 0;
const dashOffset = CIRCUMFERENCE - (normalizedProgress / 100) * CIRCUMFERENCE;
const iconName = iconNameMap[item.icon];
const cardRef = useRef<HTMLElement>(null);
useEffect(() => {
const node = cardRef.current;
if (
!node ||
!onNearViewport ||
typeof IntersectionObserver === "undefined"
) {
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
onNearViewport(item);
observer.disconnect();
}
},
{ rootMargin: "160px 0px" },
);
observer.observe(node);
return () => observer.disconnect();
}, [item, onNearViewport]);
return (
<Link
href={localizePath(`/questions-list/${item.slug}`, locale)}
aria-label={t["Open {title}"].replace("{title}", item.title)}
className="block rounded-[20px] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#F26C85]"
onFocus={() => onPrefetch?.(item)}
onPointerDown={() => onPrefetch?.(item)}
onPointerEnter={() => onPrefetch?.(item)}
>
<article
ref={cardRef}
id={item.slug}
data-question-slug={item.slug}
className="rounded-[20px] border border-white/80 bg-white px-3 py-3 shadow-[0_12px_28px_rgba(15,23,42,0.05)] transition-transform duration-200 hover:-translate-y-0.5"
@ -52,7 +84,11 @@ export function QuestionCard({
<div className="flex items-start gap-2.5">
<div className="rounded-[13px] bg-linear-to-br from-[#E03950]/15 to-[#E03950]/0 p-px shadow-[0_8px_18px_rgba(240,67,99,0.18)]">
<div className="relative flex h-[44px] w-[44px] shrink-0 items-center justify-center rounded-[12px] bg-linear-to-br from-[#E03950]/15 to-[#FE6F82]/15 text-white">
<UiIcon name={iconName} aria-hidden="true" className="size-[22px]" />
<UiIcon
name={iconName}
aria-hidden="true"
className="size-[22px]"
/>
</div>
</div>

31
src/components/Componentes/required-steps-card.test.tsx

@ -0,0 +1,31 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import RequiredStepsCard from "./required-steps-card";
vi.mock("@/translations/provider", () => ({
useI18n: () => ({
dictionary: new Proxy({}, { get: (_target, key) => String(key) }),
}),
}));
describe("RequiredStepsCard", () => {
it("renders a real empty 0/9 state without a shimmer", () => {
const { container } = render(<RequiredStepsCard completed={0} total={9} />);
expect(screen.getByText("0/9")).toBeDefined();
expect(container.querySelector(".shimmer-white-bg")).toBeNull();
expect(container.querySelectorAll("circle")[1].getAttribute("stroke-dashoffset")).toBe(
String(2 * Math.PI * 25),
);
});
it("renders required section completion independently from optional assessments", () => {
const { container } = render(<RequiredStepsCard completed={4} total={9} />);
expect(screen.getByText("4/9")).toBeDefined();
const offset = Number(
container.querySelectorAll("circle")[1].getAttribute("stroke-dashoffset"),
);
expect(offset).toBeCloseTo(2 * Math.PI * 25 * (5 / 9));
});
});

86
src/components/Componentes/required-steps-card.tsx

@ -1,63 +1,24 @@
"use client";
import { useMemo } from "react";
import { IoAlert, IoCheckmark } from "react-icons/io5";
import { useFormSchemaQuery } from "@/hooks/marriage/use-form-schema";
import { convertSchemaToFrontendItems } from "@/lib/schema-adapter";
import { useI18n } from "@/translations/provider";
import type { QuestionListItem } from "@/lib/schema-adapter";
type RequiredStepsCardProps = {
items?: QuestionListItem[];
progressBySlug?: Map<string, number>;
completed: number;
total: number;
};
type RequiredStep = {
slug: string;
required: boolean;
progress: number;
};
function getRequiredStepStats(steps: RequiredStep[]) {
const requiredSteps = steps.filter((step) => step.required);
const completedSteps = requiredSteps.filter((step) => step.progress >= 100);
return {
completed: completedSteps.length,
total: requiredSteps.length,
};
}
const RING_RADIUS = 25;
const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS;
export default function RequiredStepsCard({
items,
progressBySlug,
}: RequiredStepsCardProps = {}) {
const { dictionary: t, locale } = useI18n();
const { data: schema } = useFormSchemaQuery("profile", locale);
const questionListItems = useMemo(
() => items ?? convertSchemaToFrontendItems(schema, locale),
[items, schema, locale],
);
const steps: RequiredStep[] = useMemo(() => {
return questionListItems.map((item) => {
let progress = item.progress;
if (progressBySlug && typeof progressBySlug.get(item.slug) === "number") {
progress = progressBySlug.get(item.slug) ?? 0;
}
return {
slug: item.slug,
required: Boolean(item.required),
progress,
};
});
}, [questionListItems, progressBySlug]);
const { completed, total } = getRequiredStepStats(steps);
const completion = total > 0 ? Math.round((completed / total) * 100) : 0;
completed,
total,
}: RequiredStepsCardProps) {
const { dictionary: t } = useI18n();
const normalizedCompleted = Math.max(0, Math.min(completed, total));
const completion = total > 0 ? normalizedCompleted / total : 0;
const ringOffset = RING_CIRCUMFERENCE * (1 - completion);
const isCompleted = total > 0 && completed === total;
return (
@ -94,16 +55,27 @@ export default function RequiredStepsCard({
<div
role="img"
aria-label={t["{completed} of {total} required steps completed"]
.replace("{completed}", String(completed))
.replace("{completed}", String(normalizedCompleted))
.replace("{total}", String(total))}
className="relative flex h-[60px] w-[60px] shrink-0 items-center justify-center rounded-full"
style={{
background: `conic-gradient(#FFFFFF ${completion}%,rgba(255,255,255,0.24) 0)`,
}}
className="relative flex h-[60px] w-[60px] shrink-0 items-center justify-center"
>
<div className="absolute inset-[6px] rounded-full bg-[#40506A]" />
<svg aria-hidden="true" className="absolute inset-0 -rotate-90" viewBox="0 0 60 60">
<circle cx="30" cy="30" r={RING_RADIUS} fill="none" stroke="rgba(255,255,255,0.24)" strokeWidth="5" />
<circle
cx="30"
cy="30"
r={RING_RADIUS}
fill="none"
stroke="#FFFFFF"
strokeWidth="5"
strokeLinecap="round"
strokeDasharray={RING_CIRCUMFERENCE}
strokeDashoffset={ringOffset}
className="transition-[stroke-dashoffset] duration-500 ease-out motion-reduce:transition-none"
/>
</svg>
<span className="relative group-14 leading-none font-bold tracking-[-0.02em]">
{completed}/{total}
{normalizedCompleted}/{total}
</span>
</div>
</div>

206
src/components/Componentes/schema-question-flow.integration.test.tsx

@ -1,19 +1,36 @@
import React from "react";
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
import {
render,
screen,
fireEvent,
waitFor,
cleanup,
} from "@testing-library/react";
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { QuestionAnswersProvider, useQuestionAnswers } from "./question-answer-storage";
import {
QuestionAnswersProvider,
useQuestionAnswers,
} from "./question-answer-storage";
import { convertSchemaToFrontendItems } from "@/lib/schema-adapter";
import { useFormSchemaQuery, type FormSchemaResponse } from "@/hooks/marriage/use-form-schema";
import {
useFormOverviewQuery,
useFormSectionQuery,
type FormSchemaResponse,
} from "@/hooks/marriage/use-form-schema";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { useMarriageSectionDataQuery, useUpdateMarriageSectionDataMutation } from "@/hooks/marriage/use-section-data";
import {
useMarriageSectionDataQuery,
useUpdateMarriageSectionDataMutation,
} from "@/hooks/marriage/use-section-data";
import { QuestionRadio } from "./question-radio";
import { QuestionCheckbox } from "./question-checkbox";
import QuestionDetailClient from "@/app/questions-list/[slug]/question-detail-client";
vi.mock("@/hooks/marriage/use-form-schema", () => ({
useFormSchemaQuery: vi.fn(),
useFormOverviewQuery: vi.fn(),
useFormSectionQuery: vi.fn(),
}));
vi.mock("@/hooks/marriage/use-profile-main", () => ({
useMarriageProfileQuery: vi.fn(),
@ -36,8 +53,8 @@ const mockSchema = {
form_id: "profile",
version: 1,
answers: {
"q_radio": { value: "A", option_id: "opt_a" },
"q_check": { value: ["B", "C"], option_id: ["opt_b", "opt_c"] }
q_radio: { value: "A", option_id: "opt_a" },
q_check: { value: ["B", "C"], option_id: ["opt_b", "opt_c"] },
},
sections: [
{
@ -53,43 +70,78 @@ const mockSchema = {
title: "Card",
order: 2, // Messed up order
questions: [
{
id: "q_child", title: "Child Q", type: "text", order: 20,
required: false, is_required: true, is_visible: false,
ui_config: {}, options: []
{
id: "q_child",
title: "Child Q",
type: "text",
order: 20,
required: false,
is_required: true,
is_visible: false,
ui_config: {},
options: [],
},
{
id: "q_radio", title: "Radio Q", type: "radio", order: 5,
required: true, is_required: true, is_visible: true,
ui_config: {},
{
id: "q_radio",
title: "Radio Q",
type: "radio",
order: 5,
required: true,
is_required: true,
is_visible: true,
ui_config: {},
options: [
{ id: "opt_a_dummy", value: "A_DUMMY", label: "Option A Dummy", order: 2 },
{ id: "opt_a", value: "A", label: "Option A Canonical", order: 1 }
]
{
id: "opt_a_dummy",
value: "A_DUMMY",
label: "Option A Dummy",
order: 2,
},
{
id: "opt_a",
value: "A",
label: "Option A Canonical",
order: 1,
},
],
},
{
id: "q_check", title: "Check Q", type: "checkbox", order: 10,
required: false, is_required: false, is_visible: true,
ui_config: {},
{
id: "q_check",
title: "Check Q",
type: "checkbox",
order: 10,
required: false,
is_required: false,
is_visible: true,
ui_config: {},
options: [
{ id: "opt_c", value: "C", label: "Option C", order: 2 },
{ id: "opt_b", value: "B", label: "Option B", order: 1 }
]
}
]
{ id: "opt_b", value: "B", label: "Option B", order: 1 },
],
},
],
},
{
id: "card2",
title: "Card 2",
order: 1, // Card 2 should come first
questions: [
{ id: "q_first", title: "First Q", type: "text", order: 1, required: true, is_visible: true, ui_config: {}, options: [] },
]
}
]
}
{
id: "q_first",
title: "First Q",
type: "text",
order: 1,
required: true,
is_visible: true,
ui_config: {},
options: [],
},
],
},
],
},
],
progress: { sections_progress: {} }
progress: { sections_progress: {} },
};
describe("Schema Question Flow Integration", () => {
@ -98,8 +150,16 @@ describe("Schema Question Flow Integration", () => {
beforeEach(() => {
capturedPayload = null;
const updateMutateAsync = vi.fn(async (payload) => {
capturedPayload = payload;
return payload;
capturedPayload = {
...payload,
fields: [
...(capturedPayload?.fields ?? []).filter(
(field: any) => field.key !== payload.fields[0]?.key,
),
...payload.fields,
],
};
return { ...payload, version: 2 };
});
(useUpdateMarriageSectionDataMutation as any).mockReturnValue({
mutateAsync: updateMutateAsync,
@ -113,18 +173,44 @@ describe("Schema Question Flow Integration", () => {
slug: "sec1",
data: [
{ key: "q_radio", type: "radio", value: "A", option_id: "opt_a" },
{ key: "q_check", type: "checkbox", value: ["B", "C"], option_id: ["opt_b", "opt_c"] }
]
{
key: "q_check",
type: "checkbox",
value: ["B", "C"],
option_id: ["opt_b", "opt_c"],
},
],
},
isLoading: false,
});
(useFormSchemaQuery as any).mockReturnValue({
data: mockSchema,
(useFormOverviewQuery as any).mockReturnValue({
data: {
...mockSchema,
sections: mockSchema.sections.map(({ cards, ...section }) => ({
...section,
kind: "profile",
progress: { current_step: 0, total_steps: 0, completion_percent: 0 },
})),
},
isLoading: false,
isFetching: false,
error: null,
refetch: vi.fn(),
});
(useFormSectionQuery as any).mockReturnValue({
data: {
version: 1,
section: mockSchema.sections[0],
answers: mockSchema.answers,
progress: mockSchema.progress,
section_progress: {
current_step: 0,
total_steps: 0,
completion_percent: 0,
},
},
isLoading: false,
});
});
afterEach(() => {
@ -132,20 +218,22 @@ describe("Schema Question Flow Integration", () => {
});
it("should enforce ordering, correctly hydrate canonical values via options, and hide invisible children", async () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
// Use convertSchemaToFrontendItems directly (no mock)
const frontendItems = convertSchemaToFrontendItems(
mockSchema as unknown as FormSchemaResponse,
"en",
);
// Sort logic validation
expect(frontendItems[0].questions[0].id).toBe("q_first");
expect(frontendItems[0].questions[1].id).toBe("q_radio");
expect(frontendItems[0].questions[2].id).toBe("q_check");
expect(frontendItems[0].questions[3].id).toBe("q_child");
// Validate options sort
expect(frontendItems[0].questions[1].options[0].id).toBe("opt_a");
@ -160,13 +248,15 @@ describe("Schema Question Flow Integration", () => {
questionsListHref="/list"
title="Title"
/>
</QueryClientProvider>
</QueryClientProvider>,
);
// Wait for hydration and rendering
await waitFor(() => {
// Radio hydration
const radioInput = screen.getByLabelText("Option A Canonical") as HTMLInputElement;
const radioInput = screen.getByLabelText(
"Option A Canonical",
) as HTMLInputElement;
expect(radioInput.checked).toBe(true);
// Checkbox hydration
@ -177,15 +267,17 @@ describe("Schema Question Flow Integration", () => {
// Child should not be rendered
expect(screen.queryByText("Child Q")).toBeNull();
// q_child is required=false but is_required=true, if it was visible it should show *.
// Let's modify schema dynamically to test child visibility and requirement
});
// Interact to fire payload
const radioDummy = screen.getByLabelText("Option A Dummy") as HTMLInputElement;
const radioDummy = screen.getByLabelText(
"Option A Dummy",
) as HTMLInputElement;
fireEvent.click(radioDummy);
// Uncheck Option B and Check Option C (Wait, they are both checked by default from hydration)
const checkB = screen.getByLabelText("Option B") as HTMLInputElement;
fireEvent.click(checkB); // Should now be unchecked
@ -195,10 +287,14 @@ describe("Schema Question Flow Integration", () => {
await waitFor(() => {
expect(capturedPayload).not.toBeNull();
const radioPayload = capturedPayload.fields.find((f: any) => f.key === "q_radio");
const radioPayload = capturedPayload.fields.find(
(f: any) => f.key === "q_radio",
);
expect(radioPayload.option_id).toBe("opt_a_dummy");
const checkPayload = capturedPayload.fields.find((f: any) => f.key === "q_check");
const checkPayload = capturedPayload.fields.find(
(f: any) => f.key === "q_check",
);
expect(checkPayload.option_id).toEqual(["opt_c"]); // Only C is checked now
});
});
@ -206,15 +302,21 @@ describe("Schema Question Flow Integration", () => {
it("should redirect and not render static questions on schema failure", () => {
replaceMock.mockClear();
(useFormSchemaQuery as any).mockReturnValue({
(useFormOverviewQuery as any).mockReturnValue({
data: undefined,
isLoading: false,
isFetching: false,
error: new Error("Network Error"),
refetch: vi.fn(),
});
(useFormSectionQuery as any).mockReturnValue({
data: undefined,
isLoading: false,
});
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
render(
<QueryClientProvider client={queryClient}>
@ -227,7 +329,7 @@ describe("Schema Question Flow Integration", () => {
questionsListHref="/list"
title="Title"
/>
</QueryClientProvider>
</QueryClientProvider>,
);
// Should redirect to questions list

4
src/hooks/marriage/query-keys.ts

@ -10,6 +10,10 @@ export const marriageQueryKeys = {
"contact-info",
] as const,
profile: () => [...marriageQueryKeys.all, "profile"] as const,
formOverview: (formId: string, locale: string) =>
[...marriageQueryKeys.all, "form-overview", formId, locale] as const,
formSection: (formId: string, slug: string, locale: string) =>
[...marriageQueryKeys.all, "form-section", formId, slug, locale] as const,
sectionData: (slug: string) =>
[...marriageQueryKeys.sections(), slug, "data"] as const,
sections: () => [...marriageQueryKeys.all, "sections"] as const,

87
src/hooks/marriage/use-form-schema.ts

@ -45,6 +45,8 @@ export interface FormSection {
order: number;
estimated_minutes: number;
cards: FormCard[];
kind?: "profile" | "assessment";
assessment_type?: "cattell" | "glasser";
}
export interface FormProgressInfo {
@ -67,9 +69,79 @@ export interface FormSchemaResponse {
is_completed?: boolean;
}
export async function getFormSchema(formId: string, locale: string): Promise<FormSchemaResponse> {
export interface FormOverviewSection extends Omit<FormSection, "cards"> {
kind: "profile" | "assessment";
assessment_type?: "cattell" | "glasser";
question_count?: number;
progress: FormProgressInfo;
}
export interface FormOverviewResponse {
form_id: string;
version: number;
sections: FormOverviewSection[];
progress: FormSchemaResponse["progress"];
}
export interface FormSectionResponse {
form_id: string;
version: number;
section: FormSection;
answers: FormSchemaResponse["answers"];
progress: FormSchemaResponse["progress"];
section_progress: FormProgressInfo;
}
export async function getFormOverview(formId: string, locale: string) {
const { data } = await http.get<FormOverviewResponse>(
`/api/marriage/forms/${formId}/overview/?lang=${locale}`,
);
return data;
}
export function useFormOverviewQuery(formId: string, locale: string) {
return useQuery({
queryKey: marriageQueryKeys.formOverview(formId, locale),
queryFn: () => getFormOverview(formId, locale),
staleTime: 30 * 1000,
refetchOnMount: "always",
refetchOnWindowFocus: false,
});
}
export async function getFormSection(
formId: string,
slug: string,
locale: string,
) {
const { data } = await http.get<FormSectionResponse>(
`/api/marriage/forms/${formId}/sections/${encodeURIComponent(slug)}/?lang=${locale}`,
);
return data;
}
export function useFormSectionQuery(
formId: string,
slug: string,
locale: string,
enabled = true,
) {
return useQuery({
queryKey: marriageQueryKeys.formSection(formId, slug, locale),
queryFn: () => getFormSection(formId, slug, locale),
enabled,
staleTime: 30 * 1000,
refetchOnMount: false,
refetchOnWindowFocus: false,
});
}
export async function getFormSchema(
formId: string,
locale: string,
): Promise<FormSchemaResponse> {
const { data } = await http.get<FormSchemaResponse>(
`/api/marriage/forms/${formId}/?lang=${locale}`
`/api/marriage/forms/${formId}/?lang=${locale}`,
);
return data;
}
@ -77,7 +149,7 @@ export async function getFormSchema(formId: string, locale: string): Promise<For
export function useFormSchemaQuery<TData = FormSchemaResponse>(
formId: string,
locale: string,
options?: QueryOptions<FormSchemaResponse, TData>
options?: QueryOptions<FormSchemaResponse, TData>,
) {
return useQuery({
staleTime: 30 * 1000,
@ -98,17 +170,20 @@ export interface SaveAnswersPayload {
}>;
}
export async function saveFormAnswers(formId: string, payload: SaveAnswersPayload): Promise<FormSchemaResponse> {
export async function saveFormAnswers(
formId: string,
payload: SaveAnswersPayload,
): Promise<FormSchemaResponse> {
const { data } = await http.put<FormSchemaResponse>(
`/api/marriage/forms/${formId}/answers/`,
payload
payload,
);
return data;
}
export function useSaveFormAnswersMutation(
formId: string,
options?: MutationOptions<FormSchemaResponse, SaveAnswersPayload>
options?: MutationOptions<FormSchemaResponse, SaveAnswersPayload>,
) {
const queryClient = useQueryClient();

127
src/hooks/marriage/use-section-data.ts

@ -2,18 +2,16 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { http } from "@/lib/http";
import type { MutationOptions, QueryOptions } from "./options";
import { pathParam } from "./path-param";
import type { MutationOptions } from "./options";
import { marriageQueryKeys } from "./query-keys";
import type {
MarriageSectionData,
UpdateMarriageSectionDataPayload,
} from "./types";
import type { FormSchemaResponse } from "./use-form-schema";
import { useFormSchemaQuery } from "./use-form-schema";
import {
type FormSchemaResponse,
useFormSectionQuery,
} from "./use-form-schema";
export async function updateMarriageSectionData(
slug: string,
@ -25,22 +23,30 @@ export async function updateMarriageSectionData(
option_id: (f as any).option_id || undefined,
}));
const { data } = await http.put<FormSchemaResponse>(
`/api/marriage/forms/profile/answers/`,
{
version: payload.version,
answers: answersPayload,
}
);
const { data } = await http.patch<{
version: number;
answers: FormSchemaResponse["answers"];
affected_sections: Record<
string,
{ current_step: number; total_steps: number; completion_percent: number }
>;
progress: FormSchemaResponse["progress"];
}>(`/api/marriage/forms/profile/answers/`, {
version: payload.version,
answers: answersPayload,
});
const prog = data.progress.sections_progress[slug] || {
current_step: 0,
total_steps: 0,
completion_percent: 0.0,
};
const prog = data.affected_sections[slug] ||
data.progress.sections_progress[slug] || {
current_step: 0,
total_steps: 0,
completion_percent: 0.0,
};
return {
schema: data,
version: data.version,
progress: data.progress,
answers: data.answers,
sectionData: {
slug,
data: payload.fields,
@ -57,22 +63,48 @@ export function useMarriageSectionDataQuery(
locale: string,
) {
const normalizedSlug = slug ?? "";
return useFormSchemaQuery<MarriageSectionData>("profile", locale, {
enabled: Boolean(normalizedSlug),
select: (schema) => {
const section = schema.sections.find((item) => item.id === normalizedSlug);
const fields = section?.cards.flatMap((card) => card.questions.map((question) => {
const answer = schema.answers[question.id];
return { key: question.id, label: question.title, type: question.type, value: answer?.value ?? null, option_id: answer?.option_id };
})) || [];
const progress = schema.progress.sections_progress[normalizedSlug] || { current_step: 0, total_steps: 0, completion_percent: 0 };
return { slug: normalizedSlug, data: fields, ...progress, updated_at: null };
},
});
const query = useFormSectionQuery(
"profile",
normalizedSlug,
locale,
Boolean(normalizedSlug),
);
return {
...query,
data: query.data
? (() => {
const section = query.data.section;
const fields = section.cards.flatMap((card) =>
card.questions.map((question) => {
const answer = query.data?.answers[question.id];
return {
key: question.id,
label: question.title,
type: question.type,
value: answer?.value ?? null,
option_id: answer?.option_id,
};
}),
);
const progress = query.data.section_progress || {
current_step: 0,
total_steps: 0,
completion_percent: 0,
};
return {
slug: normalizedSlug,
data: fields,
...progress,
updated_at: null,
};
})()
: undefined,
};
}
export function useUpdateMarriageSectionDataMutation(
slug: string,
locale: string,
options?: MutationOptions<
Awaited<ReturnType<typeof updateMarriageSectionData>>,
UpdateMarriageSectionDataPayload
@ -84,9 +116,34 @@ export function useUpdateMarriageSectionDataMutation(
...options,
mutationFn: (payload) => updateMarriageSectionData(slug, payload),
onSuccess: async (data, variables, onMutateResult, context) => {
queryClient.setQueriesData<FormSchemaResponse>(
{ queryKey: ["marriage", "form-schema", "profile"] },
data.schema,
queryClient.setQueryData(
marriageQueryKeys.formSection("profile", slug, locale),
(current: any) =>
current
? {
...current,
version: data.version,
progress: data.progress,
section_progress: data.sectionData,
answers: { ...current.answers, ...data.answers },
}
: current,
);
queryClient.setQueryData(
marriageQueryKeys.formOverview("profile", locale),
(current: any) =>
current
? {
...current,
version: data.version,
progress: data.progress,
sections: current.sections.map((section: any) =>
section.id === slug
? { ...section, progress: data.sectionData }
: section,
),
}
: current,
);
await options?.onSuccess?.(data, variables, onMutateResult, context);
},

1
src/lib/marriage-profile-contract.ts

@ -0,0 +1 @@
export const REQUIRED_PROFILE_SECTION_COUNT = 9;

39
src/lib/schema-adapter-overview.test.ts

@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { convertOverviewToFrontendItems } from "./schema-adapter";
import type { FormOverviewResponse } from "@/hooks/marriage/use-form-schema";
describe("convertOverviewToFrontendItems", () => {
it("maps overview metadata without materializing questions", () => {
const overview: FormOverviewResponse = {
form_id: "profile",
version: 2,
progress: {
current_step: 1,
total_steps: 2,
completion_percent: 50,
sections_progress: {},
},
sections: [
{
id: "personal",
title: "Personal",
icon: "user-circle",
is_required: true,
order: 1,
estimated_minutes: 2,
kind: "profile",
progress: { current_step: 1, total_steps: 2, completion_percent: 50 },
},
],
};
expect(convertOverviewToFrontendItems(overview)).toEqual([
expect.objectContaining({
slug: "personal",
progress: 50,
questions: [],
checkpoints: [],
}),
]);
});
});

66
src/lib/schema-adapter.ts

@ -2,6 +2,7 @@ import type {
FormSchemaResponse,
FormSection,
FormQuestion,
FormOverviewResponse,
} from "@/hooks/marriage/use-form-schema";
import { defaultLocale, type Locale } from "@/translations/config";
@ -26,8 +27,6 @@ export type QuestionAudienceRule = {
minAge?: number;
};
export type QuestionField = {
id: string;
title: string;
@ -45,7 +44,12 @@ export type QuestionField = {
audience?: QuestionAudienceRule;
requiredWhen?: QuestionAudienceRule;
showGuardianNotice?: boolean;
options: { id: string; value: string | number; label: string; order: number }[];
options: {
id: string;
value: string | number;
label: string;
order: number;
}[];
};
export type QuestionListItem = {
@ -71,7 +75,10 @@ const iconMap: Record<string, QuestionCardIcon> = {
"layout-grid": "checklist",
};
export function mapBackendQuestionToFrontend(bq: FormQuestion, index: number): QuestionField {
export function mapBackendQuestionToFrontend(
bq: FormQuestion,
index: number,
): QuestionField {
return {
id: bq.id,
title: bq.title || "Untitled",
@ -92,20 +99,26 @@ export function mapBackendQuestionToFrontend(bq: FormQuestion, index: number): Q
noSearch: bq.ui_config?.noSearch,
},
showGuardianNotice: bq.show_guardian_notice,
options: [...(bq.options || [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
options: [...(bq.options || [])].sort(
(a, b) => (a.order ?? 0) - (b.order ?? 0),
),
};
}
export function mapBackendSectionToFrontend(
section: FormSection,
progress: number
progress: number,
): QuestionListItem {
const allQuestions: QuestionField[] = [];
let index = 0;
const cards = [...(section.cards || [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
const cards = [...(section.cards || [])].sort(
(a, b) => (a.order ?? 0) - (b.order ?? 0),
);
cards.forEach((card) => {
const questions = [...(card.questions || [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
const questions = [...(card.questions || [])].sort(
(a, b) => (a.order ?? 0) - (b.order ?? 0),
);
questions.forEach((q) => {
const fq = mapBackendQuestionToFrontend(q, index);
allQuestions.push(fq);
@ -116,7 +129,9 @@ export function mapBackendSectionToFrontend(
return {
slug: section.id,
title: section.title,
estimate: section.estimated_minutes ? `${section.estimated_minutes} min` : "5 min",
estimate: section.estimated_minutes
? `${section.estimated_minutes} min`
: "5 min",
progress: progress,
icon: iconMap[section.icon] ?? "details",
required: section.is_required,
@ -130,7 +145,7 @@ export function mapBackendSectionToFrontend(
export function convertSchemaToFrontendItems(
schema: FormSchemaResponse | undefined,
locale: Locale = defaultLocale
locale: Locale = defaultLocale,
): QuestionListItem[] {
if (!schema) return [];
@ -140,5 +155,32 @@ export function convertSchemaToFrontendItems(
return mapBackendSectionToFrontend(sec, progress);
});
return rawItems.sort((a, b) => (schema.sections.find(s => s.id === a.slug)?.order ?? 0) - (schema.sections.find(s => s.id === b.slug)?.order ?? 0));
return rawItems.sort(
(a, b) =>
(schema.sections.find((s) => s.id === a.slug)?.order ?? 0) -
(schema.sections.find((s) => s.id === b.slug)?.order ?? 0),
);
}
export function convertOverviewToFrontendItems(
overview: FormOverviewResponse | undefined,
): QuestionListItem[] {
if (!overview) return [];
return [...overview.sections]
.sort((a, b) => a.order - b.order)
.map((section) => ({
slug: section.id,
title: section.title,
estimate: section.estimated_minutes
? `${section.estimated_minutes} min`
: "5 min",
progress: section.progress.completion_percent,
icon: iconMap[section.icon] ?? "details",
required: section.is_required,
showInfoBadge: false,
summary: "",
checkpoints: [],
tooltip: "",
questions: [],
}));
}
Loading…
Cancel
Save