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
- {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