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.imageAlt} + +

+ {t.candidateContact.title} +

+
+
+ +
+ + +
+
+ + ); +} 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 + +
+ + +
+
+ + + + + +
+
+ + ); +} 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}

+
heavenly marriage

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

-
+
video play
- +
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 }) => ( +
+ + +
+ )} + /> + ) : null} + +
+ +
+ +

{t.match.title}

+ +
+
+ +
+
+ + + + + + + + +
+
+ +
+
+ +
+ +
+
+ + ); +} 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

- arrow up -
-
-
    -
  • 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}

+ +
+
- -
-
+
+ + {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 }) => ( +
+ + +
+ )} + /> + ) : null} + -
+
- +

- Profile registration + {t.questions.profileRegistration}

-
- -
+
+ +
{questionListItems.map((item) => ( - + ))}
-
- -
+ {allRequiredSectionsCompleted ? ( +
+ +
+ ) : 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 }) => ( + + )} + 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.imageAlt} +
+ +

+ {t.requestAccepted.title} +

+ +

+ {t.requestAccepted.description} +

+ +
+ +
+
+ +
+
+ {t.requestAccepted.penalty} +
+ +
+
+ lock + +

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

-
+
); } 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 ( + + ); } 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 ( + + ); } 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 ( + + ); } 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 ( + + ); } 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 ( + + ); +} + +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 ( +
+