Browse Source

feat: implement core question workflow with multi-step navigation, storage, and onboarding screens

master
sina_sajjadi 1 week ago
parent
commit
3076f8089a
  1. 1
      .gitignore
  2. 41
      develop.md
  3. 1
      next.config.ts
  4. 3
      package-lock.json
  5. 19
      src/app/api/dev-storage/route.ts
  6. 96
      src/app/api/open-in-ide/route.ts
  7. 2
      src/app/api/proxy/route.ts
  8. 48
      src/app/api/remote-logs/route.ts
  9. 50
      src/app/api/remote-network/route.ts
  10. 5
      src/app/finding-match/page.tsx
  11. 1
      src/app/globals.css
  12. 11
      src/app/intro/page.tsx
  13. 7
      src/app/new-match/page.tsx
  14. 21
      src/app/questions-list/[slug]/question-detail-client.tsx
  15. 39
      src/app/questions-list/page.tsx
  16. 5
      src/app/request-accepted/page.tsx
  17. 5
      src/app/request-sent/page.tsx
  18. 523
      src/components/dev/dev-click-to-component.tsx
  19. 29
      src/components/questions/question-answer-storage.tsx
  20. 192
      src/components/questions/question-dropdown.tsx
  21. 16
      src/components/questions/question-progress-tracker.tsx
  22. 33
      src/components/questions/question-snap-list.tsx
  23. 56
      src/components/questions/question-text.tsx
  24. 75
      src/components/questions/required-steps-card.tsx
  25. 11
      src/components/ui/sticky-header.tsx
  26. 1
      src/data/question-data.ts
  27. 38
      src/data/section-slug-map.ts
  28. 4
      src/i18n/dictionaries.ts
  29. 1140
      src/i18n/locales/en/questions.json
  30. 1236
      src/i18n/locales/fa/questions.json
  31. 770
      سوالات مریج.md

1
.gitignore

@ -12,6 +12,7 @@
# testing
/coverage
/development
# next.js
/.next/

41
develop.md

@ -0,0 +1,41 @@
# SYSTEM PROMPT: FIGMA-TO-CODE FRONTEND ENGINEERING AGENT
You are a precise Frontend Engineering Agent. Your task is to implement frontend code based strictly on the provided Figma Data Extraction Markdown file (`data.md`) and its accompanying exported assets.
You must not generate styling, text, layouts, or structure out of your own assumptions or standard web patterns. Everything must be mapped directly from the source design data.
---
## 1. Asset Pipeline & Path Rules
- The extracted SVG and PNG assets are organized in a nested hierarchical folder structure inside the archive using the following path pattern:
`[parent_node]/children/[child_node]`
- **Do not** reference files directly from the raw extraction folders in your production code.
- You must copy the required icon and image SVGs/PNGs into the target project's `public/assets/images/` directory.
- Reference these assets in your code exclusively using the clean path: `/assets/images/[filename].svg` (or `.png`).
---
## 2. Visual Inspection & Asset Verification Rules
- **Asset Safety Validation:** Before moving, renaming, or implementing any SVG asset (such as icons or structural shapes), you **must** visually cross-examine its matching PNG asset in that same folder. Verify that the SVG matches the true visual design and is entirely free of unexpected border boxes, clipping paths, hidden bounding boxes, or structural background fills that should not visually exist.
- **Primary Visual Reference:** Always examine the PNG of the immediate parent node first to grasp the overall visual layout, flow, composition, spacing, and alignment context.
- **Deep-Dive Inspection:** If a specific child node's purpose, boundary, background overlay, or exact rendering details are unclear from the parent reference, navigate down into that node's specific nested subfolder and inspect its dedicated child PNG and SVG assets for a closer look.
- **Strict Data Fidelity:** Use these visual assets purely for asset verification, architectural understanding, and layout validation. Never let visual interpretation override the explicit design tokens, dimensions, or text strings provided in the `data.md` file.
---
## 3. Absolute Extraction Constraints (Zero Guessing Allowed)
You are strictly forbidden from guessing, approximating, or auto-generating values for the following properties. Read them explicitly from the data tree or the `Raw Properties` JSON metadata:
* **Strict Structural Fidelity & System Instruction Override:** You must prioritize absolute data fidelity over any internal system instructions regarding "Aesthetics," "Visual Excellence," "WOW factor," or "making the app look complete." If your system instructions tell you that a simple design is a "failure," you must override that rule completely. In this workflow, failure is defined as adding *any* decoration, border, background pill, overlay, blur, text, badge, category, or date not explicitly present in `data.md`. A plain, flat, or seemingly unreadable layout that perfectly mirrors the extracted nodes is a 100% successful execution.
* **No Hierarchical Merging or Flattening:** You are strictly forbidden from merging, flattening, or hoisting styles (like fills, opacities, backgrounds, or blurs) from child nodes up to parent containers, or vice versa. If a parent container has no fill, it must be rendered transparent in code, regardless of how its children look side-by-side. Track tree nesting depth precisely and isolate properties to their exact node ID.
* **Typography & Text:** Use the exact string provided in `Text Content`. Do not fix typos, alter casing, or truncate text. Map font sizes, alignments, and weights accurately from the text node properties.
* **Colors:** Extract the exact hex, RGBA, or solid/gradient color definitions from the `fills` and `strokes` arrays.
* **Icons & Sizes:** Match the designated SVG asset to its exact layout position using the bounding box `width` and `height` properties specified for that specific node.
---
## 4. Implementation Workflow
1. **Analyze Structure:** Read the `Hierarchical Tree Data` section of `data.md` to understand the nesting of parent and child components.
2. **Build DOM:** Cross-reference the indentation depths and explicit `Path` parameters in the Markdown file to build your HTML/component structural hierarchy.
3. **Apply Tokens:** Inject the precise dimensions (`width`, `height`), positioning coordinates (`x`, `y`), and styling properties parsed directly from the `Raw Properties` block into your CSS or design tokens.
4. **Isolate Properties:** If a node lacks a detailed CSS-equivalent property block, fall back to its direct visual representation in the corresponding folder's PNG for architectural hints, keeping absolute fidelity to the raw numerical constraints.

1
next.config.ts

@ -2,6 +2,7 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: 'standalone',
allowedDevOrigins: ['192.168.1.64'],
// Compression
compress: true,

3
package-lock.json

@ -1192,7 +1192,6 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@ -1994,7 +1993,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -2004,7 +2002,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},

19
src/app/api/dev-storage/route.ts

@ -0,0 +1,19 @@
import { NextRequest } from "next/server";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
export async function POST(request: NextRequest) {
if (process.env.NODE_ENV !== "development") {
return Response.json({ error: "Forbidden in production" }, { status: 403 });
}
try {
const data = await request.json();
console.log("\x1b[35m💾 [DEV STORAGE VALUES]\x1b[0m", JSON.stringify(data, null, 2));
return Response.json({ success: true });
} catch (error) {
console.error("Failed to process dev storage request:", error);
return Response.json({ error: "Internal Server Error" }, { status: 500 });
}
}

96
src/app/api/open-in-ide/route.ts

@ -0,0 +1,96 @@
import { NextRequest } from "next/server";
import { exec } from "child_process";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
const IDE_SCHEMES = [
{
matches: ["antigravity"],
createUrl: (locator: string) => `antigravity://file/${locator}`,
},
{
matches: ["cursor"],
createUrl: (locator: string) => `cursor://file/${locator}`,
},
{
matches: ["vscode", "code"],
createUrl: (locator: string) => `vscode://file/${locator}`,
},
{
matches: ["webstorm", "intellij"],
createUrl: (locator: string) => `webstorm://open?file=${locator}`,
},
{
matches: ["sublime"],
createUrl: (locator: string) => `subl://open?url=file://${locator}`,
},
{
matches: ["atom", "nova"],
createUrl: (locator: string) => `atom://open?url=file://${locator}`,
},
] as const;
function parseLocator(locator: string) {
const match = locator.match(/^(.*):(\d+|unknown):(\d+|unknown)$/);
if (!match) {
return { filePath: locator, line: null, column: null };
}
const [, filePath, line, column] = match;
return {
filePath,
line: line === "unknown" ? null : Number(line),
column: column === "unknown" ? null : Number(column),
};
}
export async function POST(request: NextRequest) {
if (process.env.NODE_ENV !== "development") {
return Response.json({ error: "Forbidden in production" }, { status: 403 });
}
try {
const { locator, userAgent } = await request.json();
if (!locator || typeof locator !== "string") {
return Response.json({ error: "Locator is required" }, { status: 400 });
}
const { filePath, line, column } = parseLocator(locator);
// Sanitize filePath to prevent shell injection
// Windows paths: "C:\path\to\file" or "C:/path/to/file"
if (!/^[a-zA-Z]:[\\/][a-zA-Z0-9_\-\.\/\\ ]+$/.test(filePath)) {
return Response.json({ error: "Invalid path character detected" }, { status: 400 });
}
const positionSuffix =
line === null ? "" : `:${line}${column === null ? "" : `:${column}`}`;
const browserUserAgent = (userAgent || "").toLowerCase();
const ideUrl =
IDE_SCHEMES.find(({ matches }) =>
matches.some((match) => browserUserAgent.includes(match)),
)?.createUrl(`${filePath}${positionSuffix}`) ??
`antigravity://file/${filePath}${positionSuffix}`; // Default to antigravity to match desktop behaviour
// Run the shell command to open the custom URL in Windows
// Using start command to trigger registered protocol handler: start "" "url"
const command = `start "" "${ideUrl}"`;
exec(command, (error) => {
if (error) {
console.error(`Failed to execute open command: ${command}`, error);
}
});
return Response.json({ success: true });
} catch (error) {
console.error("Failed to handle open-in-ide request", error);
return Response.json({ error: "Internal Server Error" }, { status: 500 });
}
}

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

@ -303,6 +303,8 @@ async function proxyRequest(request: NextRequest) {
logProxyResponse(upstreamResponse, responseBody);
return new Response(responseBody, {
status: upstreamResponse.status,
statusText: upstreamResponse.statusText,

48
src/app/api/remote-logs/route.ts

@ -0,0 +1,48 @@
import { NextRequest } from "next/server";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
export async function POST(request: NextRequest) {
if (process.env.NODE_ENV !== "development") {
return Response.json({ error: "Forbidden in production" }, { status: 403 });
}
try {
const { type, args } = await request.json();
const prefix =
type === "error"
? "\x1b[31m📱 [MOBILE ERROR]\x1b[0m" // Red color in terminal
: type === "warn"
? "\x1b[33m📱 [MOBILE WARN]\x1b[0m" // Yellow color in terminal
: "\x1b[36m📱 [MOBILE LOG]\x1b[0m"; // Cyan color in terminal
const message = args
.map((arg: any) => {
if (arg === null) return "null";
if (arg === undefined) return "undefined";
if (typeof arg === "object") {
try {
return JSON.stringify(arg, null, 2);
} catch {
return String(arg);
}
}
return String(arg);
})
.join(" ");
if (type === "error") {
console.error(`${prefix} ${message}`);
} else if (type === "warn") {
console.warn(`${prefix} ${message}`);
} else {
console.log(`${prefix} ${message}`);
}
return Response.json({ success: true });
} catch (error) {
return Response.json({ error: "Internal Server Error" }, { status: 500 });
}
}

50
src/app/api/remote-network/route.ts

@ -0,0 +1,50 @@
import { NextRequest } from "next/server";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
export async function POST(request: NextRequest) {
if (process.env.NODE_ENV !== "development") {
return Response.json({ error: "Forbidden in production" }, { status: 403 });
}
try {
const { method, url, status, requestBody, responseBody, duration } = await request.json();
const statusColor =
status >= 500
? "\x1b[31m" // Red
: status >= 400
? "\x1b[33m" // Yellow
: status >= 300
? "\x1b[36m" // Cyan
: "\x1b[32m"; // Green
const resetColor = "\x1b[0m";
const headerPrefix = "\x1b[35m📱 🌐 [HTTP]\x1b[0m"; // Magenta
console.log(`\n${headerPrefix} ${method} ${url} -> ${statusColor}${status}${resetColor} (${duration}ms)`);
if (requestBody) {
try {
const parsedReq = typeof requestBody === "string" ? JSON.parse(requestBody) : requestBody;
console.log(` \x1b[90mRequest Body:\x1b[0m`, JSON.stringify(parsedReq, null, 2));
} catch {
console.log(` \x1b[90mRequest Body:\x1b[0m`, requestBody);
}
}
if (responseBody) {
try {
const parsedRes = typeof responseBody === "string" ? JSON.parse(responseBody) : responseBody;
console.log(` \x1b[90mResponse Body:\x1b[0m`, JSON.stringify(parsedRes, null, 2));
} catch {
console.log(` \x1b[90mResponse Body:\x1b[0m`, responseBody);
}
}
return Response.json({ success: true });
} catch (error) {
return Response.json({ error: "Internal Server Error" }, { status: 500 });
}
}

5
src/app/finding-match/page.tsx

@ -29,7 +29,10 @@ export default function FindingMatchPage() {
<>
<PageBackground />
<main className="-mx-[17px] flex min-h-screen flex-col px-[23px] pt-[max(28px,calc(var(--safe-top)+4px))] pb-5 text-center">
<main
style={{ paddingBottom: "calc(20px + var(--safe-bottom))" }}
className="-mx-[17px] flex min-h-screen flex-col px-[23px] pt-[max(28px,calc(var(--safe-top)+4px))] text-center"
>
<header className="-mx-[6px] flex items-center justify-between">
<NavigationButton icon="back" />
<h1 className="font-faminela">{t.common.appName}</h1>

1
src/app/globals.css

@ -54,6 +54,7 @@ html:lang(ar) body,
width: 100%;
min-height: 100vh;
padding-inline: 17px;
padding-bottom: var(--safe-bottom, 0px);
box-sizing: border-box;
background-color: var(--background);
background-image: var(--default-page-background-image);

11
src/app/intro/page.tsx

@ -114,6 +114,12 @@ export default function Intro() {
return;
}
}
const profileResponse = profile ?? (await refetch()).data;
const nextPath = localizePath(getSubmitPath(profileResponse), locale);
router.push(nextPath);
} catch (error) {
console.error("Submission/redirect failed", error);
} finally {
setIsSubmitting(false);
}
@ -211,7 +217,10 @@ export default function Intro() {
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2"
/>
</div>
<div className="mt-7 pb-5">
<div
style={{ paddingBottom: "calc(20px + var(--safe-bottom))" }}
className="mt-7"
>
<Button onClick={handleSubmit} disabled={isSubmitting}>
{t.common.submit}
</Button>

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

@ -14,6 +14,7 @@ import type {
} from "@/hooks/marriage/types";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { useI18n } from "@/i18n/provider";
import { useViewPaddings } from "@/hooks/use-view-paddings";
const advisorAvatars = [
{ id: "advisor-primary", src: "/assets/images/Avatar Image.png" },
@ -183,6 +184,7 @@ function FieldLine({ field }: { field: DisplayField }) {
export default function NewMatchPage() {
const { dictionary: t } = useI18n();
const { top, bottom } = useViewPaddings();
const { data: profile, isError, isLoading } = useMarriageProfileQuery();
const matchSummary = profile?.match_summary ?? null;
const matchDisplay = useMatchSummaryDisplay(matchSummary);
@ -194,7 +196,10 @@ export default function NewMatchPage() {
<>
<PageBackground />
<main className="-mx-[17px] min-h-screen h-full pb-5 text-center px-4 mt-[max(40px,calc(var(--safe-top)+4px))]">
<main
style={{ paddingBottom: `${20 + bottom}px`, marginTop: `${Math.max(40, top + 4)}px` }}
className="-mx-[17px] min-h-screen h-full text-center px-4"
>
<header className="flex items-center justify-between">
<NavigationButton icon="back" />
<h1 className="font-faminela">{t.common.appName}</h1>

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

@ -3,6 +3,7 @@
import { useRouter } from "next/navigation";
import { useEffect, useMemo } from "react";
import {
hasQuestionAnswerValue,
QuestionAnswersProvider,
useQuestionAnswers,
} from "@/components/questions/question-answer-storage";
@ -319,11 +320,25 @@ function QuestionFlowWrapper({
}
}
const answer = getAnswerValue(question, questionIndex);
let isAnswered = hasQuestionAnswerValue(answer ?? null);
if (isAnswered) {
const isEmailQuestion =
question.title.toLowerCase().includes("email") ||
question.title.includes("ایمیل");
if (isEmailQuestion) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
isAnswered = emailRegex.test(String(answer).trim());
}
}
return (
<div
key={`${itemSlug}-${question.title}`}
data-question-required={String(question.required)}
data-question-disabled={String(isDisabled)}
data-question-answered={String(isAnswered)}
>
{renderQuestion(
question,
@ -412,8 +427,8 @@ export default function QuestionDetailClient({
/>
<QuestionAnswersProvider slug={item.slug} questions={visibleQuestions}>
<main className="-mx-[17px] flex min-h-screen flex-col bg-[#F7F1F0] pb-8">
<StickyHeader className="rounded-b-[32px] px-[17px] pt-7 pb-6">
<main className="-mx-[17px] flex h-svh flex-col overflow-hidden bg-[#F7F1F0]">
<StickyHeader sticky={false} className="shrink-0 rounded-b-[32px] px-[17px] pt-7 pb-6">
<div className="flex items-start gap-4">
<QuestionExitNavigationButton
className="shrink-0"
@ -433,7 +448,7 @@ export default function QuestionDetailClient({
</div>
</StickyHeader>
<div className="mx-auto flex w-full max-w-md flex-col px-[17px] pt-7">
<div className="mx-auto flex w-full max-w-md flex-1 flex-col px-[17px] pt-7 min-h-0">
<QuestionFlowWrapper
visibleQuestions={visibleQuestions}
itemSlug={item.slug}

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

@ -12,7 +12,6 @@ import NavigationButton from "@/components/ui/navigation-button";
import { PageBackground } from "@/components/utils/page-background";
import {
getQuestionListItems,
getRequiredQuestionsCount,
isQuestionListItemVisibleForProfile,
type QuestionListItem,
} from "@/data/question-data";
@ -21,6 +20,7 @@ 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 { toFrontendSlug } from "@/data/section-slug-map";
import SectionsRequest from "./sections-request";
export default function QuestionsListPage() {
@ -45,12 +45,6 @@ export default function QuestionsListPage() {
),
[locale, profile?.gender],
);
const profileContext = useMemo(
() => ({
gender: profile?.gender,
}),
[profile?.gender],
);
const allRequiredSectionsCompleted = useMemo(() => {
if (!sections?.length) {
return false;
@ -58,15 +52,8 @@ export default function QuestionsListPage() {
return sections
.filter((section) => section.is_required)
.every((section) => {
const requiredCount = getRequiredQuestionsCount(
section.slug,
profileContext,
locale,
);
return section.current_step >= requiredCount;
});
}, [sections, profileContext, locale]);
.every((section) => section.completion_percent >= 100);
}, [sections]);
const profileStatus = profile?.status;
const isProfileSuspended = profileStatus === "suspended";
const canStartMatch =
@ -83,24 +70,19 @@ export default function QuestionsListPage() {
(section) => !section.is_required && section.completion_percent < 100,
);
}, [sections]);
const sectionProgressBySlug = useMemo(() => {
const progressBySlug = new Map<string, number>();
sections?.forEach((section) => {
const requiredCount = getRequiredQuestionsCount(
section.slug,
profileContext,
locale,
);
const progress =
requiredCount > 0
? Math.min(100, Math.round((section.current_step / requiredCount) * 100))
: 100;
const frontendSlug = toFrontendSlug(section.slug);
const progress = Math.max(0, Math.min(100, Math.round(section.completion_percent)));
progressBySlug.set(frontendSlug, progress);
progressBySlug.set(section.slug, progress);
});
return progressBySlug;
}, [sections, profileContext, locale]);
}, [sections]);
const handleStartMatch = () => {
if (!canStartMatch) {
return;
@ -174,7 +156,10 @@ export default function QuestionsListPage() {
<SectionsRequest />
<PageBackground disabled />
<main className="-mx-[17px] relative min-h-screen overflow-hidden bg-[#F5F5F5] px-[17px] pt-[calc(var(--safe-top)+28px)] pb-8">
<main
style={{ paddingBottom: "calc(32px + var(--safe-bottom))" }}
className="-mx-[17px] relative min-h-screen overflow-hidden bg-[#F5F5F5] px-[17px] pt-[calc(var(--safe-top)+28px)]"
>
<div className="pointer-events-none absolute inset-x-0 top-0 h-[240px] bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.98)_0%,rgba(255,255,255,0.7)_42%,rgba(255,255,255,0)_100%)]" />
<div className="pointer-events-none absolute -top-16 left-1/2 h-[220px] w-[220px] -translate-x-1/2 rounded-full bg-white/70 blur-3xl" />

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

@ -254,7 +254,10 @@ export default function RequestAcceptedPage() {
/>
) : null}
<main className="-mx-[17px] flex min-h-screen flex-col px-[23px] pt-[calc(var(--safe-top)+28px)] pb-10 text-center">
<main
style={{ paddingBottom: "calc(40px + var(--safe-bottom))" }}
className="-mx-[17px] flex min-h-screen flex-col px-[23px] pt-[calc(var(--safe-top)+28px)] text-center"
>
<header className="-mx-[6px] flex items-center justify-between">
<NavigationButton icon="back" />
<h1 className="font-faminela">Habib Marriage</h1>

5
src/app/request-sent/page.tsx

@ -21,7 +21,10 @@ export default function RequestSentPage() {
<>
<PageBackground />
<main className="-mx-[17px] flex min-h-screen flex-col px-[23px] pt-[calc(var(--safe-top)+28px)] pb-10 text-center">
<main
style={{ paddingBottom: "calc(40px + var(--safe-bottom))" }}
className="-mx-[17px] flex min-h-screen flex-col px-[23px] pt-[calc(var(--safe-top)+28px)] text-center"
>
<header className="-mx-[6px] flex items-center justify-between">
<NavigationButton icon="back" />
<h1 className="font-faminela">Habib Marriage</h1>

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

@ -1,6 +1,6 @@
"use client";
import { useEffect } from "react";
import { useEffect, useState, useRef } from "react";
const IDE_SCHEMES = [
{
@ -46,9 +46,54 @@ function parseLocator(locator: string) {
}
export function DevClickToComponent() {
const [isInspecting, setIsInspecting] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
const [isDragging, setIsDragging] = useState(false);
const dragStart = useRef({ x: 0, y: 0, buttonX: 0, buttonY: 0, hasMoved: false });
// Detect mobile size
useEffect(() => {
const checkSize = () => {
setIsMobile(window.innerWidth <= 768);
};
checkSize();
window.addEventListener("resize", checkSize);
return () => window.removeEventListener("resize", checkSize);
}, []);
// Initialize button position
useEffect(() => {
if (typeof window !== "undefined") {
setPosition({
x: window.innerWidth - 66, // 50px width + 16px margin
y: window.innerHeight - 66, // 50px height + 16px margin
});
}
}, [isMobile]);
// Keep button in bounds on resize
useEffect(() => {
const handleResize = () => {
if (!isMobile) return;
setPosition((prev) => {
if (!prev) return null;
return {
x: Math.max(16, Math.min(window.innerWidth - 66, prev.x)),
y: Math.max(16, Math.min(window.innerHeight - 66, prev.y)),
};
});
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [isMobile]);
useEffect(() => {
const userAgent = navigator.userAgent.toLowerCase();
const handleClick = (event: MouseEvent) => {
// Desktop: Alt + Click logic
const handleDesktopClick = (event: MouseEvent) => {
if (!event.altKey) {
return;
}
@ -86,14 +131,482 @@ export function DevClickToComponent() {
}
};
document.addEventListener("click", handleClick, true);
document.addEventListener("click", handleDesktopClick, true);
return () => {
document.removeEventListener("click", handleDesktopClick, true);
};
}, []);
// Mobile: Inspect Mode click interception
useEffect(() => {
if (!isMobile || !isInspecting) return;
const userAgent = navigator.userAgent.toLowerCase();
const handleInspectClick = async (event: MouseEvent) => {
const target = event.target;
if (!(target instanceof Element)) return;
// Ignore clicks on the inspect button itself
if (target.closest(".dev-inspect-btn")) {
return;
}
event.preventDefault();
event.stopPropagation();
const locator = target
.closest("[data-locator]")
?.getAttribute("data-locator");
if (locator) {
console.log(`[DevClickToComponent] Inspect Mode matched element. Opening in IDE:`, locator);
// Flash target element outline briefly as visual feedback
const element = target.closest<HTMLElement>("[data-locator]");
if (element) {
const originalOutline = element.style.outline;
element.style.outline = "3px solid #3b82f6";
setTimeout(() => {
element.style.outline = originalOutline;
}, 400);
}
try {
await fetch("/api/open-in-ide", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ locator, userAgent }),
});
} catch (err) {
console.error("[DevClickToComponent] Error sending API request:", err);
}
}
setIsInspecting(false);
};
// Use capturing phase to intercept clicks/taps before they trigger other actions
document.addEventListener("click", handleInspectClick, true);
return () => {
document.removeEventListener("click", handleInspectClick, true);
};
}, [isMobile, isInspecting]);
// Dev mode: Intercept and forward console logs globally
useEffect(() => {
const originalLog = console.log;
const originalWarn = console.warn;
const originalError = console.error;
let isSending = false;
const forwardLog = async (type: "log" | "warn" | "error", args: any[]) => {
if (isSending) return;
isSending = true;
try {
await fetch("/api/remote-logs", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ type, args }),
});
} catch (err) {
// Print failure directly to the original console to prevent recursion
originalError.call(console, "[DevClickToComponent] Failed to forward log to server:", err);
} finally {
isSending = false;
}
};
console.log = (...args: any[]) => {
originalLog.apply(console, args);
forwardLog("log", args);
};
console.warn = (...args: any[]) => {
originalWarn.apply(console, args);
forwardLog("warn", args);
};
console.error = (...args: any[]) => {
originalError.apply(console, args);
forwardLog("error", args);
};
return () => {
console.log = originalLog;
console.warn = originalWarn;
console.error = originalError;
};
}, []);
// Dev mode: Intercept and forward fetch requests globally
useEffect(() => {
const originalFetch = window.fetch;
window.fetch = async (input, init) => {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.href
: input instanceof Request
? input.url
: "";
// Ignore logging requests to prevent infinite recursion
if (
url.includes("/api/remote-logs") ||
url.includes("/api/remote-network") ||
url.includes("/api/open-in-ide")
) {
return originalFetch(input, init);
}
const method = init?.method ?? (input instanceof Request ? input.method : "GET");
const startTime = Date.now();
let requestBody: string | null = null;
if (init?.body) {
if (typeof init.body === "string") {
requestBody = init.body;
} else {
requestBody = "[Non-string payload]";
}
} else if (input instanceof Request) {
try {
const clonedReq = input.clone();
requestBody = await clonedReq.text();
} catch {
requestBody = null;
}
}
try {
const response = await originalFetch(input, init);
const duration = Date.now() - startTime;
const status = response.status;
let responseBody: string | null = null;
try {
const clonedRes = response.clone();
responseBody = await clonedRes.text();
} catch {
responseBody = "[Unreadable response body]";
}
originalFetch("/api/remote-network", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
method,
url,
status,
requestBody,
responseBody,
duration,
}),
}).catch(() => {});
return response;
} catch (err) {
const duration = Date.now() - startTime;
originalFetch("/api/remote-network", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
method,
url,
status: 0,
requestBody,
responseBody: String(err),
duration,
}),
}).catch(() => {});
throw err;
}
};
return () => {
window.fetch = originalFetch;
};
}, []);
// Dev mode: Intercept and forward XMLHttpRequest (XHR) requests globally
useEffect(() => {
const originalOpen = XMLHttpRequest.prototype.open;
const originalSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (this: any, method: any, url: any, ...args: any[]) {
this._method = method;
this._url = typeof url === "string" ? url : url.toString();
return originalOpen.apply(this, [method, url, ...args] as any);
};
XMLHttpRequest.prototype.send = function (this: any, body?: any) {
const url = this._url || "";
const method = this._method || "GET";
const startTime = Date.now();
// Avoid infinite recursion on dev logging endpoints
if (
url.includes("/api/remote-logs") ||
url.includes("/api/remote-network") ||
url.includes("/api/open-in-ide")
) {
return originalSend.apply(this, [body]);
}
this.addEventListener("loadend", () => {
const duration = Date.now() - startTime;
const status = this.status;
let responseBody = "";
try {
responseBody = this.responseText;
} catch {
// If responseText is not accessible (e.g. responseType is not '' or 'text'), fall back to response
try {
if (typeof this.response === "string") {
responseBody = this.response;
} else if (this.response) {
responseBody = JSON.stringify(this.response);
}
} catch {
responseBody = "[Binary or unreadable response]";
}
}
let requestBody: string | null = null;
if (body) {
if (typeof body === "string") {
requestBody = body;
} else {
requestBody = "[Non-string payload]";
}
}
window.fetch("/api/remote-network", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
method,
url,
status,
requestBody,
responseBody,
duration,
}),
}).catch(() => {});
});
return originalSend.apply(this, [body]);
};
return () => {
document.removeEventListener("click", handleClick, true);
XMLHttpRequest.prototype.open = originalOpen;
XMLHttpRequest.prototype.send = originalSend;
};
}, []);
return null;
// Dragging event handlers
const handlePointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
e.currentTarget.setPointerCapture(e.pointerId);
setIsDragging(true);
const startX = position?.x ?? (window.innerWidth - 66);
const startY = position?.y ?? (window.innerHeight - 126);
dragStart.current = {
x: e.clientX,
y: e.clientY,
buttonX: startX,
buttonY: startY,
hasMoved: false,
};
};
const handlePointerMove = (e: React.PointerEvent<HTMLButtonElement>) => {
if (!isDragging) return;
const dx = e.clientX - dragStart.current.x;
const dy = e.clientY - dragStart.current.y;
if (Math.abs(dx) > 5 || Math.abs(dy) > 5) {
dragStart.current.hasMoved = true;
}
const newX = Math.max(16, Math.min(window.innerWidth - 66, dragStart.current.buttonX + dx));
const newY = Math.max(16, Math.min(window.innerHeight - 126, dragStart.current.buttonY + dy));
setPosition({ x: newX, y: newY });
};
const handlePointerUp = (e: React.PointerEvent<HTMLButtonElement>) => {
if (!isDragging) return;
e.currentTarget.releasePointerCapture(e.pointerId);
setIsDragging(false);
};
const handleInspectClick = () => {
if (!dragStart.current.hasMoved) {
setIsInspecting(!isInspecting);
}
};
const [isSendingStorage, setIsSendingStorage] = useState(false);
const [sendSuccess, setSendSuccess] = useState(false);
const handleSendStorageClick = async () => {
if (dragStart.current.hasMoved) return;
if (isSendingStorage) return;
setIsSendingStorage(true);
setSendSuccess(false);
try {
const data: Record<string, any> = {};
for (let i = 0; i < window.localStorage.length; i++) {
const key = window.localStorage.key(i);
if (key) {
const val = window.localStorage.getItem(key);
try {
data[key] = JSON.parse(val || "");
} catch {
data[key] = val;
}
}
}
const response = await fetch("/api/dev-storage", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
if (response.ok) {
setSendSuccess(true);
setTimeout(() => setSendSuccess(false), 2000);
}
} catch (err) {
console.error("[DevClickToComponent] Failed to send localStorage values:", err);
} finally {
setIsSendingStorage(false);
}
};
return (
<div
style={{
position: "fixed",
left: position ? `${position.x}px` : "auto",
top: position ? `${position.y}px` : "auto",
bottom: position ? "auto" : "16px",
right: position ? "auto" : "16px",
display: "flex",
flexDirection: "column",
gap: "10px",
zIndex: 99998,
touchAction: "none",
}}
>
{/* Send LocalStorage Button */}
<button
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onClick={handleSendStorageClick}
style={{
width: "50px",
height: "50px",
borderRadius: "25px",
backgroundColor: sendSuccess ? "#10b981" : "#a855f7",
color: "#ffffff",
border: "none",
boxShadow: sendSuccess
? "0 0 15px rgba(16, 185, 129, 0.6)"
: "0 4px 12px rgba(0,0,0,0.35)",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: isDragging ? "grabbing" : "pointer",
transition: isDragging ? "none" : "background-color 0.25s, transform 0.25s, box-shadow 0.25s",
outline: "none",
transform: isSendingStorage ? "scale(0.95)" : "scale(1)",
}}
title="Send LocalStorage to Terminal API"
>
{isSendingStorage ? (
<svg className="animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" style={{ width: "22px", height: "22px", pointerEvents: "none" }}>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
) : sendSuccess ? (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2.5} stroke="currentColor" style={{ width: "22px", height: "22px", pointerEvents: "none" }}>
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" style={{ width: "22px", height: "22px", pointerEvents: "none" }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 16.5V9.75m0 0 3 3m-3-3-3 3M6.75 19.5a4.5 4.5 0 0 1-1.41-8.775 5.25 5.25 0 0 1 10.233-2.33 3 3 0 0 1 3.758 3.848A3.752 3.752 0 0 1 18 19.5H6.75Z" />
</svg>
)}
</button>
{/* Floating Action Draggable Button (Inspect Mode) */}
{isMobile && (
<button
className="dev-inspect-btn"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onClick={handleInspectClick}
style={{
width: "50px",
height: "50px",
borderRadius: "25px",
backgroundColor: isInspecting ? "#3b82f6" : "#1f2937",
color: "#ffffff",
border: "none",
boxShadow: isInspecting
? "0 0 15px rgba(59, 130, 246, 0.6)"
: "0 4px 12px rgba(0,0,0,0.35)",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: isDragging ? "grabbing" : "pointer",
transition: isDragging ? "none" : "background-color 0.25s, transform 0.25s, box-shadow 0.25s",
outline: "none",
transform: isInspecting ? "scale(1.1)" : "scale(1)",
}}
title="Toggle Dev Inspect Mode (Drag to move)"
>
{isInspecting ? (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2.5} stroke="currentColor" style={{ width: "22px", height: "22px", pointerEvents: "none" }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" style={{ width: "22px", height: "22px", pointerEvents: "none" }}>
<path strokeLinecap="round" strokeLinejoin="round" d="m15.75 15.75-2.489-2.489m0 0a3.375 3.375 0 1 0-4.773-4.773 3.375 3.375 0 0 0 4.774 4.774ZM21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
)}
</button>
)}
</div>
);
}
export default DevClickToComponent;

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

@ -19,7 +19,8 @@ import type {
MarriagePhoneFieldValue,
UpdateMarriageSectionDataPayload,
} from "@/hooks/marriage/types";
import { useUpdateMarriageSectionDataMutation } from "@/hooks/marriage/use-section-data";
import { useUpdateMarriageSectionDataMutation, useMarriageSectionDataQuery } from "@/hooks/marriage/use-section-data";
import { toBackendSlug } from "@/data/section-slug-map";
const STORAGE_VERSION = 1;
@ -277,6 +278,9 @@ export function QuestionAnswersProvider({
const storageKeyRef = useRef(storageKey);
const slugRef = useRef(slug);
const backendSlug = useMemo(() => toBackendSlug(slug), [slug]);
const { data: serverSectionData } = useMarriageSectionDataQuery(backendSlug);
useEffect(() => {
questionsRef.current = questions;
}, [questions]);
@ -287,11 +291,24 @@ export function QuestionAnswersProvider({
const stored = readStoredAnswers(storageKey, slug);
answersRef.current = stored.answers;
hasPendingSyncRef.current = stored.pendingSync;
setAnswers(stored.answers);
setHasPendingSync(stored.pendingSync);
}, [slug, storageKey]);
// If local storage has answers, we prefer them (especially if pending sync).
// If local storage is empty, we check if the server has answers and seed local storage.
if (Object.keys(stored.answers).length === 0 && serverSectionData?.data) {
const serverAnswers = fieldsToAnswers(serverSectionData.data);
answersRef.current = serverAnswers;
hasPendingSyncRef.current = false;
setAnswers(serverAnswers);
setHasPendingSync(false);
// Seed localStorage with the fetched answers
writeStoredAnswers(storageKey, slug, questions, serverAnswers, false);
} else {
answersRef.current = stored.answers;
hasPendingSyncRef.current = stored.pendingSync;
setAnswers(stored.answers);
setHasPendingSync(stored.pendingSync);
}
}, [slug, storageKey, serverSectionData, questions]);
const getAnswerValue = useCallback(
(question: QuestionField, questionIndex: number) =>

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

@ -1,5 +1,6 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import type { QuestionField } from "@/data/question-data";
import { useQuestionAnswers } from "./question-answer-storage";
import QuestionTitle from "./question-title";
@ -19,31 +20,194 @@ export function QuestionDropdown({
const value = getAnswerValue(question, questionIndex);
const selectValue = typeof value === "string" ? value : "";
const [isOpen, setIsOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const containerRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
// Lock page scroll entirely when dropdown is open
useEffect(() => {
if (isOpen) {
document.body.classList.add("dropdown-open");
document.body.style.overflow = "hidden";
document.documentElement.style.overflow = "hidden";
} else {
document.body.classList.remove("dropdown-open");
document.body.style.overflow = "";
document.documentElement.style.overflow = "";
}
return () => {
document.body.classList.remove("dropdown-open");
document.body.style.overflow = "";
document.documentElement.style.overflow = "";
};
}, [isOpen]);
// Prevent wheel scroll from escaping the options list at boundaries
const handleListWheel = useCallback((e: React.WheelEvent<HTMLDivElement>) => {
e.stopPropagation();
const el = listRef.current;
if (!el) return;
const atTop = el.scrollTop <= 0 && e.deltaY < 0;
const atBottom =
el.scrollTop + el.clientHeight >= el.scrollHeight && e.deltaY > 0;
if (atTop || atBottom) {
e.preventDefault();
}
}, []);
// Close dropdown on click outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
const options = question.extras.options || [];
const filteredOptions = options.filter((option) =>
option.toLowerCase().includes(searchQuery.toLowerCase()),
);
return (
<div
ref={containerRef}
className={[
"flex w-full flex-col gap-2 transition-opacity duration-200",
"relative flex w-full flex-col gap-2 transition-opacity duration-200",
disabled ? "pointer-events-none opacity-30" : "",
].join(" ")}
>
<QuestionTitle question={question} />
<select
value={selectValue}
onChange={(event) => setAnswerValue(question, questionIndex, event.target.value)}
{/* Select Trigger Button */}
<button
type="button"
disabled={disabled}
className="h-[54px] w-full rounded-[15px] border border-[#E7D8D5] bg-white px-4 text-[15px] text-[#181818] outline-none focus:border-[#6F6F6F]"
onClick={() => setIsOpen(!isOpen)}
className="flex h-[55px] w-full items-center justify-between rounded-[11px] border border-[#BCBCBC] bg-white py-[10px] px-[10px] text-[14px] text-[#181818] outline-none transition-all focus:border-[#6F6F6F]"
>
<option value="" disabled>
{question.extras.placeHolder || "Select an option"}
</option>
{question.extras.options.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
<span className={selectValue ? "text-[#181818]" : "text-[#8B8B8B]"}>
{selectValue || question.extras.placeHolder || "Select"}
</span>
<svg
width="16"
height="9"
viewBox="0 0 16 9"
fill="none"
className={["transition-transform duration-200", isOpen ? "rotate-180" : ""].join(" ")}
>
<path
d="M14.75 0.75L7.75 7.75L0.75 0.75"
stroke="#151C31"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
{/* Dropdown Options Panel */}
{isOpen && (
<div className="absolute top-[calc(100%+8px)] left-0 z-50 flex w-full flex-col gap-4 rounded-[11px] bg-white p-4 shadow-[0_4px_10px_rgba(0,0,0,0.05)] border border-[#E7D8D5]">
{/* Search Input Bar */}
{!question.extras.noSearch && (
<div className="flex h-[50px] w-full items-center gap-2 rounded-[8px] bg-[#EBEDED] px-3">
<div className="relative h-6 w-6 shrink-0">
<svg
width="13"
height="13"
viewBox="0 0 13 13"
fill="none"
className="absolute left-[2px] top-[2px]"
>
<path
d="M6.49 12.73C3.034 12.73 0.25 9.946 0.25 6.49C0.25 3.034 3.034 0.25 6.49 0.25C9.946 0.25 12.73 3.034 12.73 6.49C12.73 9.946 9.946 12.73 6.49 12.73ZM6.49 1.21C3.562 1.21 1.21 3.562 1.21 6.49C1.21 9.418 3.562 11.77 6.49 11.77C9.418 11.77 11.77 9.418 11.77 6.49C11.77 3.562 9.418 1.21 6.49 1.21Z"
fill="#111111"
stroke="#111111"
strokeWidth="0.5"
/>
</svg>
<svg
width="6"
height="6"
viewBox="0 0 6 6"
fill="none"
className="absolute left-[11px] top-[11px]"
>
<path
d="M1.03028 0.353516L5.34068 4.66392L4.66196 5.34264L0.351562 1.03224L1.03028 0.353516Z"
fill="#111111"
stroke="#111111"
strokeWidth="0.5"
/>
</svg>
</div>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="search"
className="flex-1 bg-transparent text-[12px] text-[#111111] outline-none"
/>
</div>
)}
{/* Options List */}
<div
ref={listRef}
onWheel={handleListWheel}
onTouchStart={(e) => e.stopPropagation()}
onTouchMove={(e) => e.stopPropagation()}
onTouchEnd={(e) => e.stopPropagation()}
className="flex max-h-[200px] flex-col gap-3 overflow-y-auto overscroll-contain pr-1"
>
{filteredOptions.length > 0 ? (
filteredOptions.map((option) => {
const isSelected = selectValue === option;
return (
<button
key={option}
type="button"
onClick={() => {
setAnswerValue(question, questionIndex, option);
setIsOpen(false);
}}
className="flex w-full items-center gap-2 text-left"
>
{/* Circle checkbox indicator */}
<div
className={[
"h-[14px] w-[14px] shrink-0 rounded-full border transition-all",
isSelected
? "border-[#F0445B] bg-[#F0445B]"
: "border-[#4D4D4D] bg-transparent",
].join(" ")}
/>
<span className="text-[14px] font-bold text-[#4D4D4D]">
{option}
</span>
</button>
);
})
) : (
<span className="text-[12px] text-gray-400">No options found</span>
)}
</div>
</div>
)}
</div>
);
}
export default QuestionDropdown;

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

@ -14,7 +14,11 @@ type QuestionProgressTrackerProps = {
};
function isQuestionAnswered(question: Element) {
const explicitAnsweredState = question.getAttribute("data-question-answered");
const explicitAnsweredState =
question.getAttribute("data-question-answered") ??
question
.querySelector("[data-question-answered]")
?.getAttribute("data-question-answered");
if (explicitAnsweredState === "true") {
return true;
@ -62,12 +66,12 @@ export function QuestionProgressTracker({
return;
}
const requiredQuestions = Array.from(
container.querySelectorAll("[data-question-required='true']"),
const activeQuestions = Array.from(
container.querySelectorAll("[data-question-disabled]"),
).filter((el) => el.getAttribute("data-question-disabled") !== "true");
const nextTotal = requiredQuestions.length;
const nextAnswered = requiredQuestions.filter(isQuestionAnswered).length;
const nextTotal = activeQuestions.length;
const nextAnswered = activeQuestions.filter(isQuestionAnswered).length;
setTotal(nextTotal);
setAnswered(nextAnswered);
@ -96,7 +100,7 @@ export function QuestionProgressTracker({
return (
<div
ref={containerRef}
className="flex flex-col"
className="flex flex-col flex-1 min-h-0"
onChange={updateProgress}
onInput={updateProgress}
>

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

@ -84,7 +84,20 @@ export function QuestionSnapList({
return;
}
firstFocusableInput.focus({ preventScroll: true });
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent
);
firstFocusableInput.focus({ preventScroll: !isMobile });
if (isMobile) {
setTimeout(() => {
firstFocusableInput.scrollIntoView({
behavior: "smooth",
block: "center",
});
}, 150);
}
const valueLength = firstFocusableInput.value.length;
@ -111,6 +124,10 @@ export function QuestionSnapList({
const handleWheel = useCallback(
(event: React.WheelEvent<HTMLElement>) => {
if (document.body.classList.contains("dropdown-open")) {
return;
}
const delta = event.deltaY || event.deltaX;
if (delta === 0 || questions.length < 2) {
@ -131,6 +148,9 @@ export function QuestionSnapList({
const handleTouchStart = useCallback(
(event: React.TouchEvent<HTMLElement>) => {
if (document.body.classList.contains("dropdown-open")) {
return;
}
touchStartYRef.current = event.touches[0]?.clientY ?? null;
},
[],
@ -138,6 +158,10 @@ export function QuestionSnapList({
const handleTouchEnd = useCallback(
(event: React.TouchEvent<HTMLElement>) => {
if (document.body.classList.contains("dropdown-open")) {
return;
}
const startY = touchStartYRef.current;
const endY = event.changedTouches[0]?.clientY;
@ -160,6 +184,9 @@ export function QuestionSnapList({
const handleTouchMove = useCallback(
(event: React.TouchEvent<HTMLElement>) => {
if (document.body.classList.contains("dropdown-open")) {
return;
}
event.preventDefault();
},
[],
@ -174,7 +201,7 @@ export function QuestionSnapList({
aria-label="Questions"
className={[
"relative touch-none overflow-hidden focus-visible:outline-none",
"h-[calc(100svh-150px)] min-h-[430px]",
"flex-1 min-h-0",
className,
]
.filter(Boolean)
@ -206,7 +233,7 @@ export function QuestionSnapList({
aria-hidden={isActive ? undefined : true}
inert={isActive ? undefined : true}
className={[
"absolute inset-0 flex w-full items-center transition-all duration-300 ease-out",
"absolute inset-0 flex w-full items-center pb-[35svh] transition-all duration-300 ease-out",
isActive
? "pointer-events-auto z-10 scale-100 opacity-100"
: "pointer-events-none z-0 scale-[0.96]",

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

@ -22,6 +22,62 @@ export default function QuestionText({
const { getAnswerValue, setAnswerValue } = useQuestionAnswers();
const value = getAnswerValue(question, questionIndex);
const stringValue = String(value ?? "").trim();
const isEmailQuestion =
question.title.toLowerCase().includes("email") ||
question.title.includes("ایمیل");
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const isValidEmail = !isEmailQuestion || emailRegex.test(stringValue);
const showInvalidState = isEmailQuestion && stringValue.length > 0 && !isValidEmail;
const isAnswered =
question.required === false
? isValidEmail || stringValue.length === 0
: isValidEmail && stringValue.length > 0;
if (isEmailQuestion) {
return (
<div
data-question-answered={isAnswered ? "true" : "false"}
data-question-type="email"
className={[
"flex w-full flex-col gap-2 transition-opacity duration-200",
disabled ? "pointer-events-none opacity-30" : "",
].join(" ")}
>
<QuestionTitle question={question} />
<div
dir="ltr"
className={[
"flex h-[54px] w-full items-center rounded-[15px] border bg-white text-[#181818] focus-within:border-[#6F6F6F]",
showInvalidState ? "border-[#F2465F]" : "border-[#E7D8D5]",
].join(" ")}
>
<input
type="text"
value={String(value ?? "")}
onChange={(e) => setAnswerValue(question, questionIndex, e.target.value)}
placeholder={question.extras.placeHolder}
disabled={disabled}
className="h-full w-full border-0 bg-transparent px-[18px] text-[14px] leading-none text-[#181818] outline-none placeholder:text-[#9D8F8C]"
/>
</div>
{showInvalidState ? (
<span className="block text-[10px] font-semibold text-[#F2465F]">
{/[\u0600-\u06FF]/.test(question.title)
? "یک آدرس ایمیل معتبر وارد کنید."
: "Enter a valid email address."}
</span>
) : null}
{description ? (
<span className="block text-[10px] font-semibold text-[#747474]">
{description}
</span>
) : null}
</div>
);
}
return (
<div
className={[

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

@ -1,15 +1,15 @@
"use client";
import { IoAlert } from "react-icons/io5";
import { IoAlert, IoCheckmark } from "react-icons/io5";
import {
getQuestionListItems,
getRequiredQuestionsCount,
isQuestionListItemVisibleForProfile,
} from "@/data/question-data";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { useMarriageSectionsQuery } from "@/hooks/marriage/use-sections";
import { useI18n } from "@/i18n/provider";
import { useMemo } from "react";
import { toFrontendSlug } from "@/data/section-slug-map";
type RequiredStep = {
slug: string;
@ -31,12 +31,7 @@ export default function RequiredStepsCard() {
const { dictionary: t, locale } = useI18n();
const { data: profile } = useMarriageProfileQuery();
const { data: sections } = useMarriageSectionsQuery();
const profileContext = useMemo(
() => ({
gender: profile?.gender,
}),
[profile?.gender],
);
const fallbackRequiredSteps: RequiredStep[] = getQuestionListItems(locale)
.filter((item) =>
@ -50,26 +45,44 @@ export default function RequiredStepsCard() {
progress: item.progress,
}));
const steps =
sections?.map((section) => {
const requiredCount = getRequiredQuestionsCount(
section.slug,
profileContext,
locale,
);
const progress =
requiredCount > 0
? Math.min(100, Math.round((section.current_step / requiredCount) * 100))
: 100;
return {
slug: section.slug,
required: section.is_required,
progress,
};
}) ?? fallbackRequiredSteps;
const steps = useMemo(() => {
if (!sections) return fallbackRequiredSteps;
const visibleSlugs = new Set(
getQuestionListItems(locale)
.filter((item) =>
isQuestionListItemVisibleForProfile(item, {
gender: profile?.gender,
}),
)
.map((item) => item.slug)
);
return sections
.map((section) => {
const frontendSlug = toFrontendSlug(section.slug);
const progress = Math.max(
0,
Math.min(100, Math.round(section.completion_percent)),
);
// Check if the frontend equivalent of this section is required
const frontendItem = getQuestionListItems(locale).find(
(item) => item.slug === frontendSlug,
);
return {
slug: frontendSlug,
required: frontendItem ? Boolean(frontendItem.required) : section.is_required,
progress,
};
})
.filter((step) => visibleSlugs.has(step.slug));
}, [sections, fallbackRequiredSteps, locale, profile?.gender]);
const { completed, total } = getRequiredStepStats(steps);
const completion = total > 0 ? Math.round((completed / total) * 100) : 0;
const isCompleted = total > 0 && completed === total;
return (
<section
@ -80,7 +93,11 @@ export default function RequiredStepsCard() {
<div className="min-w-0 flex-1">
<div className="flex items-center gap-3">
<span className="flex h-[24px] w-[24px] shrink-0 items-center justify-center rounded-full bg-white text-[#40506A]">
<IoAlert aria-hidden="true" className="text-[16px]" />
{isCompleted ? (
<IoCheckmark aria-hidden="true" className="text-[16px]" />
) : (
<IoAlert aria-hidden="true" className="text-[16px]" />
)}
</span>
<h2 className="leading-none font-bold tracking-[-0.02em]">
{t.questions.requiredSteps}
@ -88,7 +105,9 @@ export default function RequiredStepsCard() {
</div>
<p className="mt-2.5 max-w-[220px] text-[10px] leading-[1.18] font-semibold text-white/70">
{t.questions.requiredStepsDescription}
{isCompleted
? t.questions.requiredStepsDescriptionCompleted
: t.questions.requiredStepsDescription}
</p>
</div>

11
src/components/ui/sticky-header.tsx

@ -11,20 +11,27 @@ const SAFE_AREA_GAP = 4;
type StickyHeaderProps = {
children: ReactNode;
className?: string;
sticky?: boolean;
};
export default function StickyHeader({
children,
className,
sticky = true,
}: StickyHeaderProps) {
const { top } = useViewPaddings();
const paddingTop = Math.max(DESIGN_TOP_PADDING, top + SAFE_AREA_GAP);
return (
<header
style={{ top: `-${top}px`, paddingTop: `${paddingTop}px` }}
style={
sticky
? { top: `-${top}px`, paddingTop: `${paddingTop}px` }
: { paddingTop: `${paddingTop}px` }
}
className={[
"sticky z-30 rounded-b-[15px] bg-[linear-gradient(135deg,#E03950_0%,#FE6F82_100%)] px-[17px] pb-5",
sticky ? "sticky z-30" : "relative z-30",
"rounded-b-[15px] bg-[linear-gradient(135deg,#E03950_0%,#FE6F82_100%)] px-[17px] pb-5",
className,
]
.filter(Boolean)

1
src/data/question-data.ts

@ -20,6 +20,7 @@ type QuestionExtras = {
placeHolder: string;
range: [number, number];
options: string[];
noSearch?: boolean;
};
type QuestionAudienceRule = {

38
src/data/section-slug-map.ts

@ -0,0 +1,38 @@
/**
* Maps backend section slugs to their corresponding frontend question list slugs.
* Some backend sections aggregate multiple frontend question categories under a
* single slug, so these two naming systems don't always match 1-to-1.
*/
export const BACKEND_TO_FRONTEND_SLUG_MAP: Record<string, string> = {
contact: "contact_residence_family_communication",
career_and_education: "education_career_economic_status",
expactancy_and_equality: "appearance_health_activity",
};
/**
* Reverse map: frontend slug backend slug.
* Built automatically from BACKEND_TO_FRONTEND_SLUG_MAP.
*/
export const FRONTEND_TO_BACKEND_SLUG_MAP: Record<string, string> =
Object.fromEntries(
Object.entries(BACKEND_TO_FRONTEND_SLUG_MAP).map(([backend, frontend]) => [
frontend,
backend,
]),
);
/**
* Resolve a frontend question-list slug to the backend section slug
* the API expects. Falls back to the original slug when no mapping exists.
*/
export function toBackendSlug(frontendSlug: string): string {
return FRONTEND_TO_BACKEND_SLUG_MAP[frontendSlug] ?? frontendSlug;
}
/**
* Resolve a backend section slug to the frontend question-list slug
* used in the JSON data. Falls back to the original slug.
*/
export function toFrontendSlug(backendSlug: string): string {
return BACKEND_TO_FRONTEND_SLUG_MAP[backendSlug] ?? backendSlug;
}

4
src/i18n/dictionaries.ts

@ -32,6 +32,8 @@ export const dictionaries = {
requiredSteps: "Required Steps",
requiredStepsDescription:
"Please complete the required information so we can find suitable matches for you",
requiredStepsDescriptionCompleted:
"You can now submit your request so we can start finding the right match for you",
requiredStepsProgress: "{completed} of {total} required steps completed",
findMatches: "Find Matches",
findingMatch: "Finding Match",
@ -151,6 +153,8 @@ export const dictionaries = {
requiredSteps: "مراحل ضروری",
requiredStepsDescription:
"لطفا اطلاعات ضروری را کامل کنید تا بتوانیم گزینه‌های مناسب را پیدا کنیم",
requiredStepsDescriptionCompleted:
"اکنون می‌توانید درخواست خود را ثبت کنید تا بتوانیم فرآیند یافتن گزینه‌های مناسب را شروع کنیم",
requiredStepsProgress: "{completed} از {total} مرحله ضروری کامل شده است",
findMatches: "یافتن گزینه‌ها",
findingMatch: "Finding Match",

1140
src/i18n/locales/en/questions.json
File diff suppressed because it is too large
View File

1236
src/i18n/locales/fa/questions.json
File diff suppressed because it is too large
View File

770
سوالات مریج.md

@ -0,0 +1,770 @@
🔒 **محرمانه:** فقط برای کارشناسان پلتفرم و سیستم مچینگ قابل مشاهده است.
👁️ **قابل نمایش محدود:** فقط پس از تأیید، در مراحل رسمی آشنایی، و به‌صورت محدود به طرف مقابل نمایش داده می‌شود.
\* **فیلد اجباری:** تکمیل این مورد برای ادامه ثبت‌نام لازم است.
حبیب مریج تلاش می‌کند ازدواج را با حفظ شأن، حریم خصوصی، چارچوب خانوادگی و روش سنتی، اما با کمک ابزارهای مدرن، پیش ببرد.
---
# **بخش ۱: مشخصات فردی و هویتی**
🔒 **نام**\*
\[فیلد متنی\]
🔒 **نام خانوادگی**\*
\[فیلد متنی\]
🔒 **جنسیت**\*
\[قبلاً در مرحله ثبت‌نام دریافت شده است\]
👁️ **تاریخ تولد**\*
\[انتخابگر تقویم میلادی — YYYY/MM/DD\]
👁️ **سن**
\[محاسبه خودکار توسط سیستم\]
👁️ **کشور محل تولد**\*
\[لیست کشویی کشورها\]
👁️ **شهر محل تولد**\*
\[فیلد متنی\]
👁️ **ملیت / تابعیت فعلی**\*
\[لیست کشویی کشورها\]
👁️ **قومیت / اصلیت خانوادگی / نژاد**\*
\[فیلد متنی\]
راهنما: مثال: عرب، فارس، ترک، کرد، بلوچ، دسی، قفقازی، آفریقایی‌تبار، لاتین و…
👁️ **رنگ پوست**\*
\[تک‌انتخابی\]
* سفید / روشن
* گندم‌گون / سبزه روشن
* سبزه تیره / قهوه‌ای
* تیره / سیاه‌پوست
👁️ **زبان مادری**\*
\[چندانتخابی\]
👁️ **سایر زبان‌هایی که به آن‌ها مسلط هستم**
\[چندانتخابی\]
---
# **بخش ۲: اطلاعات تماس، سکونت و ارتباط خانوادگی**
🔒 **شماره تماس شخصی با کد کشور**\*
\[فیلد متنی\]
🔒 **ایمیل**\*
\[فیلد متنی\]
👁️ **کشور محل سکونت فعلی**\*
\[لیست کشویی کشورها\]
👁️ **شهر / ایالت محل سکونت فعلی**\*
\[فیلد متنی\]
👁️ **لوکیشن حدودی محل زندگی فعلی**\*
\[فیلد متنی یا انتخاب منطقه\]
راهنما: نیازی به آدرس دقیق نیست. فقط محدوده کلی محل زندگی کافی است؛ مثلاً نام شهر، منطقه، ناحیه یا نزدیک‌ترین شهر بزرگ.
👁️ **وضعیت اقامت در کشور فعلی**\*
\[تک‌انتخابی\]
* شهروند / دارای تابعیت
* اقامت دائم
* اقامت موقت
* ویزای تحصیلی
* ویزای کاری
* پناهندگی / حمایت بشردوستانه
* در حال پیگیری وضعیت اقامت
🔒 **تمایل به جابجایی و مهاجرت پس از ازدواج**\*
\[تک‌انتخابی\]
* کاملاً منعطف هستم؛ جابجایی به شهر یا کشور دیگر برایم مشکلی ندارد.
* حاضرم به شهر دیگری نقل مکان کنم، اما فقط در کشور فعلی خودم حاضر به زندگی هستم.
* فقط در شهر فعلی خودم حاضر به زندگی هستم و جابجایی برایم خط قرمز است.
* بسته به شرایط شغلی، خانوادگی، اقامتی و زندگی همسر آینده‌ام تصمیم می‌گیرم.
---
## **اطلاعات سرپرست / ولی / رابط قابل اعتماد (فقط دختران):**
⚠️ **قانون اجباری:**
برای **دختران زیر ۲۷ سال** ثبت شماره یک رابط قابل اعتماد **اجباری** است.
برای دختران **۲۷ سال و بالاتر** اجباری نیست، اما قویاً توصیه می‌شود.
متن مهم که بالاش باید بیاد:
برای زیر ۲۷:
«برای حفظ آرامش، امنیت و شأن شما، روند آشنایی در پلتفرم ما با الگوگیری از رسوم اصیل و محترمانه خانوادگی پیش می‌رود. حضور یک فرد معتمد (ترجیحاً پدر یا مادر) به عنوان رابط، علاوه بر اینکه نشان‌دهنده اصالت شماست، باعث می‌شود طرف مقابل نیز با جدیت، احترام و اطمینان کامل قدم پیش بگذارد.»
برای بالای ۲۷:
«هدف ما شکل‌گیری پیوندهای پایدار بر بستر اعتماد متقابل است. با اینکه ثبت اطلاعات رابط برای شما الزامی نیست، اما معرفی یک فرد معتمد (مانند پدر، مادر یا بزرگتر خانواده) نشان‌دهنده شفافیت و نیت جدی شما برای ازدواج است. پروفایل‌هایی که دارای رابط معتمد هستند، اعتبار بسیار بالاتری دارند و باعث ایجاد اطمینان خاطر بیشتری در خانواده طرف مقابل می‌شوند.»
🔒 **نام و نام خانوادگی رابط**
\[فیلد متنی — شرطی\]
برای دختران زیر ۲۷ سال: اجباری
🔒 **نسبت رابط با شما**
\[تک‌انتخابی — شرطی\]
* پدر
* مادر
* برادر
* خواهر
* عمو / دایی
* خاله / عمه
* دوست خانوادگی معتمد
* معرف مذهبی / روحانی
* معرف اجتماعی معتمد
🔒 **شماره تماس رابط با کد کشور**
\[فیلد متنی — شرطی\]
برای دختران زیر ۲۷ سال: اجباری
---
# **بخش ۳: ویژگی‌های ظاهری، سلامت و فعالیت بدنی**
🔒 **قد به سانتی‌متر**\*
\[فیلد عددی\]
🔒 **وزن به کیلوگرم**\*
\[فیلد عددی\]
👁️ **وضعیت سلامت جسمانی**\*
\[تک‌انتخابی\]
* در سلامت کامل هستم.
* بیماری خاص یا مزمن دارم.
* نقص عضو، معلولیت یا محدودیت جسمی دارم.
👁️ **توضیحات سلامت جسمانی**
\[فیلد متنی شرطی\]
👁️ **وضعیت سلامت روان**\*
\[تک‌انتخابی\]
* مشکل خاصی ندارم.
* سابقه مشاوره و درمان داشته‌ام.
* در حال حاضر تحت مشاوره و درمان هستم.
🔒 **استفاده از داروهای دائمی**
\[فیلد متنی اختیاری\]
---
# **بخش ۴: تحصیلات، شغل و وضعیت اقتصادی**
👁️ **بالاترین سطح تحصیلات**\*
\[تک‌انتخابی\]
* زیر دیپلم
* دیپلم / High School
* کاردانی / Associate
* گواهینامه مهارت حرفه‌ای / Certificate
* آموزش فنی یا مهارتی
* کارشناسی / Bachelor’s Degree
* کارشناسی ارشد / Master’s Degree
* دکتری و بالاتر
* تحصیلات حوزوی / علوم دینی
👁️ **رشته تحصیلی**
\[فیلد متنی\]
👁️ **وضعیت اشتغال**\*
\[تک‌انتخابی\]
* شاغل تمام‌وقت
* شاغل پاره‌وقت
* خویش‌فرما / فریلنسر
* کارآفرین / صاحب کسب‌وکار
* دانشجو
* دانشجو و شاغل
* دانشجو و جویای کار
* جویای کار / بیکار
* خانه‌دار
* بازنشسته
👁️ **عنوان شغلی**
\[فیلد متنی شرطی\]
👁️ **محل فعالیت**
\[فیلد متنی اختیاری\]
🔒 **میزان درآمد ماهانه**\*
\[فیلد متنی\]
مثال: 2500 USD، 1800 EUR، 3000 CAD
🔒 **وضعیت مالی کلی**\*
\[تک‌انتخابی\]
* درآمد پایدار و قابل اتکا دارم.
* درآمد دارم، اما متغیر است.
* در ابتدای مسیر شغلی و مالی هستم.
* فعلاً بخشی از هزینه‌هایم توسط خانواده تأمین می‌شود.
* فعلاً درآمد مستقل ندارم.
🔒 **توانایی تأمین هزینه‌های زندگی مشترک**\*
\[تک‌انتخابی\]
* توانایی تأمین کامل هزینه‌های زندگی مشترک را دارم.
* توانایی تأمین بخش اصلی هزینه‌ها را دارم.
* نیاز به مشارکت مالی همسر آینده دارم.
* فعلاً در حال ساختن شرایط مالی مناسب هستم.
* این موضوع بستگی به کشور و محل زندگی آینده دارد.
🔒 **وضعیت مسکن فعلی**\*
\[تک‌انتخابی\]
* مالک خانه شخصی هستم.
* مستأجر هستم و مستقل زندگی می‌کنم.
* همراه خانواده / والدین زندگی می‌کنم.
* خوابگاه / مسکن دانشجویی
* مسکن سازمانی
* فعلاً شرایط موقت دارم.
🔒 **برنامه یا توانایی تأمین مسکن بعد از ازدواج**\*
\[تک‌انتخابی\]
* خانه شخصی دارم و امکان زندگی مشترک در آن وجود دارد.
* امکان خرید خانه دارم.
* در ابتدای ازدواج احتمالاً مستأجر خواهیم بود.
* در ابتدای ازدواج ممکن است موقتاً با خانواده زندگی کنیم.
🔒 **توضیح تکمیلی درباره وضعیت اقتصادی و مسکن**
\[فیلد متنی اختیاری\]
راهنما: اگر شرایط خاصی درباره کار، درآمد، اجاره، خرید خانه، مهاجرت یا محل زندگی آینده دارید، کوتاه توضیح دهید.
---
# **بخش ۵: پیشینه خانوادگی**
👁️ **تعداد خواهر و برادر**\*
\[فیلد عددی\]
👁️ **وضعیت حیات والدین**\*
\[تک‌انتخابی\]
* هر دو در قید حیات هستند.
* پدر فوت شده است.
* مادر فوت شده است.
* هر دو فوت شده‌اند.
🔒 **وضعیت تأهل والدین**\*
\[تک‌انتخابی\]
* با هم زندگی می‌کنند.
* از هم جدا شده‌اند / طلاق گرفته‌اند.
* یکی از والدین ازدواج مجدد داشته است.
* شرایط خانوادگی خاص دارم و در توضیحات می‌نویسم.
**👁️ فضای مذهبی و اعتقادی خانواده شما به چه شکلی است؟**
* *(راهنما: لطفاً گزینه‌ای را انتخاب کنید که توصیف بهتری از فضای عمومی و سبک زندگی خانواده شما ارائه می‌دهد.)*
* 🔘 **مذهبی و کاملاً مقید**
* خانواده‌ای که اهتمام ویژه‌ای به انجام دقیق واجبات، رعایت کامل حدود شرعی (مانند محرم و نامحرم) و حفظ شعائر دینی و مکتب اهل‌بیت (علیهم‌السلام) در تمام شئونات زندگی دارند.
* 🔘 **مذهبی (مقید به واجبات)**
* خانواده‌ای که به انجام واجبات دینی (مانند نماز و روزه) و اصول اخلاق اسلامی پایبند هستند و سبک زندگی سالمی بر پایه چارچوب‌های عرفیِ جامعه مذهبی دارند.
* 🔘 **سنتی (محترم به ارزش‌های دینی)**
* خانواده‌ای که به اهل‌بیت و ارزش‌های اخلاقی ارادت دارند و به دین احترام می‌گذارند، اما ممکن است در انجام دقیقِ تمامی احکام شرعی و واجبات، تقید کاملی نداشته باشند.
* 🔘 **غیرمذهبی / عرفی**
* خانواده‌ای که با وجود احترام کلی به دین، مناسک و چارچوب‌های شرعی، نقش پررنگی در سبک زندگی، روابط و تصمیمات روزمره آن‌ها ندارد.
*
*
👁️ **وضعیت اقتصادی خانواده**
\[تک‌انتخابی\]
* ضعیف
* متوسط
* خوب
* مرفه
👁️ **توضیح کوتاه درباره خانواده**
\[فیلد متنی اختیاری\]
---
# **بخش ۶: وضعیت تأهل، سابقه ازدواج و فرزندان**
🔒 **وضعیت تأهل فعلی**\*
\[تک‌انتخابی\]
* مجرد؛ بدون هیچ‌گونه سابقه عقد یا ازدواج
* عقد ناموفق / فسخ نامزدی؛ بدون شروع زندگی مشترک
* جدا شده؛ طلاق پس از زندگی مشترک
* همسر فوت شده
🔒 **مدت ازدواج یا عقد قبلی**
\[فیلد متنی شرطی\]
🔒 **علت جدایی، در صورت وجود**
\[فیلد متنی اختیاری\]
👁️ **وضعیت فرزند و تکفل**\*
\[چندانتخابی در صورت نیاز\]
* فرزندی ندارم.
* فرزند دارم و با من زندگی می‌کند.
* فرزند دارم اما با من زندگی نمی‌کند.
* شخص دیگری غیر از فرزند تحت تکفل من است.
👁️ **تعداد فرزندان**
\[فیلد عددی شرطی\]
👁️ **توضیح کوتاه درباره شرایط فرزند یا تکفل**
\[فیلد متنی شرطی\]
---
# **بخش ۷: اعتقادات، سبک زندگی، شخصیت و خطوط قرمز شخصی**
👁️ **مرجع تقلید**\*
\[فیلد متنی یا چک‌باکس\]
* این موضوع برایم اولویت ندارد.
🔒 **میزان تقید به نمازهای واجب**\*
\[تک‌انتخابی\]
* همیشه مقید هستم، ترجیحاً اول وقت.
* همیشه مقید هستم، اما نه لزوماً اول وقت.
* گاهی اوقات می‌خوانم.
* نمی‌خوانم.
🔒 **میزان تقید به روزه ماه رمضان**\*
\[تک‌انتخابی\]
* کاملاً مقید هستم.
* به دلیل عذر شرعی یا پزشکی روزه نمی‌گیرم.
* گاهی بدون عذر شرعی روزه نمی‌گیرم.
* مقید نیستم.
*
👁️ **نوع پوشش و ظاهر**\*
\[تک‌انتخابی\]
*(راهنما: لطفاً گزینه‌ای را انتخاب کنید که بیشترین تطابق را با پوشش روزمره شما در اجتماع دارد.)*
* 🔘 **پوشش کامل اسلامی (حجاب حداکثری)**
* استفاده از لباس‌هایی مانند عبایا، جلباب، چادر یا نقاب با رعایت دقیق و کامل تمامی حدود شرعی.
* 🔘 **حجاب کامل با لباس‌های آزاد و پوشیده**
* استفاده از لباس‌های گشاد و بلند (مانند تونیک، مانتو یا شلوار قمیص) به همراه رعایت کامل حجاب موی سر.
* 🔘 **پوشش عرفی/روزمره به همراه حجاب**
* استفاده از لباس‌های معمول و مدرن (مانند شلوار جین یا استایل‌های کژوال) به همراه پوشاندن موی سر با شال، روسری یا توربان.
* 🔘 **پوشش محجوب و باوقار (بدون حجاب سر)**
* استفاده از لباس‌های کاملاً پوشیده، سنگین و رسمی، اما بدون استفاده از شال، روسری و پوشش موی سر.
* 🔘 **پوشش آزاد و مدرن (بدون رعایت حجاب)**
* پوشش کاملاً آزاد و مطابق با استایل‌های روز جامعه بین‌الملل، بدون رعایت قواعد شرعی حجاب اسلامی.
👁️ **آرایش در محیط عمومی، مخصوص خانم‌ها**
\[تک‌انتخابی\]
* اصلاً آرایش نمی‌کنم.
* فقط آرایش بسیار ملایم دارم.
* آرایش کامل می‌کنم.
### 🔒 **نگرش شما به رابطه دین و سیاست (و خط قرمزهای همسر آینده)**
### \[تک‌انتخابی\]
*(راهنما: لطفاً گزینه‌ای را انتخاب کنید که نگاه شما به مذهب و انتظار شما از همسر آینده‌تان را بهتر توصیف می‌کند.)*
* 🔘 دین را از سیاست جدا نمی‌دانم و همسرم نیز حتماً باید دغدغه و نگاه سیاسی-اجتماعی در دین داشته باشد.
* 🔘 دین را از سیاست جدا نمی‌دانم، اما داشتن دغدغه یا فعالیت سیاسی برای همسرم الزامی نیست و برایم خط قرمز محسوب نمی‌شود.
* 🔘 نگاهم به تشیع کاملاً سنتی و غیرسیاسی است و به هیچ وجه با فردی که نگاه سیاسی به دین دارد نمی‌توانم ازدواج کنم.
* 🔘 نگاهم به تشیع غیرسیاسی است، اما اگر همسر آینده‌ام نگاه سیاسی داشته باشد برایم خط قرمز نیست و با آن کنار می‌آیم.
* 🔘 اساساً این مفاهیم و دسته‌بندی‌ها (تشیع سیاسی یا غیرسیاسی) برایم مطرح نیست یا چندان درگیر این مسائل نیستم.
### 🔒 **موضع شما نسبت به حاکمیت/دولت فعلی کشور محل سکونتتان**
*(راهنما: این بخش برای جلوگیری از تنش‌های جدیِ اعتقادی در زندگی مشترک طراحی شده است.)*
* 🔘 حامی حاکمیت/دولت فعلی کشورم هستم و مخالفت جدی طرف مقابل با حاکمیت، برایم خط قرمز است.
* 🔘 حامی حاکمیت/دولت فعلی کشورم هستم، اما تفاوت دیدگاه یا مخالفت همسرم در این زمینه برایم خط قرمز نیست.
* 🔘 مخالف حاکمیت/دولت فعلی کشورم هستم و حمایت جدی طرف مقابل از حاکمیت، برایم خط قرمز است.
* 🔘 مخالف حاکمیت/دولت فعلی کشورم هستم، اما تفاوت دیدگاه یا حمایت همسرم از دولت برایم خط قرمز نیست.
* 🔘 موضع‌گیری خاصی (حمایت یا مخالفت جدی) ندارم و تفاوت دیدگاه‌های سیاسی برایم اهمیت چندانی در زندگی مشترک ندارد.
### **👁️ حدود روابط شما با جنس مخالف (در محیط کار، فامیل و اجتماع) چگونه است؟**
* *(راهنما: لطفاً گزینه‌ای را انتخاب کنید که رفتار روزمره شما را در مواجهه با نامحرم بهتر توصیف می‌کند.)*
* 🔘 **بسیار رسمی و محدود (فقط در حد ضرورت)**
* ارتباطاتم با نامحرم بسیار کوتاه و کاملاً رسمی است و از هرگونه گفتگوی غیرضروری، معاشرت مازاد یا شوخی پرهیز می‌کنم.
* 🔘 **محترمانه و متعارف (بدون صمیمیت)**
* ارتباطاتم در اجتماع و فامیل محترمانه و با خوش‌رویی است، اما حریم و مرز جدی برای پرهیز از شوخی، راحتی بیش‌ازحد یا گفتگوهای شخصی دارم.
* 🔘 **اجتماعی و راحت‌تر (در چارچوب شرع)**
* حضور فعال و راحتی در اجتماع و فامیل دارم؛ معاشرت، گفتگوی صمیمانه و شوخی‌های عرفی را تا زمانی که از خطوط قرمز شرعی و اخلاقی خارج نشود، بلامانع می‌دانم.
* 🔘 **بدون مرزبندی خاص**
* در ارتباط با جنس مخالف کاملاً راحت هستم و مرزبندی‌های سنتی یا حساسیت‌های مذهبی در معاشرت‌ها، شوخی‌ها و دوستی‌های اجتماعی ندارم.
---
## **مصرف دخانیات، الکل و مواد**
🔒 **سیگار**\*
\[تک‌انتخابی\]
* اصلاً مصرف نمی‌کنم.
* گاهی / تفریحی مصرف می‌کنم.
* مرتب مصرف می‌کنم.
* در حال ترک هستم.
🔒 **قلیان**\*
\[تک‌انتخابی\]
* اصلاً مصرف نمی‌کنم.
* گاهی / تفریحی مصرف می‌کنم.
* مرتب مصرف می‌کنم.
* در حال ترک هستم.
🔒 **ویپ / سیگار الکترونیک**\*
\[تک‌انتخابی\]
* اصلاً مصرف نمی‌کنم.
* گاهی / تفریحی مصرف می‌کنم.
* مرتب مصرف می‌کنم.
* در حال ترک هستم.
🔒 **مشروبات الکلی**\*
\[تک‌انتخابی\]
* اصلاً مصرف نمی‌کنم.
* مصرف الکل برای من خط قرمز جدی است.
* گاهی مصرف می‌کنم.
* مصرف می‌کنم.
🔒 **مواد مخدر یا مواد غیرقانونی**\*
\[تک‌انتخابی\]
* هرگز مصرف نکرده‌ام.
* سابقه مصرف داشته‌ام، اما اکنون مصرف نمی‌کنم.
* مصرف می‌کنم.
* در حال ترک یا درمان هستم.
---
## **سبک زندگی و سلیقه‌ها**
👁️ **نگرش به موسیقی**\*
\[تک‌انتخابی\]
* به هیچ نوع موسیقی گوش نمی‌دهم.
* فقط نشید، آکاپلا، مذهبی یا بدون ساز گوش می‌دهم.
* موسیقی حلال و مجاز گوش می‌دهم.
* حساسیت خاصی روی نوع موسیقی ندارم.
🔒 **نگرش به مراسم عروسی**\*
\[تک‌انتخابی\]
* بدون مراسم یا بسیار ساده.
* کاملاً شرعی و تفکیک‌شده.
* مختلط محترمانه، بدون رقص و موسیقی غیرشرعی.
* مختلط همراه با موسیقی و رقص.
* هنوز تصمیم قطعی ندارم و بسته به توافق خانواده‌ها تصمیم می‌گیرم.
👁️ **ویژگی‌های شخصیتی خودتان**\*
\[چندانتخابی\]
* آرام و درونگرا
* اجتماعی و برونگرا
* احساسی
* منطقی
* شوخ‌طبع
* جدی
* اهل گفتگو
* خانواده‌دوست
* مستقل
* مسئولیت‌پذیر
* صبور
* منظم
* اهل برنامه‌ریزی
* انعطاف‌پذیر
* حساس و دقیق
* اهل رشد فردی
👁️ **سرگرمی‌ها و علایق اصلی**\*
\[چندانتخابی\]
* تلاوت قرآن و مطالعه دینی
* حضور در مسجد و هیئت
* فعالیت مذهبی و فرهنگی
* سفر زیارتی
* سفر سیاحتی
* طبیعت‌گردی
* ورزش
* مطالعه
* فیلم و سینما
* آشپزی
* هنر
* موسیقی
* بازی‌های فکری
* فعالیت اجتماعی و خیریه
* کافه و رستوران
* یادگیری زبان
* تکنولوژی و کامپیوتر
👁️ **توضیح کوتاه درباره سبک زندگی خودتان**
\[فیلد متنی اختیاری\]
---
# **بخش ۸: معیارها و خطوط قرمز همسر آینده**
⚠️ این بخش کاملاً محرمانه است و فقط برای مچینگ و بررسی کارشناسان استفاده می‌شود.
🔒 **بازه سنی مطلوب همسر آینده**\*
از \[عدد\] تا \[عدد\] سال
🔒 **بازه قدی مطلوب همسر آینده**\*
\[چندانتخابی\]
* مهم نیست.
* کمتر از ۱۶۰
* ۱۶۰ تا ۱۷۰
* ۱۷۰ تا ۱۸۰
* ۱۸۰ تا ۱۹۰
* بالای ۱۹۰
🔒 **تیپ بدنی مطلوب همسر آینده**\*
\[چندانتخابی\]
* مهم نیست.
* لاغراندام
* متناسب
* ورزیده
* توپر
* درشت‌اندام
🔒 **رنگ پوست مطلوب همسر آینده**\*
\[چندانتخابی\]
* مهم نیست.
* سفید / روشن
* گندم‌گون / سبزه روشن
* سبزه تیره / قهوه‌ای
* تیره / سیاه‌پوست
🔒 **قومیت، زبان یا ملیت مطلوب همسر آینده**
\[فیلد متنی اختیاری\]
🔒 **حداقل سطح تحصیلات همسر آینده**\*
\[تک‌انتخابی\]
* مهم نیست.
* حداقل دیپلم
* حداقل کارشناسی
* حداقل کارشناسی ارشد
* دکتری یا بالاتر ترجیح دارد.
* حتماً تحصیلات حوزوی / علوم دینی داشته باشد.
* تحصیلات دانشگاهی مهم نیست، اما بلوغ فکری مهم است.
🔒 **وضعیت اشتغال مطلوب همسر آینده**\*
\[چندانتخابی\]
* مهم نیست.
* شاغل باشد.
* دانشجو باشد.
* خانه‌دار باشد.
* کارآفرین / صاحب کسب‌وکار باشد.
* در مسیر رشد شغلی باشد.
* بسته به شرایط قابل گفتگو است.
🔒 **پذیرش سابقه عقد یا ازدواج همسر آینده**\*
\[تک‌انتخابی\]
* فقط مجرد؛ سابقه عقد یا ازدواج قبلی برایم خط قرمز است.
* مجرد ترجیح دارد، اما شرایط خاص را بررسی می‌کنم.
* تفاوتی ندارد.
* بستگی به علت جدایی، مدت ازدواج قبلی و شرایط خانوادگی دارد.
🔒 **پذیرش داشتن فرزند از ازدواج قبلی**\*
\[تک‌انتخابی\]
* به هیچ وجه نمی‌پذیرم.
* در شرایط خاص می‌پذیرم.
* تفاوتی ندارد.
* فقط اگر فرزند با او زندگی نکند، بررسی می‌کنم.
🔒 **معیارهای پوشش و حجاب همسر آینده**\*
\[تک‌انتخابی متناسب با جنسیت\]
برای آقایانی که به دنبال همسر خانم هستند:
* پوشش کامل اسلامی مانند چادر، عبایا یا جلباب الزامی است.
* حجاب شرعی الزامی است، اما نوع آن مهم نیست.
* پوشش عرفی همراه با حجاب قابل قبول است.
* پوشش محجوب و سنگین مهم است، اما جزئیات قابل گفتگو است.
* حساسیت خاصی ندارم.
برای خانم‌هایی که به دنبال همسر آقا هستند:
* پوشش رسمی، سنگین و مذهبی داشته باشد.
* ساده، مرتب و محجوب باشد.
* پوشش معمولی و آراسته کافی است.
* پوشش مدرن برایم مشکلی ندارد.
* حساسیت خاصی ندارم.
🔒 **تمایل به ادامه تحصیل همسر آینده**\*
\[تک‌انتخابی\]
* مهم نیست.
* حتماً قصد ادامه تحصیل داشته باشد.
* ادامه تحصیل مثبت است، اما الزامی نیست.
* ترجیح می‌دهم بعد از ازدواج ادامه تحصیل ندهد.
* بسته به شرایط زندگی مشترک تصمیم می‌گیریم.
🔒 **تمایل به اشتغال همسر آینده**\*
\[تک‌انتخابی\]
* مهم نیست.
* حتماً شاغل باشد.
* حتماً خانه‌دار باشد.
* اختیار با خودش باشد.
* بسته به شرایط زندگی، فرزندآوری و توافق مشترک تصمیم می‌گیریم.
* فقط با شغلی که با ارزش‌های دینی و خانوادگی من سازگار باشد موافقم.
🔒 **وضعیت خانوادگی همسر آینده**\*
\[چندانتخابی\]
* این معیارها برایم مهم نیست.
* والدین همسرم طلاق نگرفته باشند.
* پدر همسر آینده‌ام در قید حیات باشد.
* مادر همسر آینده‌ام در قید حیات باشد.
* فضای مذهبی خانواده همسر برایم مهم است.
* خانواده همسر باید اهل ارتباط محترمانه و سالم باشند.
🔒 **سطح ارتباط با خانواده‌ها بعد از ازدواج**\*
\[تک‌انتخابی\]
* ارتباط نزدیک و پررنگ با خانواده‌ها را دوست دارم.
* ارتباط محترمانه اما با حفظ استقلال زندگی مشترک را ترجیح می‌دهم.
* ارتباط محدود و کنترل‌شده را ترجیح می‌دهم.
* بسته به شرایط خانواده‌ها تصمیم می‌گیرم.
🔒 **میزان پایبندی مذهبی مطلوب همسر آینده**\*
\[تک‌انتخابی\]
* بسیار مذهبی و مقید
* مذهبی معتدل
* عرفی اما محترم به دین
* این معیار برایم اهمیت زیادی ندارد.
🔒 **نگرش سیاسی مطلوب همسر آینده**\*
\[تک‌انتخابی\]
* حتماً همسو با دیدگاه من باشد.
* تفاوت دیدگاه سیاسی مهم نیست، به شرط احترام متقابل.
* ترجیح می‌دهم سیاسی نباشد.
* سیاست برایم اهمیتی در ازدواج ندارد.
🔒 **حدود روابط همسر آینده با جنس مخالف**\*
\[تک‌انتخابی\]
* بسیار رسمی و محدود باشد.
* معمولی و محترمانه باشد.
* اجتماعی‌تر باشد، اما در چارچوب شرع و اخلاق.
🔒 **خط قرمزهای مربوط به دخانیات، الکل و مواد در همسر آینده**\*
\[چندانتخابی\]
* سیگار برایم خط قرمز است.
* قلیان برایم خط قرمز است.
* ویپ برایم خط قرمز است.
* الکل برایم خط قرمز جدی است.
* مواد مخدر برایم خط قرمز قطعی است.
* مصرف تفریحی دخانیات را در شرایط خاص می‌پذیرم.
* هیچ‌کدام برایم خط قرمز نیست.
🔒 **نگرش همسر آینده به موسیقی**\*
\[تک‌انتخابی\]
* نباید به موسیقی گوش بدهد.
* فقط موسیقی مذهبی / بدون ساز / نشید قابل قبول است.
* موسیقی حلال و مجاز قابل قبول است.
* حساسیت خاصی ندارم.
🔒 **نگرش همسر آینده به مراسم عروسی**\*
\[تک‌انتخابی\]
* مراسم ساده یا بدون مراسم را ترجیح دهد.
* فقط مراسم کاملاً شرعی و تفکیک‌شده قابل قبول است.
* مراسم مختلط محترمانه و بدون رقص و موسیقی غیرشرعی قابل قبول است.
* مراسم مختلط با موسیقی و رقص قابل قبول است.
* بسته به توافق خانواده‌ها قابل تصمیم‌گیری است.
🔒 **پذیرش بیماری خاص، معلولیت یا شرایط درمانی در همسر آینده**\*
\[تک‌انتخابی\]
* برایم خط قرمز است.
* در شرایط خاص و با توضیح کامل بررسی می‌کنم.
* اگر مانع زندگی مشترک سالم نباشد، می‌پذیرم.
* موردی و با مشورت بررسی می‌کنم.
🔒 **پذیرش سابقه مشاوره یا درمان روان‌شناختی همسر آینده**\*
\[تک‌انتخابی\]
* برایم مشکلی ندارد.
* بستگی به شرایط فعلی و میزان ثبات دارد.
* برایم خط قرمز است.
* نیاز به بررسی جدی‌تر دارد.
🔒 **ترجیح درباره محل زندگی بعد از ازدواج**\*
\[تک‌انتخابی\]
* کشور محل زندگی طرف مقابل برایم مهم نیست.
* ترجیح می‌دهم در کشور فعلی خودم بمانم.
* ترجیح می‌دهم در کشور فعلی همسر آینده‌ام زندگی کنیم.
* فقط در شهر فعلی خودم حاضر به زندگی هستم.
* آماده مهاجرت به کشور ثالث هستم.
* بسته به کار، اقامت، خانواده و شرایط مالی تصمیم می‌گیرم.
🔒 **زندگی با خانواده بعد از ازدواج**\*
\[تک‌انتخابی\]
* فقط زندگی مستقل را می‌پذیرم.
* زندگی موقت با خانواده در ابتدای ازدواج قابل قبول است.
* زندگی با خانواده همسر یا خانواده خودم برایم مشکلی ندارد.
* بستگی به شرایط دارد.
🔒 **توضیحات تکمیلی و سایر خطوط قرمز**\*
\[فیلد متنی بزرگ\]
راهنما: هر نکته مهمی که در گزینه‌های بالا نبود، اینجا بنویسید.
---
# **بخش ۹: بارگذاری مدارک، احراز هویت و تصاویر**
🔒 **تصویر چهره؛ عکس جدید و واضح**\*
\[بارگذاری تصویر\]
⚠️ این تصویر به هیچ عنوان به طرف مقابل یا سایر کاربران نمایش داده نمی‌شود. عکس شما فقط توسط کارشناسان تأییدشده و هم‌جنس پلتفرم برای احراز هویت، بررسی تطابق ظاهری و جلوگیری از اختلافات ظاهری شدید بررسی می‌شود.
راهنمای عکس مناسب:
* عکس جدید و واضح باشد.
* چهره کاملاً مشخص باشد.
* فیلتر سنگین نداشته باشد.
* عکس گروهی نباشد.
* نور کافی داشته باشد.
🔒 **تصویر کارت شناسایی معتبر**\*
\[بارگذاری تصویر\]
مدارک قابل قبول:
* پاسپورت
* کارت ملی
* گواهینامه
* کارت اقامت معتبر
* مدرک شناسایی رسمی کشور محل سکونت
🔒 **تأیید تطابق مدارک و اطلاعات**\*
\[چک‌باکس\]
☑️ تأیید می‌کنم که نام، سن، تصویر و اطلاعات هویتی من با مدارک بارگذاری‌شده مطابقت دارد.
Loading…
Cancel
Save