diff --git a/package-lock.json b/package-lock.json index 86713a2..78826a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@tanstack/react-query": "^5.100.5", "axios": "^1.15.2", + "google-libphonenumber": "^3.2.44", "next": "16.2.3", "react": "19.2.4", "react-dom": "19.2.4", @@ -18,6 +19,7 @@ "devDependencies": { "@biomejs/biome": "2.2.0", "@tailwindcss/postcss": "^4", + "@types/google-libphonenumber": "^7.4.30", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -1167,6 +1169,13 @@ "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": { "version": "20.19.39", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", @@ -1459,6 +1468,15 @@ "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": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", diff --git a/package.json b/package.json index 98b44ba..7aff5fd 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "dependencies": { "@tanstack/react-query": "^5.100.5", "axios": "^1.15.2", + "google-libphonenumber": "^3.2.44", "next": "16.2.3", "react": "19.2.4", "react-dom": "19.2.4", @@ -20,6 +21,7 @@ "devDependencies": { "@biomejs/biome": "2.2.0", "@tailwindcss/postcss": "^4", + "@types/google-libphonenumber": "^7.4.30", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/src/app/api/proxy/route.ts b/src/app/api/proxy/route.ts new file mode 100644 index 0000000..bd0ceaa --- /dev/null +++ b/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, +}; diff --git a/src/app/new-match/page.tsx b/src/app/new-match/page.tsx index 6f26a27..ebd060d 100644 --- a/src/app/new-match/page.tsx +++ b/src/app/new-match/page.tsx @@ -11,6 +11,7 @@ import type { MarriageField, MarriageFieldValue, MarriageMatchSummary, + MarriagePhoneFieldValue, } from "@/hooks/marriage/types"; import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; import { useI18n } from "@/i18n/provider"; @@ -54,6 +55,10 @@ function formatFieldValue(value: MarriageFieldValue) { return null; } + if (isMarriagePhoneFieldValue(value)) { + return `+${value.countryCode}${value.phoneNumber}`; + } + if (typeof value === "boolean") { return value ? "Yes" : "No"; } @@ -61,6 +66,21 @@ function formatFieldValue(value: MarriageFieldValue) { return String(value); } +function isMarriagePhoneFieldValue( + value: unknown, +): value is MarriagePhoneFieldValue { + if (!value || typeof value !== "object") { + return false; + } + + const phoneValue = value as Partial; + + return ( + typeof phoneValue.countryCode === "string" && + typeof phoneValue.phoneNumber === "string" + ); +} + function titleFromKey(key: string) { return key .replace(/^q\d+[_-]?/i, "") diff --git a/src/app/new-match/profile/page.tsx b/src/app/new-match/profile/page.tsx index 6bca6ae..072d8aa 100644 --- a/src/app/new-match/profile/page.tsx +++ b/src/app/new-match/profile/page.tsx @@ -15,6 +15,7 @@ import type { MarriageField, MarriageFieldValue, MarriageGender, + MarriagePhoneFieldValue, } from "@/hooks/marriage/types"; import { useRespondToMarriageCaseMutation } from "@/hooks/marriage/use-case-respond"; import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; @@ -26,6 +27,10 @@ function formatFieldValue(value: MarriageFieldValue) { return null; } + if (isMarriagePhoneFieldValue(value)) { + return `+${value.countryCode}${value.phoneNumber}`; + } + if (typeof value === "boolean") { return value ? "Yes" : "No"; } @@ -33,6 +38,21 @@ function formatFieldValue(value: MarriageFieldValue) { return String(value); } +function isMarriagePhoneFieldValue( + value: unknown, +): value is MarriagePhoneFieldValue { + if (!value || typeof value !== "object") { + return false; + } + + const phoneValue = value as Partial; + + return ( + typeof phoneValue.countryCode === "string" && + typeof phoneValue.phoneNumber === "string" + ); +} + function titleFromKey(key: string) { return key .replace(/^q\d+[_-]?/i, "") @@ -281,14 +301,18 @@ export default function NewMatchProfilePage() { className="text-[25px] leading-none text-white/80" style={{ letterSpacing: "-2.3px", fontWeight: "1000" }} > - {profile?.match_summary?.public_info.find( - (field) => field.key === "q1_full_name", - )?.value || "Name not available"} + {formatFieldValue( + profile?.match_summary?.public_info.find( + (field) => field.key === "q1_full_name", + )?.value ?? null, + ) || "Name not available"}

- {profile?.match_summary?.public_info.find( - (field) => field.key === "q1_full_name", - )?.value || "Name not available"} + {formatFieldValue( + profile?.match_summary?.public_info.find( + (field) => field.key === "q1_full_name", + )?.value ?? null, + ) || "Name not available"}

diff --git a/src/app/questions-list/[slug]/answer-pace-sheet.tsx b/src/app/questions-list/[slug]/answer-pace-sheet.tsx index 9995b5d..ce9f115 100644 --- a/src/app/questions-list/[slug]/answer-pace-sheet.tsx +++ b/src/app/questions-list/[slug]/answer-pace-sheet.tsx @@ -6,7 +6,7 @@ import { hasQuestionAnswerValue, } from "@/components/questions/question-answer-storage"; 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"; type AnswerPaceSheetProps = { @@ -34,7 +34,23 @@ function isMarriageField(value: unknown): value is MarriageField { (field.value === null || typeof field.value === "string" || 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; + + return ( + typeof phoneValue.countryCode === "string" && + typeof phoneValue.phoneNumber === "string" ); } diff --git a/src/app/questions-list/[slug]/page.tsx b/src/app/questions-list/[slug]/page.tsx index 2d40cf3..3f5fb8d 100644 --- a/src/app/questions-list/[slug]/page.tsx +++ b/src/app/questions-list/[slug]/page.tsx @@ -1,29 +1,12 @@ 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 { getQuestionListItemBySlug, getQuestionListItems, - type QuestionField, } from "@/data/question-data"; import { defaultLocale, isLocale, locales } from "@/i18n/config"; import { getDictionary } from "@/i18n/dictionaries"; -import AnswerPaceSheet from "./answer-pace-sheet"; +import QuestionDetailClient from "./question-detail-client"; export function generateStaticParams() { const slugs = new Set( @@ -42,47 +25,6 @@ type QuestionDetailPageProps = { }>; }; -function renderQuestion(question: QuestionField, questionIndex: number) { - switch (question.type) { - case "button": - return ( - - ); - case "date": - return ; - case "dropdown": - return ( - - ); - case "file": - return ; - case "number": - return ( - - ); - case "phone": - return ( - - ); - case "photo": - return ( - - ); - case "radio": - return ( - - ); - case "slider": - return ( - - ); - case "text": - return ; - default: - return null; - } -} - export default async function QuestionDetailPage({ params, }: QuestionDetailPageProps) { @@ -100,49 +42,15 @@ export default async function QuestionDetailPage({ } return ( - <> - - - - -
- -
- -

{item.title}

- -
-
- -
- - {item.questions.map((question, questionIndex) => ( -
- {renderQuestion(question, questionIndex)} -
- ))} -
-
-
-
- + ); } diff --git a/src/app/questions-list/[slug]/question-detail-client.tsx b/src/app/questions-list/[slug]/question-detail-client.tsx new file mode 100644 index 0000000..a908ad3 --- /dev/null +++ b/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 ( + + ); + case "date": + return ; + case "dropdown": + return ( + + ); + case "file": + return ; + case "number": + if ( + question.title === "Age" && + dobQuestion && + dobQuestionIndex !== undefined + ) { + return ( + + ); + } + + return ( + + ); + case "phone": + return ( + + ); + case "photo": + return ( + + ); + case "radio": + return ( + + ); + case "slider": + return ( + + ); + case "text": + return ( + + ); + 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 ( + <> + + + + +
+ +
+ +

+ {item.title} +

+ +
+
+ +
+ + {visibleQuestions.map((question, questionIndex) => ( +
+ {renderQuestion( + question, + questionIndex, + dobQuestion, + dobQuestionIndex, + )} +
+ ))} +
+
+
+
+ + ); +} diff --git a/src/app/questions-list/page.tsx b/src/app/questions-list/page.tsx index dafc4de..6a54b88 100644 --- a/src/app/questions-list/page.tsx +++ b/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 { getQuestionListItems, + getRequiredQuestionsCount, + isQuestionListItemVisibleForProfile, type QuestionListItem, } from "@/data/question-data"; import { useStartMarriageMatchMutation } from "@/hooks/marriage/use-match-start"; @@ -32,10 +34,24 @@ export default function QuestionsListPage() { }, }); const [isOptionalInfoSheetOpen, setIsOptionalInfoSheetOpen] = useState(false); - const [selectedSection, setSelectedSection] = useState( - null, + const [selectedSection, setSelectedSection] = + useState(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(() => { if (!sections?.length) { return false; @@ -43,8 +59,15 @@ export default function QuestionsListPage() { return sections .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 isProfileSuspended = profileStatus === "suspended"; const canStartMatch = @@ -65,11 +88,20 @@ export default function QuestionsListPage() { const progressBySlug = new Map(); 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; - }, [sections]); + }, [sections, profileContext, locale]); const handleStartMatch = () => { if (!canStartMatch) { return; diff --git a/src/app/request-accepted/page.tsx b/src/app/request-accepted/page.tsx index 31d3b63..349091f 100644 --- a/src/app/request-accepted/page.tsx +++ b/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 SubscriptionRequiredSheet from "@/components/ui/subscription-required-sheet"; 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 { extractHabcoinPaymentUrl, @@ -31,6 +31,12 @@ function sanitizePhoneNumber(value: MarriageField["value"]) { return null; } + if (isMarriagePhoneFieldValue(value)) { + const digits = value.phoneNumber.replace(/\D/g, ""); + + return digits ? `+${value.countryCode}${digits}` : null; + } + const trimmedValue = String(value).trim(); if (!trimmedValue) { @@ -46,6 +52,21 @@ function sanitizePhoneNumber(value: MarriageField["value"]) { return trimmedValue.startsWith("+") ? `+${digits}` : digits; } +function isMarriagePhoneFieldValue( + value: unknown, +): value is MarriagePhoneFieldValue { + if (!value || typeof value !== "object") { + return false; + } + + const phoneValue = value as Partial; + + return ( + typeof phoneValue.countryCode === "string" && + typeof phoneValue.phoneNumber === "string" + ); +} + function titleFromKey(key: string) { return key .replace(/^q\d+[_-]?/i, "") diff --git a/src/components/dev/dev-click-to-component.tsx b/src/components/dev/dev-click-to-component.tsx index ea17ecb..a549c12 100644 --- a/src/components/dev/dev-click-to-component.tsx +++ b/src/components/dev/dev-click-to-component.tsx @@ -77,7 +77,7 @@ export function DevClickToComponent() { IDE_SCHEMES.find(({ matches }) => matches.some((match) => userAgent.includes(match)), )?.createUrl(`${filePath}${positionSuffix}`) ?? - `vscode://file/${filePath}${positionSuffix}`; + `antigravity://file/${filePath}${positionSuffix}`; try { window.location.href = ideUrl; diff --git a/src/components/questions/question-answer-storage.tsx b/src/components/questions/question-answer-storage.tsx index bc2fb3c..1dc1811 100644 --- a/src/components/questions/question-answer-storage.tsx +++ b/src/components/questions/question-answer-storage.tsx @@ -12,15 +12,16 @@ import { } from "react"; import type { QuestionField } from "@/data/question-data"; import { pathParam } from "@/hooks/marriage/path-param"; +import { getApiRequestUrl } from "@/lib/http"; import type { MarriageField, MarriageFieldValue, + MarriagePhoneFieldValue, UpdateMarriageSectionDataPayload, } from "@/hooks/marriage/types"; import { useUpdateMarriageSectionDataMutation } from "@/hooks/marriage/use-section-data"; const STORAGE_VERSION = 1; -const PROXY_PATH_PARAM = "__proxyPath"; type QuestionAnswersByKey = Record; @@ -116,7 +117,21 @@ function isMarriageField(value: unknown): value is MarriageField { (field.value === null || typeof field.value === "string" || 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; + + return ( + typeof phoneValue.countryCode === "string" && + typeof phoneValue.phoneNumber === "string" ); } @@ -243,7 +258,7 @@ function writeStoredAnswers( } 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({ diff --git a/src/components/questions/question-date.tsx b/src/components/questions/question-date.tsx index 05e6f06..376f612 100644 --- a/src/components/questions/question-date.tsx +++ b/src/components/questions/question-date.tsx @@ -25,7 +25,7 @@ export function QuestionDate({ question, questionIndex }: QuestionDateProps) { 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]" /> ); diff --git a/src/components/questions/question-dropdown.tsx b/src/components/questions/question-dropdown.tsx index 157ed37..442a861 100644 --- a/src/components/questions/question-dropdown.tsx +++ b/src/components/questions/question-dropdown.tsx @@ -27,7 +27,7 @@ export function QuestionDropdown({ 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]" >