Browse Source

feat: implement modular question components, match summary display, and profile navigation infrastructure

master
sina_sajjadi 2 months ago
parent
commit
f0edf39d58
  1. 18
      package-lock.json
  2. 2
      package.json
  3. 303
      src/app/api/proxy/route.ts
  4. 20
      src/app/new-match/page.tsx
  5. 32
      src/app/new-match/profile/page.tsx
  6. 20
      src/app/questions-list/[slug]/answer-pace-sheet.tsx
  7. 106
      src/app/questions-list/[slug]/page.tsx
  8. 326
      src/app/questions-list/[slug]/question-detail-client.tsx
  9. 46
      src/app/questions-list/page.tsx
  10. 23
      src/app/request-accepted/page.tsx
  11. 2
      src/components/dev/dev-click-to-component.tsx
  12. 21
      src/components/questions/question-answer-storage.tsx
  13. 2
      src/components/questions/question-date.tsx
  14. 2
      src/components/questions/question-dropdown.tsx
  15. 2
      src/components/questions/question-file.tsx
  16. 65
      src/components/questions/question-number.tsx
  17. 175
      src/components/questions/question-phone.tsx
  18. 16
      src/components/questions/question-progress-tracker.tsx
  19. 2
      src/components/questions/question-radio.tsx
  20. 39
      src/components/questions/question-snap-list.tsx
  21. 3
      src/components/questions/question-text.tsx
  22. 46
      src/components/questions/required-steps-card.tsx
  23. 95
      src/data/question-data.ts
  24. 12
      src/hooks/marriage/types.ts
  25. 4
      src/i18n/dictionaries.ts
  26. 366
      src/i18n/locales/en/questions.json
  27. 366
      src/i18n/locales/fa/questions.json
  28. 62
      src/lib/http.ts

18
package-lock.json

@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.100.5", "@tanstack/react-query": "^5.100.5",
"axios": "^1.15.2", "axios": "^1.15.2",
"google-libphonenumber": "^3.2.44",
"next": "16.2.3", "next": "16.2.3",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
@ -18,6 +19,7 @@
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.2.0", "@biomejs/biome": "2.2.0",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/google-libphonenumber": "^7.4.30",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
@ -1167,6 +1169,13 @@
"react": "^18 || ^19" "react": "^18 || ^19"
} }
}, },
"node_modules/@types/google-libphonenumber": {
"version": "7.4.30",
"resolved": "https://registry.npmjs.org/@types/google-libphonenumber/-/google-libphonenumber-7.4.30.tgz",
"integrity": "sha512-Td1X1ayRxePEm6/jPHUBs2tT6TzW1lrVB6ZX7ViPGellyzO/0xMNi+wx5nH6jEitjznq276VGIqjK5qAju0XVw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "20.19.39", "version": "20.19.39",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
@ -1459,6 +1468,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/google-libphonenumber": {
"version": "3.2.44",
"resolved": "https://registry.npmjs.org/google-libphonenumber/-/google-libphonenumber-3.2.44.tgz",
"integrity": "sha512-9p2TghluF2LTChFMLWsDRD5N78SZDsILdUk4gyqYxBXluCyxoPiOq+Fqt7DKM+LUd33+OgRkdrc+cPR93AypCQ==",
"license": "(MIT AND Apache-2.0)",
"engines": {
"node": ">=0.10"
}
},
"node_modules/gopd": { "node_modules/gopd": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",

2
package.json

@ -12,6 +12,7 @@
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.100.5", "@tanstack/react-query": "^5.100.5",
"axios": "^1.15.2", "axios": "^1.15.2",
"google-libphonenumber": "^3.2.44",
"next": "16.2.3", "next": "16.2.3",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
@ -20,6 +21,7 @@
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.2.0", "@biomejs/biome": "2.2.0",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/google-libphonenumber": "^7.4.30",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",

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

@ -0,0 +1,303 @@
import type { NextRequest } from "next/server";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
const PROXY_PATH_PARAM = "__proxyPath";
const REQUEST_HEADERS_TO_FORWARD = [
"accept",
"accept-language",
"authorization",
"content-type",
"x-csrf-token",
"x-csrftoken",
"x-requested-with",
"x-xsrf-token",
];
const RESPONSE_HEADERS_TO_DROP = [
"connection",
"content-encoding",
"content-length",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
];
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");
class ProxyError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message);
}
}
function getApiBaseUrl() {
const apiBaseUrl =
process.env.API_BASE_URL ?? process.env.NEXT_PUBLIC_API_BASE_URL;
if (!apiBaseUrl) {
throw new ProxyError(
"API_BASE_URL or NEXT_PUBLIC_API_BASE_URL is required",
500,
);
}
try {
return new URL(apiBaseUrl);
} catch {
throw new ProxyError("API base URL is invalid", 500);
}
}
function getProxyPath(request: NextRequest) {
const proxyPath = request.nextUrl.searchParams.get(PROXY_PATH_PARAM);
if (!proxyPath) {
throw new ProxyError("Proxy path is required", 400);
}
if (/^[a-z][a-z\d+\-.]*:/i.test(proxyPath) || proxyPath.startsWith("//")) {
throw new ProxyError("Proxy path must be relative", 400);
}
return proxyPath.startsWith("/") ? proxyPath : `/${proxyPath}`;
}
function getTargetUrl(request: NextRequest) {
const targetUrl = getApiBaseUrl();
const proxyUrl = new URL(getProxyPath(request), targetUrl.origin);
const basePath = targetUrl.pathname.replace(/\/$/, "");
targetUrl.pathname = `${basePath}${proxyUrl.pathname}`;
const searchParams = new URLSearchParams(proxyUrl.search);
request.nextUrl.searchParams.forEach((value, key) => {
if (key !== PROXY_PATH_PARAM) {
searchParams.append(key, value);
}
});
targetUrl.search = searchParams.toString();
return targetUrl;
}
function getRequestHeaders(request: NextRequest, targetUrl: URL) {
const headers = new Headers();
const authKey = process.env.NEXT_PUBLIC_AUTH_KEY;
for (const header of REQUEST_HEADERS_TO_FORWARD) {
const value = request.headers.get(header);
if (value) {
headers.set(header, value);
}
}
if (authKey) {
headers.set("authorization", `token ${authKey}`);
}
headers.set("accept-encoding", "identity");
headers.set("http_x_user_language", "en");
headers.set("origin", targetUrl.origin);
headers.set("platform", "android");
headers.set("referer", `${targetUrl.origin}/`);
headers.set("user-agent", "dart:io");
return headers;
}
function getResponseHeaders(upstreamHeaders: Headers) {
const headers = new Headers(upstreamHeaders);
for (const header of RESPONSE_HEADERS_TO_DROP) {
headers.delete(header);
}
const setCookieHeaders =
(
upstreamHeaders as Headers & { getSetCookie?: () => string[] }
).getSetCookie?.() ?? [];
if (setCookieHeaders.length > 0) {
headers.delete("set-cookie");
for (const cookie of setCookieHeaders) {
headers.append("set-cookie", cookie);
}
}
return headers;
}
function getBodyLogValue(body: ArrayBuffer | undefined, contentType?: string) {
if (!body || body.byteLength === 0) {
return null;
}
if (contentType && !isTextContentType(contentType)) {
return `[${body.byteLength} bytes; ${contentType}]`;
}
const text = new TextDecoder().decode(body);
if (text.length <= MAX_LOG_BODY_LENGTH) {
return parseJsonForLog(text, text);
}
return `${text.slice(0, MAX_LOG_BODY_LENGTH)}... [truncated ${text.length - MAX_LOG_BODY_LENGTH} chars]`;
}
function isTextContentType(contentType: string) {
return (
contentType.includes("application/json") ||
contentType.includes("application/problem+json") ||
contentType.startsWith("text/") ||
contentType.includes("+json") ||
contentType.includes("+xml")
);
}
function parseJsonForLog(text: string, fallback: string) {
try {
return JSON.parse(text);
} catch {
return fallback;
}
}
function headersToObject(headers: Headers) {
return Object.fromEntries(headers.entries());
}
function logProxyRequest(
request: NextRequest,
targetUrl: URL,
requestHeaders: Headers,
requestBody: ArrayBuffer | undefined,
) {
if (!shouldLogProxy) {
return;
}
writeProxyLog("request", {
incomingRequest: {
method: request.method,
url: request.url,
nextUrl: request.nextUrl.toString(),
headers: headersToObject(request.headers),
body: getBodyLogValue(
requestBody,
request.headers.get("content-type") ?? undefined,
),
},
upstreamRequest: {
method: request.method,
url: targetUrl.toString(),
headers: headersToObject(requestHeaders),
body: getBodyLogValue(
requestBody,
requestHeaders.get("content-type") ?? undefined,
),
},
payload: getBodyLogValue(
requestBody,
request.headers.get("content-type") ?? undefined,
),
});
}
function logProxyResponse(
upstreamResponse: Response,
responseBody: ArrayBuffer,
) {
if (!shouldLogProxy) {
return;
}
writeProxyLog("response", {
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,
),
},
});
}
function writeProxyLog(label: string, value: unknown) {
console.log(`[api-proxy] ${label} ${JSON.stringify(value)}`);
}
async function proxyRequest(request: NextRequest) {
try {
const targetUrl = getTargetUrl(request);
const requestBody =
request.method === "GET" || request.method === "HEAD"
? undefined
: await request.arrayBuffer();
const requestHeaders = getRequestHeaders(request, targetUrl);
logProxyRequest(request, targetUrl, requestHeaders, requestBody);
const upstreamResponse = await fetch(targetUrl, {
method: request.method,
headers: requestHeaders,
body: requestBody,
cache: "no-store",
});
const responseBody = await upstreamResponse.arrayBuffer();
logProxyResponse(upstreamResponse, responseBody);
return new Response(responseBody, {
status: upstreamResponse.status,
statusText: upstreamResponse.statusText,
headers: getResponseHeaders(upstreamResponse.headers),
});
} catch (error) {
if (error instanceof ProxyError) {
return Response.json({ error: error.message }, { status: error.status });
}
console.error("API proxy request failed", error);
return Response.json(
{ error: "API proxy request failed" },
{ status: 502 },
);
}
}
export {
proxyRequest as DELETE,
proxyRequest as GET,
proxyRequest as HEAD,
proxyRequest as OPTIONS,
proxyRequest as PATCH,
proxyRequest as POST,
proxyRequest as PUT,
};

20
src/app/new-match/page.tsx

@ -11,6 +11,7 @@ import type {
MarriageField, MarriageField,
MarriageFieldValue, MarriageFieldValue,
MarriageMatchSummary, MarriageMatchSummary,
MarriagePhoneFieldValue,
} from "@/hooks/marriage/types"; } from "@/hooks/marriage/types";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { useI18n } from "@/i18n/provider"; import { useI18n } from "@/i18n/provider";
@ -54,6 +55,10 @@ function formatFieldValue(value: MarriageFieldValue) {
return null; return null;
} }
if (isMarriagePhoneFieldValue(value)) {
return `+${value.countryCode}${value.phoneNumber}`;
}
if (typeof value === "boolean") { if (typeof value === "boolean") {
return value ? "Yes" : "No"; return value ? "Yes" : "No";
} }
@ -61,6 +66,21 @@ function formatFieldValue(value: MarriageFieldValue) {
return String(value); return String(value);
} }
function isMarriagePhoneFieldValue(
value: unknown,
): value is MarriagePhoneFieldValue {
if (!value || typeof value !== "object") {
return false;
}
const phoneValue = value as Partial<MarriagePhoneFieldValue>;
return (
typeof phoneValue.countryCode === "string" &&
typeof phoneValue.phoneNumber === "string"
);
}
function titleFromKey(key: string) { function titleFromKey(key: string) {
return key return key
.replace(/^q\d+[_-]?/i, "") .replace(/^q\d+[_-]?/i, "")

32
src/app/new-match/profile/page.tsx

@ -15,6 +15,7 @@ import type {
MarriageField, MarriageField,
MarriageFieldValue, MarriageFieldValue,
MarriageGender, MarriageGender,
MarriagePhoneFieldValue,
} from "@/hooks/marriage/types"; } from "@/hooks/marriage/types";
import { useRespondToMarriageCaseMutation } from "@/hooks/marriage/use-case-respond"; import { useRespondToMarriageCaseMutation } from "@/hooks/marriage/use-case-respond";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
@ -26,6 +27,10 @@ function formatFieldValue(value: MarriageFieldValue) {
return null; return null;
} }
if (isMarriagePhoneFieldValue(value)) {
return `+${value.countryCode}${value.phoneNumber}`;
}
if (typeof value === "boolean") { if (typeof value === "boolean") {
return value ? "Yes" : "No"; return value ? "Yes" : "No";
} }
@ -33,6 +38,21 @@ function formatFieldValue(value: MarriageFieldValue) {
return String(value); return String(value);
} }
function isMarriagePhoneFieldValue(
value: unknown,
): value is MarriagePhoneFieldValue {
if (!value || typeof value !== "object") {
return false;
}
const phoneValue = value as Partial<MarriagePhoneFieldValue>;
return (
typeof phoneValue.countryCode === "string" &&
typeof phoneValue.phoneNumber === "string"
);
}
function titleFromKey(key: string) { function titleFromKey(key: string) {
return key return key
.replace(/^q\d+[_-]?/i, "") .replace(/^q\d+[_-]?/i, "")
@ -281,14 +301,18 @@ export default function NewMatchProfilePage() {
className="text-[25px] leading-none text-white/80" className="text-[25px] leading-none text-white/80"
style={{ letterSpacing: "-2.3px", fontWeight: "1000" }} style={{ letterSpacing: "-2.3px", fontWeight: "1000" }}
> >
{profile?.match_summary?.public_info.find(
{formatFieldValue(
profile?.match_summary?.public_info.find(
(field) => field.key === "q1_full_name", (field) => field.key === "q1_full_name",
)?.value || "Name not available"}
)?.value ?? null,
) || "Name not available"}
</p> </p>
<p className="absolute inset-0 whitespace-nowrap text-[22px] font-bold leading-none text-[#F0445B]"> <p className="absolute inset-0 whitespace-nowrap text-[22px] font-bold leading-none text-[#F0445B]">
{profile?.match_summary?.public_info.find(
{formatFieldValue(
profile?.match_summary?.public_info.find(
(field) => field.key === "q1_full_name", (field) => field.key === "q1_full_name",
)?.value || "Name not available"}
)?.value ?? null,
) || "Name not available"}
</p> </p>
</div> </div>
</div> </div>

20
src/app/questions-list/[slug]/answer-pace-sheet.tsx

@ -6,7 +6,7 @@ import {
hasQuestionAnswerValue, hasQuestionAnswerValue,
} from "@/components/questions/question-answer-storage"; } from "@/components/questions/question-answer-storage";
import InformationSheet from "@/components/ui/information-sheet"; import InformationSheet from "@/components/ui/information-sheet";
import type { MarriageField } from "@/hooks/marriage/types";
import type { MarriageField, MarriagePhoneFieldValue } from "@/hooks/marriage/types";
import { useMarriageSectionsQuery } from "@/hooks/marriage/use-sections"; import { useMarriageSectionsQuery } from "@/hooks/marriage/use-sections";
type AnswerPaceSheetProps = { type AnswerPaceSheetProps = {
@ -34,7 +34,23 @@ function isMarriageField(value: unknown): value is MarriageField {
(field.value === null || (field.value === null ||
typeof field.value === "string" || typeof field.value === "string" ||
typeof field.value === "number" || typeof field.value === "number" ||
typeof field.value === "boolean")
typeof field.value === "boolean" ||
isMarriagePhoneFieldValue(field.value))
);
}
function isMarriagePhoneFieldValue(
value: unknown,
): value is MarriagePhoneFieldValue {
if (!value || typeof value !== "object") {
return false;
}
const phoneValue = value as Partial<MarriagePhoneFieldValue>;
return (
typeof phoneValue.countryCode === "string" &&
typeof phoneValue.phoneNumber === "string"
); );
} }

106
src/app/questions-list/[slug]/page.tsx

@ -1,29 +1,12 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { QuestionAnswersProvider } from "@/components/questions/question-answer-storage";
import QuestionButton from "@/components/questions/question-button";
import QuestionDate from "@/components/questions/question-date";
import QuestionDropdown from "@/components/questions/question-dropdown";
import QuestionExitNavigationButton from "@/components/questions/question-exit-navigation-button";
import QuestionFile from "@/components/questions/question-file";
import QuestionNumber from "@/components/questions/question-number";
import QuestionPhone from "@/components/questions/question-phone";
import QuestionPhoto from "@/components/questions/question-photo";
import QuestionRadio from "@/components/questions/question-radio";
import QuestionSectionFlow from "@/components/questions/question-section-flow";
import QuestionSlider from "@/components/questions/question-slider";
import QuestionText from "@/components/questions/question-text";
import NavigationButton from "@/components/ui/navigation-button";
import StickyHeader from "@/components/ui/sticky-header";
import { PageBackground } from "@/components/utils/page-background";
import { import {
getQuestionListItemBySlug, getQuestionListItemBySlug,
getQuestionListItems, getQuestionListItems,
type QuestionField,
} from "@/data/question-data"; } from "@/data/question-data";
import { defaultLocale, isLocale, locales } from "@/i18n/config"; import { defaultLocale, isLocale, locales } from "@/i18n/config";
import { getDictionary } from "@/i18n/dictionaries"; import { getDictionary } from "@/i18n/dictionaries";
import AnswerPaceSheet from "./answer-pace-sheet";
import QuestionDetailClient from "./question-detail-client";
export function generateStaticParams() { export function generateStaticParams() {
const slugs = new Set( const slugs = new Set(
@ -42,47 +25,6 @@ type QuestionDetailPageProps = {
}>; }>;
}; };
function renderQuestion(question: QuestionField, questionIndex: number) {
switch (question.type) {
case "button":
return (
<QuestionButton question={question} questionIndex={questionIndex} />
);
case "date":
return <QuestionDate question={question} questionIndex={questionIndex} />;
case "dropdown":
return (
<QuestionDropdown question={question} questionIndex={questionIndex} />
);
case "file":
return <QuestionFile question={question} questionIndex={questionIndex} />;
case "number":
return (
<QuestionNumber question={question} questionIndex={questionIndex} />
);
case "phone":
return (
<QuestionPhone question={question} questionIndex={questionIndex} />
);
case "photo":
return (
<QuestionPhoto question={question} questionIndex={questionIndex} />
);
case "radio":
return (
<QuestionRadio question={question} questionIndex={questionIndex} />
);
case "slider":
return (
<QuestionSlider question={question} questionIndex={questionIndex} />
);
case "text":
return <QuestionText question={question} questionIndex={questionIndex} />;
default:
return null;
}
}
export default async function QuestionDetailPage({ export default async function QuestionDetailPage({
params, params,
}: QuestionDetailPageProps) { }: QuestionDetailPageProps) {
@ -100,49 +42,15 @@ export default async function QuestionDetailPage({
} }
return ( return (
<>
<PageBackground disabled />
<AnswerPaceSheet
slug={item.slug}
<QuestionDetailClient
itemSlug={item.slug}
locale={locale}
questionsListHref={questionsListHref}
title={t.questions.answerAtYourOwnPace} title={t.questions.answerAtYourOwnPace}
description={t.questions.answerAtYourOwnPaceDescription} description={t.questions.answerAtYourOwnPaceDescription}
closeLabel={t.questions.closeQuestionsList}
informationLabel={t.sheets.informationSheet}
continueLabel={t.common.continue} continueLabel={t.common.continue}
/> />
<QuestionAnswersProvider slug={item.slug} questions={item.questions}>
<main className="-mx-[17px] flex min-h-screen flex-col bg-[#F7F1F0] pb-8">
<StickyHeader className="rounded-b-[32px] px-[17px] pt-7 pb-6">
<div className="flex items-center justify-between">
<QuestionExitNavigationButton
variant="transparent"
icon="close"
iconLabel={t.questions.closeQuestionsList}
/>
<h1 className="font-semibold text-white">{item.title}</h1>
<NavigationButton
variant="transparent"
icon="info"
iconLabel={t.sheets.informationSheet}
/>
</div>
</StickyHeader>
<div className="mx-auto flex w-full max-w-md flex-col px-[17px] pt-7">
<QuestionSectionFlow
key={item.slug}
total={item.questions.length}
continueLabel={t.common.continue}
exitHref={questionsListHref}
>
{item.questions.map((question, questionIndex) => (
<div key={`${item.slug}-${question.title}`}>
{renderQuestion(question, questionIndex)}
</div>
))}
</QuestionSectionFlow>
</div>
</main>
</QuestionAnswersProvider>
</>
); );
} }

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

@ -0,0 +1,326 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useMemo } from "react";
import { QuestionAnswersProvider } from "@/components/questions/question-answer-storage";
import QuestionButton from "@/components/questions/question-button";
import QuestionDate from "@/components/questions/question-date";
import QuestionDropdown from "@/components/questions/question-dropdown";
import QuestionExitNavigationButton from "@/components/questions/question-exit-navigation-button";
import QuestionFile from "@/components/questions/question-file";
import QuestionNumber from "@/components/questions/question-number";
import QuestionPhone from "@/components/questions/question-phone";
import QuestionPhoto from "@/components/questions/question-photo";
import QuestionRadio from "@/components/questions/question-radio";
import QuestionSectionFlow from "@/components/questions/question-section-flow";
import QuestionSlider from "@/components/questions/question-slider";
import QuestionText from "@/components/questions/question-text";
import NavigationButton from "@/components/ui/navigation-button";
import StickyHeader from "@/components/ui/sticky-header";
import { PageBackground } from "@/components/utils/page-background";
import {
getQuestionListItemBySlug,
isQuestionListItemVisibleForProfile,
isQuestionRequiredForProfile,
isQuestionVisibleForProfile,
type QuestionField,
} from "@/data/question-data";
import type { MarriageGender } from "@/hooks/marriage/types";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { defaultLocale, type Locale } from "@/i18n/config";
import AnswerPaceSheet from "./answer-pace-sheet";
type QuestionDetailClientProps = {
closeLabel: string;
continueLabel: string;
description: string;
informationLabel: string;
itemSlug: string;
locale?: Locale;
questionsListHref: string;
title: string;
};
type StoredQuestionField = {
label?: string;
value?: unknown;
};
type StoredAnswers = {
fields?: StoredQuestionField[];
};
function getQuestionStorageKey(slug: string) {
return `marriage:sections:${slug}:answers`;
}
function parseStoredAge(value: unknown) {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string") {
const trimmedValue = value.trim();
if (!trimmedValue) {
return null;
}
const numericAge = Number(trimmedValue);
if (Number.isFinite(numericAge)) {
return numericAge;
}
const dateOfBirth = new Date(trimmedValue);
if (Number.isNaN(dateOfBirth.getTime())) {
return null;
}
const today = new Date();
let age = today.getFullYear() - dateOfBirth.getFullYear();
const hasBirthdayPassed =
today.getMonth() > dateOfBirth.getMonth() ||
(today.getMonth() === dateOfBirth.getMonth() &&
today.getDate() >= dateOfBirth.getDate());
if (!hasBirthdayPassed) {
age -= 1;
}
return age >= 0 ? age : null;
}
return null;
}
function getStoredAge() {
try {
const rawValue = window.localStorage.getItem(
getQuestionStorageKey("personal_info"),
);
if (!rawValue) {
return null;
}
const storedAnswers = JSON.parse(rawValue) as StoredAnswers;
const ageField = storedAnswers.fields?.find(
(field) => field.label === "Age",
);
if (ageField) {
return parseStoredAge(ageField.value);
}
const dateOfBirthField = storedAnswers.fields?.find(
(field) => field.label === "Date of Birth",
);
return parseStoredAge(dateOfBirthField?.value);
} catch {
return null;
}
}
function renderQuestion(
question: QuestionField,
questionIndex: number,
dobQuestion?: QuestionField,
dobQuestionIndex?: number,
) {
const compactTextHeight =
question.type === "text" &&
(question.title.toLowerCase().includes("name") ||
question.title.toLowerCase().includes("email") ||
question.title.toLowerCase().includes("city") ||
question.title.toLowerCase().includes("residence") ||
question.title.toLowerCase().includes("location")) &&
!question.title.toLowerCase().includes("describe") &&
!question.title.toLowerCase().includes("biography")
? "h-[54px] min-h-0 py-2"
: undefined;
switch (question.type) {
case "button":
return (
<QuestionButton question={question} questionIndex={questionIndex} />
);
case "date":
return <QuestionDate question={question} questionIndex={questionIndex} />;
case "dropdown":
return (
<QuestionDropdown question={question} questionIndex={questionIndex} />
);
case "file":
return <QuestionFile question={question} questionIndex={questionIndex} />;
case "number":
if (
question.title === "Age" &&
dobQuestion &&
dobQuestionIndex !== undefined
) {
return (
<QuestionNumber
question={question}
questionIndex={questionIndex}
derivedFromQuestion={dobQuestion}
derivedFromQuestionIndex={dobQuestionIndex}
/>
);
}
return (
<QuestionNumber question={question} questionIndex={questionIndex} />
);
case "phone":
return (
<QuestionPhone question={question} questionIndex={questionIndex} />
);
case "photo":
return (
<QuestionPhoto question={question} questionIndex={questionIndex} />
);
case "radio":
return (
<QuestionRadio question={question} questionIndex={questionIndex} />
);
case "slider":
return (
<QuestionSlider question={question} questionIndex={questionIndex} />
);
case "text":
return (
<QuestionText
question={question}
questionIndex={questionIndex}
heightClassName={compactTextHeight}
/>
);
default:
return null;
}
}
export default function QuestionDetailClient({
closeLabel,
continueLabel,
description,
informationLabel,
itemSlug,
locale = defaultLocale,
questionsListHref,
title,
}: QuestionDetailClientProps) {
const router = useRouter();
const { data: profile } = useMarriageProfileQuery();
const profileGender = profile?.gender;
const age = getStoredAge();
const item = getQuestionListItemBySlug(itemSlug, locale);
const profileContext = useMemo(
() => ({
age,
gender: profileGender as MarriageGender | null | undefined,
}),
[age, profileGender],
);
const visibleQuestions = useMemo(() => {
if (!item) {
return [];
}
return item.questions
.filter((question) =>
isQuestionVisibleForProfile(question, profileContext),
)
.map((question) => ({
...question,
required: isQuestionRequiredForProfile(question, profileContext),
}));
}, [item, profileContext]);
useEffect(() => {
if (!item || isQuestionListItemVisibleForProfile(item, profileContext)) {
return;
}
router.replace(questionsListHref);
}, [item, profileContext, questionsListHref, router]);
if (!item || !isQuestionListItemVisibleForProfile(item, profileContext)) {
return null;
}
const requiredQuestionsCount = useMemo(
() => visibleQuestions.filter((q) => q.required).length,
[visibleQuestions],
);
const dobQuestion = visibleQuestions.find(
(question) => question.title === "Date of Birth",
);
const dobQuestionIndex = visibleQuestions.findIndex(
(question) => question.title === "Date of Birth",
);
return (
<>
<PageBackground disabled />
<AnswerPaceSheet
slug={item.slug}
title={title}
description={description}
continueLabel={continueLabel}
/>
<QuestionAnswersProvider slug={item.slug} questions={visibleQuestions}>
<main className="-mx-[17px] flex min-h-screen flex-col bg-[#F7F1F0] pb-8">
<StickyHeader className="rounded-b-[32px] px-[17px] pt-7 pb-6">
<div className="flex items-start gap-4">
<QuestionExitNavigationButton
className="shrink-0"
variant="transparent"
icon="close"
iconLabel={closeLabel}
/>
<h1 className="min-w-0 flex-1 text-center font-semibold text-white">
{item.title}
</h1>
<NavigationButton
className="shrink-0"
variant="transparent"
icon="info"
iconLabel={informationLabel}
/>
</div>
</StickyHeader>
<div className="mx-auto flex w-full max-w-md flex-col px-[17px] pt-7">
<QuestionSectionFlow
key={item.slug}
total={requiredQuestionsCount}
continueLabel={continueLabel}
exitHref={questionsListHref}
>
{visibleQuestions.map((question, questionIndex) => (
<div
key={`${item.slug}-${question.title}`}
data-question-required={String(question.required)}
>
{renderQuestion(
question,
questionIndex,
dobQuestion,
dobQuestionIndex,
)}
</div>
))}
</QuestionSectionFlow>
</div>
</main>
</QuestionAnswersProvider>
</>
);
}

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

@ -12,6 +12,8 @@ import NavigationButton from "@/components/ui/navigation-button";
import { PageBackground } from "@/components/utils/page-background"; import { PageBackground } from "@/components/utils/page-background";
import { import {
getQuestionListItems, getQuestionListItems,
getRequiredQuestionsCount,
isQuestionListItemVisibleForProfile,
type QuestionListItem, type QuestionListItem,
} from "@/data/question-data"; } from "@/data/question-data";
import { useStartMarriageMatchMutation } from "@/hooks/marriage/use-match-start"; import { useStartMarriageMatchMutation } from "@/hooks/marriage/use-match-start";
@ -32,10 +34,24 @@ export default function QuestionsListPage() {
}, },
}); });
const [isOptionalInfoSheetOpen, setIsOptionalInfoSheetOpen] = useState(false); const [isOptionalInfoSheetOpen, setIsOptionalInfoSheetOpen] = useState(false);
const [selectedSection, setSelectedSection] = useState<QuestionListItem | null>(
null,
const [selectedSection, setSelectedSection] =
useState<QuestionListItem | null>(null);
const questionListItems = useMemo(
() =>
getQuestionListItems(locale).filter((item) =>
isQuestionListItemVisibleForProfile(item, {
gender: profile?.gender,
}),
),
[locale, profile?.gender],
);
const profileContext = useMemo(
() => ({
gender: profile?.gender,
age: profile?.age,
}),
[profile?.gender, profile?.age],
); );
const questionListItems = getQuestionListItems(locale);
const allRequiredSectionsCompleted = useMemo(() => { const allRequiredSectionsCompleted = useMemo(() => {
if (!sections?.length) { if (!sections?.length) {
return false; return false;
@ -43,8 +59,15 @@ export default function QuestionsListPage() {
return sections return sections
.filter((section) => section.is_required) .filter((section) => section.is_required)
.every((section) => section.current_step >= section.total_steps);
}, [sections]);
.every((section) => {
const requiredCount = getRequiredQuestionsCount(
section.slug,
profileContext,
locale,
);
return section.current_step >= requiredCount;
});
}, [sections, profileContext, locale]);
const profileStatus = profile?.status; const profileStatus = profile?.status;
const isProfileSuspended = profileStatus === "suspended"; const isProfileSuspended = profileStatus === "suspended";
const canStartMatch = const canStartMatch =
@ -65,11 +88,20 @@ export default function QuestionsListPage() {
const progressBySlug = new Map<string, number>(); const progressBySlug = new Map<string, number>();
sections?.forEach((section) => { sections?.forEach((section) => {
progressBySlug.set(section.slug, section.completion_percent);
const requiredCount = getRequiredQuestionsCount(
section.slug,
profileContext,
locale,
);
const progress =
requiredCount > 0
? Math.min(100, Math.round((section.current_step / requiredCount) * 100))
: 100;
progressBySlug.set(section.slug, progress);
}); });
return progressBySlug; return progressBySlug;
}, [sections]);
}, [sections, profileContext, locale]);
const handleStartMatch = () => { const handleStartMatch = () => {
if (!canStartMatch) { if (!canStartMatch) {
return; return;

23
src/app/request-accepted/page.tsx

@ -10,7 +10,7 @@ import FemaleConsentSheet from "@/components/ui/female-consent-sheet";
import NavigationButton from "@/components/ui/navigation-button"; import NavigationButton from "@/components/ui/navigation-button";
import SubscriptionRequiredSheet from "@/components/ui/subscription-required-sheet"; import SubscriptionRequiredSheet from "@/components/ui/subscription-required-sheet";
import { PageBackground } from "@/components/utils/page-background"; import { PageBackground } from "@/components/utils/page-background";
import type { MarriageField } from "@/hooks/marriage/types";
import type { MarriageField, MarriagePhoneFieldValue } from "@/hooks/marriage/types";
import { useMarriageContactInfoQuery } from "@/hooks/marriage/use-contact-info"; import { useMarriageContactInfoQuery } from "@/hooks/marriage/use-contact-info";
import { import {
extractHabcoinPaymentUrl, extractHabcoinPaymentUrl,
@ -31,6 +31,12 @@ function sanitizePhoneNumber(value: MarriageField["value"]) {
return null; return null;
} }
if (isMarriagePhoneFieldValue(value)) {
const digits = value.phoneNumber.replace(/\D/g, "");
return digits ? `+${value.countryCode}${digits}` : null;
}
const trimmedValue = String(value).trim(); const trimmedValue = String(value).trim();
if (!trimmedValue) { if (!trimmedValue) {
@ -46,6 +52,21 @@ function sanitizePhoneNumber(value: MarriageField["value"]) {
return trimmedValue.startsWith("+") ? `+${digits}` : digits; return trimmedValue.startsWith("+") ? `+${digits}` : digits;
} }
function isMarriagePhoneFieldValue(
value: unknown,
): value is MarriagePhoneFieldValue {
if (!value || typeof value !== "object") {
return false;
}
const phoneValue = value as Partial<MarriagePhoneFieldValue>;
return (
typeof phoneValue.countryCode === "string" &&
typeof phoneValue.phoneNumber === "string"
);
}
function titleFromKey(key: string) { function titleFromKey(key: string) {
return key return key
.replace(/^q\d+[_-]?/i, "") .replace(/^q\d+[_-]?/i, "")

2
src/components/dev/dev-click-to-component.tsx

@ -77,7 +77,7 @@ export function DevClickToComponent() {
IDE_SCHEMES.find(({ matches }) => IDE_SCHEMES.find(({ matches }) =>
matches.some((match) => userAgent.includes(match)), matches.some((match) => userAgent.includes(match)),
)?.createUrl(`${filePath}${positionSuffix}`) ?? )?.createUrl(`${filePath}${positionSuffix}`) ??
`vscode://file/${filePath}${positionSuffix}`;
`antigravity://file/${filePath}${positionSuffix}`;
try { try {
window.location.href = ideUrl; window.location.href = ideUrl;

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

@ -12,15 +12,16 @@ import {
} from "react"; } from "react";
import type { QuestionField } from "@/data/question-data"; import type { QuestionField } from "@/data/question-data";
import { pathParam } from "@/hooks/marriage/path-param"; import { pathParam } from "@/hooks/marriage/path-param";
import { getApiRequestUrl } from "@/lib/http";
import type { import type {
MarriageField, MarriageField,
MarriageFieldValue, MarriageFieldValue,
MarriagePhoneFieldValue,
UpdateMarriageSectionDataPayload, UpdateMarriageSectionDataPayload,
} from "@/hooks/marriage/types"; } from "@/hooks/marriage/types";
import { useUpdateMarriageSectionDataMutation } from "@/hooks/marriage/use-section-data"; import { useUpdateMarriageSectionDataMutation } from "@/hooks/marriage/use-section-data";
const STORAGE_VERSION = 1; const STORAGE_VERSION = 1;
const PROXY_PATH_PARAM = "__proxyPath";
type QuestionAnswersByKey = Record<string, MarriageField>; type QuestionAnswersByKey = Record<string, MarriageField>;
@ -116,7 +117,21 @@ function isMarriageField(value: unknown): value is MarriageField {
(field.value === null || (field.value === null ||
typeof field.value === "string" || typeof field.value === "string" ||
typeof field.value === "number" || typeof field.value === "number" ||
typeof field.value === "boolean")
typeof field.value === "boolean" ||
isMarriagePhoneFieldValue(field.value))
);
}
function isMarriagePhoneFieldValue(value: unknown): value is MarriagePhoneFieldValue {
if (!value || typeof value !== "object") {
return false;
}
const phoneValue = value as Partial<MarriagePhoneFieldValue>;
return (
typeof phoneValue.countryCode === "string" &&
typeof phoneValue.phoneNumber === "string"
); );
} }
@ -243,7 +258,7 @@ function writeStoredAnswers(
} }
function getKeepalivePatchUrl(slug: string) { function getKeepalivePatchUrl(slug: string) {
return `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/marriage/sections/${pathParam(slug)}/data/`;
return getApiRequestUrl(`/api/marriage/sections/${pathParam(slug)}/data/`);
} }
export function QuestionAnswersProvider({ export function QuestionAnswersProvider({

2
src/components/questions/question-date.tsx

@ -25,7 +25,7 @@ export function QuestionDate({ question, questionIndex }: QuestionDateProps) {
setValue(nextValue.length > 0 ? nextValue : null); setValue(nextValue.length > 0 ? nextValue : null);
}} }}
className="h-[54px] w-full rounded-[15px] border border-[#E7D8D5] bg-white px-4 text-[15px] text-[#181818] outline-none focus:border-[#F43F5E]"
className="h-[54px] w-full rounded-[15px] border border-[#E7D8D5] bg-white px-4 text-[15px] text-[#181818] outline-none focus:border-[#6F6F6F]"
/> />
</label> </label>
); );

2
src/components/questions/question-dropdown.tsx

@ -27,7 +27,7 @@ export function QuestionDropdown({
setValue(nextValue.length > 0 ? nextValue : null); setValue(nextValue.length > 0 ? nextValue : null);
}} }}
className="h-[54px] w-full rounded-[15px] border border-[#E7D8D5] bg-white px-4 text-[15px] text-[#181818] outline-none focus:border-[#F43F5E]"
className="h-[54px] w-full rounded-[15px] border border-[#E7D8D5] bg-white px-4 text-[15px] text-[#181818] outline-none focus:border-[#6F6F6F]"
> >
<option value="" disabled> <option value="" disabled>
{question.extras.placeHolder || "Select an option"} {question.extras.placeHolder || "Select an option"}

2
src/components/questions/question-file.tsx

@ -43,7 +43,7 @@ export function QuestionFile({ question, questionIndex }: QuestionFileProps) {
return ( return (
<label data-question-type={question.type} className="block space-y-3"> <label data-question-type={question.type} className="block space-y-3">
<QuestionTitle question={question} /> <QuestionTitle question={question} />
<span className="relative flex aspect-[727/330] min-h-[156px] w-full cursor-pointer flex-col items-center justify-center rounded-[29px] border-2 border-dashed border-[#8D8D8D] bg-[#F7F7F7] text-center transition-colors duration-200 focus-within:outline-2 focus-within:outline-offset-4 focus-within:outline-[#F26C85] hover:border-[#777777]">
<span className="relative flex aspect-[727/330] min-h-[156px] w-full cursor-pointer flex-col items-center justify-center rounded-[29px] border-2 border-dashed border-[#8D8D8D] bg-[#F7F7F7] text-center transition-colors duration-200 focus-within:outline-2 focus-within:outline-offset-4 focus-within:outline-[#6F6F6F] hover:border-[#777777]">
<input <input
type="file" type="file"
required={question.required} required={question.required}

65
src/components/questions/question-number.tsx

@ -1,5 +1,6 @@
"use client"; "use client";
import { useI18n } from "@/i18n/provider";
import type { QuestionField } from "@/data/question-data"; import type { QuestionField } from "@/data/question-data";
import { useQuestionAnswer } from "./question-answer-storage"; import { useQuestionAnswer } from "./question-answer-storage";
import QuestionTitle from "./question-title"; import QuestionTitle from "./question-title";
@ -7,16 +8,42 @@ import QuestionTitle from "./question-title";
type QuestionNumberProps = { type QuestionNumberProps = {
question: QuestionField; question: QuestionField;
questionIndex: number; questionIndex: number;
disabled?: boolean;
valueOverride?: string;
derivedFromQuestion?: QuestionField;
derivedFromQuestionIndex?: number;
}; };
export function QuestionNumber({ export function QuestionNumber({
question, question,
questionIndex, questionIndex,
disabled = false,
valueOverride,
derivedFromQuestion,
derivedFromQuestionIndex,
}: QuestionNumberProps) { }: QuestionNumberProps) {
const { dictionary } = useI18n();
const [min, max] = question.extras.range; const [min, max] = question.extras.range;
const { setValue, value } = useQuestionAnswer(question, questionIndex); const { setValue, value } = useQuestionAnswer(question, questionIndex);
const derivedAnswer = useQuestionAnswer(
derivedFromQuestion ?? question,
derivedFromQuestionIndex ?? questionIndex,
).value;
const derivedDateValue =
question.title === "Age" && typeof derivedAnswer === "string"
? calculateAge(derivedAnswer)
: undefined;
const inputValue = const inputValue =
typeof value === "number" || typeof value === "string" ? String(value) : "";
valueOverride ??
derivedDateValue ??
(typeof value === "number" || typeof value === "string"
? String(value)
: "");
const numericValue = Number.parseFloat(inputValue);
const isOutOfRange =
!Number.isNaN(numericValue) &&
((min > 0 && numericValue < min) || (max > 0 && numericValue > max));
return ( return (
<label data-question-type={question.type} className="block space-y-3"> <label data-question-type={question.type} className="block space-y-3">
@ -24,11 +51,16 @@ export function QuestionNumber({
<input <input
type="number" type="number"
required={question.required} required={question.required}
disabled={disabled || question.title === "Age"}
min={min || undefined} min={min || undefined}
max={max || undefined} max={max || undefined}
placeholder={question.extras.placeHolder} placeholder={question.extras.placeHolder}
value={inputValue} value={inputValue}
onChange={(event) => { onChange={(event) => {
if (disabled) {
return;
}
const nextValue = event.target.value; const nextValue = event.target.value;
if (nextValue.length === 0) { if (nextValue.length === 0) {
@ -40,10 +72,39 @@ export function QuestionNumber({
setValue(Number.isNaN(parsedValue) ? nextValue : parsedValue); setValue(Number.isNaN(parsedValue) ? nextValue : parsedValue);
}} }}
className="h-[54px] w-full rounded-[15px] border border-[#E7D8D5] bg-white px-4 text-[15px] text-[#181818] outline-none placeholder:text-[#9D8F8C] focus:border-[#F43F5E]"
className={[
"h-[54px] w-full rounded-[15px] border px-4 text-[15px] text-[#181818] outline-none placeholder:text-[#9D8F8C] focus:border-[#6F6F6F] disabled:bg-[#F5F2F1] disabled:text-[#7C7472]",
isOutOfRange ? "border-[#F2465F]" : "border-[#E7D8D5] bg-white",
].join(" ")}
/> />
{isOutOfRange ? (
<span className="block text-[10px] font-semibold text-[#F2465F]">
{dictionary.common.rangeError}
</span>
) : null}
</label> </label>
); );
} }
function calculateAge(dateOfBirth: string) {
const birthDate = new Date(dateOfBirth);
if (Number.isNaN(birthDate.getTime())) {
return "";
}
const today = new Date();
let age = today.getFullYear() - birthDate.getFullYear();
const monthDifference = today.getMonth() - birthDate.getMonth();
if (
monthDifference < 0 ||
(monthDifference === 0 && today.getDate() < birthDate.getDate())
) {
age -= 1;
}
return String(Math.max(age, 0));
}
export default QuestionNumber; export default QuestionNumber;

175
src/components/questions/question-phone.tsx

@ -1,7 +1,9 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { PhoneNumberFormat, PhoneNumberUtil } from "google-libphonenumber";
import type { QuestionField } from "@/data/question-data"; import type { QuestionField } from "@/data/question-data";
import type { MarriagePhoneFieldValue } from "@/hooks/marriage/types";
import { useQuestionAnswer } from "./question-answer-storage"; import { useQuestionAnswer } from "./question-answer-storage";
import QuestionTitle from "./question-title"; import QuestionTitle from "./question-title";
@ -16,6 +18,23 @@ type PhoneValueParts = {
phoneValue: string; phoneValue: string;
}; };
const phoneUtil = PhoneNumberUtil.getInstance();
function isMarriagePhoneFieldValue(
value: unknown,
): value is MarriagePhoneFieldValue {
if (!value || typeof value !== "object") {
return false;
}
const phoneValue = value as Partial<MarriagePhoneFieldValue>;
return (
typeof phoneValue.countryCode === "string" &&
typeof phoneValue.phoneNumber === "string"
);
}
function readPhoneValue(value: unknown, fallbackCode: string): PhoneValueParts { function readPhoneValue(value: unknown, fallbackCode: string): PhoneValueParts {
if (value === null) { if (value === null) {
return { return {
@ -24,6 +43,15 @@ function readPhoneValue(value: unknown, fallbackCode: string): PhoneValueParts {
}; };
} }
if (isMarriagePhoneFieldValue(value)) {
return {
codeValue: value.countryCode
? `+${value.countryCode.replace(/^\+/, "")}`
: fallbackCode,
phoneValue: value.phoneNumber,
};
}
if (typeof value !== "string") { if (typeof value !== "string") {
return { return {
codeValue: fallbackCode, codeValue: fallbackCode,
@ -48,14 +76,25 @@ function readPhoneValue(value: unknown, fallbackCode: string): PhoneValueParts {
} }
if (value.startsWith("+")) { if (value.startsWith("+")) {
try {
const parsedNumber = phoneUtil.parse(value);
const countryCode = parsedNumber.getCountryCode();
const nationalNumber = String(parsedNumber.getNationalNumber());
return { return {
codeValue: value,
phoneValue: "",
codeValue: countryCode ? `+${countryCode}` : fallbackCode,
phoneValue: nationalNumber,
};
} catch {
return {
codeValue: fallbackCode,
phoneValue: value,
}; };
} }
}
return { return {
codeValue: "",
codeValue: fallbackCode,
phoneValue: value, phoneValue: value,
}; };
} }
@ -76,6 +115,79 @@ function writePhoneValue(codeValue: string, phoneValue: string) {
return phoneValue; return phoneValue;
} }
function toStoredPhoneValue(
codeValue: string,
phoneValue: string,
): MarriagePhoneFieldValue | null {
const normalizedCountryCode = sanitizeCountryCode(codeValue).replace(/^\+/, "");
const normalizedPhoneNumber = phoneValue.trim().replace(/\s+/g, "");
if (!normalizedCountryCode && !normalizedPhoneNumber) {
return null;
}
return {
countryCode: normalizedCountryCode,
phoneNumber: normalizedPhoneNumber,
};
}
function sanitizeCountryCode(value: string) {
const sanitized = value.replace(/[^\d+]/g, "");
if (sanitized.length === 0) {
return "";
}
return sanitized.startsWith("+")
? `+${sanitized.slice(1).replace(/\+/g, "")}`
: `+${sanitized.replace(/\+/g, "")}`;
}
function sanitizePhoneNumber(value: string) {
return value.replace(/[^\d\s\-().]/g, "");
}
function getNormalizedPhoneValue(codeValue: string, phoneValue: string) {
const nextCodeValue = sanitizeCountryCode(codeValue);
const nextPhoneValue = phoneValue.trim();
if (nextCodeValue.length === 0 && nextPhoneValue.length === 0) {
return {
isValid: !nextPhoneValue.length,
normalizedValue: null,
};
}
if (nextCodeValue.length === 0 || nextPhoneValue.length === 0) {
return {
isValid: false,
normalizedValue: null,
};
}
try {
const parsedNumber = phoneUtil.parse(`${nextCodeValue} ${nextPhoneValue}`);
if (!phoneUtil.isValidNumber(parsedNumber)) {
return {
isValid: false,
normalizedValue: null,
};
}
return {
isValid: true,
normalizedValue: phoneUtil.format(parsedNumber, PhoneNumberFormat.E164),
};
} catch {
return {
isValid: false,
normalizedValue: null,
};
}
}
export function QuestionPhone({ export function QuestionPhone({
question, question,
questionIndex, questionIndex,
@ -87,6 +199,15 @@ export function QuestionPhone({
const [codeValue, setCodeValue] = useState(initialValue.codeValue); const [codeValue, setCodeValue] = useState(initialValue.codeValue);
const [phoneValue, setPhoneValue] = useState(initialValue.phoneValue); const [phoneValue, setPhoneValue] = useState(initialValue.phoneValue);
const lastCommittedValueRef = useRef(value); const lastCommittedValueRef = useRef(value);
const normalizedPhoneState = getNormalizedPhoneValue(codeValue, phoneValue);
const showInvalidState =
codeValue.trim().length > 0 &&
phoneValue.trim().length > 0 &&
!normalizedPhoneState.isValid;
const isAnswered =
question.required === false
? normalizedPhoneState.isValid || phoneValue.trim().length === 0
: normalizedPhoneState.isValid;
useEffect(() => { useEffect(() => {
if (value === lastCommittedValueRef.current) { if (value === lastCommittedValueRef.current) {
@ -101,58 +222,78 @@ export function QuestionPhone({
}, [defaultCodeValue, value]); }, [defaultCodeValue, value]);
const updateStoredValue = (nextCodeValue: string, nextPhoneValue: string) => { const updateStoredValue = (nextCodeValue: string, nextPhoneValue: string) => {
const nextValue = writePhoneValue(nextCodeValue, nextPhoneValue);
const draftValue = writePhoneValue(nextCodeValue, nextPhoneValue);
const nextPhoneState = getNormalizedPhoneValue(
nextCodeValue,
nextPhoneValue,
);
const nextValue =
draftValue === null
? null
: nextPhoneState.isValid
? toStoredPhoneValue(nextCodeValue, nextPhoneValue)
: null;
lastCommittedValueRef.current = nextValue; lastCommittedValueRef.current = nextValue;
setValue(nextValue); setValue(nextValue);
}; };
return ( return (
<label data-question-type={question.type} className="block space-y-3">
<label
data-question-answered={isAnswered ? "true" : "false"}
data-question-type={question.type}
className="block space-y-3"
>
<QuestionTitle question={question} /> <QuestionTitle question={question} />
<div <div
dir="ltr" dir="ltr"
className="flex w-full items-center overflow-hidden rounded-[11px] border border-[#E7D8D5] bg-white text-[#292A2E] focus-within:border-[#F43F5E]"
className={[
"flex h-[54px] w-full items-center rounded-[15px] border bg-white text-[#181818] focus-within:border-[#6F6F6F]",
showInvalidState ? "border-[#F2465F]" : "border-[#E7D8D5]",
].join(" ")}
> >
<div className="flex shrink-0 items-center pr-2.5 pl-2 py-[17px]">
<div className="flex shrink-0 items-center pl-2.5 pr-2">
<input <input
type="tel" type="tel"
inputMode="tel" inputMode="tel"
aria-label="Country code" aria-label="Country code"
maxLength={4} maxLength={4}
required={question.required} required={question.required}
placeholder={defaultCodeValue}
value={codeValue} value={codeValue}
onChange={(event) => { onChange={(event) => {
const nextCodeValue = event.target.value;
const nextCodeValue = sanitizeCountryCode(event.target.value);
setCodeValue(nextCodeValue); setCodeValue(nextCodeValue);
updateStoredValue(nextCodeValue, phoneValue); updateStoredValue(nextCodeValue, phoneValue);
}} }}
className="border-0 w-14 bg-transparent text-center text-[14px] leading-none text-[#181818] tabular-nums outline-none placeholder:text-[#9D8F8C]"
/>
<span
aria-hidden="true"
className="mx-3 h-[17px] w-px bg-[#181818]/70"
className="w-[33px] border-0 bg-transparent p-0 text-left text-[14px] leading-none text-[#181818] tabular-nums outline-none placeholder:text-[#9D8F8C]"
/> />
<span aria-hidden="true" className="h-5 w-px bg-[#181818]/35" />
</div> </div>
<span className="flex min-w-0 flex-1 items-center pr-6 sm:pr-8">
<span className="flex min-w-0 flex-1 items-center pr-4">
<input <input
type="tel" type="tel"
inputMode="tel" inputMode="tel"
required={question.required} required={question.required}
placeholder={question.extras.placeHolder}
placeholder={question.extras.placeHolder?.replace(/^\+\d+\s*/, "")}
value={phoneValue} value={phoneValue}
onChange={(event) => { onChange={(event) => {
const nextPhoneValue = event.target.value;
const nextPhoneValue = sanitizePhoneNumber(event.target.value);
setPhoneValue(nextPhoneValue); setPhoneValue(nextPhoneValue);
updateStoredValue(codeValue, nextPhoneValue); updateStoredValue(codeValue, nextPhoneValue);
}} }}
dir="ltr" dir="ltr"
className="h-full w-full border-0 bg-transparent p-0 text-left text-[14px] leading-none tracking-[-0.03em] text-[#181818] tabular-nums outline-none placeholder:text-[#9D8F8C]"
className="h-full w-full border-0 bg-transparent p-0 text-left text-[14px] leading-none text-[#181818] tabular-nums outline-none placeholder:text-[#9D8F8C]"
/> />
</span> </span>
</div> </div>
{showInvalidState ? (
<span className="block text-[10px] font-semibold text-[#F2465F]">
Enter a valid phone number with country code.
</span>
) : null}
</label> </label>
); );
} }

16
src/components/questions/question-progress-tracker.tsx

@ -14,16 +14,22 @@ type QuestionProgressTrackerProps = {
}; };
function isQuestionAnswered(question: Element) { function isQuestionAnswered(question: Element) {
const explicitAnsweredState = question.getAttribute("data-question-answered");
if (explicitAnsweredState === "true") {
return true;
}
if (explicitAnsweredState === "false") {
return false;
}
const inputs = Array.from( const inputs = Array.from(
question.querySelectorAll< question.querySelectorAll<
HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
>("input, select, textarea"), >("input, select, textarea"),
); );
if (inputs.length === 0) {
return question.getAttribute("data-question-answered") === "true";
}
return inputs.some((input) => { return inputs.some((input) => {
if (input instanceof HTMLInputElement) { if (input instanceof HTMLInputElement) {
if (input.type === "checkbox" || input.type === "radio") { if (input.type === "checkbox" || input.type === "radio") {
@ -57,7 +63,7 @@ export function QuestionProgressTracker({
const questions = Array.from( const questions = Array.from(
container.querySelectorAll("[data-question-type]"), container.querySelectorAll("[data-question-type]"),
);
).filter((el) => el.getAttribute("data-question-required") === "true");
const nextAnswered = questions.filter(isQuestionAnswered).length; const nextAnswered = questions.filter(isQuestionAnswered).length;
setAnswered(Math.min(nextAnswered, safeTotal)); setAnswered(Math.min(nextAnswered, safeTotal));

2
src/components/questions/question-radio.tsx

@ -15,7 +15,7 @@ export function QuestionRadio({ question, questionIndex }: QuestionRadioProps) {
const groupId = useId(); const groupId = useId();
const options = question.extras.options; const options = question.extras.options;
const { setValue, value } = useQuestionAnswer(question, questionIndex); const { setValue, value } = useQuestionAnswer(question, questionIndex);
const selectedOption = typeof value === "string" ? value : (options[0] ?? "");
const selectedOption = typeof value === "string" ? value : undefined;
if (options.length === 0) { if (options.length === 0) {
return null; return null;

39
src/components/questions/question-snap-list.tsx

@ -12,6 +12,11 @@ import {
const WHEEL_GESTURE_IDLE_MS = 320; const WHEEL_GESTURE_IDLE_MS = 320;
const TOUCH_MIN_DISTANCE = 8; const TOUCH_MIN_DISTANCE = 8;
const AUTO_FOCUS_SELECTOR = [
'textarea:not([disabled])',
'input[type="text"]:not([disabled])',
'input[type="number"]:not([disabled])',
].join(", ");
type QuestionSnapListProps = { type QuestionSnapListProps = {
children: ReactNode; children: ReactNode;
@ -32,6 +37,7 @@ export function QuestionSnapList({
const wheelLockedRef = useRef(false); const wheelLockedRef = useRef(false);
const wheelUnlockTimeoutRef = useRef<number | null>(null); const wheelUnlockTimeoutRef = useRef<number | null>(null);
const touchStartYRef = useRef<number | null>(null); const touchStartYRef = useRef<number | null>(null);
const questionRefs = useRef<Array<HTMLDivElement | null>>([]);
const [activeIndex, setActiveIndex] = useState(0); const [activeIndex, setActiveIndex] = useState(0);
const stepQuestion = useCallback( const stepQuestion = useCallback(
@ -62,6 +68,36 @@ export function QuestionSnapList({
}, WHEEL_GESTURE_IDLE_MS); }, WHEEL_GESTURE_IDLE_MS);
}, []); }, []);
useEffect(() => {
const activeQuestion = questionRefs.current[activeIndex];
if (!activeQuestion) {
return;
}
const focusFrame = window.requestAnimationFrame(() => {
const firstFocusableInput = activeQuestion.querySelector<
HTMLInputElement | HTMLTextAreaElement
>(AUTO_FOCUS_SELECTOR);
if (!firstFocusableInput) {
return;
}
firstFocusableInput.focus({ preventScroll: true });
const valueLength = firstFocusableInput.value.length;
if (valueLength > 0 && typeof firstFocusableInput.setSelectionRange === "function") {
firstFocusableInput.setSelectionRange(valueLength, valueLength);
}
});
return () => {
window.cancelAnimationFrame(focusFrame);
};
}, [activeIndex]);
useEffect(() => { useEffect(() => {
return () => { return () => {
if (wheelUnlockTimeoutRef.current !== null) { if (wheelUnlockTimeoutRef.current !== null) {
@ -160,6 +196,9 @@ export function QuestionSnapList({
return ( return (
<div <div
key={questionKey} key={questionKey}
ref={(element) => {
questionRefs.current[index] = element;
}}
aria-current={isActive ? "step" : undefined} aria-current={isActive ? "step" : undefined}
aria-hidden={isActive ? undefined : true} aria-hidden={isActive ? undefined : true}
inert={isActive ? undefined : true} inert={isActive ? undefined : true}

3
src/components/questions/question-text.tsx

@ -25,6 +25,7 @@ export function QuestionText({
<QuestionTitle question={question} /> <QuestionTitle question={question} />
<textarea <textarea
required={question.required} required={question.required}
rows={1}
placeholder={question.extras.placeHolder} placeholder={question.extras.placeHolder}
value={textValue} value={textValue}
onChange={(event) => { onChange={(event) => {
@ -32,7 +33,7 @@ export function QuestionText({
setValue(nextValue.length > 0 ? nextValue : null); setValue(nextValue.length > 0 ? nextValue : null);
}} }}
className={`${heightClassName} w-full resize-none rounded-[15px] border border-[#8B8B8B] bg-white px-4 py-3 text-[#181818] outline-none placeholder:text-[#8B8B8B] focus:border-[#F43F5E]`}
className={`w-full resize-none rounded-[15px] border border-[#8B8B8B] bg-white px-4 py-3 text-[#181818] outline-none placeholder:text-[#8B8B8B] focus:border-[#6F6F6F] ${heightClassName}`}
/> />
{description ? ( {description ? (
<span className="block text-[10px] font-semibold text-[#747474]"> <span className="block text-[10px] font-semibold text-[#747474]">

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

@ -1,9 +1,15 @@
"use client"; "use client";
import { IoAlert } from "react-icons/io5"; import { IoAlert } from "react-icons/io5";
import { getQuestionListItems } from "@/data/question-data";
import {
getQuestionListItems,
getRequiredQuestionsCount,
isQuestionListItemVisibleForProfile,
} from "@/data/question-data";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { useMarriageSectionsQuery } from "@/hooks/marriage/use-sections"; import { useMarriageSectionsQuery } from "@/hooks/marriage/use-sections";
import { useI18n } from "@/i18n/provider"; import { useI18n } from "@/i18n/provider";
import { useMemo } from "react";
type RequiredStep = { type RequiredStep = {
slug: string; slug: string;
@ -23,20 +29,46 @@ function getRequiredStepStats(steps: RequiredStep[]) {
export default function RequiredStepsCard() { export default function RequiredStepsCard() {
const { dictionary: t, locale } = useI18n(); const { dictionary: t, locale } = useI18n();
const { data: profile } = useMarriageProfileQuery();
const { data: sections } = useMarriageSectionsQuery(); const { data: sections } = useMarriageSectionsQuery();
const fallbackRequiredSteps: RequiredStep[] = getQuestionListItems(
locale,
).map((item) => ({
const profileContext = useMemo(
() => ({
gender: profile?.gender,
age: profile?.age,
}),
[profile?.gender, profile?.age],
);
const fallbackRequiredSteps: RequiredStep[] = getQuestionListItems(locale)
.filter((item) =>
isQuestionListItemVisibleForProfile(item, {
gender: profile?.gender,
}),
)
.map((item) => ({
slug: item.slug, slug: item.slug,
required: Boolean(item.required), required: Boolean(item.required),
progress: item.progress, progress: item.progress,
})); }));
const steps = const steps =
sections?.map((section) => ({
sections?.map((section) => {
const requiredCount = getRequiredQuestionsCount(
section.slug,
profileContext,
locale,
);
const progress =
requiredCount > 0
? Math.min(100, Math.round((section.current_step / requiredCount) * 100))
: 100;
return {
slug: section.slug, slug: section.slug,
required: section.is_required, required: section.is_required,
progress: section.completion_percent,
})) ?? fallbackRequiredSteps;
progress,
};
}) ?? fallbackRequiredSteps;
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;

95
src/data/question-data.ts

@ -1,3 +1,4 @@
import type { MarriageGender } from "@/hooks/marriage/types";
import { defaultLocale, type Locale } from "@/i18n/config"; import { defaultLocale, type Locale } from "@/i18n/config";
import enQuestions from "@/i18n/locales/en/questions.json"; import enQuestions from "@/i18n/locales/en/questions.json";
import faQuestions from "@/i18n/locales/fa/questions.json"; import faQuestions from "@/i18n/locales/fa/questions.json";
@ -21,6 +22,12 @@ type QuestionExtras = {
options: string[]; options: string[];
}; };
type QuestionAudienceRule = {
genders?: MarriageGender[];
maxAge?: number;
minAge?: number;
};
export type QuestionField = { export type QuestionField = {
title: string; title: string;
type: string; type: string;
@ -29,6 +36,8 @@ export type QuestionField = {
description: string; description: string;
tooltip: string; tooltip: string;
extras: QuestionExtras; extras: QuestionExtras;
audience?: QuestionAudienceRule;
requiredWhen?: QuestionAudienceRule;
}; };
export type QuestionListItem = { export type QuestionListItem = {
@ -44,6 +53,7 @@ export type QuestionListItem = {
checkpoints: readonly string[]; checkpoints: readonly string[];
tooltip: string; tooltip: string;
questions: readonly QuestionField[]; questions: readonly QuestionField[];
audience?: QuestionAudienceRule;
}; };
type RawQuestionListItem = { type RawQuestionListItem = {
@ -56,6 +66,7 @@ type RawQuestionListItem = {
progress: number; progress: number;
description: string; description: string;
questions: QuestionField[]; questions: QuestionField[];
audience?: QuestionAudienceRule;
}; };
const iconMap: Record<string, QuestionCardIcon> = { const iconMap: Record<string, QuestionCardIcon> = {
@ -97,3 +108,87 @@ export function getQuestionListItemBySlug(
) { ) {
return getQuestionListItems(locale).find((item) => item.slug === slug); return getQuestionListItems(locale).find((item) => item.slug === slug);
} }
function matchesAudienceRule(
rule: QuestionAudienceRule | undefined,
profile: {
age?: number | null;
gender?: MarriageGender | null;
},
) {
if (!rule) {
return true;
}
if (
rule.genders?.length &&
(!profile.gender || !rule.genders.includes(profile.gender))
) {
return false;
}
if (
typeof rule.minAge === "number" &&
(profile.age == null || profile.age < rule.minAge)
) {
return false;
}
if (
typeof rule.maxAge === "number" &&
(profile.age == null || profile.age > rule.maxAge)
) {
return false;
}
return true;
}
export function isQuestionListItemVisibleForProfile(
item: QuestionListItem,
profile: {
age?: number | null;
gender?: MarriageGender | null;
},
) {
return matchesAudienceRule(item.audience, profile);
}
export function isQuestionVisibleForProfile(
question: QuestionField,
profile: {
age?: number | null;
gender?: MarriageGender | null;
},
) {
return matchesAudienceRule(question.audience, profile);
}
export function isQuestionRequiredForProfile(
question: QuestionField,
profile: {
age?: number | null;
gender?: MarriageGender | null;
},
) {
return (
question.required ||
(Boolean(question.requiredWhen) &&
matchesAudienceRule(question.requiredWhen, profile))
);
}
export function getRequiredQuestionsCount(
slug: string,
profile: {
age?: number | null;
gender?: MarriageGender | null;
},
locale: Locale = defaultLocale,
) {
const item = getQuestionListItemBySlug(slug, locale);
if (!item) return 0;
return item.questions.filter((q) => isQuestionRequiredForProfile(q, profile))
.length;
}

12
src/hooks/marriage/types.ts

@ -25,7 +25,17 @@ export type MarriageCaseStatus =
| "finalized" | "finalized"
| "dismissed"; | "dismissed";
export type MarriageFieldValue = string | number | boolean | null;
export type MarriagePhoneFieldValue = {
countryCode: string;
phoneNumber: string;
};
export type MarriageFieldValue =
| string
| number
| boolean
| MarriagePhoneFieldValue
| null;
export type MarriageField = { export type MarriageField = {
key: string; key: string;

4
src/i18n/dictionaries.ts

@ -1,4 +1,4 @@
import type { Locale } from "@/i18n/config";
import type { Locale } from "@/i18n/config";
export const dictionaries = { export const dictionaries = {
en: { en: {
@ -13,6 +13,7 @@ export const dictionaries = {
back: "Back", back: "Back",
required: "Required", required: "Required",
estimateTime: "Estimate time", estimateTime: "Estimate time",
rangeError: "The value entered seems incorrect. Please provide a realistic value.",
}, },
intro: { intro: {
imageAlt: "heavenly marriage", imageAlt: "heavenly marriage",
@ -131,6 +132,7 @@ export const dictionaries = {
back: "بازگشت", back: "بازگشت",
required: "ضروری", required: "ضروری",
estimateTime: "زمان تقریبی", estimateTime: "زمان تقریبی",
rangeError: "مقدار وارد شده صحیح به نظر نمی‌رسد. لطفاً یک عدد واقعی وارد کنید.",
}, },
intro: { intro: {
imageAlt: "ازدواج آسمانی", imageAlt: "ازدواج آسمانی",

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

@ -1,6 +1,6 @@
[ [
{ {
"title": "Personal Info",
"title": "Personal and identity details",
"icon": "user-circle", "icon": "user-circle",
"slug": "personal_info", "slug": "personal_info",
"required": true, "required": true,
@ -13,7 +13,6 @@
"title": "Full Name", "title": "Full Name",
"type": "text", "type": "text",
"required": true, "required": true,
"description": "Enter your legal full name as it appears on official documents.",
"tooltip": "Use your passport or national ID spelling.", "tooltip": "Use your passport or national ID spelling.",
"extras": { "extras": {
"placeHolder": "e.g. Sara Ahmadi", "placeHolder": "e.g. Sara Ahmadi",
@ -21,11 +20,21 @@
"options": [] "options": []
} }
}, },
{
"title": "Last Name",
"type": "text",
"required": true,
"tooltip": "Use the same spelling as your official records.",
"extras": {
"placeHolder": "e.g. Ahmadi",
"range": [0, 0],
"options": []
}
},
{ {
"title": "Date of Birth", "title": "Date of Birth",
"type": "date", "type": "date",
"required": true, "required": true,
"description": "Select your date of birth.",
"tooltip": "Make sure the date matches your official record.", "tooltip": "Make sure the date matches your official record.",
"extras": { "extras": {
"placeHolder": "YYYY-MM-DD", "placeHolder": "YYYY-MM-DD",
@ -34,26 +43,355 @@
} }
}, },
{ {
"title": "Gender",
"type": "radio",
"required": true,
"description": "Choose the gender option that applies to you.",
"tooltip": "Only one option can be selected.",
"title": "Age",
"type": "number",
"required": false,
"tooltip": "No manual entry is required.",
"extras": { "extras": {
"placeHolder": "", "placeHolder": "",
"range": [0, 0], "range": [0, 0],
"options": ["Female", "Male", "Prefer not to say"]
"options": []
} }
}, },
{ {
"title": "Age",
"title": "Birth City",
"type": "text",
"required": true,
"tooltip": "Use the city name in English.",
"extras": {
"placeHolder": "e.g. Tehran",
"range": [0, 0],
"options": []
}
}
]
},
{
"title": "Contact, Residence, and Family Communication",
"icon": "file-text",
"slug": "contact_residence_family_communication",
"required": true,
"estimateTime": "20 minutes",
"tooltip": "Contact and current residence details.",
"progress": 0,
"description": "Captures contact details, current residence, and family communication information.",
"questions": [
{
"title": "Liaison's Full Name",
"type": "text",
"required": false,
"requiredWhen": {
"genders": ["female"],
"maxAge": 26
},
"tooltip": "Enter the full name of your liaison or contact person.",
"extras": {
"placeHolder": "e.g. Sara Ahmadi",
"range": [0, 0],
"options": []
}
},
{
"title": "Relationship to Liaison",
"type": "radio",
"required": false,
"requiredWhen": {
"genders": ["female"],
"maxAge": 26
},
"tooltip": "Select your relationship with the liaison.",
"extras": {
"placeHolder": "Select one option",
"range": [0, 0],
"options": [
"Father",
"Mother",
"Brother",
"Sister",
"Paternal / Maternal Uncle",
"Paternal / Maternal Aunt",
"Trusted Family Friend",
"Religious / Clerical Sponsor",
"Trusted Social Sponsor"
]
}
},
{
"title": "Liaison's Contact Number with Country Code",
"type": "phone",
"required": false,
"requiredWhen": {
"genders": ["female"],
"maxAge": 26
},
"tooltip": "Enter the contact number of your liaison including country code.",
"extras": {
"placeHolder": "e.g. +98 912 123 4567",
"range": [0, 0],
"options": []
}
}
]
},
{
"title": "Physical Appearance, Health, and Physical Activity",
"icon": "heart-handshake",
"slug": "appearance_health_activity",
"required": true,
"estimateTime": "15 minutes",
"tooltip": "Physical appearance and health details.",
"progress": 0,
"description": "Collects details about your physical appearance, health status, and mental well-being.",
"questions": [
{
"title": "Height in Centimeters",
"type": "number", "type": "number",
"required": true, "required": true,
"description": "Provide your current age in years.",
"tooltip": "Numbers only.",
"tooltip": "Enter your height in cm.",
"extras": {
"placeHolder": "e.g. 175",
"range": [50, 300],
"options": []
}
},
{
"title": "Weight in Kilograms",
"type": "number",
"required": true,
"tooltip": "Enter your weight in kg.",
"extras": {
"placeHolder": "e.g. 70",
"range": [20, 600],
"options": []
}
},
{
"title": "Physical Health Status",
"type": "radio",
"required": true,
"tooltip": "Select your current physical health status.",
"extras": {
"placeHolder": "Select one option",
"range": [0, 0],
"options": [
"I am in perfect health.",
"I have a specific or chronic illness.",
"I have a physical deformity, disability, or limitation."
]
}
},
{
"title": "Physical Health Description",
"type": "text",
"required": false,
"tooltip": "Provide more details if you have any health conditions or limitations.",
"extras": {
"placeHolder": "Enter details here...",
"range": [0, 0],
"options": []
}
},
{
"title": "Mental Health Status",
"type": "radio",
"required": true,
"tooltip": "Select your current mental health status.",
"extras": { "extras": {
"placeHolder": "e.g. 29",
"range": [18, 80],
"placeHolder": "Select one option",
"range": [0, 0],
"options": [
"I have no specific problems.",
"I have a history of counseling and treatment.",
"I am currently undergoing counseling and treatment."
]
}
},
{
"title": "Use of Permanent Medications",
"type": "text",
"required": false,
"tooltip": "List any medications you take regularly.",
"extras": {
"placeHolder": "e.g. Insulin, etc.",
"range": [0, 0],
"options": []
}
}
]
},
{
"title": "Education, Career, and Economic Status",
"icon": "school",
"slug": "education_career_economic_status",
"required": true,
"estimateTime": "25 minutes",
"tooltip": "Education, career, and financial details.",
"progress": 0,
"description": "Collects information about your educational background, employment status, and financial situation.",
"questions": [
{
"title": "Highest Level of Education",
"type": "radio",
"required": true,
"tooltip": "Select your highest completed level of education.",
"extras": {
"placeHolder": "Select one option",
"range": [0, 0],
"options": [
"Below High School",
"High School Diploma",
"Associate Degree",
"Professional Certificate",
"Technical or Vocational Training",
"Bachelor’s Degree",
"Master’s Degree",
"Doctorate and Above",
"Religious / Clerical Studies"
]
}
},
{
"title": "Field of Study",
"type": "text",
"required": false,
"tooltip": "Enter your major or field of study.",
"extras": {
"placeHolder": "e.g. Computer Science",
"range": [0, 0],
"options": []
}
},
{
"title": "Employment Status",
"type": "radio",
"required": true,
"tooltip": "Select your current employment status.",
"extras": {
"placeHolder": "Select one option",
"range": [0, 0],
"options": [
"Full-time Employed",
"Part-time Employed",
"Self-employed / Freelancer",
"Entrepreneur / Business Owner",
"Student",
"Working Student",
"Student and Job Seeking",
"Job Seeking / Unemployed",
"Homemaker",
"Retired"
]
}
},
{
"title": "Job Title",
"type": "text",
"required": false,
"tooltip": "Enter your current job title.",
"extras": {
"placeHolder": "e.g. Software Engineer",
"range": [0, 0],
"options": []
}
},
{
"title": "Work Location",
"type": "text",
"required": false,
"tooltip": "Enter the city or company where you work (optional).",
"extras": {
"placeHolder": "e.g. Tehran, Remote",
"range": [0, 0],
"options": []
}
},
{
"title": "Monthly Income",
"type": "text",
"required": true,
"tooltip": "Enter your approximate monthly income with currency.",
"extras": {
"placeHolder": "e.g. 2500 USD, 1800 EUR",
"range": [0, 0],
"options": []
}
},
{
"title": "Overall Financial Status",
"type": "radio",
"required": true,
"tooltip": "Select the option that best describes your financial situation.",
"extras": {
"placeHolder": "Select one option",
"range": [0, 0],
"options": [
"Stable and reliable income",
"Income is variable",
"At the start of career and financial path",
"Partially supported by family",
"No independent income"
]
}
},
{
"title": "Ability to Support Marriage Expenses",
"type": "radio",
"required": true,
"tooltip": "Rate your ability to provide for a joint household.",
"extras": {
"placeHolder": "Select one option",
"range": [0, 0],
"options": [
"Fully able to support expenses",
"Able to support the main portion of expenses",
"Need future partner's financial participation",
"Currently building suitable financial conditions",
"Depends on the country and future residence"
]
}
},
{
"title": "Current Housing Status",
"type": "radio",
"required": true,
"tooltip": "Select your current living arrangement.",
"extras": {
"placeHolder": "Select one option",
"range": [0, 0],
"options": [
"Homeowner",
"Renting independently",
"Living with family / parents",
"Dormitory / Student housing",
"Organizational housing",
"Temporary conditions"
]
}
},
{
"title": "Post-Marriage Housing Plan",
"type": "radio",
"required": true,
"tooltip": "Select your plan or ability for housing after marriage.",
"extras": {
"placeHolder": "Select one option",
"range": [0, 0],
"options": [
"Have a personal home for living together",
"Can buy a home",
"Will likely rent at the start",
"May temporarily live with family at the start"
]
}
},
{
"title": "Additional Comments on Economic and Housing Status",
"type": "text",
"required": false,
"tooltip": "Briefly explain any special conditions regarding work, income, housing, or migration.",
"extras": {
"placeHolder": "Enter your explanation here...",
"range": [0, 0],
"options": [] "options": []
} }
} }

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

@ -1,6 +1,6 @@
[ [
{ {
"title": "Personal Info",
"title": "Personal and identity details",
"icon": "user-circle", "icon": "user-circle",
"slug": "personal_info", "slug": "personal_info",
"required": true, "required": true,
@ -13,7 +13,6 @@
"title": "Full Name", "title": "Full Name",
"type": "text", "type": "text",
"required": true, "required": true,
"description": "Enter your legal full name as it appears on official documents.",
"tooltip": "Use your passport or national ID spelling.", "tooltip": "Use your passport or national ID spelling.",
"extras": { "extras": {
"placeHolder": "e.g. Sara Ahmadi", "placeHolder": "e.g. Sara Ahmadi",
@ -21,11 +20,21 @@
"options": [] "options": []
} }
}, },
{
"title": "Last Name",
"type": "text",
"required": true,
"tooltip": "Use the same spelling as your official records.",
"extras": {
"placeHolder": "e.g. Ahmadi",
"range": [0, 0],
"options": []
}
},
{ {
"title": "Date of Birth", "title": "Date of Birth",
"type": "date", "type": "date",
"required": true, "required": true,
"description": "Select your date of birth.",
"tooltip": "Make sure the date matches your official record.", "tooltip": "Make sure the date matches your official record.",
"extras": { "extras": {
"placeHolder": "YYYY-MM-DD", "placeHolder": "YYYY-MM-DD",
@ -34,26 +43,355 @@
} }
}, },
{ {
"title": "Gender",
"type": "radio",
"required": true,
"description": "Choose the gender option that applies to you.",
"tooltip": "Only one option can be selected.",
"title": "Age",
"type": "number",
"required": false,
"tooltip": "No manual entry is required.",
"extras": { "extras": {
"placeHolder": "", "placeHolder": "",
"range": [0, 0], "range": [0, 0],
"options": ["Female", "Male", "Prefer not to say"]
"options": []
} }
}, },
{ {
"title": "Age",
"title": "Birth City",
"type": "text",
"required": true,
"tooltip": "Use the city name in English.",
"extras": {
"placeHolder": "e.g. Tehran",
"range": [0, 0],
"options": []
}
}
]
},
{
"title": "اطلاعات تماس، سکونت و ارتباطات خانوادگی",
"icon": "file-text",
"slug": "contact_residence_family_communication",
"required": true,
"estimateTime": "20 دقیقه",
"tooltip": "جزئیات تماس و محل سکونت فعلی.",
"progress": 0,
"description": "ثبت جزئیات تماس، محل سکونت فعلی و اطلاعات ارتباطات خانوادگی.",
"questions": [
{
"title": "نام و نام خانوادگی رابط",
"type": "text",
"required": false,
"requiredWhen": {
"genders": ["female"],
"maxAge": 26
},
"tooltip": "نام کامل رابط یا شخص مورد اعتماد خود را وارد کنید.",
"extras": {
"placeHolder": "مثلاً سارا احمدی",
"range": [0, 0],
"options": []
}
},
{
"title": "نسبت رابط با شما",
"type": "radio",
"required": false,
"requiredWhen": {
"genders": ["female"],
"maxAge": 26
},
"tooltip": "نسبت این شخص با خود را انتخاب کنید.",
"extras": {
"placeHolder": "انتخاب کنید",
"range": [0, 0],
"options": [
"پدر",
"مادر",
"برادر",
"خواهر",
"عمو / دایی",
"خاله / عمه",
"دوست خانوادگی معتمد",
"معرف مذهبی / روحانی",
"معرف اجتماعی معتمد"
]
}
},
{
"title": "شماره تماس رابط با کد کشور",
"type": "phone",
"required": false,
"requiredWhen": {
"genders": ["female"],
"maxAge": 26
},
"tooltip": "شماره تماس مستقیم رابط خود را با فرمت بین‌المللی وارد کنید.",
"extras": {
"placeHolder": "مثلاً +98 912 123 4567",
"range": [0, 0],
"options": []
}
}
]
},
{
"title": "ویژگی‌های ظاهری، سلامت و فعالیت بدنی",
"icon": "heart-handshake",
"slug": "appearance_health_activity",
"required": true,
"estimateTime": "15 دقیقه",
"tooltip": "جزئیات ظاهر فیزیکی و وضعیت سلامت.",
"progress": 0,
"description": "ثبت جزئیات مربوط به ظاهر فیزیکی، وضعیت سلامت جسمانی و روانی شما.",
"questions": [
{
"title": "قد به سانتی‌متر",
"type": "number",
"required": true,
"tooltip": "قد خود را به سانتی‌متر وارد کنید.",
"extras": {
"placeHolder": "مثلاً ۱۷۵",
"range": [50, 300],
"options": []
}
},
{
"title": "وزن به کیلوگرم",
"type": "number", "type": "number",
"required": true, "required": true,
"description": "Provide your current age in years.",
"tooltip": "Numbers only.",
"tooltip": "وزن خود را به کیلوگرم وارد کنید.",
"extras": {
"placeHolder": "مثلاً ۷۰",
"range": [20, 600],
"options": []
}
},
{
"title": "وضعیت سلامت جسمانی",
"type": "radio",
"required": true,
"tooltip": "وضعیت سلامت جسمانی فعلی خود را انتخاب کنید.",
"extras": {
"placeHolder": "یک گزینه را انتخاب کنید",
"range": [0, 0],
"options": [
"در سلامت کامل هستم.",
"بیماری خاص یا مزمن دارم.",
"نقص عضو، معلولیت یا محدودیت جسمی دارم."
]
}
},
{
"title": "توضیحات سلامت جسمانی",
"type": "text",
"required": false,
"tooltip": "اگر بیماری یا محدودیت خاصی دارید، توضیحات بیشتری ارائه دهید.",
"extras": {
"placeHolder": "توضیحات را اینجا وارد کنید...",
"range": [0, 0],
"options": []
}
},
{
"title": "وضعیت سلامت روان",
"type": "radio",
"required": true,
"tooltip": "وضعیت سلامت روان فعلی خود را انتخاب کنید.",
"extras": {
"placeHolder": "یک گزینه را انتخاب کنید",
"range": [0, 0],
"options": [
"مشکل خاصی ندارم.",
"سابقه مشاوره و درمان داشته‌ام.",
"در حال حاضر تحت مشاوره و درمان هستم."
]
}
},
{
"title": "استفاده از داروهای دائمی",
"type": "text",
"required": false,
"tooltip": "لیست داروهایی که به طور منظم مصرف می‌کنید را بنویسید.",
"extras": {
"placeHolder": "مثلاً انسولین و غیره",
"range": [0, 0],
"options": []
}
}
]
},
{
"title": "تحصیلات، شغل و وضعیت اقتصادی",
"icon": "school",
"slug": "education_career_economic_status",
"required": true,
"estimateTime": "25 دقیقه",
"tooltip": "جزئیات تحصیلات، شغل و وضعیت مالی.",
"progress": 0,
"description": "ثبت اطلاعات مربوط به پیشینه تحصیلی، وضعیت اشتغال و شرایط مالی شما.",
"questions": [
{
"title": "بالاترین سطح تحصیلات",
"type": "radio",
"required": true,
"tooltip": "آخرین مدرک تحصیلی تکمیل شده خود را انتخاب کنید.",
"extras": {
"placeHolder": "یک گزینه را انتخاب کنید",
"range": [0, 0],
"options": [
"زیر دیپلم",
"دیپلم / High School",
"کاردانی / Associate",
"گواهینامه مهارت حرفه‌ای / Certificate",
"آموزش فنی یا مهارتی",
"کارشناسی / Bachelor’s Degree",
"کارشناسی ارشد / Master’s Degree",
"دکتری و بالاتر",
"تحصیلات حوزوی / علوم دینی"
]
}
},
{
"title": "رشته تحصیلی",
"type": "text",
"required": false,
"tooltip": "رشته یا تخصص تحصیلی خود را وارد کنید.",
"extras": {
"placeHolder": "مثلاً مهندسی کامپیوتر",
"range": [0, 0],
"options": []
}
},
{
"title": "وضعیت اشتغال",
"type": "radio",
"required": true,
"tooltip": "وضعیت فعلی اشتغال خود را انتخاب کنید.",
"extras": {
"placeHolder": "یک گزینه را انتخاب کنید",
"range": [0, 0],
"options": [
"شاغل تمام‌وقت",
"شاغل پاره‌وقت",
"خویش‌فرما / فریلنسر",
"کارآفرین / صاحب کسب‌وکار",
"دانشجو",
"دانشجو و شاغل",
"دانشجو و جویای کار",
"جویای کار / بیکار",
"خانه‌دار",
"بازنشسته"
]
}
},
{
"title": "عنوان شغلی",
"type": "text",
"required": false,
"tooltip": "عنوان شغلی فعلی خود را وارد کنید.",
"extras": {
"placeHolder": "مثلاً مهندس نرم‌افزار",
"range": [0, 0],
"options": []
}
},
{
"title": "محل فعالیت",
"type": "text",
"required": false,
"tooltip": "شهر یا شرکت محل فعالیت خود را وارد کنید (اختیاری).",
"extras": { "extras": {
"placeHolder": "e.g. 29",
"range": [18, 80],
"placeHolder": "مثلاً تهران، دورکاری",
"range": [0, 0],
"options": []
}
},
{
"title": "میزان درآمد ماهانه",
"type": "text",
"required": true,
"tooltip": "میزان درآمد ماهانه تقریبی خود را با ذکر واحد پول وارد کنید.",
"extras": {
"placeHolder": "مثلاً 2500 USD، 1800 EUR",
"range": [0, 0],
"options": []
}
},
{
"title": "وضعیت مالی کلی",
"type": "radio",
"required": true,
"tooltip": "گزینه‌ای که بهترین توصیف از وضعیت مالی شماست را انتخاب کنید.",
"extras": {
"placeHolder": "یک گزینه را انتخاب کنید",
"range": [0, 0],
"options": [
"درآمد پایدار و قابل اتکا دارم.",
"درآمد دارم، اما متغیر است.",
"در ابتدای مسیر شغلی و مالی هستم.",
"فعلاً بخشی از هزینه‌هایم توسط خانواده تأمین می‌شود.",
"فعلاً درآمد مستقل ندارم."
]
}
},
{
"title": "توانایی تأمین هزینه‌های زندگی مشترک",
"type": "radio",
"required": true,
"tooltip": "توانایی خود در تأمین هزینه‌های یک زندگی مشترک را ارزیابی کنید.",
"extras": {
"placeHolder": "یک گزینه را انتخاب کنید",
"range": [0, 0],
"options": [
"توانایی تأمین کامل هزینه‌های زندگی مشترک را دارم.",
"توانایی تأمین بخش اصلی هزینه‌ها را دارم.",
"نیاز به مشارکت مالی همسر آینده دارم.",
"فعلاً در حال ساختن شرایط مالی مناسب هستم.",
"این موضوع بستگی به کشور و محل زندگی آینده دارد."
]
}
},
{
"title": "وضعیت مسکن فعلی",
"type": "radio",
"required": true,
"tooltip": "وضعیت فعلی سکونت خود را انتخاب کنید.",
"extras": {
"placeHolder": "یک گزینه را انتخاب کنید",
"range": [0, 0],
"options": [
"مالک خانه شخصی هستم.",
"مستأجر هستم و مستقل زندگی می‌کنم.",
"همراه خانواده / والدین زندگی می‌کنم.",
"خوابگاه / مسکن دانشجویی",
"مسکن سازمانی",
"فعلاً شرایط موقت دارم."
]
}
},
{
"title": "برنامه یا توانایی تأمین مسکن بعد از ازدواج",
"type": "radio",
"required": true,
"tooltip": "برنامه یا توانایی خود برای مسکن بعد از ازدواج را انتخاب کنید.",
"extras": {
"placeHolder": "یک گزینه را انتخاب کنید",
"range": [0, 0],
"options": [
"خانه شخصی دارم و امکان زندگی مشترک در آن وجود دارد.",
"امکان خرید خانه دارم.",
"در ابتدای ازدواج احتمالاً مستأجر خواهیم بود.",
"در ابتدای ازدواج ممکن است موقتاً با خانواده زندگی کنیم."
]
}
},
{
"title": "توضیح تکمیلی درباره وضعیت اقتصادی و مسکن",
"type": "text",
"required": false,
"tooltip": "اگر شرایط خاصی درباره کار، درآمد، اجاره، خرید خانه، مهاجرت یا محل زندگی آینده دارید، کوتاه توضیح دهید.",
"extras": {
"placeHolder": "توضیحات خود را اینجا وارد کنید...",
"range": [0, 0],
"options": [] "options": []
} }
} }

62
src/lib/http.ts

@ -1,10 +1,68 @@
import axios from "axios";
import axios, { type InternalAxiosRequestConfig } from "axios";
const PROXY_PATH_PARAM = "__proxyPath";
const LOCALHOST_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1"]);
function isAbsoluteUrl(url: string) {
return /^[a-z][a-z\d+\-.]*:\/\//i.test(url);
}
function isLocalhost() {
if (typeof window === "undefined") {
return false;
}
return LOCALHOST_HOSTNAMES.has(window.location.hostname);
}
function withProxyPathParam(
params: InternalAxiosRequestConfig["params"],
proxyPath: string,
) {
if (params instanceof URLSearchParams) {
const nextParams = new URLSearchParams(params);
nextParams.set(PROXY_PATH_PARAM, proxyPath);
return nextParams;
}
if (typeof params === "string") {
const nextParams = new URLSearchParams(params);
nextParams.set(PROXY_PATH_PARAM, proxyPath);
return nextParams;
}
return {
...(params && typeof params === "object" ? params : {}),
[PROXY_PATH_PARAM]: proxyPath,
};
}
export function getApiRequestUrl(path: string) {
if (!isAbsoluteUrl(path) && isLocalhost()) {
const searchParams = new URLSearchParams({
[PROXY_PATH_PARAM]: path,
});
return `/api/proxy?${searchParams.toString()}`;
}
return `${process.env.NEXT_PUBLIC_API_BASE_URL}${path}`;
}
export const http = axios.create({ export const http = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_BASE_URL,
baseURL: isLocalhost() ? "/api/proxy" : process.env.NEXT_PUBLIC_API_BASE_URL,
headers: { headers: {
Accept: "application/json", Accept: "application/json",
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
withCredentials: true, withCredentials: true,
}); });
http.interceptors.request.use((config) => {
if (isLocalhost() && config.url && !isAbsoluteUrl(config.url)) {
config.params = withProxyPathParam(config.params, config.url);
config.url = "";
}
return config;
});
Loading…
Cancel
Save