diff --git a/next-dev-3000.err.log b/next-dev-3000.err.log
new file mode 100644
index 0000000..9f6608a
--- /dev/null
+++ b/next-dev-3000.err.log
@@ -0,0 +1,13 @@
+Error: spawn EPERM
+ at ChildProcess.spawn (node:internal/child_process:421:11)
+ at spawn (node:child_process:796:9)
+ at fork (node:child_process:174:10)
+ at D:\sajjadi\marriage\node_modules\next\dist\cli\next-dev.js:253:45
+ at new Promise ()
+ at startServer (D:\sajjadi\marriage\node_modules\next\dist\cli\next-dev.js:221:16)
+ at runDevServer (D:\sajjadi\marriage\node_modules\next\dist\cli\next-dev.js:355:23)
+ at Module.nextDev (D:\sajjadi\marriage\node_modules\next\dist\cli\next-dev.js:363:11) {
+ errno: -4048,
+ code: 'EPERM',
+ syscall: 'spawn'
+}
diff --git a/next-dev-3000.log b/next-dev-3000.log
new file mode 100644
index 0000000..069451e
--- /dev/null
+++ b/next-dev-3000.log
@@ -0,0 +1,4 @@
+
+> marriage@0.1.0 dev
+> next dev --hostname 127.0.0.1 --port 3000
+
diff --git a/next-dev-3001.err.log b/next-dev-3001.err.log
new file mode 100644
index 0000000..bbf4ae5
--- /dev/null
+++ b/next-dev-3001.err.log
@@ -0,0 +1,8 @@
+⨯ Another next dev server is already running.
+
+- Local: http://localhost:3000
+- PID: 9080
+- Dir: D:\sajjadi\marriage
+- Log: .next\dev\logs\next-development.log
+
+Run taskkill /PID 9080 /F to stop it.
diff --git a/next-dev-3001.log b/next-dev-3001.log
new file mode 100644
index 0000000..0e32152
--- /dev/null
+++ b/next-dev-3001.log
@@ -0,0 +1,10 @@
+
+> marriage@0.1.0 dev
+> next dev --port 3001
+
+▲ Next.js 16.2.3 (Turbopack)
+- Local: http://localhost:3001
+- Network: http://10.2.0.2:3001
+- Environments: .env
+✓ Ready in 634ms
+[?25h
diff --git a/package-lock.json b/package-lock.json
index b73efa6..86713a2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,8 @@
"name": "marriage",
"version": "0.1.0",
"dependencies": {
+ "@tanstack/react-query": "^5.100.5",
+ "axios": "^1.15.2",
"next": "16.2.3",
"react": "19.2.4",
"react-dom": "19.2.4",
@@ -1139,6 +1141,32 @@
"tailwindcss": "4.2.2"
}
},
+ "node_modules/@tanstack/query-core": {
+ "version": "5.100.5",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.5.tgz",
+ "integrity": "sha512-t20KrhKkf0HXzqQkPbJ5erhFesup68BAbwFgYmTrS7bxMF7O5MdmL8jUkik4thsG7Hg00fblz30h6yF1d5TxGg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/react-query": {
+ "version": "5.100.5",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.5.tgz",
+ "integrity": "sha512-aNwj1mi2v2bQ9IxkyR1grLOUkv3BYWoykHy9KDyLNbjC3tsahbOHJibK+Wjtr1wRhG59/AvJhiJG5OlthaCgJA==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/query-core": "5.100.5"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^18 || ^19"
+ }
+ },
"node_modules/@types/node": {
"version": "20.19.39",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
@@ -1170,6 +1198,23 @@
"@types/react": "^19.2.0"
}
},
+ "node_modules/asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "license": "MIT"
+ },
+ "node_modules/axios": {
+ "version": "1.15.2",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz",
+ "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==",
+ "license": "MIT",
+ "dependencies": {
+ "follow-redirects": "^1.15.11",
+ "form-data": "^4.0.5",
+ "proxy-from-env": "^2.1.0"
+ }
+ },
"node_modules/baseline-browser-mapping": {
"version": "2.10.18",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.18.tgz",
@@ -1182,6 +1227,19 @@
"node": ">=6.0.0"
}
},
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/caniuse-lite": {
"version": "1.0.30001787",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz",
@@ -1208,6 +1266,18 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "license": "MIT",
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -1215,6 +1285,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -1225,6 +1304,20 @@
"node": ">=8"
}
},
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/enhanced-resolve": {
"version": "5.20.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz",
@@ -1239,6 +1332,145 @@
"node": ">=10.13.0"
}
},
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/follow-redirects": {
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
+ "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/RubenVerborgh"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0"
+ },
+ "peerDependenciesMeta": {
+ "debug": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/form-data": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
+ "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
+ "license": "MIT",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.2",
+ "mime-types": "^2.1.12"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -1246,6 +1478,45 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
+ "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
@@ -1527,6 +1798,36 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -1661,6 +1962,15 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/proxy-from-env": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
+ "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/react": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
diff --git a/package.json b/package.json
index dda289c..98b44ba 100644
--- a/package.json
+++ b/package.json
@@ -10,6 +10,8 @@
"format": "biome format --write"
},
"dependencies": {
+ "@tanstack/react-query": "^5.100.5",
+ "axios": "^1.15.2",
"next": "16.2.3",
"react": "19.2.4",
"react-dom": "19.2.4",
diff --git a/proxy.ts b/proxy.ts
new file mode 100644
index 0000000..413b819
--- /dev/null
+++ b/proxy.ts
@@ -0,0 +1,31 @@
+import { type NextRequest, NextResponse } from "next/server";
+import { defaultLocale, isLocale } from "@/i18n/config";
+
+function getPreferredLocale(request: NextRequest) {
+ const acceptLanguage = request.headers.get("accept-language") ?? "";
+
+ if (acceptLanguage.toLowerCase().includes("fa")) {
+ return "fa";
+ }
+
+ return defaultLocale;
+}
+
+export function proxy(request: NextRequest) {
+ const { pathname } = request.nextUrl;
+ const pathnameHasLocale = isLocale(pathname.split("/")[1]);
+
+ if (pathnameHasLocale) {
+ return NextResponse.next();
+ }
+
+ const locale = getPreferredLocale(request);
+ const url = request.nextUrl.clone();
+ url.pathname = `/${locale}${pathname === "/" ? "" : pathname}`;
+
+ return NextResponse.redirect(url);
+}
+
+export const config = {
+ matcher: ["/((?!api|_next|favicon.ico|assets|fonts).*)"],
+};
diff --git a/public/assets/images/Avatar Image.png b/public/assets/images/Avatar Image.png
new file mode 100644
index 0000000..5fe1f03
Binary files /dev/null and b/public/assets/images/Avatar Image.png differ
diff --git a/public/assets/images/Ellipse 370.png b/public/assets/images/Ellipse 370.png
new file mode 100644
index 0000000..d0f219f
Binary files /dev/null and b/public/assets/images/Ellipse 370.png differ
diff --git a/public/assets/images/Frame 1597880476.svg b/public/assets/images/Frame 1597880476.svg
new file mode 100644
index 0000000..bafc0ea
--- /dev/null
+++ b/public/assets/images/Frame 1597880476.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/public/assets/images/Frame 1597880477.svg b/public/assets/images/Frame 1597880477.svg
new file mode 100644
index 0000000..2a15e4a
--- /dev/null
+++ b/public/assets/images/Frame 1597880477.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/public/assets/images/Frame 2095586679.svg b/public/assets/images/Frame 2095586679.svg
new file mode 100644
index 0000000..3235f5a
--- /dev/null
+++ b/public/assets/images/Frame 2095586679.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/public/assets/images/Group 1000004916.png b/public/assets/images/Group 1000004916.png
new file mode 100644
index 0000000..807bd59
Binary files /dev/null and b/public/assets/images/Group 1000004916.png differ
diff --git a/public/assets/images/Group 1597880466.svg b/public/assets/images/Group 1597880466.svg
new file mode 100644
index 0000000..a7e21f0
--- /dev/null
+++ b/public/assets/images/Group 1597880466.svg
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/assets/images/Group 1597880467.svg b/public/assets/images/Group 1597880467.svg
new file mode 100644
index 0000000..c77e97b
--- /dev/null
+++ b/public/assets/images/Group 1597880467.svg
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/assets/images/Group 1597880468.svg b/public/assets/images/Group 1597880468.svg
new file mode 100644
index 0000000..c8eaf4e
--- /dev/null
+++ b/public/assets/images/Group 1597880468.svg
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/assets/images/Group 159788fd0467.svg b/public/assets/images/Group 159788fd0467.svg
new file mode 100644
index 0000000..576269e
--- /dev/null
+++ b/public/assets/images/Group 159788fd0467.svg
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/assets/images/Icon.svg b/public/assets/images/Icon.svg
new file mode 100644
index 0000000..e8a187c
--- /dev/null
+++ b/public/assets/images/Icon.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/public/assets/images/Image.svg b/public/assets/images/Image.svg
new file mode 100644
index 0000000..2a99794
--- /dev/null
+++ b/public/assets/images/Image.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/public/assets/images/Union.svg b/public/assets/images/Union.svg
new file mode 100644
index 0000000..76ddcb5
--- /dev/null
+++ b/public/assets/images/Union.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/public/assets/images/Vectorcheck.svg b/public/assets/images/Vectorcheck.svg
new file mode 100644
index 0000000..b75dd6c
--- /dev/null
+++ b/public/assets/images/Vectorcheck.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/public/assets/images/cuida_history-outlfdsaine.svg b/public/assets/images/cuida_history-outlfdsaine.svg
new file mode 100644
index 0000000..88f193a
--- /dev/null
+++ b/public/assets/images/cuida_history-outlfdsaine.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/assets/images/disabled Group 27033.svg b/public/assets/images/disabled Group 27033.svg
new file mode 100644
index 0000000..951562a
--- /dev/null
+++ b/public/assets/images/disabled Group 27033.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/public/assets/images/enabled Group 27032.svg b/public/assets/images/enabled Group 27032.svg
new file mode 100644
index 0000000..c092721
--- /dev/null
+++ b/public/assets/images/enabled Group 27032.svg
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/assets/images/fluent_arrow-exit-12-regular.svg b/public/assets/images/fluent_arrow-exit-12-regular.svg
new file mode 100644
index 0000000..09baa37
--- /dev/null
+++ b/public/assets/images/fluent_arrow-exit-12-regular.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/public/assets/images/icon-park-outline_diamond.svg b/public/assets/images/icon-park-outline_diamond.svg
new file mode 100644
index 0000000..e8c4dc2
--- /dev/null
+++ b/public/assets/images/icon-park-outline_diamond.svg
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/assets/images/icon-park-solid_success.svg b/public/assets/images/icon-park-solid_success.svg
index 4a460e3..9887e91 100644
--- a/public/assets/images/icon-park-solid_success.svg
+++ b/public/assets/images/icon-park-solid_success.svg
@@ -1,15 +1,15 @@
-
+
-
-
+
+
-
-
-
+
+
+
diff --git a/public/assets/images/material-symbols_lock.svg b/public/assets/images/material-symbols_lock.svg
new file mode 100644
index 0000000..cd49ffd
--- /dev/null
+++ b/public/assets/images/material-symbols_lock.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/public/assets/images/noun-wedding-rings-6540466 1.svg b/public/assets/images/noun-wedding-rings-6540466 1.svg
new file mode 100644
index 0000000..4da4f34
--- /dev/null
+++ b/public/assets/images/noun-wedding-rings-6540466 1.svg
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/assets/images/tabler_user-filled.svg b/public/assets/images/tabler_user-filled.svg
index b65b012..d688353 100644
--- a/public/assets/images/tabler_user-filled.svg
+++ b/public/assets/images/tabler_user-filled.svg
@@ -1,9 +1,9 @@
-
+
-
-
-
+
+
+
diff --git a/public/assets/images/typcn_heart-full-outline.svg b/public/assets/images/typcn_heart-full-outline.svg
index 0749d16..a4c0c8e 100644
--- a/public/assets/images/typcn_heart-full-outline.svg
+++ b/public/assets/images/typcn_heart-full-outline.svg
@@ -1,9 +1,9 @@
-
+
-
-
-
+
+
+
diff --git a/src/app/[lang]/candidate-contact/page.tsx b/src/app/[lang]/candidate-contact/page.tsx
new file mode 100644
index 0000000..0354a6e
--- /dev/null
+++ b/src/app/[lang]/candidate-contact/page.tsx
@@ -0,0 +1 @@
+export { default } from "@/app/candidate-contact/page";
diff --git a/src/app/[lang]/finding-match/page.tsx b/src/app/[lang]/finding-match/page.tsx
new file mode 100644
index 0000000..826da69
--- /dev/null
+++ b/src/app/[lang]/finding-match/page.tsx
@@ -0,0 +1 @@
+export { default } from "@/app/finding-match/page";
diff --git a/src/app/[lang]/intro/page.tsx b/src/app/[lang]/intro/page.tsx
new file mode 100644
index 0000000..35a7ac9
--- /dev/null
+++ b/src/app/[lang]/intro/page.tsx
@@ -0,0 +1 @@
+export { default } from "@/app/intro/page";
diff --git a/src/app/[lang]/layout.tsx b/src/app/[lang]/layout.tsx
new file mode 100644
index 0000000..27da477
--- /dev/null
+++ b/src/app/[lang]/layout.tsx
@@ -0,0 +1,31 @@
+import { notFound } from "next/navigation";
+import LanguageSwitcher from "@/components/ui/language-switcher";
+import { isLocale, localeDirections } from "@/i18n/config";
+import { I18nProvider } from "@/i18n/provider";
+
+export function generateStaticParams() {
+ return [{ lang: "en" }, { lang: "fa" }];
+}
+
+export default async function LocaleLayout({
+ children,
+ params,
+}: {
+ children: React.ReactNode;
+ params: Promise<{ lang: string }>;
+}) {
+ const { lang } = await params;
+
+ if (!isLocale(lang)) {
+ notFound();
+ }
+
+ return (
+
+
+
+ {children}
+
+
+ );
+}
diff --git a/src/app/[lang]/new-match/page.tsx b/src/app/[lang]/new-match/page.tsx
new file mode 100644
index 0000000..caee618
--- /dev/null
+++ b/src/app/[lang]/new-match/page.tsx
@@ -0,0 +1 @@
+export { default } from "@/app/new-match/page";
diff --git a/src/app/[lang]/page.tsx b/src/app/[lang]/page.tsx
new file mode 100644
index 0000000..35a7ac9
--- /dev/null
+++ b/src/app/[lang]/page.tsx
@@ -0,0 +1 @@
+export { default } from "@/app/intro/page";
diff --git a/src/app/[lang]/questions-list/[slug]/page.tsx b/src/app/[lang]/questions-list/[slug]/page.tsx
new file mode 100644
index 0000000..2a365d6
--- /dev/null
+++ b/src/app/[lang]/questions-list/[slug]/page.tsx
@@ -0,0 +1,4 @@
+export {
+ default,
+ generateStaticParams,
+} from "@/app/questions-list/[slug]/page";
diff --git a/src/app/[lang]/questions-list/page.tsx b/src/app/[lang]/questions-list/page.tsx
new file mode 100644
index 0000000..8653f4a
--- /dev/null
+++ b/src/app/[lang]/questions-list/page.tsx
@@ -0,0 +1 @@
+export { default } from "@/app/questions-list/page";
diff --git a/src/app/[lang]/request-accepted/page.tsx b/src/app/[lang]/request-accepted/page.tsx
new file mode 100644
index 0000000..3b96543
--- /dev/null
+++ b/src/app/[lang]/request-accepted/page.tsx
@@ -0,0 +1 @@
+export { default } from "@/app/request-accepted/page";
diff --git a/src/app/[lang]/slider/page.tsx b/src/app/[lang]/slider/page.tsx
new file mode 100644
index 0000000..197a187
--- /dev/null
+++ b/src/app/[lang]/slider/page.tsx
@@ -0,0 +1 @@
+export { default } from "@/app/slider/page";
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/candidate-contact/page.tsx b/src/app/candidate-contact/page.tsx
new file mode 100644
index 0000000..1f4ea78
--- /dev/null
+++ b/src/app/candidate-contact/page.tsx
@@ -0,0 +1,64 @@
+"use client";
+
+import Image from "next/image";
+import { useState } from "react";
+import Button from "@/components/ui/button";
+import CallResultSheet from "@/components/ui/call-result-sheet";
+import DismissReasonSheet from "@/components/ui/dismiss-reason-sheet";
+import NavigationButton from "@/components/ui/navigation-button";
+import { useI18n } from "@/i18n/provider";
+
+export default function CandidateContactPage() {
+ const { dictionary: t } = useI18n();
+ const [isCallResultSheetOpen, setIsCallResultSheetOpen] = useState(false);
+ const [isDismissReasonSheetOpen, setIsDismissReasonSheetOpen] =
+ useState(false);
+
+ return (
+ <>
+ {isCallResultSheetOpen ? (
+ setIsCallResultSheetOpen(false)}
+ onOtherReasonsClick={() => setIsDismissReasonSheetOpen(true)}
+ />
+ ) : null}
+ {isDismissReasonSheetOpen ? (
+ setIsDismissReasonSheetOpen(false)}
+ />
+ ) : null}
+
+
+
+
+ {t.common.appName}
+
+
+
+
+
+
+
+
+ {t.candidateContact.title}
+
+
+
+
+
+ setIsCallResultSheetOpen(true)}>
+ {t.candidateContact.contacted}
+
+
+ {t.candidateContact.noContactYet}
+
+
+
+ >
+ );
+}
diff --git a/src/app/finding-match/page.tsx b/src/app/finding-match/page.tsx
new file mode 100644
index 0000000..d3c2f75
--- /dev/null
+++ b/src/app/finding-match/page.tsx
@@ -0,0 +1,106 @@
+"use client";
+
+import Image from "next/image";
+import Link from "next/link";
+import { FaPen } from "react-icons/fa6";
+import Button from "@/components/ui/button";
+import NavigationButton from "@/components/ui/navigation-button";
+import { PageBackground } from "@/components/utils/page-background";
+import { localizePath } from "@/i18n/config";
+import { useI18n } from "@/i18n/provider";
+
+const advisorAvatars = [
+ { id: "advisor-primary", src: "/assets/images/Avatar Image.png" },
+ { id: "advisor-secondary", src: "/assets/images/Ellipse 370.png" },
+ { id: "advisor-tertiary", src: "/assets/images/Avatar Image.png" },
+];
+
+export default function FindingMatchPage() {
+ const { dictionary: t, locale } = useI18n();
+ const copy = t.findingMatch;
+
+ return (
+ <>
+
+
+
+
+
+ {t.common.appName}
+
+
+
+
+
+
+
+
+
+ {copy.title}
+
+
+
+ {copy.description}
+
+
+
+
+
+
+ {copy.advisorTitle}
+
+
+ {copy.advisorDescription}
+
+
+
+
+ {advisorAvatars.map((avatar) => (
+
+
+
+ ))}
+
+ +7
+
+
+
+
+ {copy.getAdvisor}
+
+
+
+
+
+
+
+ {copy.editProfile}
+
+
+
+
+ >
+ );
+}
diff --git a/src/app/globals.css b/src/app/globals.css
index e147d86..045037e 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -24,7 +24,7 @@ body {
display: flex;
justify-content: center;
color: var(--foreground);
- font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
+ font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
}
html:lang(ar) body,
@@ -65,3 +65,54 @@ body[data-page-background="none"] .app-shell {
body[data-page-background="custom"] .app-shell {
background-image: var(--page-background-image);
}
+
+.question-slider-range {
+ height: 6px;
+ width: calc(100% - 18px);
+ margin-inline-start: 9px;
+ cursor: pointer;
+ appearance: none;
+ border-radius: 999px;
+ outline: none;
+}
+
+.question-slider-range::-webkit-slider-runnable-track {
+ height: 14px;
+ border-radius: 999px;
+}
+
+.question-slider-range::-webkit-slider-thumb {
+ width: 18px;
+ height: 18px;
+ margin-top: -2px;
+ appearance: none;
+ border: 0;
+ border-radius: 999px;
+ background: #ffffff;
+ box-shadow:
+ 0 2px 7px rgb(0 0 0 / 22%),
+ 0 0 0 2px rgb(0 0 0 / 8%);
+}
+
+.question-slider-range::-moz-range-track {
+ height: 14px;
+ border-radius: 999px;
+ background: transparent;
+}
+
+.question-slider-range::-moz-range-progress {
+ height: 14px;
+ border-radius: 999px;
+ background: #f43f5e;
+}
+
+.question-slider-range::-moz-range-thumb {
+ width: 44px;
+ height: 44px;
+ border: 0;
+ border-radius: 999px;
+ background: #ffffff;
+ box-shadow:
+ 0 2px 7px rgb(0 0 0 / 22%),
+ 0 0 0 1px rgb(0 0 0 / 8%);
+}
diff --git a/src/app/intro/page.tsx b/src/app/intro/page.tsx
index 148cafb..495c275 100644
--- a/src/app/intro/page.tsx
+++ b/src/app/intro/page.tsx
@@ -1,29 +1,45 @@
+"use client";
+
import Image from "next/image";
import Button from "@/components/ui/button";
import NavigationButton from "@/components/ui/navigation-button";
+import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
+import { localizePath } from "@/i18n/config";
+import { useI18n } from "@/i18n/provider";
export default function Intro() {
+ const { dictionary: t, locale } = useI18n();
+ const { data: profile } = useMarriageProfileQuery();
+ const submitPath =
+ profile?.status === "pending_onboarding"
+ ? "/rules"
+ : profile?.status === "pending_info"
+ ? "/questions-list"
+ : profile?.status === "waiting"
+ ? "/finding-match"
+ : "/slider";
+ const submitHref = localizePath(submitPath, locale);
+
return (
- Habib Marriage
-
+ {t.common.appName}
+
- A Path to Heavenly Marriage
+ {t.intro.title}
-
- We have come together with the goal of creating a secure and
- confidential path for "permanent marriage" among Muslims
+
+ {t.intro.description}
@@ -37,7 +53,7 @@ export default function Intro() {
120
- user profile
+ {t.intro.userProfile}
@@ -51,7 +67,7 @@ export default function Intro() {
14
- matches
+ {t.intro.matches}
@@ -65,28 +81,28 @@ export default function Intro() {
14
- marriage
+ {t.intro.marriage}
-
+
- Submit
+ {t.common.submit}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 610494f..93217d5 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { Amiri } from "next/font/google";
import localFont from "next/font/local";
import DevClickToComponent from "@/components/dev/dev-click-to-component";
+import Providers from "./providers";
import "./globals.css";
const faminela = localFont({
@@ -30,7 +31,9 @@ export default function RootLayout({
return (
- {children}
+
+ {children}
+
{process.env.NODE_ENV === "development" ? (
) : null}
diff --git a/src/app/new-match/page.tsx b/src/app/new-match/page.tsx
new file mode 100644
index 0000000..9de6abf
--- /dev/null
+++ b/src/app/new-match/page.tsx
@@ -0,0 +1,193 @@
+"use client";
+
+import Image from "next/image";
+import { useRouter } from "next/navigation";
+import { useState } from "react";
+import { IoClose } from "react-icons/io5";
+import Button from "@/components/ui/button";
+import InformationSheet from "@/components/ui/information-sheet";
+import NavigationButton from "@/components/ui/navigation-button";
+import StickyHeader from "@/components/ui/sticky-header";
+import { localizePath } from "@/i18n/config";
+import { useI18n } from "@/i18n/provider";
+
+type MatchFieldProps = {
+ label: string;
+ value: string;
+ hint?: string;
+};
+
+type MatchSplitFieldProps = {
+ label: string;
+ hint?: string;
+ values: [string, string];
+};
+
+function MatchField({ label, value, hint }: MatchFieldProps) {
+ return (
+
+
+ {label}
+ {hint ? (
+
+ {hint}
+
+ ) : null}
+
+
+ {value}
+
+
+ );
+}
+
+function MatchSplitField({ label, hint, values }: MatchSplitFieldProps) {
+ return (
+
+
+ {label}
+ {hint ? (
+
+ {hint}
+
+ ) : null}
+
+
+ {values.map((value) => (
+
+ {value}
+
+ ))}
+
+
+ );
+}
+
+export default function NewMatchPage() {
+ const { dictionary: t, locale } = useI18n();
+ const [isAcceptSheetOpen, setIsAcceptSheetOpen] = useState(false);
+ const [shouldNavigateToRequestAccepted, setShouldNavigateToRequestAccepted] =
+ useState(false);
+ const router = useRouter();
+
+ return (
+ <>
+ {isAcceptSheetOpen ? (
+ {
+ setIsAcceptSheetOpen(false);
+
+ if (shouldNavigateToRequestAccepted) {
+ setShouldNavigateToRequestAccepted(false);
+ router.push(localizePath("/request-accepted", locale));
+ }
+ }}
+ buttons={({ close }) => (
+
+
+ {t.common.cancel}
+
+ {
+ setShouldNavigateToRequestAccepted(true);
+ close();
+ }}
+ >
+ {t.common.confirm}
+
+
+ )}
+ />
+ ) : null}
+
+
+
+
+
+
{t.match.title}
+
+
+
+
+
+
+
+
+
+
+ setIsAcceptSheetOpen(true)}
+ type="button"
+ >
+
+
+
+
+ >
+ );
+}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 1f71712..35a7ac9 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,44 +1 @@
-import InfoProgressCard from "@/components/ui/info-progress-card";
-import NavigationButton from "@/components/ui/navigation-button";
-import Image from "next/image";
-import QUESTIONS from "@/data/mock-questions.json"
-
-export default function Home() {
- return (
-
-
-
- Profile registration
-
-
-
-
-
Bookings Terms & Conditions
-
-
-
-
- You will be contacted by your consultant.
- The call may start 10–15 minutes earlier or later than scheduled.
- Make sure you are available and in a quiet place at least 10 minutes before the session.
-
-
-
-
- {
- QUESTIONS.map((question) => (
-
- ))
- }
-
-
- );
-}
+export { default } from "@/app/intro/page";
diff --git a/src/app/providers.tsx b/src/app/providers.tsx
new file mode 100644
index 0000000..08c4b20
--- /dev/null
+++ b/src/app/providers.tsx
@@ -0,0 +1,26 @@
+"use client";
+
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { type ReactNode, useState } from "react";
+
+type ProvidersProps = {
+ children: ReactNode;
+};
+
+export default function Providers({ children }: ProvidersProps) {
+ const [queryClient] = useState(
+ () =>
+ new QueryClient({
+ defaultOptions: {
+ queries: {
+ refetchOnWindowFocus: false,
+ retry: 1,
+ },
+ },
+ }),
+ );
+
+ return (
+ {children}
+ );
+}
diff --git a/src/app/questions-list/[slug]/answer-pace-sheet.tsx b/src/app/questions-list/[slug]/answer-pace-sheet.tsx
new file mode 100644
index 0000000..9995b5d
--- /dev/null
+++ b/src/app/questions-list/[slug]/answer-pace-sheet.tsx
@@ -0,0 +1,117 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+import {
+ getQuestionAnswersStorageKey,
+ hasQuestionAnswerValue,
+} from "@/components/questions/question-answer-storage";
+import InformationSheet from "@/components/ui/information-sheet";
+import type { MarriageField } from "@/hooks/marriage/types";
+import { useMarriageSectionsQuery } from "@/hooks/marriage/use-sections";
+
+type AnswerPaceSheetProps = {
+ continueLabel: string;
+ description: string;
+ slug: string;
+ title: string;
+};
+
+type StoredQuestionAnswers = {
+ fields?: unknown;
+};
+
+function isMarriageField(value: unknown): value is MarriageField {
+ if (!value || typeof value !== "object") {
+ return false;
+ }
+
+ const field = value as Partial;
+
+ return (
+ typeof field.key === "string" &&
+ typeof field.label === "string" &&
+ typeof field.type === "string" &&
+ (field.value === null ||
+ typeof field.value === "string" ||
+ typeof field.value === "number" ||
+ typeof field.value === "boolean")
+ );
+}
+
+function hasStoredQuestionProgress(slugs: readonly string[]) {
+ try {
+ return slugs.some((slug) => {
+ const rawValue = window.localStorage.getItem(
+ getQuestionAnswersStorageKey(slug),
+ );
+
+ if (!rawValue) {
+ return false;
+ }
+
+ const storedValue = JSON.parse(rawValue) as StoredQuestionAnswers;
+ const fields = Array.isArray(storedValue.fields)
+ ? storedValue.fields.filter(isMarriageField)
+ : [];
+
+ return fields.some((field) => hasQuestionAnswerValue(field.value));
+ });
+ } catch {
+ return false;
+ }
+}
+
+export default function AnswerPaceSheet({
+ continueLabel,
+ description,
+ slug,
+ title,
+}: AnswerPaceSheetProps) {
+ const { data: sections, isSuccess } = useMarriageSectionsQuery();
+ const [hasSeenSheet, setHasSeenSheet] = useState(true);
+ const [hasLocalProgress, setHasLocalProgress] = useState(true);
+ const storageKey = `marriage:sections:${slug}:answer-pace-sheet-seen`;
+ const sectionSlugs = useMemo(
+ () => sections?.map((section) => section.slug) ?? [slug],
+ [sections, slug],
+ );
+
+ const hasAnySectionProgress = useMemo(() => {
+ if (!sections?.length) {
+ return true;
+ }
+
+ return sections.some(
+ (section) => section.current_step > 0 || section.completion_percent >= 1,
+ );
+ }, [sections]);
+
+ const isOpen =
+ isSuccess && !hasSeenSheet && !hasLocalProgress && !hasAnySectionProgress;
+
+ useEffect(() => {
+ setHasSeenSheet(window.sessionStorage.getItem(storageKey) === "true");
+ setHasLocalProgress(hasStoredQuestionProgress(sectionSlugs));
+ }, [sectionSlugs, storageKey]);
+
+ useEffect(() => {
+ if (!isOpen) {
+ return;
+ }
+
+ window.sessionStorage.setItem(storageKey, "true");
+ }, [isOpen, storageKey]);
+
+ if (!isOpen) {
+ return null;
+ }
+
+ return (
+
+ );
+}
diff --git a/src/app/questions-list/[slug]/page.tsx b/src/app/questions-list/[slug]/page.tsx
index 9511af2..d2c909a 100644
--- a/src/app/questions-list/[slug]/page.tsx
+++ b/src/app/questions-list/[slug]/page.tsx
@@ -1,52 +1,78 @@
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 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 Button from "@/components/ui/button";
-import InformationSheet from "@/components/ui/information-sheet";
+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,
- questionListItems,
} from "@/data/question-data";
+import { defaultLocale, isLocale, locales } from "@/i18n/config";
+import { getDictionary } from "@/i18n/dictionaries";
+import AnswerPaceSheet from "./answer-pace-sheet";
export function generateStaticParams() {
- return questionListItems.map((item) => ({
- slug: item.slug,
- }));
+ const slugs = new Set(
+ locales.flatMap((locale) =>
+ getQuestionListItems(locale).map((item) => item.slug),
+ ),
+ );
+
+ return Array.from(slugs).map((slug) => ({ slug }));
}
type QuestionDetailPageProps = {
params: Promise<{
+ lang?: string;
slug: string;
}>;
};
-function renderQuestion(question: QuestionField) {
+function renderQuestion(question: QuestionField, questionIndex: number) {
switch (question.type) {
case "button":
- return ;
+ return (
+
+ );
case "date":
- return ;
+ return ;
case "dropdown":
- return ;
+ return (
+
+ );
case "file":
- return ;
+ return ;
case "number":
- return ;
+ return (
+
+ );
+ case "photo":
+ return (
+
+ );
case "radio":
- return ;
+ return (
+
+ );
case "slider":
- return ;
+ return (
+
+ );
case "text":
- return ;
+ return ;
default:
return null;
}
@@ -55,8 +81,14 @@ function renderQuestion(question: QuestionField) {
export default async function QuestionDetailPage({
params,
}: QuestionDetailPageProps) {
- const { slug } = await params;
- const item = getQuestionListItemBySlug(slug);
+ const { lang, slug } = await params;
+ const hasLocalePath = isLocale(lang);
+ const locale = hasLocalePath ? lang : defaultLocale;
+ const questionsListHref = hasLocalePath
+ ? `/${locale}/questions-list`
+ : "/questions-list";
+ const t = getDictionary(locale);
+ const item = getQuestionListItemBySlug(slug, locale);
if (!item) {
notFound();
@@ -65,28 +97,47 @@ export default async function QuestionDetailPage({
return (
<>
-
-
-
-
- {item.questions.map((question) => (
-
- {renderQuestion(question)}
-
- ))}
-
+
+
+
+
+
+
{item.title}
+
+
+
-
- Continue
-
-
-
+
+
+ {item.questions.map((question, questionIndex) => (
+
+ {renderQuestion(question, questionIndex)}
+
+ ))}
+
+
+
+
>
);
}
diff --git a/src/app/questions-list/page.tsx b/src/app/questions-list/page.tsx
index 9e6ef19..5ced3de 100644
--- a/src/app/questions-list/page.tsx
+++ b/src/app/questions-list/page.tsx
@@ -1,53 +1,178 @@
-import BookingTermsCard from "@/components/questions/booking-terms-card";
+"use client";
+
+import Image from "next/image";
+import { useRouter } from "next/navigation";
+import { useMemo, useState } from "react";
import QuestionCard from "@/components/questions/question-card";
+import RequiredStepsCard from "@/components/questions/required-steps-card";
import Button from "@/components/ui/button";
+import InformationSheet from "@/components/ui/information-sheet";
import NavigationButton from "@/components/ui/navigation-button";
import { PageBackground } from "@/components/utils/page-background";
-import { bookingTerms, questionListItems } from "@/data/question-data";
+import { getQuestionListItems } from "@/data/question-data";
+import { useStartMarriageMatchMutation } from "@/hooks/marriage/use-match-start";
+import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
+import { useMarriageSectionsQuery } from "@/hooks/marriage/use-sections";
+import { localizePath } from "@/i18n/config";
+import { useI18n } from "@/i18n/provider";
+import SectionsRequest from "./sections-request";
export default function QuestionsListPage() {
+ const { dictionary: t, locale } = useI18n();
+ const router = useRouter();
+ const { data: profile } = useMarriageProfileQuery();
+ const { data: sections } = useMarriageSectionsQuery();
+ const startMatchMutation = useStartMarriageMatchMutation({
+ onSuccess: () => {
+ router.push(localizePath("/finding-match", locale));
+ },
+ });
+ const [isOptionalInfoSheetOpen, setIsOptionalInfoSheetOpen] = useState(false);
+ const questionListItems = getQuestionListItems(locale);
+ const allRequiredSectionsCompleted = useMemo(() => {
+ if (!sections?.length) {
+ return false;
+ }
+
+ return sections
+ .filter((section) => section.is_required)
+ .every((section) => section.current_step >= section.total_steps);
+ }, [sections]);
+ const profileStatus = profile?.status;
+ const isProfileSuspended = profileStatus === "suspended";
+ const canStartMatch =
+ profileStatus === "pending_info" &&
+ !isProfileSuspended &&
+ allRequiredSectionsCompleted;
+ const isStartMatchDisabled = startMatchMutation.isPending || !canStartMatch;
+ const hasIncompleteOptionalSections = useMemo(() => {
+ if (!sections?.length) {
+ return false;
+ }
+
+ return sections.some(
+ (section) => !section.is_required && section.completion_percent < 100,
+ );
+ }, [sections]);
+ const sectionProgressBySlug = useMemo(() => {
+ const progressBySlug = new Map();
+
+ sections?.forEach((section) => {
+ progressBySlug.set(section.slug, section.completion_percent);
+ });
+
+ return progressBySlug;
+ }, [sections]);
+ const handleStartMatch = () => {
+ if (!canStartMatch) {
+ return;
+ }
+
+ startMatchMutation.mutate();
+ };
+
return (
<>
+ {isOptionalInfoSheetOpen ? (
+
+ {t.questions.optionalInfoPromptDescription}
+
+ }
+ onClose={() => setIsOptionalInfoSheetOpen(false)}
+ buttons={({ close }) => (
+
+
+ {t.common.cancel}
+
+ {
+ close();
+ handleStartMatch();
+ }}
+ >
+ {t.questions.findingMatch}
+
+
+ )}
+ />
+ ) : null}
+
-
+
-
+
- Profile registration
+ {t.questions.profileRegistration}
-
+
+
+
{questionListItems.map((item) => (
-
+
))}
-
-
- Find Matches
-
-
+ {allRequiredSectionsCompleted ? (
+
+ {
+ if (hasIncompleteOptionalSections) {
+ setIsOptionalInfoSheetOpen(true);
+ return;
+ }
+
+ handleStartMatch();
+ }}
+ >
+
+
+
+ {t.questions.findingMatch}
+
+
+
+
+ ) : null}
>
diff --git a/src/app/questions-list/sections-request.tsx b/src/app/questions-list/sections-request.tsx
new file mode 100644
index 0000000..85d55f2
--- /dev/null
+++ b/src/app/questions-list/sections-request.tsx
@@ -0,0 +1,89 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+import { IoClose } from "react-icons/io5";
+import Button from "@/components/ui/button";
+import InformationSheet from "@/components/ui/information-sheet";
+import { bookingTerms } from "@/data/question-data";
+import { useMarriageSectionsQuery } from "@/hooks/marriage/use-sections";
+
+const FIRST_ENTRY_TERMS_SEEN_KEY = "marriage:first-entry-terms-seen";
+
+const FIRST_ENTRY_TERMS = [
+ ...bookingTerms,
+ "Do you struggle to understand the underlying psychological drivers that dictate your interpersonal connections and communication barriers?",
+ "Taking this test is not mandatory, but it will help you better search for a spouse. The Kettle test is a test for self-knowledge and better understanding of your spouse.",
+ 'Do you operate based on superficial behavioral adaptations, or are you aware of the deep "source traits" that fundamentally control your decision-making processes?',
+ "Do you struggle to understand the underlying psychological drivers that dictate your interpersonal connections and communication barriers?",
+ "Taking this test is not mandatory, but it will help you better search for a spouse. The Kettle test is a test for self-knowledge and better understanding of your spouse.",
+ 'Do you operate based on superficial behavioral adaptations, or are you aware of the deep "source traits" that fundamentally control your decision-making processes?',
+] as const;
+
+export default function SectionsRequest() {
+ const { data: sections, isSuccess } = useMarriageSectionsQuery();
+ const [hasSeenSheet, setHasSeenSheet] = useState(true);
+
+ const hasNoProgression = useMemo(() => {
+ if (!sections?.length) {
+ return false;
+ }
+
+ return sections.every(
+ (section) => section.current_step <= 0 && section.completion_percent <= 0,
+ );
+ }, [sections]);
+
+ const isOpen = isSuccess && hasNoProgression && !hasSeenSheet;
+
+ useEffect(() => {
+ setHasSeenSheet(
+ window.sessionStorage.getItem(FIRST_ENTRY_TERMS_SEEN_KEY) === "true",
+ );
+ }, []);
+
+ useEffect(() => {
+ if (!isOpen) {
+ return;
+ }
+
+ window.sessionStorage.setItem(FIRST_ENTRY_TERMS_SEEN_KEY, "true");
+ }, [isOpen]);
+
+ if (!isOpen) {
+ return null;
+ }
+
+ return (
+ (
+
+
+ Bookings Terms & Conditions
+
+
+
+
+
+ )}
+ description={
+
+ {FIRST_ENTRY_TERMS.map((item, index) => (
+ {item}
+ ))}
+
+ }
+ buttons={({ close }) => (
+
+ Got it
+
+ )}
+ className="text-left"
+ />
+ );
+}
diff --git a/src/app/request-accepted/page.tsx b/src/app/request-accepted/page.tsx
new file mode 100644
index 0000000..552a02e
--- /dev/null
+++ b/src/app/request-accepted/page.tsx
@@ -0,0 +1,96 @@
+"use client";
+
+import Image from "next/image";
+import { useRouter } from "next/navigation";
+import Button from "@/components/ui/button";
+import NavigationButton from "@/components/ui/navigation-button";
+import { PageBackground } from "@/components/utils/page-background";
+import { localizePath } from "@/i18n/config";
+import { useI18n } from "@/i18n/provider";
+
+export default function RequestAcceptedPage() {
+ const { dictionary: t, locale } = useI18n();
+ const router = useRouter();
+
+ return (
+ <>
+
+
+
+
+
+ {t.common.appName}
+
+
+
+
+
+
+
+
+
+
+
+
+ {t.requestAccepted.title}
+
+
+
+ {t.requestAccepted.description}
+
+
+
+
+ router.push(localizePath("/candidate-contact", locale))
+ }
+ >
+ {t.requestAccepted.viewContact}
+
+
+
+
+
+
+ {t.requestAccepted.penalty}
+
+
+
+
+
+
+
+ {t.requestAccepted.profileLocked}
+
+
+
+
+ {t.requestAccepted.lockedDescription}
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/components/questions/question-answer-storage.tsx b/src/components/questions/question-answer-storage.tsx
new file mode 100644
index 0000000..55c3197
--- /dev/null
+++ b/src/components/questions/question-answer-storage.tsx
@@ -0,0 +1,445 @@
+"use client";
+
+import {
+ createContext,
+ type ReactNode,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import type { QuestionField } from "@/data/question-data";
+import { pathParam } from "@/hooks/marriage/path-param";
+import type {
+ MarriageField,
+ MarriageFieldValue,
+ 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;
+
+type StoredQuestionAnswers = {
+ version: typeof STORAGE_VERSION;
+ slug: string;
+ current_step: number;
+ fields: MarriageField[];
+ pending_sync: boolean;
+ updated_at: string;
+};
+
+type FlushAnswersOptions = {
+ force?: boolean;
+};
+
+type QuestionAnswersContextValue = {
+ flushAnswers: (options?: FlushAnswersOptions) => Promise;
+ getAnswerValue: (
+ question: QuestionField,
+ questionIndex: number,
+ ) => MarriageFieldValue | undefined;
+ hasPendingSync: boolean;
+ isSaving: boolean;
+ setAnswerValue: (
+ question: QuestionField,
+ questionIndex: number,
+ value: MarriageFieldValue,
+ ) => void;
+};
+
+type QuestionAnswersProviderProps = {
+ children: ReactNode;
+ questions: readonly QuestionField[];
+ slug: string;
+};
+
+const QuestionAnswersContext =
+ createContext(null);
+
+function hashString(value: string) {
+ let hash = 0;
+
+ for (let index = 0; index < value.length; index += 1) {
+ hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
+ }
+
+ return hash.toString(36);
+}
+
+function slugifyQuestionTitle(title: string) {
+ const slug = title
+ .normalize("NFKD")
+ .replace(/[\u0300-\u036f]/g, "")
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "_")
+ .replace(/^_+|_+$/g, "");
+
+ return slug || `field_${hashString(title)}`;
+}
+
+function getQuestionFieldKey(question: QuestionField, questionIndex: number) {
+ return `q${questionIndex + 1}_${slugifyQuestionTitle(question.title)}`;
+}
+
+export function getQuestionAnswersStorageKey(slug: string) {
+ return `marriage:sections:${slug}:answers`;
+}
+
+export function hasQuestionAnswerValue(value: MarriageFieldValue) {
+ if (value === null) {
+ return false;
+ }
+
+ if (typeof value === "string") {
+ return value.trim().length > 0;
+ }
+
+ return true;
+}
+
+function isMarriageField(value: unknown): value is MarriageField {
+ if (!value || typeof value !== "object") {
+ return false;
+ }
+
+ const field = value as Partial;
+
+ return (
+ typeof field.key === "string" &&
+ typeof field.label === "string" &&
+ typeof field.type === "string" &&
+ (field.value === null ||
+ typeof field.value === "string" ||
+ typeof field.value === "number" ||
+ typeof field.value === "boolean")
+ );
+}
+
+function createQuestionField(
+ question: QuestionField,
+ questionIndex: number,
+ value: MarriageFieldValue,
+): MarriageField {
+ return {
+ key: getQuestionFieldKey(question, questionIndex),
+ label: question.title,
+ type: question.type,
+ value,
+ };
+}
+
+function getOrderedFields(
+ answers: QuestionAnswersByKey,
+ questions: readonly QuestionField[],
+) {
+ const orderedFields: MarriageField[] = [];
+ const orderedKeys = new Set();
+
+ questions.forEach((question, index) => {
+ const key = getQuestionFieldKey(question, index);
+ const field = answers[key];
+
+ if (field) {
+ orderedFields.push(field);
+ orderedKeys.add(key);
+ }
+ });
+
+ Object.entries(answers).forEach(([key, field]) => {
+ if (!orderedKeys.has(key)) {
+ orderedFields.push(field);
+ }
+ });
+
+ return orderedFields;
+}
+
+function getCurrentStep(fields: MarriageField[]) {
+ return fields.filter((field) => hasQuestionAnswerValue(field.value)).length;
+}
+
+function createPayload(
+ answers: QuestionAnswersByKey,
+ questions: readonly QuestionField[],
+): UpdateMarriageSectionDataPayload {
+ const fields = getOrderedFields(answers, questions);
+
+ return {
+ current_step: getCurrentStep(fields),
+ fields,
+ };
+}
+
+function fieldsToAnswers(fields: MarriageField[]) {
+ return fields.reduce((nextAnswers, field) => {
+ nextAnswers[field.key] = field;
+ return nextAnswers;
+ }, {});
+}
+
+function readStoredAnswers(storageKey: string, slug: string) {
+ try {
+ const rawValue = window.localStorage.getItem(storageKey);
+
+ if (!rawValue) {
+ return {
+ answers: {},
+ pendingSync: false,
+ };
+ }
+
+ const storedValue = JSON.parse(rawValue) as Partial;
+ const fields = Array.isArray(storedValue.fields)
+ ? storedValue.fields.filter(isMarriageField)
+ : [];
+
+ return {
+ answers: fieldsToAnswers(fields),
+ pendingSync:
+ storedValue.slug === slug &&
+ (storedValue.pending_sync ?? fields.length > 0),
+ };
+ } catch {
+ return {
+ answers: {},
+ pendingSync: false,
+ };
+ }
+}
+
+function writeStoredAnswers(
+ storageKey: string,
+ slug: string,
+ questions: readonly QuestionField[],
+ answers: QuestionAnswersByKey,
+ pendingSync: boolean,
+) {
+ try {
+ const payload = createPayload(answers, questions);
+
+ if (payload.fields.length === 0) {
+ window.localStorage.removeItem(storageKey);
+ return;
+ }
+
+ const storedValue: StoredQuestionAnswers = {
+ version: STORAGE_VERSION,
+ slug,
+ current_step: payload.current_step,
+ fields: payload.fields,
+ pending_sync: pendingSync,
+ updated_at: new Date().toISOString(),
+ };
+
+ window.localStorage.setItem(storageKey, JSON.stringify(storedValue));
+ } catch {
+ // localStorage can fail in private mode or when storage quota is exhausted.
+ }
+}
+
+function getKeepalivePatchUrl(slug: string) {
+ const searchParams = new URLSearchParams({
+ [PROXY_PATH_PARAM]: `/api/marriage/sections/${pathParam(slug)}/data/`,
+ });
+
+ return `/api/proxy?${searchParams.toString()}`;
+}
+
+export function QuestionAnswersProvider({
+ children,
+ questions,
+ slug,
+}: QuestionAnswersProviderProps) {
+ const storageKey = useMemo(() => getQuestionAnswersStorageKey(slug), [slug]);
+ const [answers, setAnswers] = useState({});
+ const [hasPendingSync, setHasPendingSync] = useState(false);
+ const { isPending: isSaving, mutateAsync } =
+ useUpdateMarriageSectionDataMutation(slug);
+ const answersRef = useRef({});
+ const hasPendingSyncRef = useRef(false);
+ const questionsRef = useRef(questions);
+ const storageKeyRef = useRef(storageKey);
+ const slugRef = useRef(slug);
+
+ useEffect(() => {
+ questionsRef.current = questions;
+ }, [questions]);
+
+ useEffect(() => {
+ storageKeyRef.current = storageKey;
+ slugRef.current = slug;
+
+ const stored = readStoredAnswers(storageKey, slug);
+
+ answersRef.current = stored.answers;
+ hasPendingSyncRef.current = stored.pendingSync;
+ setAnswers(stored.answers);
+ setHasPendingSync(stored.pendingSync);
+ }, [slug, storageKey]);
+
+ const getAnswerValue = useCallback(
+ (question: QuestionField, questionIndex: number) =>
+ answers[getQuestionFieldKey(question, questionIndex)]?.value,
+ [answers],
+ );
+
+ const setAnswerValue = useCallback(
+ (
+ question: QuestionField,
+ questionIndex: number,
+ value: MarriageFieldValue,
+ ) => {
+ const field = createQuestionField(question, questionIndex, value);
+
+ setAnswers((currentAnswers) => {
+ const nextAnswers = {
+ ...currentAnswers,
+ [field.key]: field,
+ };
+
+ answersRef.current = nextAnswers;
+ hasPendingSyncRef.current = true;
+ writeStoredAnswers(
+ storageKeyRef.current,
+ slugRef.current,
+ questionsRef.current,
+ nextAnswers,
+ true,
+ );
+
+ return nextAnswers;
+ });
+ setHasPendingSync(true);
+ },
+ [],
+ );
+
+ const flushAnswers = useCallback(
+ async (options?: FlushAnswersOptions) => {
+ if (!hasPendingSyncRef.current && !options?.force) {
+ return;
+ }
+
+ const payload = createPayload(answersRef.current, questionsRef.current);
+
+ if (payload.fields.length === 0) {
+ return;
+ }
+
+ await mutateAsync(payload);
+
+ hasPendingSyncRef.current = false;
+ setHasPendingSync(false);
+ writeStoredAnswers(
+ storageKeyRef.current,
+ slugRef.current,
+ questionsRef.current,
+ answersRef.current,
+ false,
+ );
+ },
+ [mutateAsync],
+ );
+ const flushAnswersRef = useRef(flushAnswers);
+
+ useEffect(() => {
+ flushAnswersRef.current = flushAnswers;
+ }, [flushAnswers]);
+
+ useEffect(() => {
+ const flushWithKeepalive = () => {
+ if (!hasPendingSyncRef.current) {
+ return;
+ }
+
+ const payload = createPayload(answersRef.current, questionsRef.current);
+
+ if (payload.fields.length === 0) {
+ return;
+ }
+
+ fetch(getKeepalivePatchUrl(slugRef.current), {
+ body: JSON.stringify(payload),
+ credentials: "include",
+ headers: {
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ },
+ keepalive: true,
+ method: "PATCH",
+ })
+ .then((response) => {
+ if (!response.ok) {
+ return;
+ }
+
+ hasPendingSyncRef.current = false;
+ writeStoredAnswers(
+ storageKeyRef.current,
+ slugRef.current,
+ questionsRef.current,
+ answersRef.current,
+ false,
+ );
+ })
+ .catch(() => {
+ // The local draft stays marked pending so a later exit can retry.
+ });
+ };
+
+ window.addEventListener("pagehide", flushWithKeepalive);
+
+ return () => {
+ window.removeEventListener("pagehide", flushWithKeepalive);
+ void flushAnswersRef.current();
+ };
+ }, []);
+
+ const contextValue = useMemo(
+ () => ({
+ flushAnswers,
+ getAnswerValue,
+ hasPendingSync,
+ isSaving,
+ setAnswerValue,
+ }),
+ [flushAnswers, getAnswerValue, hasPendingSync, isSaving, setAnswerValue],
+ );
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useQuestionAnswers() {
+ const context = useContext(QuestionAnswersContext);
+
+ if (!context) {
+ throw new Error(
+ "useQuestionAnswers must be used inside QuestionAnswersProvider",
+ );
+ }
+
+ return context;
+}
+
+export function useQuestionAnswer(
+ question: QuestionField,
+ questionIndex: number,
+) {
+ const context = useContext(QuestionAnswersContext);
+
+ return {
+ setValue: (value: MarriageFieldValue) => {
+ context?.setAnswerValue(question, questionIndex, value);
+ },
+ value: context?.getAnswerValue(question, questionIndex),
+ };
+}
diff --git a/src/components/questions/question-button.tsx b/src/components/questions/question-button.tsx
index de8b5ab..cfa0582 100644
--- a/src/components/questions/question-button.tsx
+++ b/src/components/questions/question-button.tsx
@@ -1,28 +1,47 @@
-import type { QuestionField } from "@/data/question-data";
+"use client";
+
import { IoInformation } from "react-icons/io5";
+import type { QuestionField } from "@/data/question-data";
+import { useQuestionAnswer } from "./question-answer-storage";
+import QuestionTitle from "./question-title";
type QuestionButtonProps = {
question: QuestionField;
+ questionIndex: number;
};
-export function QuestionButton({ question }: QuestionButtonProps) {
+export function QuestionButton({
+ question,
+ questionIndex,
+}: QuestionButtonProps) {
+ const { setValue, value } = useQuestionAnswer(question, questionIndex);
+ const isAnswered = value === true;
+
return (
-
+
-
{question.title}
+
{question.tooltip ? (
-
+
) : (
)}
-
-
- {question.description}
-
-
+
setValue(true)}
+ className="min-h-[50px] rounded-[15px] bg-[#181818] px-5 py-3 text-[15px] font-semibold text-white"
+ >
+ {question.extras.options[0] ||
+ question.extras.placeHolder ||
+ "Continue"}
+
);
}
diff --git a/src/components/questions/question-card.tsx b/src/components/questions/question-card.tsx
index b1ae7e7..9d3c784 100644
--- a/src/components/questions/question-card.tsx
+++ b/src/components/questions/question-card.tsx
@@ -9,9 +9,12 @@ import {
IoSchool,
} from "react-icons/io5";
import type { QuestionCardIcon, QuestionListItem } from "@/data/question-data";
+import { localizePath } from "@/i18n/config";
+import { useI18n } from "@/i18n/provider";
type QuestionCardProps = {
item: QuestionListItem;
+ progress?: number | null;
};
const RADIUS = 8;
@@ -25,15 +28,22 @@ const iconMap: Record
= {
contact: IoPeople,
};
-export function QuestionCard({ item }: QuestionCardProps) {
- const normalizedProgress = Math.max(0, Math.min(item.progress, 100));
+export function QuestionCard({
+ item,
+ progress = item.progress,
+}: QuestionCardProps) {
+ const { dictionary: t, locale } = useI18n();
+ const hasProgress = typeof progress === "number" && Number.isFinite(progress);
+ const normalizedProgress = hasProgress
+ ? Math.max(0, Math.min(Math.round(progress), 100))
+ : 0;
const dashOffset = CIRCUMFERENCE - (normalizedProgress / 100) * CIRCUMFERENCE;
const CardIcon = iconMap[item.icon];
return (
{item.required ? (
- (Required)
+ ({t.common.required})
) : null}
- Estimate time: {item.estimate}
+ {t.common.estimateTime}: {item.estimate}
{item.showInfoBadge ? (
-
+
) : (
@@ -100,7 +110,7 @@ export function QuestionCard({ item }: QuestionCardProps) {
/>
- {normalizedProgress}%
+ {hasProgress ? `${normalizedProgress}%` : "..."}
diff --git a/src/components/questions/question-date.tsx b/src/components/questions/question-date.tsx
index e31f63e..05e6f06 100644
--- a/src/components/questions/question-date.tsx
+++ b/src/components/questions/question-date.tsx
@@ -1,11 +1,34 @@
+"use client";
+
import type { QuestionField } from "@/data/question-data";
+import { useQuestionAnswer } from "./question-answer-storage";
+import QuestionTitle from "./question-title";
type QuestionDateProps = {
question: QuestionField;
+ questionIndex: number;
};
-export function QuestionDate({ question }: QuestionDateProps) {
- return
;
+export function QuestionDate({ question, questionIndex }: QuestionDateProps) {
+ const { setValue, value } = useQuestionAnswer(question, questionIndex);
+ const dateValue = typeof value === "string" ? value : "";
+
+ return (
+
+
+ {
+ const nextValue = event.target.value;
+
+ 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]"
+ />
+
+ );
}
export default QuestionDate;
diff --git a/src/components/questions/question-dropdown.tsx b/src/components/questions/question-dropdown.tsx
index 0662f25..157ed37 100644
--- a/src/components/questions/question-dropdown.tsx
+++ b/src/components/questions/question-dropdown.tsx
@@ -1,11 +1,45 @@
+"use client";
+
import type { QuestionField } from "@/data/question-data";
+import { useQuestionAnswer } from "./question-answer-storage";
+import QuestionTitle from "./question-title";
type QuestionDropdownProps = {
question: QuestionField;
+ questionIndex: number;
};
-export function QuestionDropdown({ question }: QuestionDropdownProps) {
- return
;
+export function QuestionDropdown({
+ question,
+ questionIndex,
+}: QuestionDropdownProps) {
+ const { setValue, value } = useQuestionAnswer(question, questionIndex);
+ const selectValue = typeof value === "string" ? value : "";
+
+ return (
+
+
+ {
+ const nextValue = event.target.value;
+
+ 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]"
+ >
+
+ {question.extras.placeHolder || "Select an option"}
+
+ {question.extras.options.map((option) => (
+
+ {option}
+
+ ))}
+
+
+ );
}
export default QuestionDropdown;
diff --git a/src/components/questions/question-exit-navigation-button.tsx b/src/components/questions/question-exit-navigation-button.tsx
new file mode 100644
index 0000000..bc642ee
--- /dev/null
+++ b/src/components/questions/question-exit-navigation-button.tsx
@@ -0,0 +1,39 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { useState } from "react";
+import NavigationButton, {
+ type NavigationButtonProps,
+} from "@/components/ui/navigation-button";
+import { useQuestionAnswers } from "./question-answer-storage";
+
+export function QuestionExitNavigationButton(props: NavigationButtonProps) {
+ const router = useRouter();
+ const { flushAnswers } = useQuestionAnswers();
+ const [isLeaving, setIsLeaving] = useState(false);
+
+ return (
+ {
+ props.onClick?.(event);
+
+ if (event.defaultPrevented) {
+ return;
+ }
+
+ event.preventDefault();
+ setIsLeaving(true);
+
+ try {
+ await flushAnswers({ force: true });
+ } finally {
+ router.back();
+ }
+ }}
+ />
+ );
+}
+
+export default QuestionExitNavigationButton;
diff --git a/src/components/questions/question-file.tsx b/src/components/questions/question-file.tsx
index c7c39b3..2d9b46f 100644
--- a/src/components/questions/question-file.tsx
+++ b/src/components/questions/question-file.tsx
@@ -1,11 +1,80 @@
+"use client";
+
+import Image from "next/image";
+import { useState } from "react";
import type { QuestionField } from "@/data/question-data";
+import { useUploadTmpMediaMutation } from "@/hooks/marriage/use-upload-tmp-media";
+import { useQuestionAnswer } from "./question-answer-storage";
+import QuestionTitle from "./question-title";
type QuestionFileProps = {
question: QuestionField;
+ questionIndex: number;
};
-export function QuestionFile({ question }: QuestionFileProps) {
- return
;
+export function QuestionFile({ question, questionIndex }: QuestionFileProps) {
+ const [selectedFileName, setSelectedFileName] = useState(null);
+ const acceptedFiles = question.extras.options
+ .map((option) => option.replace(/^\./, ""))
+ .join(", ");
+ const { setValue } = useQuestionAnswer(question, questionIndex);
+ const uploadTmpMediaMutation = useUploadTmpMediaMutation({
+ onSuccess: (response) => {
+ setValue(response.path);
+ },
+ onError: () => {
+ setValue(null);
+ },
+ });
+
+ function handleFileChange(files: FileList | null) {
+ const file = files?.[0];
+
+ if (!file) {
+ setSelectedFileName(null);
+ setValue(null);
+ return;
+ }
+
+ setSelectedFileName(file.name);
+ uploadTmpMediaMutation.mutate(file);
+ }
+
+ return (
+
+
+
+ handleFileChange(event.target.files)}
+ className="absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0"
+ />
+
+
+ {uploadTmpMediaMutation.isPending
+ ? "uploading..."
+ : (selectedFileName ?? "uplaod certifacates")}
+
+ {uploadTmpMediaMutation.isError ? (
+
+ Upload failed. Please try again.
+
+ ) : acceptedFiles ? (
+
+ {acceptedFiles}
+
+ ) : null}
+
+
+ );
}
export default QuestionFile;
diff --git a/src/components/questions/question-number.tsx b/src/components/questions/question-number.tsx
index 19a7a17..deffed1 100644
--- a/src/components/questions/question-number.tsx
+++ b/src/components/questions/question-number.tsx
@@ -1,11 +1,49 @@
+"use client";
+
import type { QuestionField } from "@/data/question-data";
+import { useQuestionAnswer } from "./question-answer-storage";
+import QuestionTitle from "./question-title";
type QuestionNumberProps = {
question: QuestionField;
+ questionIndex: number;
};
-export function QuestionNumber({ question }: QuestionNumberProps) {
- return
;
+export function QuestionNumber({
+ question,
+ questionIndex,
+}: QuestionNumberProps) {
+ const [min, max] = question.extras.range;
+ const { setValue, value } = useQuestionAnswer(question, questionIndex);
+ const inputValue =
+ typeof value === "number" || typeof value === "string" ? String(value) : "";
+
+ return (
+
+
+ {
+ const nextValue = event.target.value;
+
+ if (nextValue.length === 0) {
+ setValue(null);
+ return;
+ }
+
+ const parsedValue = event.target.valueAsNumber;
+
+ 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]"
+ />
+
+ );
}
export default QuestionNumber;
diff --git a/src/components/questions/question-photo.tsx b/src/components/questions/question-photo.tsx
new file mode 100644
index 0000000..5e6c8c1
--- /dev/null
+++ b/src/components/questions/question-photo.tsx
@@ -0,0 +1,66 @@
+"use client";
+
+import Image from "next/image";
+import type { ReactNode } from "react";
+import type { QuestionField } from "@/data/question-data";
+import { useQuestionAnswer } from "./question-answer-storage";
+
+type QuestionPhotoProps = {
+ question: QuestionField;
+ questionIndex: number;
+ description?: ReactNode;
+};
+
+function getFileInputValue(files: FileList | null) {
+ const fileNames = Array.from(files ?? []).map((file) => file.name);
+
+ return fileNames.length > 0 ? fileNames.join(", ") : null;
+}
+
+export function QuestionPhoto({
+ question,
+ questionIndex,
+ description,
+}: QuestionPhotoProps) {
+ const acceptedFiles = question.extras.options.join(",");
+ const descriptionContent = description ?? question.description;
+ const { setValue } = useQuestionAnswer(question, questionIndex);
+
+ return (
+
+ setValue(getFileInputValue(event.target.files))}
+ className="sr-only"
+ />
+
+
+
+ {question.title}
+ {question.required ? (
+ *
+ ) : null}
+
+ {descriptionContent ? (
+
+ {descriptionContent}
+
+ ) : null}
+
+
+ );
+}
+
+export default QuestionPhoto;
diff --git a/src/components/questions/question-progress-tracker.tsx b/src/components/questions/question-progress-tracker.tsx
new file mode 100644
index 0000000..d873147
--- /dev/null
+++ b/src/components/questions/question-progress-tracker.tsx
@@ -0,0 +1,128 @@
+"use client";
+
+import {
+ type ReactNode,
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+} from "react";
+
+type QuestionProgressTrackerProps = {
+ children: ReactNode;
+ total: number;
+};
+
+function isQuestionAnswered(question: Element) {
+ const inputs = Array.from(
+ question.querySelectorAll<
+ HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
+ >("input, select, textarea"),
+ );
+
+ if (inputs.length === 0) {
+ return question.getAttribute("data-question-answered") === "true";
+ }
+
+ return inputs.some((input) => {
+ if (input instanceof HTMLInputElement) {
+ if (input.type === "checkbox" || input.type === "radio") {
+ return input.checked;
+ }
+
+ if (input.type === "file") {
+ return input.files !== null && input.files.length > 0;
+ }
+ }
+
+ return input.value.trim().length > 0;
+ });
+}
+
+export function QuestionProgressTracker({
+ children,
+ total,
+}: QuestionProgressTrackerProps) {
+ const containerRef = useRef(null);
+ const [answered, setAnswered] = useState(0);
+ const safeTotal = Math.max(total, 0);
+ const progress = safeTotal > 0 ? (answered / safeTotal) * 100 : 0;
+
+ const updateProgress = useCallback(() => {
+ const container = containerRef.current;
+
+ if (!container) {
+ return;
+ }
+
+ const questions = Array.from(
+ container.querySelectorAll("[data-question-type]"),
+ );
+ const nextAnswered = questions.filter(isQuestionAnswered).length;
+
+ setAnswered(Math.min(nextAnswered, safeTotal));
+ }, [safeTotal]);
+
+ useEffect(() => {
+ const container = containerRef.current;
+
+ if (!container) {
+ return;
+ }
+
+ const observer = new MutationObserver(updateProgress);
+
+ observer.observe(container, {
+ attributeFilter: ["data-question-answered"],
+ attributes: true,
+ subtree: true,
+ });
+
+ updateProgress();
+
+ return () => observer.disconnect();
+ }, [updateProgress]);
+
+ return (
+
+
+
+
+
+ fields to complete
+
+ {answered} /{safeTotal}
+
+
+
+
0 ? "min-w-[37px]" : "",
+ ]
+ .filter(Boolean)
+ .join(" ")}
+ style={{ width: `${progress}%` }}
+ />
+
+
+
+
+ {children}
+
+ );
+}
+
+export default QuestionProgressTracker;
diff --git a/src/components/questions/question-radio.tsx b/src/components/questions/question-radio.tsx
index b091e8d..e89e12a 100644
--- a/src/components/questions/question-radio.tsx
+++ b/src/components/questions/question-radio.tsx
@@ -1,11 +1,65 @@
+"use client";
+
+import { useId } from "react";
+
import type { QuestionField } from "@/data/question-data";
+import { useQuestionAnswer } from "./question-answer-storage";
+import QuestionTitle from "./question-title";
type QuestionRadioProps = {
question: QuestionField;
+ questionIndex: number;
};
-export function QuestionRadio({ question }: QuestionRadioProps) {
- return
;
+export function QuestionRadio({ question, questionIndex }: QuestionRadioProps) {
+ const groupId = useId();
+ const options = question.extras.options;
+ const { setValue, value } = useQuestionAnswer(question, questionIndex);
+ const selectedOption = typeof value === "string" ? value : (options[0] ?? "");
+
+ if (options.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+
+ {options.map((option) => {
+ const optionId = `${groupId}-${option}`;
+ const isSelected = selectedOption === option;
+
+ return (
+
+ setValue(option)}
+ />
+ {option}
+
+ );
+ })}
+
+
+ );
}
export default QuestionRadio;
diff --git a/src/components/questions/question-section-flow.tsx b/src/components/questions/question-section-flow.tsx
new file mode 100644
index 0000000..dd8df14
--- /dev/null
+++ b/src/components/questions/question-section-flow.tsx
@@ -0,0 +1,70 @@
+"use client";
+
+import Image from "next/image";
+import { useRouter } from "next/navigation";
+import type { ReactNode } from "react";
+import { useCallback, useState } from "react";
+import Button from "@/components/ui/button";
+import { useQuestionAnswers } from "./question-answer-storage";
+import QuestionProgressTracker from "./question-progress-tracker";
+import QuestionSnapList from "./question-snap-list";
+
+type QuestionSectionFlowProps = {
+ children: ReactNode;
+ continueLabel: string;
+ exitHref: string;
+ total: number;
+};
+
+export function QuestionSectionFlow({
+ children,
+ continueLabel,
+ exitHref,
+ total,
+}: QuestionSectionFlowProps) {
+ const router = useRouter();
+ const { flushAnswers, isSaving } = useQuestionAnswers();
+ const [isLeaving, setIsLeaving] = useState(false);
+ const handleQuestionExit = useCallback(() => {
+ void flushAnswers({ force: true });
+ }, [flushAnswers]);
+ const handleContinue = useCallback(async () => {
+ setIsLeaving(true);
+
+ try {
+ await flushAnswers({ force: true });
+ } finally {
+ router.replace(exitHref);
+ }
+ }, [exitHref, flushAnswers, router]);
+
+ return (
+
+
+ }
+ footer={
+ void handleContinue()}
+ >
+ {continueLabel}
+
+ }
+ onQuestionExit={handleQuestionExit}
+ >
+ {children}
+
+
+ );
+}
+
+export default QuestionSectionFlow;
diff --git a/src/components/questions/question-slider.tsx b/src/components/questions/question-slider.tsx
index f330de8..283193f 100644
--- a/src/components/questions/question-slider.tsx
+++ b/src/components/questions/question-slider.tsx
@@ -1,11 +1,105 @@
+"use client";
+
+import { useLayoutEffect, useRef, useState } from "react";
import type { QuestionField } from "@/data/question-data";
+import { useQuestionAnswer } from "./question-answer-storage";
+import QuestionTitle from "./question-title";
type QuestionSliderProps = {
question: QuestionField;
+ questionIndex: number;
};
-export function QuestionSlider({ question }: QuestionSliderProps) {
- return
;
+export function QuestionSlider({
+ question,
+ questionIndex,
+}: QuestionSliderProps) {
+ const [min, max] = question.extras.range;
+ const initialValue = Math.round((min + max) / 2);
+ const { setValue, value: storedValue } = useQuestionAnswer(
+ question,
+ questionIndex,
+ );
+ const value = typeof storedValue === "number" ? storedValue : initialValue;
+ const progress = max === min ? 0 : ((value - min) / (max - min)) * 100;
+ const sliderWrapperRef = useRef
(null);
+ const sliderRef = useRef(null);
+ const thumbWidth = 18;
+ const bubbleHalfWidth = 20;
+ const [bubblePosition, setBubblePosition] = useState(bubbleHalfWidth);
+ const steps = Array.from(
+ { length: max - min + 1 },
+ (_, index) => min + index,
+ );
+
+ useLayoutEffect(() => {
+ const wrapper = sliderWrapperRef.current;
+ const slider = sliderRef.current;
+
+ if (!wrapper || !slider) {
+ return;
+ }
+
+ const updateBubblePosition = () => {
+ const wrapperRect = wrapper.getBoundingClientRect();
+ const sliderRect = slider.getBoundingClientRect();
+ const sliderLeft = sliderRect.left - wrapperRect.left;
+ const thumbCenter =
+ sliderLeft +
+ thumbWidth / 2 +
+ (progress / 100) * (sliderRect.width - thumbWidth);
+
+ setBubblePosition(
+ Math.min(
+ Math.max(thumbCenter, bubbleHalfWidth),
+ wrapperRect.width - bubbleHalfWidth,
+ ),
+ );
+ };
+
+ updateBubblePosition();
+
+ const resizeObserver = new ResizeObserver(updateBubblePosition);
+ resizeObserver.observe(wrapper);
+ resizeObserver.observe(slider);
+
+ return () => resizeObserver.disconnect();
+ }, [progress]);
+
+ return (
+
+
+
+
+
+ {value}
+
+
setValue(Number(event.target.value))}
+ className="question-slider-range w-full"
+ style={{
+ background: `linear-gradient(to right, #F43F5E 0%, #F43F5E ${progress}%, #FAD1D8 ${progress}%, #FAD1D8 100%)`,
+ }}
+ />
+
+
+ {steps.map((step) => (
+ {step}
+ ))}
+
+
+
+ );
}
export default QuestionSlider;
diff --git a/src/components/questions/question-snap-list.tsx b/src/components/questions/question-snap-list.tsx
new file mode 100644
index 0000000..390f1f3
--- /dev/null
+++ b/src/components/questions/question-snap-list.tsx
@@ -0,0 +1,199 @@
+"use client";
+
+import {
+ Children,
+ isValidElement,
+ type ReactNode,
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+} from "react";
+
+const WHEEL_GESTURE_IDLE_MS = 320;
+const TOUCH_MIN_DISTANCE = 8;
+
+type QuestionSnapListProps = {
+ children: ReactNode;
+ className?: string;
+ footer?: ReactNode;
+ firstQuestionHint?: ReactNode;
+ onQuestionExit?: (currentIndex: number, nextIndex: number) => void;
+};
+
+export function QuestionSnapList({
+ children,
+ className,
+ footer,
+ firstQuestionHint,
+ onQuestionExit,
+}: QuestionSnapListProps) {
+ const questions = Children.toArray(children);
+ const wheelLockedRef = useRef(false);
+ const wheelUnlockTimeoutRef = useRef(null);
+ const touchStartYRef = useRef(null);
+ const [activeIndex, setActiveIndex] = useState(0);
+
+ const stepQuestion = useCallback(
+ (direction: 1 | -1) => {
+ const nextIndex = Math.max(
+ 0,
+ Math.min(questions.length - 1, activeIndex + direction),
+ );
+
+ if (nextIndex === activeIndex) {
+ return;
+ }
+
+ onQuestionExit?.(activeIndex, nextIndex);
+ setActiveIndex(nextIndex);
+ },
+ [activeIndex, onQuestionExit, questions.length],
+ );
+
+ const scheduleWheelUnlock = useCallback(() => {
+ if (wheelUnlockTimeoutRef.current !== null) {
+ window.clearTimeout(wheelUnlockTimeoutRef.current);
+ }
+
+ wheelUnlockTimeoutRef.current = window.setTimeout(() => {
+ wheelLockedRef.current = false;
+ wheelUnlockTimeoutRef.current = null;
+ }, WHEEL_GESTURE_IDLE_MS);
+ }, []);
+
+ useEffect(() => {
+ return () => {
+ if (wheelUnlockTimeoutRef.current !== null) {
+ window.clearTimeout(wheelUnlockTimeoutRef.current);
+ }
+ };
+ }, []);
+
+ const handleWheel = useCallback(
+ (event: React.WheelEvent) => {
+ const delta = event.deltaY || event.deltaX;
+
+ if (delta === 0 || questions.length < 2) {
+ return;
+ }
+
+ event.preventDefault();
+
+ if (!wheelLockedRef.current) {
+ wheelLockedRef.current = true;
+ stepQuestion(delta > 0 ? 1 : -1);
+ }
+
+ scheduleWheelUnlock();
+ },
+ [questions.length, scheduleWheelUnlock, stepQuestion],
+ );
+
+ const handleTouchStart = useCallback(
+ (event: React.TouchEvent) => {
+ touchStartYRef.current = event.touches[0]?.clientY ?? null;
+ },
+ [],
+ );
+
+ const handleTouchEnd = useCallback(
+ (event: React.TouchEvent) => {
+ const startY = touchStartYRef.current;
+ const endY = event.changedTouches[0]?.clientY;
+
+ touchStartYRef.current = null;
+
+ if (startY === null || endY === undefined || questions.length < 2) {
+ return;
+ }
+
+ const distance = startY - endY;
+
+ if (Math.abs(distance) < TOUCH_MIN_DISTANCE) {
+ return;
+ }
+
+ stepQuestion(distance > 0 ? 1 : -1);
+ },
+ [questions.length, stepQuestion],
+ );
+
+ const handleTouchMove = useCallback(
+ (event: React.TouchEvent) => {
+ event.preventDefault();
+ },
+ [],
+ );
+
+ if (questions.length === 0) {
+ return null;
+ }
+
+ return (
+
+ {questions.map((question, index) => {
+ const offset = index - activeIndex;
+ const isActive = offset === 0;
+ const isAdjacent = Math.abs(offset) === 1;
+ const showFooter = Boolean(
+ footer && isActive && index === questions.length - 1,
+ );
+ const questionKey =
+ isValidElement(question) && question.key !== null
+ ? question.key
+ : String(question);
+
+ return (
+
+
+ {question}
+ {showFooter ?
{footer}
: null}
+
+
+ );
+ })}
+ {firstQuestionHint ? (
+
+ {firstQuestionHint}
+
+ ) : null}
+
+ );
+}
+
+export default QuestionSnapList;
diff --git a/src/components/questions/question-text.tsx b/src/components/questions/question-text.tsx
index 317ed59..75cceec 100644
--- a/src/components/questions/question-text.tsx
+++ b/src/components/questions/question-text.tsx
@@ -1,11 +1,46 @@
+"use client";
+
import type { QuestionField } from "@/data/question-data";
+import { useQuestionAnswer } from "./question-answer-storage";
+import QuestionTitle from "./question-title";
type QuestionTextProps = {
question: QuestionField;
+ questionIndex: number;
+ description?: string;
+ heightClassName?: string;
};
-export function QuestionText({ question }: QuestionTextProps) {
- return
;
+export function QuestionText({
+ question,
+ questionIndex,
+ description,
+ heightClassName = "min-h-[116px]",
+}: QuestionTextProps) {
+ const { setValue, value } = useQuestionAnswer(question, questionIndex);
+ const textValue = typeof value === "string" ? value : "";
+
+ return (
+
+
+
+ );
}
export default QuestionText;
diff --git a/src/components/questions/question-title.tsx b/src/components/questions/question-title.tsx
new file mode 100644
index 0000000..2e346b6
--- /dev/null
+++ b/src/components/questions/question-title.tsx
@@ -0,0 +1,44 @@
+import { IoEyeOff } from "react-icons/io5";
+import type { QuestionField } from "@/data/question-data";
+
+type QuestionTitleProps = {
+ question: QuestionField;
+ className?: string;
+};
+
+export function QuestionTitle({ question, className }: QuestionTitleProps) {
+ return (
+
+ {question.private ? (
+
+
+ Private Field (Visible to Habib Marriage advisors only)
+
+ ) : null}
+
+
+ {question.title}
+ {question.description ? (
+
+ ({question.description})
+
+ ) : null}
+ {question.required ? (
+ *
+ ) : null}
+
+
+ );
+}
+
+export default QuestionTitle;
diff --git a/src/components/questions/required-steps-card.tsx b/src/components/questions/required-steps-card.tsx
new file mode 100644
index 0000000..64c0c23
--- /dev/null
+++ b/src/components/questions/required-steps-card.tsx
@@ -0,0 +1,82 @@
+"use client";
+
+import { IoAlert } from "react-icons/io5";
+import { getQuestionListItems } from "@/data/question-data";
+import { useMarriageSectionsQuery } from "@/hooks/marriage/use-sections";
+import { useI18n } from "@/i18n/provider";
+
+type RequiredStep = {
+ slug: string;
+ required: boolean;
+ progress: number;
+};
+
+function getRequiredStepStats(steps: RequiredStep[]) {
+ const requiredSteps = steps.filter((step) => step.required);
+ const completedSteps = requiredSteps.filter((step) => step.progress >= 100);
+
+ return {
+ completed: completedSteps.length,
+ total: requiredSteps.length,
+ };
+}
+
+export default function RequiredStepsCard() {
+ const { dictionary: t, locale } = useI18n();
+ const { data: sections } = useMarriageSectionsQuery();
+ const fallbackRequiredSteps: RequiredStep[] = getQuestionListItems(
+ locale,
+ ).map((item) => ({
+ slug: item.slug,
+ required: Boolean(item.required),
+ progress: item.progress,
+ }));
+ const steps =
+ sections?.map((section) => ({
+ slug: section.slug,
+ required: section.is_required,
+ progress: section.completion_percent,
+ })) ?? fallbackRequiredSteps;
+ const { completed, total } = getRequiredStepStats(steps);
+ const completion = total > 0 ? Math.round((completed / total) * 100) : 0;
+
+ return (
+
+
+
+
+
+
+
+
+ {t.questions.requiredSteps}
+
+
+
+
+ {t.questions.requiredStepsDescription}
+
+
+
+
+
+
+ {completed}/{total}
+
+
+
+
+ );
+}
diff --git a/src/components/sliders/slider-page.tsx b/src/components/sliders/slider-page.tsx
index 67a30f8..77ee9cd 100644
--- a/src/components/sliders/slider-page.tsx
+++ b/src/components/sliders/slider-page.tsx
@@ -2,7 +2,12 @@
import { useRouter } from "next/navigation";
import type { TouchEvent } from "react";
-import { useState } from "react";
+import { useEffect, useState } from "react";
+import type {
+ GenderAnswer,
+ RegistrationAnswer,
+} from "@/components/sliders/slider-slide";
+import { SliderSlideFive } from "@/components/sliders/slider-slide-five";
import { SliderSlideFour } from "@/components/sliders/slider-slide-four";
import {
SliderSlideOne,
@@ -10,29 +15,94 @@ import {
} from "@/components/sliders/slider-slide-one";
import { SliderSlideThree } from "@/components/sliders/slider-slide-three";
import { SliderSlideTwo } from "@/components/sliders/slider-slide-two";
+import Button from "@/components/ui/button";
import NavigationButton from "@/components/ui/navigation-button";
+import { useUpdateMarriageProfileBasicMutation } from "@/hooks/marriage/use-profile-basic";
-const SLIDE_COUNT = 4;
+const BASE_SLIDE_COUNT = 4;
+const FINAL_SLIDE_COUNT = 5;
const SWIPE_THRESHOLD = 40;
-const SLIDES = [
- { id: "slide-1", component: SliderSlideOne },
- { id: "slide-2", component: SliderSlideTwo },
- { id: "slide-3", component: SliderSlideThree },
- { id: "slide-4", component: SliderSlideFour },
-] as const;
+const SLIDER_ANSWERS_STORAGE_KEY = "marriage-slider-answers";
+
+type SavedSliderAnswers = {
+ gender: GenderAnswer;
+ registration: RegistrationAnswer;
+};
+
+const isGenderAnswer = (value: unknown): value is GenderAnswer =>
+ value === "man" || value === "woman";
+
+const isRegistrationAnswer = (value: unknown): value is RegistrationAnswer =>
+ value === "self" || value === "other";
export default function SliderPage() {
const router = useRouter();
const [activeSlide, setActiveSlide] = useState(0);
+ const [selectedGender, setSelectedGender] = useState("woman");
+ const [selectedRegistration, setSelectedRegistration] =
+ useState("other");
+ const [hasLoadedSavedAnswers, setHasLoadedSavedAnswers] = useState(false);
const [touchStartX, setTouchStartX] = useState(null);
- const shouldShowBottomActions =
- activeSlide === 0 ||
- activeSlide === 1 ||
- activeSlide === 2 ||
- activeSlide === 3;
+ const updateProfileBasicMutation = useUpdateMarriageProfileBasicMutation();
+ const hasFinalNotice = selectedGender === "woman";
+ const maxSlideIndex = hasFinalNotice
+ ? FINAL_SLIDE_COUNT - 1
+ : BASE_SLIDE_COUNT - 1;
+ const displaySlideCount =
+ hasFinalNotice && activeSlide === FINAL_SLIDE_COUNT - 1
+ ? FINAL_SLIDE_COUNT
+ : BASE_SLIDE_COUNT;
+ const navDotCount = displaySlideCount;
+
+ useEffect(() => {
+ const savedAnswers = window.localStorage.getItem(
+ SLIDER_ANSWERS_STORAGE_KEY,
+ );
+
+ if (!savedAnswers) {
+ setHasLoadedSavedAnswers(true);
+ return;
+ }
+
+ try {
+ const parsed = JSON.parse(savedAnswers) as Partial;
+
+ if (isGenderAnswer(parsed.gender)) {
+ setSelectedGender(parsed.gender);
+ }
+
+ if (isRegistrationAnswer(parsed.registration)) {
+ setSelectedRegistration(parsed.registration);
+ }
+ } catch {
+ window.localStorage.removeItem(SLIDER_ANSWERS_STORAGE_KEY);
+ }
+
+ setHasLoadedSavedAnswers(true);
+ }, []);
+
+ useEffect(() => {
+ if (!hasLoadedSavedAnswers) {
+ return;
+ }
+
+ window.localStorage.setItem(
+ SLIDER_ANSWERS_STORAGE_KEY,
+ JSON.stringify({
+ gender: selectedGender,
+ registration: selectedRegistration,
+ }),
+ );
+ }, [hasLoadedSavedAnswers, selectedGender, selectedRegistration]);
+
+ useEffect(() => {
+ if (!hasFinalNotice && activeSlide >= FINAL_SLIDE_COUNT - 1) {
+ setActiveSlide(BASE_SLIDE_COUNT - 1);
+ }
+ }, [activeSlide, hasFinalNotice]);
const goToSlide = (index: number) => {
- setActiveSlide(Math.max(0, Math.min(index, SLIDE_COUNT - 1)));
+ setActiveSlide(Math.max(0, Math.min(index, maxSlideIndex)));
};
const goToPreviousSlide = () => {
@@ -43,6 +113,15 @@ export default function SliderPage() {
goToSlide(activeSlide + 1);
};
+ const completeSlider = async () => {
+ await updateProfileBasicMutation.mutateAsync({
+ gender: selectedGender === "man" ? "male" : "female",
+ is_registering_for_self: selectedRegistration === "self",
+ });
+
+ router.push("/questions-list");
+ };
+
const handleTouchStart = (event: TouchEvent) => {
setTouchStartX(event.touches[0]?.clientX ?? null);
};
@@ -71,20 +150,20 @@ export default function SliderPage() {
router.back()}
/>
- {SLIDES.map(({ id }, index) => {
+ {Array.from({ length: navDotCount }, (_, index) => {
const isActive = index === activeSlide;
return (
- {SLIDES.map(({ id, component: SlideComponent }, index) => (
-
- ))}
+
+
+
+
+ {hasFinalNotice ? : null}
- {shouldShowBottomActions ? (
-
-
-
-
-
+
+
+
+ {activeSlide === 0 ? (
+
+ ) : activeSlide === BASE_SLIDE_COUNT - 1 ? (
+
+ ) : activeSlide === maxSlideIndex ? (
+
+ ) : (
+
+ )}
- ) : null}
+
+
+ );
+}
+
+type SliderStepActionsProps = {
+ onBack: () => void;
+ onNext: () => void;
+};
+
+function SliderStepActions({ onBack, onNext }: SliderStepActionsProps) {
+ return (
+
+
+ Back
+
+
+ Next
+
+
+ );
+}
+
+type SliderFinalActionsProps = {
+ isFinishing?: boolean;
+ onBack: () => void;
+ onFinish: () => void | Promise
;
+};
+
+function SliderFinalActions({
+ isFinishing = false,
+ onBack,
+ onFinish,
+}: SliderFinalActionsProps) {
+ return (
+
+
+ Back
+
+
+ {isFinishing ? "Saving..." : "Finish"}
+
);
}
diff --git a/src/components/sliders/slider-slide-five.tsx b/src/components/sliders/slider-slide-five.tsx
new file mode 100644
index 0000000..a41da30
--- /dev/null
+++ b/src/components/sliders/slider-slide-five.tsx
@@ -0,0 +1,40 @@
+import type { SliderSlideProps } from "@/components/sliders/slider-slide";
+
+const NOTICE_ITEMS = [
+ "Your information is kept strictly confidential.",
+ "Your details are only used for the matching process.",
+ "Nothing is shared without your consent.",
+ "You are always in control of what happens next.",
+ "Contact details are shared only after your approval.",
+ "We provide a safe and respectful environment at every step.",
+];
+
+export function SliderSlideFive({ index }: SliderSlideProps) {
+ return (
+
+
+
+
Submit Process
+
5/5 Final Notice
+
+
+
+
+ Your privacy and safety are our top priorities. We are committed to
+ keeping your information secure and giving you full control
+ throughout the process.
+
+
+
+ {NOTICE_ITEMS.map((item) => (
+ {item}
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/components/sliders/slider-slide-four.tsx b/src/components/sliders/slider-slide-four.tsx
index 9d0e6e0..03edea6 100644
--- a/src/components/sliders/slider-slide-four.tsx
+++ b/src/components/sliders/slider-slide-four.tsx
@@ -1,12 +1,20 @@
"use client";
-import { useState } from "react";
-import type { SliderSlideProps } from "@/components/sliders/slider-slide";
+import type {
+ RegistrationAnswer,
+ SliderSlideProps,
+} from "@/components/sliders/slider-slide";
-export function SliderSlideFour({ index }: SliderSlideProps) {
- const [selectedOption, setSelectedOption] = useState<"self" | "other">(
- "other",
- );
+type SliderSlideFourProps = SliderSlideProps & {
+ selectedRegistration: RegistrationAnswer;
+ onRegistrationChange: (registration: RegistrationAnswer) => void;
+};
+
+export function SliderSlideFour({
+ index,
+ selectedRegistration,
+ onRegistrationChange,
+}: SliderSlideFourProps) {
const options = [
{ id: "self", label: "Registering for myself" },
{ id: "other", label: "Registering for someone else" },
@@ -34,7 +42,7 @@ export function SliderSlideFour({ index }: SliderSlideProps) {
{options.map((option) => {
- const isSelected = option.id === selectedOption;
+ const isSelected = option.id === selectedRegistration;
return (
setSelectedOption(option.id)}
+ onClick={() => onRegistrationChange(option.id)}
>
-
+
@@ -107,11 +107,24 @@ export function SliderSlideOne({ index }: SliderSlideProps) {
);
}
-export function SliderSlideOneActions() {
+type SliderSlideOneActionsProps = {
+ onAccept: () => void;
+};
+
+export function SliderSlideOneActions({
+ onAccept,
+}: SliderSlideOneActionsProps) {
return (
-
-
Decline
-
+
+
+ Decline
+
+
Accept
diff --git a/src/components/sliders/slider-slide-three.tsx b/src/components/sliders/slider-slide-three.tsx
index bbc78a6..9717000 100644
--- a/src/components/sliders/slider-slide-three.tsx
+++ b/src/components/sliders/slider-slide-three.tsx
@@ -1,7 +1,46 @@
+"use client";
+
import Image from "next/image";
-import type { SliderSlideProps } from "@/components/sliders/slider-slide";
+import type {
+ GenderAnswer,
+ SliderSlideProps,
+} from "@/components/sliders/slider-slide";
+
+type GenderOption = {
+ id: GenderAnswer;
+ label: string;
+ alt: string;
+ enabledSrc: string;
+ disabledSrc: string;
+};
+
+const GENDER_OPTIONS: GenderOption[] = [
+ {
+ id: "man",
+ label: "Submit Man",
+ alt: "man",
+ enabledSrc: "/assets/images/Group 27033.svg",
+ disabledSrc: "/assets/images/disabled Group 27033.svg",
+ },
+ {
+ id: "woman",
+ label: "Submit Woman",
+ alt: "woman",
+ enabledSrc: "/assets/images/enabled Group 27032.svg",
+ disabledSrc: "/assets/images/Group 27032.svg",
+ },
+];
-export function SliderSlideThree({ index }: SliderSlideProps) {
+type SliderSlideThreeProps = SliderSlideProps & {
+ selectedGender: GenderAnswer;
+ onGenderChange: (gender: GenderAnswer) => void;
+};
+
+export function SliderSlideThree({
+ index,
+ selectedGender,
+ onGenderChange,
+}: SliderSlideThreeProps) {
return (
-
-
+ {GENDER_OPTIONS.map((option) => {
+ const isSelected = option.id === selectedGender;
+
+ return (
+
onGenderChange(option.id)}
+ >
+
+
+
+
+
+ {option.label}
+
+
+ );
+ })}
diff --git a/src/components/sliders/slider-slide-two.tsx b/src/components/sliders/slider-slide-two.tsx
index 2c09c81..7a82069 100644
--- a/src/components/sliders/slider-slide-two.tsx
+++ b/src/components/sliders/slider-slide-two.tsx
@@ -13,7 +13,7 @@ export function SliderSlideTwo({ index }: SliderSlideProps) {
{index + 1}/4 terms & conditions
-
+
+
+
+
+
+ Dr. Hasti Masoudi
+
+
+
+ We have come together with the goal of creating a secure and
+ confidential path for "permanent marriage" among Muslims
+
+
);
}
diff --git a/src/components/sliders/slider-slide.tsx b/src/components/sliders/slider-slide.tsx
index 9ee1617..189c5d7 100644
--- a/src/components/sliders/slider-slide.tsx
+++ b/src/components/sliders/slider-slide.tsx
@@ -2,6 +2,9 @@ export type SliderSlideProps = {
index: number;
};
+export type GenderAnswer = "man" | "woman";
+export type RegistrationAnswer = "self" | "other";
+
export function SliderSlide({ index }: SliderSlideProps) {
return (
,
"children"
> & {
- children: string;
+ children: ReactNode;
description?: string;
variant?: ButtonVariant;
arrowDirection?: ArrowDirection;
countdownSeconds?: number;
+ href?: string;
};
const FILLED_STROKE = "#FFFFFF";
@@ -29,15 +39,20 @@ export function Button({
arrowDirection,
countdownSeconds = 0,
disabled,
+ href,
className,
type = "button",
...props
}: ButtonProps) {
+ const { locale } = useI18n();
const countdownId = useId();
const isCountdown = variant === "countdown";
const isOutlined = variant === "outlined";
const initialCountdown = Math.max(0, Math.ceil(countdownSeconds));
const [remainingSeconds, setRemainingSeconds] = useState(initialCountdown);
+ const [animatedProgress, setAnimatedProgress] = useState(
+ isCountdown && initialCountdown > 0 ? 0 : 1,
+ );
useEffect(() => {
setRemainingSeconds(initialCountdown);
@@ -55,12 +70,37 @@ export function Button({
return () => window.clearTimeout(timeoutId);
}, [isCountdown, remainingSeconds]);
+ useEffect(() => {
+ if (!isCountdown || initialCountdown <= 0) {
+ setAnimatedProgress(1);
+ return;
+ }
+
+ let animationFrameId = 0;
+ const startedAt = window.performance.now();
+ const duration = initialCountdown * 1000;
+
+ setAnimatedProgress(0);
+
+ const updateProgress = (currentTime: number) => {
+ const elapsed = currentTime - startedAt;
+ const nextProgress = Math.min(1, elapsed / duration);
+
+ setAnimatedProgress(nextProgress);
+
+ if (nextProgress < 1) {
+ animationFrameId = window.requestAnimationFrame(updateProgress);
+ }
+ };
+
+ animationFrameId = window.requestAnimationFrame(updateProgress);
+
+ return () => window.cancelAnimationFrame(animationFrameId);
+ }, [initialCountdown, isCountdown]);
+
const countdownLocked = isCountdown && remainingSeconds > 0;
const isDisabled = disabled || countdownLocked;
- const progress =
- isCountdown && initialCountdown > 0
- ? (initialCountdown - remainingSeconds) / initialCountdown
- : 1;
+ const progress = isCountdown ? animatedProgress : 1;
const widthClass = variant === "secondary" ? "w-1/2" : "w-full";
const baseClassName = [
@@ -70,12 +110,12 @@ export function Button({
"justify-center",
"rounded-[11px]",
"px-4",
- "py-[14px]",
+ countdownLocked ? "py-[9px]" : "py-[14px]",
"text-center",
"transition-opacity",
isOutlined
? "border border-[#8B8B8B] bg-transparent text-[#8B8B8B]"
- : "bg-linear-to-tl from-[#FF8DF0] to-[#F14B46] text-white",
+ : "bg-linear-to-tl from-[#FE6F82] to-[#E03950] text-white",
isDisabled ? "cursor-not-allowed opacity-50" : "cursor-pointer",
className,
]
@@ -101,7 +141,7 @@ export function Button({
);
};
- return (
+ const button = (
);
+
+ return href ? (
+ {button}
+ ) : (
+ button
+ );
}
type CountdownProgressProps = {
diff --git a/src/components/ui/call-result-sheet.tsx b/src/components/ui/call-result-sheet.tsx
new file mode 100644
index 0000000..a49f840
--- /dev/null
+++ b/src/components/ui/call-result-sheet.tsx
@@ -0,0 +1,212 @@
+"use client";
+
+import type { HTMLAttributes } from "react";
+import { useEffect, useId, useState } from "react";
+import Button from "@/components/ui/button";
+import { useI18n } from "@/i18n/provider";
+
+const EXIT_ANIMATION_MS = 220;
+
+export type CallResultSheetProps = Omit<
+ HTMLAttributes,
+ "title"
+> & {
+ closeOnOutside?: boolean;
+ onClose?: () => void;
+ onSubmit?: (value: string) => void;
+ onOtherReasonsClick?: () => void;
+};
+
+export function CallResultSheet({
+ closeOnOutside = true,
+ onClose,
+ onSubmit,
+ onOtherReasonsClick,
+ className,
+ ...props
+}: CallResultSheetProps) {
+ const { dictionary: t } = useI18n();
+ const callResultOptions = t.sheets.callOptions;
+ const groupId = useId();
+ const [isVisible, setIsVisible] = useState(true);
+ const [isEntering, setIsEntering] = useState(true);
+ const [isClosing, setIsClosing] = useState(false);
+ const [selectedReason, setSelectedReason] = useState(
+ callResultOptions[0],
+ );
+
+ const closeSheet = () => {
+ if (isClosing) {
+ return;
+ }
+
+ setIsClosing(true);
+ };
+
+ useEffect(() => {
+ const frameId = window.requestAnimationFrame(() => {
+ setIsEntering(false);
+ });
+
+ return () => {
+ window.cancelAnimationFrame(frameId);
+ };
+ }, []);
+
+ useEffect(() => {
+ if (!isVisible) {
+ return;
+ }
+
+ const previousBodyOverflow = document.body.style.overflow;
+ const previousHtmlOverflow = document.documentElement.style.overflow;
+
+ document.body.style.overflow = "hidden";
+ document.documentElement.style.overflow = "hidden";
+
+ return () => {
+ document.body.style.overflow = previousBodyOverflow;
+ document.documentElement.style.overflow = previousHtmlOverflow;
+ };
+ }, [isVisible]);
+
+ useEffect(() => {
+ if (!isClosing) {
+ return;
+ }
+
+ const timeoutId = window.setTimeout(() => {
+ setIsVisible(false);
+ onClose?.();
+ }, EXIT_ANIMATION_MS);
+
+ return () => {
+ window.clearTimeout(timeoutId);
+ };
+ }, [isClosing, onClose]);
+
+ if (!isVisible) {
+ return null;
+ }
+
+ return (
+ {
+ if (closeOnOutside && event.target === event.currentTarget) {
+ closeSheet();
+ }
+ }}
+ onKeyDown={(event) => {
+ if (
+ closeOnOutside &&
+ event.target === event.currentTarget &&
+ (event.key === "Escape" || event.key === "Enter" || event.key === " ")
+ ) {
+ event.preventDefault();
+ closeSheet();
+ }
+ }}
+ >
+
+
+
+ {t.sheets.callResult}
+
+
+
+
+ {t.sheets.selectCallResult}
+
+
+
+ {callResultOptions.map((option) => {
+ const checked = selectedReason === option;
+
+ return (
+
+ {
+ setSelectedReason(option);
+
+ if (
+ option ===
+ callResultOptions[callResultOptions.length - 1]
+ ) {
+ onOtherReasonsClick?.();
+ closeSheet();
+ }
+ }}
+ />
+
+ {checked ? (
+
+ ) : null}
+
+
+ {option}
+
+
+ );
+ })}
+
+
+
+
+ {
+ onSubmit?.(selectedReason);
+ closeSheet();
+ }}
+ >
+ {t.common.submit}
+
+
+
+
+
+ );
+}
+
+export default CallResultSheet;
diff --git a/src/components/ui/dismiss-reason-sheet.tsx b/src/components/ui/dismiss-reason-sheet.tsx
new file mode 100644
index 0000000..9e0a22c
--- /dev/null
+++ b/src/components/ui/dismiss-reason-sheet.tsx
@@ -0,0 +1,159 @@
+"use client";
+
+import type { HTMLAttributes } from "react";
+import { useEffect, useState } from "react";
+import Button from "@/components/ui/button";
+import { useI18n } from "@/i18n/provider";
+
+const EXIT_ANIMATION_MS = 220;
+
+export type DismissReasonSheetProps = Omit<
+ HTMLAttributes,
+ "title"
+> & {
+ closeOnOutside?: boolean;
+ onClose?: () => void;
+ onSubmit?: (value: string) => void;
+};
+
+export function DismissReasonSheet({
+ closeOnOutside = true,
+ onClose,
+ onSubmit,
+ className,
+ ...props
+}: DismissReasonSheetProps) {
+ const { dictionary: t } = useI18n();
+ const [isVisible, setIsVisible] = useState(true);
+ const [isEntering, setIsEntering] = useState(true);
+ const [isClosing, setIsClosing] = useState(false);
+ const [reasonText, setReasonText] = useState("");
+
+ const closeSheet = () => {
+ if (isClosing) {
+ return;
+ }
+
+ setIsClosing(true);
+ };
+
+ useEffect(() => {
+ const frameId = window.requestAnimationFrame(() => {
+ setIsEntering(false);
+ });
+
+ return () => {
+ window.cancelAnimationFrame(frameId);
+ };
+ }, []);
+
+ useEffect(() => {
+ if (!isVisible) {
+ return;
+ }
+
+ const previousBodyOverflow = document.body.style.overflow;
+ const previousHtmlOverflow = document.documentElement.style.overflow;
+
+ document.body.style.overflow = "hidden";
+ document.documentElement.style.overflow = "hidden";
+
+ return () => {
+ document.body.style.overflow = previousBodyOverflow;
+ document.documentElement.style.overflow = previousHtmlOverflow;
+ };
+ }, [isVisible]);
+
+ useEffect(() => {
+ if (!isClosing) {
+ return;
+ }
+
+ const timeoutId = window.setTimeout(() => {
+ setIsVisible(false);
+ onClose?.();
+ }, EXIT_ANIMATION_MS);
+
+ return () => {
+ window.clearTimeout(timeoutId);
+ };
+ }, [isClosing, onClose]);
+
+ if (!isVisible) {
+ return null;
+ }
+
+ return (
+ {
+ if (closeOnOutside && event.target === event.currentTarget) {
+ closeSheet();
+ }
+ }}
+ onKeyDown={(event) => {
+ if (
+ closeOnOutside &&
+ event.target === event.currentTarget &&
+ (event.key === "Escape" || event.key === "Enter" || event.key === " ")
+ ) {
+ event.preventDefault();
+ closeSheet();
+ }
+ }}
+ >
+
+
+
+ {t.sheets.dismissReasons}
+
+
+
+ {t.sheets.dismissDescription}
+
+
+
+
+
+ );
+}
+
+export default DismissReasonSheet;
diff --git a/src/components/ui/information-sheet.tsx b/src/components/ui/information-sheet.tsx
index 49b5f56..bee27c7 100644
--- a/src/components/ui/information-sheet.tsx
+++ b/src/components/ui/information-sheet.tsx
@@ -4,14 +4,19 @@ import Image, { type StaticImageData } from "next/image";
import type { HTMLAttributes, ReactNode } from "react";
import { useEffect, useState } from "react";
import Button from "@/components/ui/button";
+import { useI18n } from "@/i18n/provider";
+
+const EXIT_ANIMATION_MS = 220;
type InformationSheetPresetIcon =
| "play"
| "warning"
| "coin"
+ | "check"
| "stash_play-solid.svg"
| "warning.svg"
- | "coin.svg";
+ | "coin.svg"
+ | "Vectorcheck.svg";
type InformationSheetIcon =
| InformationSheetPresetIcon
@@ -27,11 +32,12 @@ export type InformationSheetProps = Omit<
HTMLAttributes,
"children" | "title"
> & {
- icon?: InformationSheetIcon;
- title: ReactNode;
+ icon?: InformationSheetIcon | null;
+ title: ReactNode | ((controls: { close: () => void }) => ReactNode);
description?: ReactNode;
- buttons?: ReactNode;
+ buttons?: ReactNode | ((controls: { close: () => void }) => ReactNode);
closeOnOutside?: boolean;
+ onClose?: () => void;
};
const DEFAULT_ICON = {
@@ -76,9 +82,25 @@ const ICON_PRESETS: Record<
width: 50,
height: 50,
},
+ check: {
+ src: "/assets/images/Vectorcheck.svg",
+ alt: "Check",
+ width: 36,
+ height: 36,
+ },
+ "Vectorcheck.svg": {
+ src: "/assets/images/Vectorcheck.svg",
+ alt: "Check",
+ width: 36,
+ height: 36,
+ },
};
-function resolveIcon(icon: InformationSheetIcon | undefined) {
+function resolveIcon(icon: InformationSheetIcon | null | undefined) {
+ if (icon === null) {
+ return null;
+ }
+
if (!icon) {
return DEFAULT_ICON;
}
@@ -108,22 +130,47 @@ export function InformationSheet({
description,
buttons,
closeOnOutside = true,
+ onClose,
className,
...props
}: InformationSheetProps) {
- const [isOpen, setIsOpen] = useState(true);
+ const { dictionary: t } = useI18n();
+ const [isVisible, setIsVisible] = useState(true);
+ const [isEntering, setIsEntering] = useState(true);
+ const [isClosing, setIsClosing] = useState(false);
const resolvedIcon = resolveIcon(icon);
+ const closeSheet = () => {
+ if (isClosing) {
+ return;
+ }
+
+ setIsClosing(true);
+ };
+ const controls = { close: closeSheet };
+ const resolvedTitle = typeof title === "function" ? title(controls) : title;
const resolvedButtons =
typeof buttons === "string" ? (
- setIsOpen(false)} className="py-[18px]">
+
{buttons}
+ ) : typeof buttons === "function" ? (
+ buttons(controls)
) : (
buttons
);
useEffect(() => {
- if (!isOpen) {
+ const frameId = window.requestAnimationFrame(() => {
+ setIsEntering(false);
+ });
+
+ return () => {
+ window.cancelAnimationFrame(frameId);
+ };
+ }, []);
+
+ useEffect(() => {
+ if (!isVisible) {
return;
}
@@ -137,22 +184,44 @@ export function InformationSheet({
document.body.style.overflow = previousBodyOverflow;
document.documentElement.style.overflow = previousHtmlOverflow;
};
- }, [isOpen]);
+ }, [isVisible]);
+
+ useEffect(() => {
+ if (!isClosing) {
+ return;
+ }
+
+ const timeoutId = window.setTimeout(() => {
+ setIsVisible(false);
+ onClose?.();
+ }, EXIT_ANIMATION_MS);
+
+ return () => {
+ window.clearTimeout(timeoutId);
+ };
+ }, [isClosing, onClose]);
- if (!isOpen) {
+ if (!isVisible) {
return null;
}
return (
{
if (closeOnOutside && event.target === event.currentTarget) {
- setIsOpen(false);
+ closeSheet();
}
}}
onKeyDown={(event) => {
@@ -162,34 +231,42 @@ export function InformationSheet({
(event.key === "Escape" || event.key === "Enter" || event.key === " ")
) {
event.preventDefault();
- setIsOpen(false);
+ closeSheet();
}
}}
>
-
-
-
- {title}
+ {resolvedIcon ? (
+
+ ) : null}
+
+
+ {resolvedTitle}
{description ? (
- {description}
+
{description}
) : null}
{resolvedButtons ? (
diff --git a/src/components/ui/language-switcher.tsx b/src/components/ui/language-switcher.tsx
new file mode 100644
index 0000000..2ece9a7
--- /dev/null
+++ b/src/components/ui/language-switcher.tsx
@@ -0,0 +1,37 @@
+"use client";
+
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import { localeLabels, locales, localizePath } from "@/i18n/config";
+import { useI18n } from "@/i18n/provider";
+
+export default function LanguageSwitcher() {
+ const pathname = usePathname();
+ const { locale } = useI18n();
+
+ return (
+
+
+ {locales.map((nextLocale) => {
+ const isActive = locale === nextLocale;
+
+ return (
+
+ {localeLabels[nextLocale]}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/components/ui/navigation-button.tsx b/src/components/ui/navigation-button.tsx
index d2d69fd..f3b0dd8 100644
--- a/src/components/ui/navigation-button.tsx
+++ b/src/components/ui/navigation-button.tsx
@@ -1,11 +1,19 @@
"use client";
import Image from "next/image";
+import { useRouter } from "next/navigation";
import type { ButtonHTMLAttributes } from "react";
import { GoArrowLeft } from "react-icons/go";
-import { IoCloseOutline } from "react-icons/io5";
+import { useI18n } from "@/i18n/provider";
-type NavigationButtonIcon = "back" | "support" | "close";
+type NavigationButtonIcon =
+ | "back"
+ | "support"
+ | "close"
+ | "info"
+ | "document"
+ | "subscription";
+type NavigationButtonVariant = "default" | "transparent";
export type NavigationButtonProps = Omit<
ButtonHTMLAttributes
,
@@ -13,15 +21,19 @@ export type NavigationButtonProps = Omit<
> & {
icon: NavigationButtonIcon;
iconLabel?: string;
+ variant?: NavigationButtonVariant;
};
export function NavigationButton({
icon,
iconLabel,
+ variant = "default",
type = "button",
className,
...props
}: NavigationButtonProps) {
+ const router = useRouter();
+ const { dictionary: t } = useI18n();
const iconNode = (() => {
switch (icon) {
case "back":
@@ -30,9 +42,13 @@ export function NavigationButton({
);
case "close":
return (
-
);
case "support":
@@ -46,6 +62,39 @@ export function NavigationButton({
height={24}
/>
);
+ case "info":
+ return (
+
+ );
+ case "document":
+ return (
+
+ );
+ case "subscription":
+ return (
+
+ );
default:
return null;
}
@@ -55,9 +104,21 @@ export function NavigationButton({
{
+ props.onClick?.(event);
+
+ if (
+ !event.defaultPrevented &&
+ (icon === "back" || icon === "close") &&
+ !props.onClick
+ ) {
+ router.back();
+ }
+ }}
className={[
- "inline-flex items-center justify-center rounded-[15px] bg-[#FFFFFF] p-2",
+ "inline-flex items-center justify-center rounded-[15px] p-2",
+ variant === "transparent" ? "bg-white/20" : "bg-[#FFFFFF]",
className,
]
.filter(Boolean)
diff --git a/src/components/ui/sticky-header.tsx b/src/components/ui/sticky-header.tsx
new file mode 100644
index 0000000..d26d29e
--- /dev/null
+++ b/src/components/ui/sticky-header.tsx
@@ -0,0 +1,24 @@
+import type { ReactNode } from "react";
+
+type StickyHeaderProps = {
+ children: ReactNode;
+ className?: string;
+};
+
+export default function StickyHeader({
+ children,
+ className,
+}: StickyHeaderProps) {
+ return (
+
+ );
+}
diff --git a/src/data/question-data.ts b/src/data/question-data.ts
index 580a95c..2fd8990 100644
--- a/src/data/question-data.ts
+++ b/src/data/question-data.ts
@@ -1,4 +1,6 @@
-import rawQuestions from "@/data/mock-questions.json";
+import { defaultLocale, type Locale } from "@/i18n/config";
+import enQuestions from "@/i18n/locales/en/questions.json";
+import faQuestions from "@/i18n/locales/fa/questions.json";
export const bookingTerms = [
"You will be contacted by your consultant.",
@@ -23,6 +25,7 @@ export type QuestionField = {
title: string;
type: string;
required: boolean;
+ private?: boolean;
description: string;
tooltip: string;
extras: QuestionExtras;
@@ -57,27 +60,40 @@ type RawQuestionListItem = {
const iconMap: Record = {
"user-circle": "profile",
+ school: "education",
"heart-handshake": "details",
"file-text": "contact",
"layout-grid": "checklist",
};
-export const questionListItems: readonly QuestionListItem[] = (
- rawQuestions as RawQuestionListItem[]
-).map((item) => ({
- slug: item.slug,
- title: item.title,
- estimate: item.estimateTime,
- progress: item.progress,
- icon: iconMap[item.icon] ?? "details",
- required: item.required,
- showInfoBadge: Boolean(item.tooltip),
- summary: item.description,
- checkpoints: item.questions.map((question) => question.title),
- tooltip: item.tooltip,
- questions: item.questions,
-}));
+const questionsByLocale: Record = {
+ en: enQuestions as RawQuestionListItem[],
+ fa: faQuestions as RawQuestionListItem[],
+};
+
+function mapQuestionListItem(item: RawQuestionListItem): QuestionListItem {
+ return {
+ slug: item.slug,
+ title: item.title,
+ estimate: item.estimateTime,
+ progress: item.progress,
+ icon: iconMap[item.icon] ?? "details",
+ required: item.required,
+ showInfoBadge: Boolean(item.tooltip),
+ summary: item.description,
+ checkpoints: item.questions.map((question) => question.title),
+ tooltip: item.tooltip,
+ questions: item.questions,
+ };
+}
+
+export function getQuestionListItems(locale: Locale = defaultLocale) {
+ return questionsByLocale[locale].map(mapQuestionListItem);
+}
-export function getQuestionListItemBySlug(slug: string) {
- return questionListItems.find((item) => item.slug === slug);
+export function getQuestionListItemBySlug(
+ slug: string,
+ locale: Locale = defaultLocale,
+) {
+ return getQuestionListItems(locale).find((item) => item.slug === slug);
}
diff --git a/src/hooks/marriage/options.ts b/src/hooks/marriage/options.ts
new file mode 100644
index 0000000..27cd4de
--- /dev/null
+++ b/src/hooks/marriage/options.ts
@@ -0,0 +1,19 @@
+import type {
+ UseMutationOptions,
+ UseQueryOptions,
+} from "@tanstack/react-query";
+import type { ApiError } from "./types";
+
+export type QueryOptions = Omit<
+ UseQueryOptions,
+ "queryFn" | "queryKey"
+>;
+
+export type MutationOptions<
+ TData,
+ TVariables = void,
+ TOnMutateResult = unknown,
+> = Omit<
+ UseMutationOptions,
+ "mutationFn"
+>;
diff --git a/src/hooks/marriage/path-param.ts b/src/hooks/marriage/path-param.ts
new file mode 100644
index 0000000..1905505
--- /dev/null
+++ b/src/hooks/marriage/path-param.ts
@@ -0,0 +1,3 @@
+import type { CaseId } from "./types";
+
+export const pathParam = (value: CaseId) => encodeURIComponent(String(value));
diff --git a/src/hooks/marriage/query-keys.ts b/src/hooks/marriage/query-keys.ts
new file mode 100644
index 0000000..add9238
--- /dev/null
+++ b/src/hooks/marriage/query-keys.ts
@@ -0,0 +1,16 @@
+import type { CaseId } from "./types";
+
+export const marriageQueryKeys = {
+ all: ["marriage"] as const,
+ contactInfo: (caseId: CaseId | "") =>
+ [
+ ...marriageQueryKeys.all,
+ "cases",
+ String(caseId),
+ "contact-info",
+ ] as const,
+ profile: () => [...marriageQueryKeys.all, "profile"] as const,
+ sectionData: (slug: string) =>
+ [...marriageQueryKeys.sections(), slug, "data"] as const,
+ sections: () => [...marriageQueryKeys.all, "sections"] as const,
+};
diff --git a/src/hooks/marriage/types.ts b/src/hooks/marriage/types.ts
new file mode 100644
index 0000000..58dffc8
--- /dev/null
+++ b/src/hooks/marriage/types.ts
@@ -0,0 +1,158 @@
+import type { AxiosError } from "axios";
+
+export type ApiError = AxiosError;
+
+export type CaseId = number | string;
+
+export type MarriageGender = "male" | "female";
+
+export type MarriageProfileStatus =
+ | "pending_onboarding"
+ | "pending_info"
+ | "waiting"
+ | "in_case"
+ | "suspended"
+ | "matched";
+
+export type MarriageCaseStatus =
+ | "introduced"
+ | "male_accepted"
+ | "male_rejected"
+ | "female_rejected"
+ | "payment_pending"
+ | "payment_done"
+ | "finalized"
+ | "dismissed";
+
+export type MarriageFieldValue = string | number | boolean | null;
+
+export type MarriageField = {
+ key: string;
+ label: string;
+ type: string;
+ value: MarriageFieldValue;
+};
+
+export type MarriageRecommendedPlan = {
+ id: number;
+ title: string;
+ price: string;
+};
+
+export type MarriageSubscriptionPlan = MarriageRecommendedPlan & {
+ plan_type: string;
+ duration_days: number | null;
+ usage_limit: number | null;
+};
+
+export type MarriageActiveSubscription = {
+ id: number;
+ plan: MarriageSubscriptionPlan;
+ start_date: string;
+ end_date: string | null;
+ total_usages: number;
+ is_active: boolean;
+ is_valid: boolean;
+};
+
+export type MarriageActiveCase = {
+ case_id: number;
+ status: MarriageCaseStatus;
+ introduced_at: string | null;
+};
+
+export type MarriageMatchSummary = {
+ id: number;
+ gender: MarriageGender;
+ overall_completion_percent: number;
+ public_info: MarriageField[];
+};
+
+export type MarriageProfile = {
+ id: number;
+ status: MarriageProfileStatus;
+ gender: MarriageGender | null;
+ is_registering_for_self: boolean | null;
+ can_edit_profile: boolean;
+ can_message_expert: boolean;
+ active_subscription: MarriageActiveSubscription | null;
+ is_ready_for_match: boolean;
+ active_case: MarriageActiveCase | null;
+ needs_subscription: boolean;
+ recommended_plan?: MarriageRecommendedPlan | null;
+ match_summary: MarriageMatchSummary | null;
+};
+
+export type UpdateMarriageProfileBasicPayload = {
+ gender: MarriageGender;
+ is_registering_for_self: boolean;
+};
+
+export type MarriageSection = {
+ id: number;
+ slug: string;
+ title: string;
+ is_required: boolean;
+ importance_weight: number;
+ estimated_minutes: number | null;
+ total_steps: number;
+ order: number;
+ current_step: number;
+ completion_percent: number;
+};
+
+export type MarriageSectionData = {
+ id?: number;
+ slug: string;
+ data: MarriageField[] | null;
+ current_step: number;
+ total_steps: number;
+ completion_percent: number;
+ updated_at: string | null;
+};
+
+export type UpdateMarriageSectionDataPayload = {
+ current_step: number;
+ fields: MarriageField[];
+};
+
+export type StartMarriageMatchResponse = {
+ detail: string;
+};
+
+export type MarriageCase = {
+ id: number;
+ status: MarriageCaseStatus;
+ introduced_at: string | null;
+ male_responded_at?: string | null;
+ female_responded_at?: string | null;
+ payment_done_at?: string | null;
+ contact_shared_at?: string | null;
+};
+
+export type RespondMarriageCasePayload = {
+ action: "accept" | "reject";
+ reason_code?: string;
+ custom_note?: string;
+};
+
+export type SubmitMarriageContactStatusPayload = {
+ action: "contacted" | "no_contact";
+};
+
+export type MarriageCaseActionResponse = {
+ detail: string;
+ case: MarriageCase;
+};
+
+export type MarriageContactInfoResponse = {
+ contact_info: MarriageField[] | null;
+};
+
+export type UploadTmpMediaResponse = {
+ success: boolean;
+ path: string;
+ apath: string;
+ name: string;
+ size: string;
+};
diff --git a/src/hooks/marriage/use-case-respond.ts b/src/hooks/marriage/use-case-respond.ts
new file mode 100644
index 0000000..aeec792
--- /dev/null
+++ b/src/hooks/marriage/use-case-respond.ts
@@ -0,0 +1,50 @@
+"use client";
+
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { http } from "@/lib/http";
+import type { MutationOptions } from "./options";
+import { pathParam } from "./path-param";
+import { marriageQueryKeys } from "./query-keys";
+import type {
+ CaseId,
+ MarriageCaseActionResponse,
+ RespondMarriageCasePayload,
+} from "./types";
+
+export async function respondToMarriageCase(
+ caseId: CaseId,
+ payload: RespondMarriageCasePayload,
+) {
+ const { data } = await http.post(
+ `/api/marriage/cases/${pathParam(caseId)}/respond/`,
+ payload,
+ );
+
+ return data;
+}
+
+export function useRespondToMarriageCaseMutation(
+ caseId: CaseId,
+ options?: MutationOptions<
+ MarriageCaseActionResponse,
+ RespondMarriageCasePayload
+ >,
+) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ ...options,
+ mutationFn: (payload) => respondToMarriageCase(caseId, payload),
+ onSuccess: async (data, variables, onMutateResult, context) => {
+ await Promise.all([
+ queryClient.invalidateQueries({
+ queryKey: marriageQueryKeys.profile(),
+ }),
+ queryClient.invalidateQueries({
+ queryKey: marriageQueryKeys.contactInfo(caseId),
+ }),
+ ]);
+ await options?.onSuccess?.(data, variables, onMutateResult, context);
+ },
+ });
+}
diff --git a/src/hooks/marriage/use-contact-info.ts b/src/hooks/marriage/use-contact-info.ts
new file mode 100644
index 0000000..7bbbd36
--- /dev/null
+++ b/src/hooks/marriage/use-contact-info.ts
@@ -0,0 +1,32 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import { http } from "@/lib/http";
+import type { QueryOptions } from "./options";
+import { pathParam } from "./path-param";
+import { marriageQueryKeys } from "./query-keys";
+import type { CaseId, MarriageContactInfoResponse } from "./types";
+
+export async function getMarriageContactInfo(caseId: CaseId) {
+ const { data } = await http.get(
+ `/api/marriage/cases/${pathParam(caseId)}/contact-info/`,
+ );
+
+ return data;
+}
+
+export function useMarriageContactInfoQuery<
+ TData = MarriageContactInfoResponse,
+>(
+ caseId: CaseId | null | undefined,
+ options?: QueryOptions,
+) {
+ const normalizedCaseId = caseId ?? "";
+
+ return useQuery({
+ ...options,
+ enabled: Boolean(normalizedCaseId) && options?.enabled !== false,
+ queryFn: () => getMarriageContactInfo(normalizedCaseId),
+ queryKey: marriageQueryKeys.contactInfo(normalizedCaseId),
+ });
+}
diff --git a/src/hooks/marriage/use-contact-status.ts b/src/hooks/marriage/use-contact-status.ts
new file mode 100644
index 0000000..0872af6
--- /dev/null
+++ b/src/hooks/marriage/use-contact-status.ts
@@ -0,0 +1,50 @@
+"use client";
+
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { http } from "@/lib/http";
+import type { MutationOptions } from "./options";
+import { pathParam } from "./path-param";
+import { marriageQueryKeys } from "./query-keys";
+import type {
+ CaseId,
+ MarriageCaseActionResponse,
+ SubmitMarriageContactStatusPayload,
+} from "./types";
+
+export async function submitMarriageContactStatus(
+ caseId: CaseId,
+ payload: SubmitMarriageContactStatusPayload,
+) {
+ const { data } = await http.post(
+ `/api/marriage/cases/${pathParam(caseId)}/contact-status/`,
+ payload,
+ );
+
+ return data;
+}
+
+export function useSubmitMarriageContactStatusMutation(
+ caseId: CaseId,
+ options?: MutationOptions<
+ MarriageCaseActionResponse,
+ SubmitMarriageContactStatusPayload
+ >,
+) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ ...options,
+ mutationFn: (payload) => submitMarriageContactStatus(caseId, payload),
+ onSuccess: async (data, variables, onMutateResult, context) => {
+ await Promise.all([
+ queryClient.invalidateQueries({
+ queryKey: marriageQueryKeys.profile(),
+ }),
+ queryClient.invalidateQueries({
+ queryKey: marriageQueryKeys.contactInfo(caseId),
+ }),
+ ]);
+ await options?.onSuccess?.(data, variables, onMutateResult, context);
+ },
+ });
+}
diff --git a/src/hooks/marriage/use-match-start.ts b/src/hooks/marriage/use-match-start.ts
new file mode 100644
index 0000000..e5da39d
--- /dev/null
+++ b/src/hooks/marriage/use-match-start.ts
@@ -0,0 +1,37 @@
+"use client";
+
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { http } from "@/lib/http";
+import type { MutationOptions } from "./options";
+import { marriageQueryKeys } from "./query-keys";
+import type { StartMarriageMatchResponse } from "./types";
+
+export async function startMarriageMatch() {
+ const { data } = await http.post(
+ "/api/marriage/match/start/",
+ );
+
+ return data;
+}
+
+export function useStartMarriageMatchMutation(
+ options?: MutationOptions,
+) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ ...options,
+ mutationFn: startMarriageMatch,
+ onSuccess: async (data, variables, onMutateResult, context) => {
+ await Promise.all([
+ queryClient.invalidateQueries({
+ queryKey: marriageQueryKeys.profile(),
+ }),
+ queryClient.invalidateQueries({
+ queryKey: marriageQueryKeys.sections(),
+ }),
+ ]);
+ await options?.onSuccess?.(data, variables, onMutateResult, context);
+ },
+ });
+}
diff --git a/src/hooks/marriage/use-profile-basic.ts b/src/hooks/marriage/use-profile-basic.ts
new file mode 100644
index 0000000..9318d2a
--- /dev/null
+++ b/src/hooks/marriage/use-profile-basic.ts
@@ -0,0 +1,38 @@
+"use client";
+
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { http } from "@/lib/http";
+import type { MutationOptions } from "./options";
+import { marriageQueryKeys } from "./query-keys";
+import type {
+ MarriageProfile,
+ UpdateMarriageProfileBasicPayload,
+} from "./types";
+
+export async function updateMarriageProfileBasic(
+ payload: UpdateMarriageProfileBasicPayload,
+) {
+ const { data } = await http.patch(
+ "/api/marriage/profile/basic/",
+ payload,
+ );
+
+ return data;
+}
+
+export function useUpdateMarriageProfileBasicMutation(
+ options?: MutationOptions,
+) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ ...options,
+ mutationFn: updateMarriageProfileBasic,
+ onSuccess: async (data, variables, onMutateResult, context) => {
+ await queryClient.invalidateQueries({
+ queryKey: marriageQueryKeys.profile(),
+ });
+ await options?.onSuccess?.(data, variables, onMutateResult, context);
+ },
+ });
+}
diff --git a/src/hooks/marriage/use-profile-main.ts b/src/hooks/marriage/use-profile-main.ts
new file mode 100644
index 0000000..61798b2
--- /dev/null
+++ b/src/hooks/marriage/use-profile-main.ts
@@ -0,0 +1,25 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import { http } from "@/lib/http";
+import type { QueryOptions } from "./options";
+import { marriageQueryKeys } from "./query-keys";
+import type { MarriageProfile } from "./types";
+
+export async function getMarriageProfile() {
+ const { data } = await http.get(
+ "/api/marriage/profile/main/",
+ );
+
+ return data;
+}
+
+export function useMarriageProfileQuery(
+ options?: QueryOptions,
+) {
+ return useQuery({
+ ...options,
+ queryFn: getMarriageProfile,
+ queryKey: marriageQueryKeys.profile(),
+ });
+}
diff --git a/src/hooks/marriage/use-section-data.ts b/src/hooks/marriage/use-section-data.ts
new file mode 100644
index 0000000..1615179
--- /dev/null
+++ b/src/hooks/marriage/use-section-data.ts
@@ -0,0 +1,74 @@
+"use client";
+
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { http } from "@/lib/http";
+import type { MutationOptions, QueryOptions } from "./options";
+import { pathParam } from "./path-param";
+import { marriageQueryKeys } from "./query-keys";
+import type {
+ MarriageSectionData,
+ UpdateMarriageSectionDataPayload,
+} from "./types";
+
+export async function getMarriageSectionData(slug: string) {
+ const { data } = await http.get(
+ `/api/marriage/sections/${pathParam(slug)}/data/`,
+ );
+
+ return data;
+}
+
+export async function updateMarriageSectionData(
+ slug: string,
+ payload: UpdateMarriageSectionDataPayload,
+) {
+ const { data } = await http.patch(
+ `/api/marriage/sections/${pathParam(slug)}/data/`,
+ payload,
+ );
+
+ return data;
+}
+
+export function useMarriageSectionDataQuery(
+ slug: string | null | undefined,
+ options?: QueryOptions,
+) {
+ const normalizedSlug = slug ?? "";
+
+ return useQuery({
+ ...options,
+ enabled: Boolean(normalizedSlug) && options?.enabled !== false,
+ queryFn: () => getMarriageSectionData(normalizedSlug),
+ queryKey: marriageQueryKeys.sectionData(normalizedSlug),
+ });
+}
+
+export function useUpdateMarriageSectionDataMutation(
+ slug: string,
+ options?: MutationOptions<
+ MarriageSectionData,
+ UpdateMarriageSectionDataPayload
+ >,
+) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ ...options,
+ mutationFn: (payload) => updateMarriageSectionData(slug, payload),
+ onSuccess: async (data, variables, onMutateResult, context) => {
+ await Promise.all([
+ queryClient.invalidateQueries({
+ queryKey: marriageQueryKeys.profile(),
+ }),
+ queryClient.invalidateQueries({
+ queryKey: marriageQueryKeys.sections(),
+ }),
+ queryClient.invalidateQueries({
+ queryKey: marriageQueryKeys.sectionData(slug),
+ }),
+ ]);
+ await options?.onSuccess?.(data, variables, onMutateResult, context);
+ },
+ });
+}
diff --git a/src/hooks/marriage/use-sections.ts b/src/hooks/marriage/use-sections.ts
new file mode 100644
index 0000000..37981be
--- /dev/null
+++ b/src/hooks/marriage/use-sections.ts
@@ -0,0 +1,23 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import { http } from "@/lib/http";
+import type { QueryOptions } from "./options";
+import { marriageQueryKeys } from "./query-keys";
+import type { MarriageSection } from "./types";
+
+export async function getMarriageSections() {
+ const { data } = await http.get("/api/marriage/sections/");
+
+ return data;
+}
+
+export function useMarriageSectionsQuery(
+ options?: QueryOptions,
+) {
+ return useQuery({
+ ...options,
+ queryFn: getMarriageSections,
+ queryKey: marriageQueryKeys.sections(),
+ });
+}
diff --git a/src/hooks/marriage/use-upload-tmp-media.ts b/src/hooks/marriage/use-upload-tmp-media.ts
new file mode 100644
index 0000000..89f9054
--- /dev/null
+++ b/src/hooks/marriage/use-upload-tmp-media.ts
@@ -0,0 +1,37 @@
+"use client";
+
+import { useMutation } from "@tanstack/react-query";
+import { http } from "@/lib/http";
+import type { MutationOptions } from "./options";
+import type { UploadTmpMediaResponse } from "./types";
+
+const CSRF_TOKEN =
+ "53kqNKySTv3q4K3OolQqLEgaeF9pdPdAEnxrMARaUfvFrIGK57Qje67ifYUDMUQP";
+
+export async function uploadTmpMedia(file: File) {
+ const formData = new FormData();
+ formData.append("file", file);
+
+ const { data } = await http.post(
+ "/upload-tmp-media/",
+ formData,
+ {
+ headers: {
+ Accept: "application/json",
+ "Content-Type": "multipart/form-data",
+ "X-CSRFToken": CSRF_TOKEN,
+ },
+ },
+ );
+
+ return data;
+}
+
+export function useUploadTmpMediaMutation(
+ options?: MutationOptions,
+) {
+ return useMutation({
+ ...options,
+ mutationFn: uploadTmpMedia,
+ });
+}
diff --git a/src/i18n/config.ts b/src/i18n/config.ts
new file mode 100644
index 0000000..efba23a
--- /dev/null
+++ b/src/i18n/config.ts
@@ -0,0 +1,31 @@
+export const locales = ["en", "fa"] as const;
+
+export type Locale = (typeof locales)[number];
+
+export const defaultLocale: Locale = "en";
+
+export const localeLabels: Record = {
+ en: "English",
+ fa: "فارسی",
+};
+
+export const localeDirections: Record = {
+ en: "ltr",
+ fa: "rtl",
+};
+
+export function isLocale(value: string | undefined): value is Locale {
+ return Boolean(value && locales.includes(value as Locale));
+}
+
+export function stripLocale(pathname: string) {
+ const segments = pathname.split("/");
+ return isLocale(segments[1]) ? `/${segments.slice(2).join("/")}` : pathname;
+}
+
+export function localizePath(pathname: string, locale: Locale) {
+ const normalizedPath = stripLocale(pathname);
+ const suffix = normalizedPath === "/" ? "" : normalizedPath;
+
+ return `/${locale}${suffix}`;
+}
diff --git a/src/i18n/dictionaries.ts b/src/i18n/dictionaries.ts
new file mode 100644
index 0000000..f2b9058
--- /dev/null
+++ b/src/i18n/dictionaries.ts
@@ -0,0 +1,236 @@
+import type { Locale } from "@/i18n/config";
+
+export const dictionaries = {
+ en: {
+ common: {
+ appName: "Habib Marriage",
+ submit: "Submit",
+ continue: "Continue",
+ cancel: "Cancel",
+ confirm: "Confirm",
+ support: "Support",
+ back: "Back",
+ required: "Required",
+ estimateTime: "Estimate time",
+ },
+ intro: {
+ imageAlt: "heavenly marriage",
+ title: "A Path to Heavenly Marriage",
+ description:
+ 'We have come together with the goal of creating a secure and confidential path for "permanent marriage" among Muslims',
+ userProfile: "user profile",
+ matches: "matches",
+ marriage: "marriage",
+ videoAlt: "video",
+ playAlt: "play",
+ },
+ questions: {
+ profileRegistration: "Profile registration",
+ closeQuestionsList: "Close questions list",
+ requiredSteps: "Required Steps",
+ requiredStepsDescription:
+ "Please complete the required information so we can find suitable matches for you",
+ requiredStepsProgress: "{completed} of {total} required steps completed",
+ findMatches: "Find Matches",
+ findingMatch: "Finding Match",
+ optionalInfoPromptTitle: "Important point",
+ optionalInfoPromptDescription:
+ "You've completed all required fields. However, filling in all sections will help us find better matches for you",
+ completeNecessaryForms: "(Complete Necessary forms)",
+ openQuestion: "Open {title}",
+ answerAtYourOwnPace: "Answer at Your Own Pace",
+ answerAtYourOwnPaceDescription:
+ "You can pause the survey anytime and resume later. Your progress is saved automatically.",
+ },
+ match: {
+ title: "New Match",
+ goBack: "Go back",
+ acceptProfile: "Accept Profile",
+ acceptDescription:
+ "Are you sure you've fully reviewed the profile and are ready to proceed?",
+ fields: {
+ birthYear: "Year of birth",
+ nationality: "Nationality",
+ residence: "City and country of residence",
+ futureResidence: "City and country of future residence",
+ religion: "Religion",
+ countryCity: "Country / city",
+ currentlyLivingIn: "(currently living in)",
+ education: "Education",
+ occupation: "Occupation",
+ },
+ values: {
+ iranian: "Iranian",
+ iran: "Iran",
+ tehran: "Tehran",
+ muslim: "Muslim",
+ education: "Bachelor's degree in architecture",
+ occupation: "Interior designer",
+ },
+ },
+ requestAccepted: {
+ imageAlt: "Request accepted",
+ title: "Request Accepted",
+ description:
+ "You can now view their family's contact details and arrange further steps.",
+ viewContact: "View Contact",
+ penalty:
+ "Please note: if you do not make contact within 2 days, a penalty may apply",
+ profileLocked: "Profile is locked",
+ lockedDescription:
+ "You can't edit your profile while we're searching for matches",
+ },
+ findingMatch: {
+ title: "Finding Match ...",
+ description:
+ "We will search for suitable matches based on your preferences. Once we find one, we'll show you their profile. If you approve, we will proceed on your behalf",
+ advisorTitle: "Get an advisor",
+ advisorDescription:
+ "Not sure what to do next? Our psychology section is here to guide you at every step.",
+ getAdvisor: "Get Advisor",
+ editProfile: "Edit Profile",
+ },
+ candidateContact: {
+ imageAlt: "Selected candidate contact status",
+ title: "The selected candidate will contact your family shortly.",
+ contacted: "contacted",
+ noContactYet: "no contact yet ?",
+ afterTwoDays: "(after 2 days)",
+ },
+ sheets: {
+ informationSheet: "Information sheet",
+ callResult: "Call result",
+ selectCallResult: "Select call result",
+ callOptions: [
+ "Not a good personal fit",
+ "No mutual interest",
+ "Different expectations",
+ "No connection felt",
+ "Location not suitable",
+ "Other reasons",
+ ],
+ dismissReasons: "Dismiss reasons",
+ dismissDescription:
+ "Please provide the full reason for rejecting the submitted item",
+ dismissPlaceholder: "Your explanatory text ...",
+ },
+ },
+ fa: {
+ common: {
+ appName: "ازدواج حبیب",
+ submit: "ثبت",
+ continue: "ادامه",
+ cancel: "لغو",
+ confirm: "تایید",
+ support: "پشتیبانی",
+ back: "بازگشت",
+ required: "ضروری",
+ estimateTime: "زمان تقریبی",
+ },
+ intro: {
+ imageAlt: "ازدواج آسمانی",
+ title: "مسیری برای ازدواج آسمانی",
+ description:
+ "ما با هدف ایجاد مسیری امن و محرمانه برای ازدواج دائم میان مسلمانان کنار هم آمدهایم",
+ userProfile: "پروفایل کاربر",
+ matches: "معرفیها",
+ marriage: "ازدواج",
+ videoAlt: "ویدیو",
+ playAlt: "پخش",
+ },
+ questions: {
+ profileRegistration: "ثبت اطلاعات پروفایل",
+ closeQuestionsList: "بستن فهرست سوالات",
+ requiredSteps: "مراحل ضروری",
+ requiredStepsDescription:
+ "لطفا اطلاعات ضروری را کامل کنید تا بتوانیم گزینههای مناسب را پیدا کنیم",
+ requiredStepsProgress: "{completed} از {total} مرحله ضروری کامل شده است",
+ findMatches: "یافتن گزینهها",
+ findingMatch: "Finding Match",
+ optionalInfoPromptTitle: "Important point",
+ optionalInfoPromptDescription:
+ "You've completed all required fields. However, filling in all sections will help us find better matches for you",
+ completeNecessaryForms: "(تکمیل فرمهای ضروری)",
+ openQuestion: "باز کردن {title}",
+ answerAtYourOwnPace: "با آرامش پاسخ دهید",
+ answerAtYourOwnPaceDescription:
+ "هر زمان خواستید میتوانید فرم را متوقف کنید و بعدا ادامه دهید. پیشرفت شما خودکار ذخیره میشود.",
+ },
+ match: {
+ title: "گزینه جدید",
+ goBack: "بازگشت",
+ acceptProfile: "پذیرش پروفایل",
+ acceptDescription:
+ "آیا مطمئن هستید پروفایل را کامل بررسی کردهاید و آماده ادامه هستید؟",
+ fields: {
+ birthYear: "سال تولد",
+ nationality: "ملیت",
+ residence: "شهر و کشور محل سکونت",
+ futureResidence: "شهر و کشور محل سکونت آینده",
+ religion: "دین",
+ countryCity: "کشور / شهر",
+ currentlyLivingIn: "(محل سکونت فعلی)",
+ education: "تحصیلات",
+ occupation: "شغل",
+ },
+ values: {
+ iranian: "ایرانی",
+ iran: "ایران",
+ tehran: "تهران",
+ muslim: "مسلمان",
+ education: "کارشناسی معماری",
+ occupation: "طراح داخلی",
+ },
+ },
+ requestAccepted: {
+ imageAlt: "درخواست پذیرفته شد",
+ title: "درخواست پذیرفته شد",
+ description:
+ "اکنون میتوانید اطلاعات تماس خانواده ایشان را ببینید و مراحل بعدی را هماهنگ کنید.",
+ viewContact: "مشاهده تماس",
+ penalty: "توجه: اگر تا ۲ روز تماس برقرار نکنید، ممکن است جریمه اعمال شود",
+ profileLocked: "پروفایل قفل است",
+ lockedDescription:
+ "تا زمانی که در حال یافتن گزینه هستیم، امکان ویرایش پروفایل وجود ندارد",
+ },
+ findingMatch: {
+ title: "در حال یافتن گزینه ...",
+ description:
+ "ما بر اساس ترجیحات شما گزینههای مناسب را جستجو میکنیم. وقتی گزینهای پیدا شود، پروفایل او را به شما نشان میدهیم. اگر تایید کنید، از طرف شما مراحل را ادامه میدهیم",
+ advisorTitle: "دریافت مشاور",
+ advisorDescription:
+ "نمیدانید قدم بعدی چیست؟ بخش روانشناسی ما در هر مرحله شما را راهنمایی میکند.",
+ getAdvisor: "دریافت مشاور",
+ editProfile: "ویرایش پروفایل",
+ },
+ candidateContact: {
+ imageAlt: "وضعیت تماس گزینه انتخابشده",
+ title: "گزینه انتخابشده بهزودی با خانواده شما تماس میگیرد.",
+ contacted: "تماس گرفته شد",
+ noContactYet: "هنوز تماس نگرفته؟",
+ afterTwoDays: "(بعد از ۲ روز)",
+ },
+ sheets: {
+ informationSheet: "پنل اطلاعات",
+ callResult: "نتیجه تماس",
+ selectCallResult: "انتخاب نتیجه تماس",
+ callOptions: [
+ "تناسب شخصی کافی نبود",
+ "تمایل دوطرفه وجود نداشت",
+ "انتظارات متفاوت بود",
+ "ارتباط شکل نگرفت",
+ "موقعیت مکانی مناسب نبود",
+ "دلایل دیگر",
+ ],
+ dismissReasons: "دلایل رد کردن",
+ dismissDescription: "لطفا دلیل کامل رد کردن مورد ارسالشده را بنویسید",
+ dismissPlaceholder: "متن توضیحی شما ...",
+ },
+ },
+} as const;
+
+export type Dictionary = (typeof dictionaries)[Locale];
+
+export function getDictionary(locale: Locale): Dictionary {
+ return dictionaries[locale];
+}
diff --git a/src/data/mock-questions.json b/src/i18n/locales/en/questions.json
similarity index 68%
rename from src/data/mock-questions.json
rename to src/i18n/locales/en/questions.json
index d8e5f9b..01f6aaa 100644
--- a/src/data/mock-questions.json
+++ b/src/i18n/locales/en/questions.json
@@ -1,13 +1,13 @@
[
{
- "title": "Personal Information",
+ "title": "Personal Info",
"icon": "user-circle",
- "slug": "personal-information",
+ "slug": "personal_info",
"required": true,
- "estimateTime": "5 minutes",
+ "estimateTime": "30 minutes",
"tooltip": "Basic identity and profile details for the applicant.",
- "progress": 35,
- "description": "Collects general personal details to start the marriage application flow.",
+ "progress": 0,
+ "description": "Collects personal details to start the marriage application flow.",
"questions": [
{
"title": "Full Name",
@@ -60,14 +60,14 @@
]
},
{
- "title": "Preferences",
- "icon": "heart-handshake",
- "slug": "preferences",
- "required": false,
- "estimateTime": "7 minutes",
- "tooltip": "Relationship expectations and lifestyle preferences.",
- "progress": 60,
- "description": "Captures values, preferences, and match expectations.",
+ "title": "Career & Educattion",
+ "icon": "school",
+ "slug": "career_and_education",
+ "required": true,
+ "estimateTime": "20 minutes",
+ "tooltip": "Career and education details.",
+ "progress": 0,
+ "description": "Captures career, education, and background details.",
"questions": [
{
"title": "Preferred City",
@@ -120,14 +120,14 @@
]
},
{
- "title": "Documents",
- "icon": "file-text",
- "slug": "documents",
+ "title": "Expactancy & equality",
+ "icon": "heart-handshake",
+ "slug": "expactancy_and_equality",
"required": true,
- "estimateTime": "3 minutes",
- "tooltip": "Upload supporting documents required for verification.",
- "progress": 100,
- "description": "Handles document uploads and verification-related information.",
+ "estimateTime": "40 minutes",
+ "tooltip": "Relationship expectations and equality preferences.",
+ "progress": 0,
+ "description": "Captures expectations, equality preferences, and relationship readiness.",
"questions": [
{
"title": "National ID Upload",
@@ -156,14 +156,14 @@
]
},
{
- "title": "Question Type Showcase",
- "icon": "layout-grid",
- "slug": "question-type-showcase",
- "required": false,
- "estimateTime": "10 minutes",
- "tooltip": "A complete sample box that demonstrates every supported question type with all fields populated.",
+ "title": "Cantact",
+ "icon": "file-text",
+ "slug": "contact",
+ "required": true,
+ "estimateTime": "Not specified",
+ "tooltip": "Contact details and communication preferences.",
"progress": 0,
- "description": "Includes one example of each question type so the UI can be tested against a complete dataset.",
+ "description": "Captures contact details and preferred communication channels.",
"questions": [
{
"title": "Short Biography",
@@ -260,6 +260,78 @@
"range": [0, 0],
"options": [".jpg", ".jpeg", ".png"]
}
+ },
+ {
+ "title": "User Photo",
+ "type": "photo",
+ "required": false,
+ "description": "Please ensure your photo follows the required dress guidelines. Your photo will be reviewed by our female staff and handled with strict confidentiality. It will not be shared with anyone",
+ "tooltip": "This exercises the photo upload type.",
+ "extras": {
+ "placeHolder": "Choose an image",
+ "range": [0, 0],
+ "options": [".jpg", ".jpeg", ".png"]
+ }
+ }
+ ]
+ },
+ {
+ "title": "Katel Test",
+ "icon": "layout-grid",
+ "slug": "katel_test",
+ "required": false,
+ "estimateTime": "30 minutes",
+ "tooltip": "Katel test section.",
+ "progress": 0,
+ "description": "Captures Katel test responses.",
+ "questions": [
+ {
+ "title": "Katel Test Step 1",
+ "type": "radio",
+ "required": false,
+ "description": "Select the option that applies.",
+ "tooltip": "This is the first Katel test step.",
+ "extras": {
+ "placeHolder": "Select one option",
+ "range": [0, 0],
+ "options": ["Option A", "Option B", "Option C"]
+ }
+ },
+ {
+ "title": "Katel Test Step 2",
+ "type": "radio",
+ "required": false,
+ "description": "Select the option that applies.",
+ "tooltip": "This is the second Katel test step.",
+ "extras": {
+ "placeHolder": "Select one option",
+ "range": [0, 0],
+ "options": ["Option A", "Option B", "Option C"]
+ }
+ },
+ {
+ "title": "Katel Test Step 3",
+ "type": "radio",
+ "required": false,
+ "description": "Select the option that applies.",
+ "tooltip": "This is the third Katel test step.",
+ "extras": {
+ "placeHolder": "Select one option",
+ "range": [0, 0],
+ "options": ["Option A", "Option B", "Option C"]
+ }
+ },
+ {
+ "title": "Katel Test Step 4",
+ "type": "radio",
+ "required": false,
+ "description": "Select the option that applies.",
+ "tooltip": "This is the fourth Katel test step.",
+ "extras": {
+ "placeHolder": "Select one option",
+ "range": [0, 0],
+ "options": ["Option A", "Option B", "Option C"]
+ }
}
]
}
diff --git a/src/i18n/locales/fa/questions.json b/src/i18n/locales/fa/questions.json
new file mode 100644
index 0000000..01f6aaa
--- /dev/null
+++ b/src/i18n/locales/fa/questions.json
@@ -0,0 +1,338 @@
+[
+ {
+ "title": "Personal Info",
+ "icon": "user-circle",
+ "slug": "personal_info",
+ "required": true,
+ "estimateTime": "30 minutes",
+ "tooltip": "Basic identity and profile details for the applicant.",
+ "progress": 0,
+ "description": "Collects personal details to start the marriage application flow.",
+ "questions": [
+ {
+ "title": "Full Name",
+ "type": "text",
+ "required": true,
+ "description": "Enter your legal full name as it appears on official documents.",
+ "tooltip": "Use your passport or national ID spelling.",
+ "extras": {
+ "placeHolder": "e.g. Sara Ahmadi",
+ "range": [0, 0],
+ "options": []
+ }
+ },
+ {
+ "title": "Date of Birth",
+ "type": "date",
+ "required": true,
+ "description": "Select your date of birth.",
+ "tooltip": "Make sure the date matches your official record.",
+ "extras": {
+ "placeHolder": "YYYY-MM-DD",
+ "range": [0, 0],
+ "options": []
+ }
+ },
+ {
+ "title": "Gender",
+ "type": "radio",
+ "required": true,
+ "description": "Choose the gender option that applies to you.",
+ "tooltip": "Only one option can be selected.",
+ "extras": {
+ "placeHolder": "",
+ "range": [0, 0],
+ "options": ["Female", "Male", "Prefer not to say"]
+ }
+ },
+ {
+ "title": "Age",
+ "type": "number",
+ "required": true,
+ "description": "Provide your current age in years.",
+ "tooltip": "Numbers only.",
+ "extras": {
+ "placeHolder": "e.g. 29",
+ "range": [18, 80],
+ "options": []
+ }
+ }
+ ]
+ },
+ {
+ "title": "Career & Educattion",
+ "icon": "school",
+ "slug": "career_and_education",
+ "required": true,
+ "estimateTime": "20 minutes",
+ "tooltip": "Career and education details.",
+ "progress": 0,
+ "description": "Captures career, education, and background details.",
+ "questions": [
+ {
+ "title": "Preferred City",
+ "type": "dropdown",
+ "required": false,
+ "description": "Select the city you prefer to live in after marriage.",
+ "tooltip": "You can use this to help with compatibility matching.",
+ "extras": {
+ "placeHolder": "Choose a city",
+ "range": [0, 0],
+ "options": ["Tehran", "Isfahan", "Shiraz", "Tabriz", "Mashhad"]
+ }
+ },
+ {
+ "title": "Describe Your Ideal Partner",
+ "type": "text",
+ "required": false,
+ "description": "Write a short description of the qualities you value most.",
+ "tooltip": "Keep it concise and specific.",
+ "extras": {
+ "placeHolder": "Kind, family-oriented, emotionally mature...",
+ "range": [0, 0],
+ "options": []
+ }
+ },
+ {
+ "title": "Importance of Family Values",
+ "type": "slider",
+ "required": true,
+ "description": "Rate how important family values are to you.",
+ "tooltip": "Move the slider from low to high importance.",
+ "extras": {
+ "placeHolder": "",
+ "range": [1, 10],
+ "options": []
+ }
+ },
+ {
+ "title": "Ready to Continue",
+ "type": "button",
+ "required": false,
+ "description": "Confirms that you want to proceed to the next section.",
+ "tooltip": "This can be used as a UI action trigger.",
+ "extras": {
+ "placeHolder": "Continue",
+ "range": [0, 0],
+ "options": ["Continue"]
+ }
+ }
+ ]
+ },
+ {
+ "title": "Expactancy & equality",
+ "icon": "heart-handshake",
+ "slug": "expactancy_and_equality",
+ "required": true,
+ "estimateTime": "40 minutes",
+ "tooltip": "Relationship expectations and equality preferences.",
+ "progress": 0,
+ "description": "Captures expectations, equality preferences, and relationship readiness.",
+ "questions": [
+ {
+ "title": "National ID Upload",
+ "type": "file",
+ "required": true,
+ "description": "Upload a clear image or PDF of your national ID.",
+ "tooltip": "Accepted formats can be restricted in the UI layer.",
+ "extras": {
+ "placeHolder": "Choose a file",
+ "range": [0, 0],
+ "options": [".jpg", ".png", ".pdf"]
+ }
+ },
+ {
+ "title": "Marriage Timeline",
+ "type": "dropdown",
+ "required": false,
+ "description": "Choose your preferred timeline for marriage.",
+ "tooltip": "This helps prioritize urgency and compatibility.",
+ "extras": {
+ "placeHolder": "Select a timeline",
+ "range": [0, 0],
+ "options": ["Within 6 months", "6-12 months", "1-2 years", "Flexible"]
+ }
+ }
+ ]
+ },
+ {
+ "title": "Cantact",
+ "icon": "file-text",
+ "slug": "contact",
+ "required": true,
+ "estimateTime": "Not specified",
+ "tooltip": "Contact details and communication preferences.",
+ "progress": 0,
+ "description": "Captures contact details and preferred communication channels.",
+ "questions": [
+ {
+ "title": "Short Biography",
+ "type": "text",
+ "required": true,
+ "description": "Provide a short written introduction.",
+ "tooltip": "This exercises the text input type.",
+ "extras": {
+ "placeHolder": "Write a short introduction about yourself",
+ "range": [0, 0],
+ "options": []
+ }
+ },
+ {
+ "title": "Preferred Wedding Date",
+ "type": "date",
+ "required": false,
+ "description": "Select your preferred wedding date.",
+ "tooltip": "This exercises the date picker type.",
+ "extras": {
+ "placeHolder": "YYYY-MM-DD",
+ "range": [0, 0],
+ "options": []
+ }
+ },
+ {
+ "title": "Preferred Contact Method",
+ "type": "radio",
+ "required": true,
+ "description": "Choose one preferred contact method.",
+ "tooltip": "This exercises the radio selection type.",
+ "extras": {
+ "placeHolder": "Select one option",
+ "range": [0, 0],
+ "options": ["Phone", "WhatsApp", "Email"]
+ }
+ },
+ {
+ "title": "Household Size Preference",
+ "type": "number",
+ "required": false,
+ "description": "Enter the household size you are comfortable with.",
+ "tooltip": "This exercises the number input type.",
+ "extras": {
+ "placeHolder": "e.g. 4",
+ "range": [1, 12],
+ "options": []
+ }
+ },
+ {
+ "title": "Current City",
+ "type": "dropdown",
+ "required": true,
+ "description": "Choose the city you currently live in.",
+ "tooltip": "This exercises the dropdown type.",
+ "extras": {
+ "placeHolder": "Select your city",
+ "range": [0, 0],
+ "options": ["Tehran", "Karaj", "Shiraz", "Mashhad"]
+ }
+ },
+ {
+ "title": "Importance of Shared Goals",
+ "type": "slider",
+ "required": true,
+ "description": "Rate how important shared long-term goals are to you.",
+ "tooltip": "This exercises the slider type.",
+ "extras": {
+ "placeHolder": "Move the slider",
+ "range": [1, 10],
+ "options": []
+ }
+ },
+ {
+ "title": "Review Answers",
+ "type": "button",
+ "required": false,
+ "description": "Use this action to review the current section.",
+ "tooltip": "This exercises the button type.",
+ "extras": {
+ "placeHolder": "Review",
+ "range": [0, 0],
+ "options": ["Review"]
+ }
+ },
+ {
+ "title": "Profile Photo Upload",
+ "type": "file",
+ "required": false,
+ "description": "Upload a profile photo or supporting image.",
+ "tooltip": "This exercises the file upload type.",
+ "extras": {
+ "placeHolder": "Choose an image",
+ "range": [0, 0],
+ "options": [".jpg", ".jpeg", ".png"]
+ }
+ },
+ {
+ "title": "User Photo",
+ "type": "photo",
+ "required": false,
+ "description": "Please ensure your photo follows the required dress guidelines. Your photo will be reviewed by our female staff and handled with strict confidentiality. It will not be shared with anyone",
+ "tooltip": "This exercises the photo upload type.",
+ "extras": {
+ "placeHolder": "Choose an image",
+ "range": [0, 0],
+ "options": [".jpg", ".jpeg", ".png"]
+ }
+ }
+ ]
+ },
+ {
+ "title": "Katel Test",
+ "icon": "layout-grid",
+ "slug": "katel_test",
+ "required": false,
+ "estimateTime": "30 minutes",
+ "tooltip": "Katel test section.",
+ "progress": 0,
+ "description": "Captures Katel test responses.",
+ "questions": [
+ {
+ "title": "Katel Test Step 1",
+ "type": "radio",
+ "required": false,
+ "description": "Select the option that applies.",
+ "tooltip": "This is the first Katel test step.",
+ "extras": {
+ "placeHolder": "Select one option",
+ "range": [0, 0],
+ "options": ["Option A", "Option B", "Option C"]
+ }
+ },
+ {
+ "title": "Katel Test Step 2",
+ "type": "radio",
+ "required": false,
+ "description": "Select the option that applies.",
+ "tooltip": "This is the second Katel test step.",
+ "extras": {
+ "placeHolder": "Select one option",
+ "range": [0, 0],
+ "options": ["Option A", "Option B", "Option C"]
+ }
+ },
+ {
+ "title": "Katel Test Step 3",
+ "type": "radio",
+ "required": false,
+ "description": "Select the option that applies.",
+ "tooltip": "This is the third Katel test step.",
+ "extras": {
+ "placeHolder": "Select one option",
+ "range": [0, 0],
+ "options": ["Option A", "Option B", "Option C"]
+ }
+ },
+ {
+ "title": "Katel Test Step 4",
+ "type": "radio",
+ "required": false,
+ "description": "Select the option that applies.",
+ "tooltip": "This is the fourth Katel test step.",
+ "extras": {
+ "placeHolder": "Select one option",
+ "range": [0, 0],
+ "options": ["Option A", "Option B", "Option C"]
+ }
+ }
+ ]
+ }
+]
diff --git a/src/i18n/provider.tsx b/src/i18n/provider.tsx
new file mode 100644
index 0000000..1f6a4f6
--- /dev/null
+++ b/src/i18n/provider.tsx
@@ -0,0 +1,47 @@
+"use client";
+
+import {
+ createContext,
+ type ReactNode,
+ useContext,
+ useEffect,
+ useMemo,
+} from "react";
+import { defaultLocale, type Locale, localeDirections } from "@/i18n/config";
+import { type Dictionary, dictionaries } from "@/i18n/dictionaries";
+
+type I18nContextValue = {
+ locale: Locale;
+ dictionary: Dictionary;
+};
+
+const I18nContext = createContext({
+ locale: defaultLocale,
+ dictionary: dictionaries[defaultLocale],
+});
+
+type I18nProviderProps = {
+ children: ReactNode;
+ locale: Locale;
+};
+
+export function I18nProvider({ children, locale }: I18nProviderProps) {
+ useEffect(() => {
+ document.documentElement.lang = locale;
+ document.documentElement.dir = localeDirections[locale];
+ }, [locale]);
+
+ const value = useMemo(
+ () => ({
+ locale,
+ dictionary: dictionaries[locale],
+ }),
+ [locale],
+ );
+
+ return {children} ;
+}
+
+export function useI18n() {
+ return useContext(I18nContext);
+}
diff --git a/src/lib/http.ts b/src/lib/http.ts
new file mode 100644
index 0000000..7a3f8d1
--- /dev/null
+++ b/src/lib/http.ts
@@ -0,0 +1,47 @@
+import axios, { type InternalAxiosRequestConfig } from "axios";
+
+const PROXY_PATH_PARAM = "__proxyPath";
+
+export const http = axios.create({
+ baseURL: "/api/proxy",
+ headers: {
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ },
+ withCredentials: true,
+});
+
+function isAbsoluteUrl(url: string) {
+ return /^[a-z][a-z\d+\-.]*:\/\//i.test(url);
+}
+
+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,
+ };
+}
+
+http.interceptors.request.use((config) => {
+ if (config.url && !isAbsoluteUrl(config.url)) {
+ config.params = withProxyPathParam(config.params, config.url);
+ config.url = "";
+ }
+
+ return config;
+});