43 changed files with 2444 additions and 156 deletions
-
18next.config.ts
-
3package.json
-
BINpublic/assets/fonts/ArabicYekanXRegular.woff2
-
BINpublic/assets/fonts/Faminela.woff2
-
BINpublic/assets/fonts/NotoNastaliqUrdu.woff2
-
BINpublic/assets/fonts/SegoeUIBold.woff2
-
BINpublic/assets/fonts/YekanXFaNum-R.woff2
-
BINpublic/assets/fonts/faminela.woff2
-
BINpublic/assets/fonts/segoeui.woff2
-
BINpublic/fonts/Amiri/Amiri-Bold.woff2
-
BINpublic/fonts/Amiri/Amiri-BoldItalic.woff2
-
BINpublic/fonts/Amiri/Amiri-Italic.woff2
-
BINpublic/fonts/Amiri/Amiri-Regular.woff2
-
BINpublic/fonts/Faminela/Faminela.woff2
-
BINpublic/fonts/segoeui/SegoeUIBold.woff2
-
BINpublic/fonts/segoeui/segoeui.woff2
-
BINpublic/fonts/urdu/NotoNastaliqUrdu.woff2
-
BINpublic/fonts/yekanx-arabic/ArabicYekanXRegular.woff2
-
BINpublic/fonts/yekanx/YekanXFaNum-R.woff2
-
27src/app/globals.css
-
14src/app/layout.tsx
-
87src/app/questions-list/[slug]/answer-pace-sheet.test.tsx
-
32src/app/questions-list/[slug]/question-detail-client.tsx
-
11src/app/questions-list/questions-list-client.tsx
-
93src/app/sheet-lab/page.tsx
-
108src/components/Componentes/information-sheet.test.tsx
-
143src/components/Componentes/information-sheet.tsx
-
16src/components/Componentes/question-checkbox.tsx
-
17src/components/Componentes/question-dropdown.tsx
-
4src/components/Componentes/question-progress-tracker.tsx
-
350src/components/Componentes/question-sheet.test.tsx
-
373src/components/Componentes/question-sheet.tsx
-
191src/components/Componentes/terms-sheet.test.tsx
-
434src/components/Componentes/terms-sheet.tsx
-
32src/components/Componentes/ui-icon.tsx
-
255src/data/country-calling-codes.ts
-
10src/hooks/marriage/use-form-schema.ts
-
37src/lib/auth-bridge.test.ts
-
16src/lib/geo-region.ts
-
119src/lib/marriage-cookie.test.ts
-
132src/lib/multi-select-helper.test.ts
-
76src/lib/multi-select-helper.ts
-
2src/lib/schema-adapter.ts
@ -0,0 +1,87 @@ |
|||||
|
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; |
||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
||||
|
import AnswerPaceSheet, { |
||||
|
isAnswerPaceSheetSeen, |
||||
|
markAnswerPaceSheetSeen, |
||||
|
} from "./answer-pace-sheet"; |
||||
|
|
||||
|
vi.mock("@/translations/provider", () => ({ |
||||
|
useI18n: vi.fn(() => ({ |
||||
|
locale: "fa", |
||||
|
dictionary: { |
||||
|
"Information sheet": "شیت اطلاعات", |
||||
|
Close: "بستن", |
||||
|
}, |
||||
|
})), |
||||
|
})); |
||||
|
|
||||
|
vi.mock("@/lib/first-entry-helper", () => ({ |
||||
|
isFirstEntryCompleted: vi.fn(() => false), |
||||
|
})); |
||||
|
|
||||
|
describe("AnswerPaceSheet", () => { |
||||
|
beforeEach(() => { |
||||
|
window.localStorage.clear(); |
||||
|
}); |
||||
|
|
||||
|
afterEach(() => { |
||||
|
cleanup(); |
||||
|
window.localStorage.clear(); |
||||
|
}); |
||||
|
|
||||
|
it("does not render when activeQuestionIndex is less than 3", () => { |
||||
|
render( |
||||
|
<AnswerPaceSheet |
||||
|
activeQuestionIndex={2} |
||||
|
title="با آرامش پاسخ دهید" |
||||
|
description="میتوانید هر زمان نظرسنجی را متوقف کرده و بعداً ادامه دهید." |
||||
|
continueLabel="متوجه شدم" |
||||
|
/>, |
||||
|
); |
||||
|
|
||||
|
expect(screen.queryByRole("dialog")).toBeNull(); |
||||
|
}); |
||||
|
|
||||
|
it("renders when activeQuestionIndex >= 3 and displays inline play icon synchronously without network img tag", () => { |
||||
|
render( |
||||
|
<AnswerPaceSheet |
||||
|
activeQuestionIndex={3} |
||||
|
title="با آرامش پاسخ دهید" |
||||
|
description="میتوانید هر زمان نظرسنجی را متوقف کرده و بعداً ادامه دهید." |
||||
|
continueLabel="متوجه شدم" |
||||
|
/>, |
||||
|
); |
||||
|
|
||||
|
const dialog = screen.getByRole("dialog"); |
||||
|
expect(dialog).toBeDefined(); |
||||
|
expect(screen.getByText("با آرامش پاسخ دهید")).toBeDefined(); |
||||
|
expect( |
||||
|
screen.getByText( |
||||
|
"میتوانید هر زمان نظرسنجی را متوقف کرده و بعداً ادامه دهید.", |
||||
|
), |
||||
|
).toBeDefined(); |
||||
|
|
||||
|
// Critical: must render inline SVG play icon instantly with NO <img> tag
|
||||
|
expect(dialog.querySelector("img")).toBeNull(); |
||||
|
const playSvg = dialog.querySelector('svg[aria-label="Play"]'); |
||||
|
expect(playSvg).not.toBeNull(); |
||||
|
expect(playSvg?.getAttribute("viewBox")).toBe("0 0 50 50"); |
||||
|
}); |
||||
|
|
||||
|
it("marks sheet as seen and closes on button click", async () => { |
||||
|
render( |
||||
|
<AnswerPaceSheet |
||||
|
activeQuestionIndex={3} |
||||
|
title="با آرامش پاسخ دهید" |
||||
|
description="میتوانید هر زمان نظرسنجی را متوقف کرده و بعداً ادامه دهید." |
||||
|
continueLabel="متوجه شدم" |
||||
|
/>, |
||||
|
); |
||||
|
|
||||
|
const continueBtn = screen.getByRole("button", { name: "متوجه شدم" }); |
||||
|
fireEvent.click(continueBtn); |
||||
|
await waitFor(() => { |
||||
|
expect(isAnswerPaceSheetSeen()).toBe(true); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,93 @@ |
|||||
|
"use client"; |
||||
|
|
||||
|
/** |
||||
|
* Standalone dev page for testing QuestionSheet sizing behavior in a browser. |
||||
|
* No login, no backend: profile query data is seeded into the QueryClient so |
||||
|
* QuestionAnswersProvider never needs the API. Hardcoded questions only. |
||||
|
*/ |
||||
|
|
||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; |
||||
|
import { useState } from "react"; |
||||
|
import QuestionSheet from "@/components/Componentes/question-sheet"; |
||||
|
import { QuestionAnswersProvider } from "@/components/Componentes/question-answer-storage"; |
||||
|
import { marriageQueryKeys } from "@/hooks/marriage/query-keys"; |
||||
|
import type { QuestionField } from "@/lib/schema-adapter"; |
||||
|
import { I18nProvider } from "@/translations/provider"; |
||||
|
|
||||
|
function makeOptions(count: number, prefix: string): QuestionField["options"] { |
||||
|
return Array.from({ length: count }, (_, index) => ({ |
||||
|
id: `${prefix}-${index + 1}`, |
||||
|
value: `${prefix}-${index + 1}`, |
||||
|
label: `${prefix} ${index + 1}`, |
||||
|
order: index + 1, |
||||
|
})); |
||||
|
} |
||||
|
|
||||
|
const fourOptionQuestion: QuestionField = { |
||||
|
id: "sheet_lab.four_options", |
||||
|
title: "Sheet Lab — 4 options (compact)", |
||||
|
type: "dropdown", |
||||
|
order: 1, |
||||
|
required: false, |
||||
|
baseRequired: false, |
||||
|
isVisible: true, |
||||
|
description: "", |
||||
|
tooltip: "", |
||||
|
extras: { placeHolder: "Select (4)", range: [0, 1], options: [] }, |
||||
|
options: makeOptions(4, "Option"), |
||||
|
ui_config: {}, |
||||
|
}; |
||||
|
|
||||
|
const manyOptionQuestion: QuestionField = { |
||||
|
id: "sheet_lab.many_options", |
||||
|
title: "Sheet Lab — 67 options (draggable)", |
||||
|
type: "dropdown", |
||||
|
order: 2, |
||||
|
required: false, |
||||
|
baseRequired: false, |
||||
|
isVisible: true, |
||||
|
description: "", |
||||
|
tooltip: "", |
||||
|
extras: { placeHolder: "Select (67)", range: [0, 1], options: [] }, |
||||
|
options: makeOptions(67, "Item"), |
||||
|
ui_config: {}, |
||||
|
}; |
||||
|
|
||||
|
export default function SheetLabPage() { |
||||
|
const [queryClient] = useState(() => { |
||||
|
const qc = new QueryClient({ |
||||
|
defaultOptions: { |
||||
|
queries: { |
||||
|
refetchOnMount: false, |
||||
|
refetchOnWindowFocus: false, |
||||
|
refetchOnReconnect: false, |
||||
|
retry: false, |
||||
|
staleTime: Infinity, |
||||
|
}, |
||||
|
}, |
||||
|
}); |
||||
|
// Seed the profile cache so QuestionAnswersProvider never hits the API.
|
||||
|
qc.setQueryData(marriageQueryKeys.profile(), { |
||||
|
id: 1, |
||||
|
can_edit_profile: true, |
||||
|
}); |
||||
|
return qc; |
||||
|
}); |
||||
|
|
||||
|
return ( |
||||
|
<I18nProvider locale="en"> |
||||
|
<QueryClientProvider client={queryClient}> |
||||
|
<QuestionAnswersProvider |
||||
|
slug="sheet-lab" |
||||
|
questions={[fourOptionQuestion, manyOptionQuestion]} |
||||
|
> |
||||
|
<main className="mx-auto flex max-w-[480px] flex-col gap-8 p-4 pt-10"> |
||||
|
<h1 className="text-lg font-bold">Sheet Lab</h1> |
||||
|
<QuestionSheet question={fourOptionQuestion} /> |
||||
|
<QuestionSheet question={manyOptionQuestion} /> |
||||
|
</main> |
||||
|
</QuestionAnswersProvider> |
||||
|
</QueryClientProvider> |
||||
|
</I18nProvider> |
||||
|
); |
||||
|
} |
||||
@ -0,0 +1,191 @@ |
|||||
|
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; |
||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
||||
|
import TermsSheet from "./terms-sheet"; |
||||
|
|
||||
|
vi.mock("@/translations/provider", () => ({ |
||||
|
useI18n: () => ({ |
||||
|
locale: "fa", |
||||
|
dictionary: { |
||||
|
"terms & conditions": "قوانین و مقررات", |
||||
|
"Got it": "متوجه شدم", |
||||
|
"Close terms and conditions": "بستن قوانین و مقررات", |
||||
|
}, |
||||
|
}), |
||||
|
})); |
||||
|
|
||||
|
describe("TermsSheet Component (DraggableScrollableSheet Parity)", () => { |
||||
|
beforeEach(() => { |
||||
|
vi.clearAllMocks(); |
||||
|
}); |
||||
|
|
||||
|
afterEach(() => { |
||||
|
cleanup(); |
||||
|
}); |
||||
|
|
||||
|
it("does not render when isOpen is false", () => { |
||||
|
render(<TermsSheet isOpen={false} />); |
||||
|
expect(screen.queryByRole("dialog")).toBeNull(); |
||||
|
}); |
||||
|
|
||||
|
it("renders with initial 75% height when isOpen is true", () => { |
||||
|
render(<TermsSheet isOpen={true} />); |
||||
|
|
||||
|
const dialog = screen.getByRole("dialog"); |
||||
|
expect(dialog).toBeInTheDocument(); |
||||
|
|
||||
|
const section = dialog.querySelector("section")!; |
||||
|
expect(section).toHaveClass("flutter-draggable-sheet"); |
||||
|
expect(screen.getByText("قوانین و مقررات")).toBeInTheDocument(); |
||||
|
expect(screen.getByText("متوجه شدم")).toBeInTheDocument(); |
||||
|
}); |
||||
|
|
||||
|
it("grows sheet when pulling up at top of content", () => { |
||||
|
render(<TermsSheet isOpen={true} />); |
||||
|
|
||||
|
const dialog = screen.getByRole("dialog"); |
||||
|
const section = dialog.querySelector("section")!; |
||||
|
const content = screen.getByTestId("terms-sheet-content"); |
||||
|
|
||||
|
Object.defineProperty(content, "scrollHeight", { value: 1200, configurable: true }); |
||||
|
Object.defineProperty(content, "clientHeight", { value: 400, configurable: true }); |
||||
|
Object.defineProperty(content, "scrollTop", { value: 0, configurable: true }); |
||||
|
|
||||
|
fireEvent.touchStart(content, { touches: [{ clientX: 0, clientY: 400 }] }); |
||||
|
fireEvent.touchMove(content, { touches: [{ clientX: 0, clientY: 300 }] }); |
||||
|
|
||||
|
expect(section.style.getPropertyValue("--sheet-size")).toBe( |
||||
|
(0.75 + 100 / window.innerHeight).toFixed(4), |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
it("snaps to 1.0 when pulled up significantly and released", () => { |
||||
|
render(<TermsSheet isOpen={true} />); |
||||
|
|
||||
|
const dialog = screen.getByRole("dialog"); |
||||
|
const section = dialog.querySelector("section")!; |
||||
|
const content = screen.getByTestId("terms-sheet-content"); |
||||
|
|
||||
|
Object.defineProperty(content, "scrollHeight", { value: 1200, configurable: true }); |
||||
|
Object.defineProperty(content, "clientHeight", { value: 400, configurable: true }); |
||||
|
Object.defineProperty(content, "scrollTop", { value: 0, configurable: true }); |
||||
|
|
||||
|
// Pull up 100px from 0.75
|
||||
|
fireEvent.touchStart(content, { touches: [{ clientX: 0, clientY: 400 }] }); |
||||
|
fireEvent.touchMove(content, { touches: [{ clientX: 0, clientY: 300 }] }); |
||||
|
fireEvent.touchEnd(content); |
||||
|
|
||||
|
// Snaps cleanly to 1.0000
|
||||
|
expect(section.style.getPropertyValue("--sheet-size")).toBe("1.0000"); |
||||
|
}); |
||||
|
|
||||
|
it("snaps back to 0.75 when pulled up slightly and released", () => { |
||||
|
render(<TermsSheet isOpen={true} />); |
||||
|
|
||||
|
const dialog = screen.getByRole("dialog"); |
||||
|
const section = dialog.querySelector("section")!; |
||||
|
const content = screen.getByTestId("terms-sheet-content"); |
||||
|
|
||||
|
Object.defineProperty(content, "scrollHeight", { value: 1200, configurable: true }); |
||||
|
Object.defineProperty(content, "clientHeight", { value: 400, configurable: true }); |
||||
|
Object.defineProperty(content, "scrollTop", { value: 0, configurable: true }); |
||||
|
|
||||
|
// Pull up only 12px (below 30px threshold)
|
||||
|
fireEvent.touchStart(content, { touches: [{ clientX: 0, clientY: 400 }] }); |
||||
|
fireEvent.touchMove(content, { touches: [{ clientX: 0, clientY: 388 }] }); |
||||
|
fireEvent.touchEnd(content); |
||||
|
|
||||
|
// Snaps back to 0.7500
|
||||
|
expect(section.style.getPropertyValue("--sheet-size")).toBe("0.7500"); |
||||
|
}); |
||||
|
|
||||
|
it("snaps from 1.0 down to 0.75 when pulled down and released", () => { |
||||
|
render(<TermsSheet isOpen={true} />); |
||||
|
|
||||
|
const dialog = screen.getByRole("dialog"); |
||||
|
const section = dialog.querySelector("section")!; |
||||
|
const content = screen.getByTestId("terms-sheet-content"); |
||||
|
|
||||
|
Object.defineProperty(content, "scrollHeight", { value: 1200, configurable: true }); |
||||
|
Object.defineProperty(content, "clientHeight", { value: 400, configurable: true }); |
||||
|
Object.defineProperty(content, "scrollTop", { value: 0, configurable: true }); |
||||
|
|
||||
|
// Expand to 1.0 first
|
||||
|
fireEvent.touchStart(content, { touches: [{ clientX: 0, clientY: 400 }] }); |
||||
|
fireEvent.touchMove(content, { touches: [{ clientX: 0, clientY: 300 }] }); |
||||
|
fireEvent.touchEnd(content); |
||||
|
expect(section.style.getPropertyValue("--sheet-size")).toBe("1.0000"); |
||||
|
|
||||
|
// Pull down 50px from 1.0
|
||||
|
fireEvent.touchStart(content, { touches: [{ clientX: 0, clientY: 300 }] }); |
||||
|
fireEvent.touchMove(content, { touches: [{ clientX: 0, clientY: 350 }] }); |
||||
|
fireEvent.touchEnd(content); |
||||
|
|
||||
|
// Snaps down to 0.7500
|
||||
|
expect(section.style.getPropertyValue("--sheet-size")).toBe("0.7500"); |
||||
|
}); |
||||
|
|
||||
|
it("grows sheet on wheel event at top of content", () => { |
||||
|
render(<TermsSheet isOpen={true} />); |
||||
|
|
||||
|
const dialog = screen.getByRole("dialog"); |
||||
|
const section = dialog.querySelector("section")!; |
||||
|
const content = screen.getByTestId("terms-sheet-content"); |
||||
|
|
||||
|
Object.defineProperty(content, "scrollHeight", { value: 1200, configurable: true }); |
||||
|
Object.defineProperty(content, "clientHeight", { value: 400, configurable: true }); |
||||
|
Object.defineProperty(content, "scrollTop", { value: 0, configurable: true }); |
||||
|
|
||||
|
fireEvent.wheel(content, { deltaY: 120 }); |
||||
|
|
||||
|
expect(section.style.getPropertyValue("--sheet-size")).toBe( |
||||
|
(0.75 + 120 / window.innerHeight).toFixed(4), |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
it("shrinks to 60% floor and closes when pulled past it (shouldCloseOnMinExtent: true)", async () => { |
||||
|
const handleClose = vi.fn(); |
||||
|
render(<TermsSheet isOpen={true} onClose={handleClose} />); |
||||
|
|
||||
|
const dialog = screen.getByRole("dialog"); |
||||
|
const section = dialog.querySelector("section")!; |
||||
|
const content = screen.getByTestId("terms-sheet-content"); |
||||
|
|
||||
|
Object.defineProperty(content, "scrollTop", { value: 0, configurable: true }); |
||||
|
|
||||
|
// Pull down at the top: sheet shrinks and clamps at 0.6 floor
|
||||
|
fireEvent.touchStart(content, { touches: [{ clientX: 0, clientY: 400 }] }); |
||||
|
fireEvent.touchMove(content, { touches: [{ clientX: 0, clientY: 560 }] }); |
||||
|
expect(section.style.getPropertyValue("--sheet-size")).toBe("0.6000"); |
||||
|
|
||||
|
// Pull further past 0.6: triggers close
|
||||
|
fireEvent.touchMove(content, { touches: [{ clientX: 0, clientY: 580 }] }); |
||||
|
|
||||
|
await waitFor(() => { |
||||
|
expect(handleClose).toHaveBeenCalled(); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it("calls onClose when Got it button is clicked", async () => { |
||||
|
const handleClose = vi.fn(); |
||||
|
render(<TermsSheet isOpen={true} onClose={handleClose} />); |
||||
|
|
||||
|
const gotItButton = screen.getByRole("button", { name: "متوجه شدم" }); |
||||
|
fireEvent.click(gotItButton); |
||||
|
|
||||
|
await waitFor(() => { |
||||
|
expect(handleClose).toHaveBeenCalled(); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it("calls onClose when close X button is clicked", async () => { |
||||
|
const handleClose = vi.fn(); |
||||
|
render(<TermsSheet isOpen={true} onClose={handleClose} />); |
||||
|
|
||||
|
const closeButton = screen.getByRole("button", { name: "بستن قوانین و مقررات" }); |
||||
|
fireEvent.click(closeButton); |
||||
|
|
||||
|
await waitFor(() => { |
||||
|
expect(handleClose).toHaveBeenCalled(); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,255 @@ |
|||||
|
/** |
||||
|
* ITU-T E.164 country calling codes mapped by ISO 3166-1 alpha-2 country code. |
||||
|
* Ultra-lightweight (~3KB) replacement for importing the entire google-libphonenumber |
||||
|
* library into the critical bootstrap and I18n bundles. |
||||
|
*/ |
||||
|
export const COUNTRY_CALLING_CODES: Readonly<Record<string, string>> = { |
||||
|
AF: "93", |
||||
|
AL: "355", |
||||
|
DZ: "213", |
||||
|
AS: "1", |
||||
|
AD: "376", |
||||
|
AO: "244", |
||||
|
AI: "1", |
||||
|
AG: "1", |
||||
|
AR: "54", |
||||
|
AM: "374", |
||||
|
AW: "297", |
||||
|
AU: "61", |
||||
|
AT: "43", |
||||
|
AZ: "994", |
||||
|
BS: "1", |
||||
|
BH: "973", |
||||
|
BD: "880", |
||||
|
BB: "1", |
||||
|
BY: "375", |
||||
|
BE: "32", |
||||
|
BZ: "501", |
||||
|
BJ: "229", |
||||
|
BM: "1", |
||||
|
BT: "975", |
||||
|
BO: "591", |
||||
|
BA: "387", |
||||
|
BW: "267", |
||||
|
BR: "55", |
||||
|
IO: "246", |
||||
|
VG: "1", |
||||
|
BN: "673", |
||||
|
BG: "359", |
||||
|
BF: "226", |
||||
|
BI: "257", |
||||
|
KH: "855", |
||||
|
CM: "237", |
||||
|
CA: "1", |
||||
|
CV: "238", |
||||
|
KY: "1", |
||||
|
CF: "236", |
||||
|
TD: "235", |
||||
|
CL: "56", |
||||
|
CN: "86", |
||||
|
CX: "61", |
||||
|
CC: "61", |
||||
|
CO: "57", |
||||
|
KM: "269", |
||||
|
CK: "682", |
||||
|
CR: "506", |
||||
|
HR: "385", |
||||
|
CU: "53", |
||||
|
CW: "599", |
||||
|
CY: "357", |
||||
|
CZ: "420", |
||||
|
CD: "243", |
||||
|
DK: "45", |
||||
|
DJ: "253", |
||||
|
DM: "1", |
||||
|
DO: "1", |
||||
|
EC: "593", |
||||
|
EG: "20", |
||||
|
SV: "503", |
||||
|
GQ: "240", |
||||
|
ER: "291", |
||||
|
EE: "372", |
||||
|
SZ: "268", |
||||
|
ET: "251", |
||||
|
FK: "500", |
||||
|
FO: "298", |
||||
|
FJ: "679", |
||||
|
FI: "358", |
||||
|
FR: "33", |
||||
|
GF: "594", |
||||
|
PF: "689", |
||||
|
GA: "241", |
||||
|
GM: "220", |
||||
|
GE: "995", |
||||
|
DE: "49", |
||||
|
GH: "233", |
||||
|
GI: "350", |
||||
|
GR: "30", |
||||
|
GL: "299", |
||||
|
GD: "1", |
||||
|
GP: "590", |
||||
|
GU: "1", |
||||
|
GT: "502", |
||||
|
GG: "44", |
||||
|
GN: "224", |
||||
|
GW: "245", |
||||
|
GY: "592", |
||||
|
HT: "509", |
||||
|
HN: "504", |
||||
|
HK: "852", |
||||
|
HU: "36", |
||||
|
IS: "354", |
||||
|
IN: "91", |
||||
|
ID: "62", |
||||
|
IR: "98", |
||||
|
IQ: "964", |
||||
|
IE: "353", |
||||
|
IM: "44", |
||||
|
IL: "972", |
||||
|
IT: "39", |
||||
|
CI: "225", |
||||
|
JM: "1", |
||||
|
JP: "81", |
||||
|
JE: "44", |
||||
|
JO: "962", |
||||
|
KZ: "7", |
||||
|
KE: "254", |
||||
|
KI: "686", |
||||
|
XK: "383", |
||||
|
KW: "965", |
||||
|
KG: "996", |
||||
|
LA: "856", |
||||
|
LV: "371", |
||||
|
LB: "961", |
||||
|
LS: "266", |
||||
|
LR: "231", |
||||
|
LY: "218", |
||||
|
LI: "423", |
||||
|
LT: "370", |
||||
|
LU: "352", |
||||
|
MO: "853", |
||||
|
MG: "261", |
||||
|
MW: "265", |
||||
|
MY: "60", |
||||
|
MV: "960", |
||||
|
ML: "223", |
||||
|
MT: "356", |
||||
|
MH: "692", |
||||
|
MQ: "596", |
||||
|
MR: "222", |
||||
|
MU: "230", |
||||
|
YT: "262", |
||||
|
MX: "52", |
||||
|
FM: "691", |
||||
|
MD: "373", |
||||
|
MC: "377", |
||||
|
MN: "976", |
||||
|
ME: "382", |
||||
|
MS: "1", |
||||
|
MA: "212", |
||||
|
MZ: "258", |
||||
|
MM: "95", |
||||
|
NA: "264", |
||||
|
NR: "674", |
||||
|
NP: "977", |
||||
|
NL: "31", |
||||
|
NC: "687", |
||||
|
NZ: "64", |
||||
|
NI: "505", |
||||
|
NE: "227", |
||||
|
NG: "234", |
||||
|
NU: "683", |
||||
|
NF: "672", |
||||
|
KP: "850", |
||||
|
MK: "389", |
||||
|
MP: "1", |
||||
|
NO: "47", |
||||
|
OM: "968", |
||||
|
PK: "92", |
||||
|
PW: "680", |
||||
|
PS: "970", |
||||
|
PA: "507", |
||||
|
PG: "675", |
||||
|
PY: "595", |
||||
|
PE: "51", |
||||
|
PH: "63", |
||||
|
PL: "48", |
||||
|
PT: "351", |
||||
|
PR: "1", |
||||
|
QA: "974", |
||||
|
CG: "242", |
||||
|
RE: "262", |
||||
|
RO: "40", |
||||
|
RU: "7", |
||||
|
RW: "250", |
||||
|
BL: "590", |
||||
|
SH: "290", |
||||
|
KN: "1", |
||||
|
LC: "1", |
||||
|
MF: "590", |
||||
|
PM: "508", |
||||
|
VC: "1", |
||||
|
WS: "685", |
||||
|
SM: "378", |
||||
|
ST: "239", |
||||
|
SA: "966", |
||||
|
SN: "221", |
||||
|
RS: "381", |
||||
|
SC: "248", |
||||
|
SL: "232", |
||||
|
SG: "65", |
||||
|
SX: "1", |
||||
|
SK: "421", |
||||
|
SI: "386", |
||||
|
SB: "677", |
||||
|
SO: "252", |
||||
|
ZA: "27", |
||||
|
KR: "82", |
||||
|
SS: "211", |
||||
|
ES: "34", |
||||
|
LK: "94", |
||||
|
SD: "249", |
||||
|
SR: "597", |
||||
|
SE: "46", |
||||
|
CH: "41", |
||||
|
SY: "963", |
||||
|
TW: "886", |
||||
|
TJ: "992", |
||||
|
TZ: "255", |
||||
|
TH: "66", |
||||
|
TL: "670", |
||||
|
TG: "228", |
||||
|
TK: "690", |
||||
|
TO: "676", |
||||
|
TT: "1", |
||||
|
TN: "216", |
||||
|
TR: "90", |
||||
|
TM: "993", |
||||
|
TC: "1", |
||||
|
TV: "688", |
||||
|
UG: "256", |
||||
|
UA: "380", |
||||
|
AE: "971", |
||||
|
GB: "44", |
||||
|
US: "1", |
||||
|
UY: "598", |
||||
|
UZ: "998", |
||||
|
VU: "678", |
||||
|
VA: "379", |
||||
|
VE: "58", |
||||
|
VN: "84", |
||||
|
VI: "1", |
||||
|
WF: "681", |
||||
|
EH: "212", |
||||
|
YE: "967", |
||||
|
ZM: "260", |
||||
|
ZW: "263", |
||||
|
}; |
||||
|
|
||||
|
export function getCallingCodeForCountry( |
||||
|
isoCode?: string | null, |
||||
|
): string | undefined { |
||||
|
if (!isoCode) return undefined; |
||||
|
const upper = isoCode.trim().toUpperCase(); |
||||
|
return COUNTRY_CALLING_CODES[upper]; |
||||
|
} |
||||
@ -0,0 +1,119 @@ |
|||||
|
import { describe, expect, it } from "vitest"; |
||||
|
import { getInitialMarriageProfile } from "@/hooks/marriage/use-profile-main"; |
||||
|
import type { MarriageProfileResponse } from "@/hooks/marriage/types"; |
||||
|
|
||||
|
/** |
||||
|
* Robust parser replicating the layout.tsx and auth-bridge.ts cookie resolution strategy. |
||||
|
*/ |
||||
|
export function parseMarriageCookie( |
||||
|
rawCookie: string | null | undefined, |
||||
|
): { parsedJson: any; profile?: MarriageProfileResponse } | null { |
||||
|
if (!rawCookie || !rawCookie.trim()) return null; |
||||
|
|
||||
|
const trimmed = rawCookie.trim(); |
||||
|
|
||||
|
// Strategy 1: Try decoding URI component first (Standard RFC-6265 percent-encoded cookie from Flutter)
|
||||
|
try { |
||||
|
const decoded = decodeURIComponent(trimmed); |
||||
|
const parsed = JSON.parse(decoded); |
||||
|
return { |
||||
|
parsedJson: parsed, |
||||
|
profile: getInitialMarriageProfile(parsed), |
||||
|
}; |
||||
|
} catch { |
||||
|
// Strategy 2: Fallback for legacy raw unencoded JSON cookies
|
||||
|
try { |
||||
|
const parsed = JSON.parse(trimmed); |
||||
|
return { |
||||
|
parsedJson: parsed, |
||||
|
profile: getInitialMarriageProfile(parsed), |
||||
|
}; |
||||
|
} catch { |
||||
|
return null; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
describe("HABIB_MARRIAGE_DATA Cookie Encoding & Decoding Suite", () => { |
||||
|
it("successfully parses standard URL-encoded JSON with Persian characters (Uri.encodeComponent)", () => { |
||||
|
const originalData = { |
||||
|
id: 101, |
||||
|
status: "match_found", |
||||
|
gender: "male", |
||||
|
city: "تهران", |
||||
|
bio: "مهندس کامپیوتر، علاقهمند به کتابخوانی و طبیعتگردی", |
||||
|
match_summary: { |
||||
|
id: 202, |
||||
|
gender: "female", |
||||
|
public_info: [ |
||||
|
{ label: "شهر", value: "اصفهان" }, |
||||
|
{ label: "تحصیلات", value: "کارشناسی ارشد" }, |
||||
|
], |
||||
|
}, |
||||
|
}; |
||||
|
|
||||
|
// Simulate Flutter: Uri.encodeComponent(json.encode(marriageData))
|
||||
|
const encodedCookie = encodeURIComponent(JSON.stringify(originalData)); |
||||
|
|
||||
|
// Verify it's purely US-ASCII
|
||||
|
expect(/^[\x00-\x7F]*$/.test(encodedCookie)).toBe(true); |
||||
|
|
||||
|
const result = parseMarriageCookie(encodedCookie); |
||||
|
expect(result).not.toBeNull(); |
||||
|
expect(result?.parsedJson.city).toBe("تهران"); |
||||
|
expect(result?.parsedJson.bio).toBe("مهندس کامپیوتر، علاقهمند به کتابخوانی و طبیعتگردی"); |
||||
|
expect(result?.parsedJson.match_summary.public_info[0].value).toBe("اصفهان"); |
||||
|
expect(result?.profile?.status).toBe("match_found"); |
||||
|
}); |
||||
|
|
||||
|
it("successfully parses legacy raw unencoded JSON with Persian characters", () => { |
||||
|
const legacyRawCookie = JSON.stringify({ |
||||
|
id: 102, |
||||
|
status: "active", |
||||
|
gender: "female", |
||||
|
city: "مشهد", |
||||
|
interests: ["زیارت", "خانواده"], |
||||
|
}); |
||||
|
|
||||
|
const result = parseMarriageCookie(legacyRawCookie); |
||||
|
expect(result).not.toBeNull(); |
||||
|
expect(result?.parsedJson.city).toBe("مشهد"); |
||||
|
expect(result?.parsedJson.interests).toEqual(["زیارت", "خانواده"]); |
||||
|
}); |
||||
|
|
||||
|
it("handles complex strings with percentage signs, quotes, and emojis without crashing", () => { |
||||
|
const complexData = { |
||||
|
id: 103, |
||||
|
status: "match_found", |
||||
|
note: "تطابق با دقت ۱۰۰% و رضایت ۹۵% 💍❤️", |
||||
|
query: "param1=val¶m2=50%off", |
||||
|
}; |
||||
|
|
||||
|
const encodedCookie = encodeURIComponent(JSON.stringify(complexData)); |
||||
|
const result = parseMarriageCookie(encodedCookie); |
||||
|
|
||||
|
expect(result).not.toBeNull(); |
||||
|
expect(result?.parsedJson.note).toBe("تطابق با دقت ۱۰۰% و رضایت ۹۵% 💍❤️"); |
||||
|
expect(result?.parsedJson.query).toBe("param1=val¶m2=50%off"); |
||||
|
}); |
||||
|
|
||||
|
it("handles unencoded JSON containing raw percent sign gracefully via fallback", () => { |
||||
|
// If an unencoded cookie contains raw %, decodeURIComponent will throw URIError.
|
||||
|
// The parser MUST catch it and fallback to JSON.parse directly.
|
||||
|
const rawWithPercent = '{"status":"active","progress":"100% completed","city":"شیراز"}'; |
||||
|
|
||||
|
const result = parseMarriageCookie(rawWithPercent); |
||||
|
expect(result).not.toBeNull(); |
||||
|
expect(result?.parsedJson.progress).toBe("100% completed"); |
||||
|
expect(result?.parsedJson.city).toBe("شیراز"); |
||||
|
}); |
||||
|
|
||||
|
it("returns null safely for corrupted, empty, or invalid cookies", () => { |
||||
|
expect(parseMarriageCookie(null)).toBeNull(); |
||||
|
expect(parseMarriageCookie("")).toBeNull(); |
||||
|
expect(parseMarriageCookie(" ")).toBeNull(); |
||||
|
expect(parseMarriageCookie("undefined")).toBeNull(); |
||||
|
expect(parseMarriageCookie("invalid-not-json")).toBeNull(); |
||||
|
expect(parseMarriageCookie("%E0%A4%A")).toBeNull(); // Malformed URI sequence
|
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,132 @@ |
|||||
|
import { describe, it, expect } from "vitest"; |
||||
|
import { |
||||
|
isExclusiveOption, |
||||
|
resolveMultiOptionToggle, |
||||
|
type MultiSelectOption, |
||||
|
} from "./multi-select-helper"; |
||||
|
|
||||
|
describe("multi-select-helper", () => { |
||||
|
const options: MultiSelectOption[] = [ |
||||
|
{ id: "opt1", value: "football", label: "Football" }, |
||||
|
{ id: "opt2", value: "swimming", label: "Swimming" }, |
||||
|
{ id: "opt3", value: "running", label: "Running" }, |
||||
|
{ id: "opt_none", value: "none", label: "None of the above", is_exclusive: true }, |
||||
|
]; |
||||
|
|
||||
|
describe("isExclusiveOption", () => { |
||||
|
it("identifies explicit is_exclusive property", () => { |
||||
|
expect(isExclusiveOption({ id: "custom", is_exclusive: true })).toBe(true); |
||||
|
expect(isExclusiveOption({ id: "custom", is_exclusive: false })).toBe(false); |
||||
|
}); |
||||
|
|
||||
|
it("accepts the explicit flag and rejects everything else (SSOT: backend flag only)", () => { |
||||
|
expect(isExclusiveOption({ id: "custom", is_exclusive: true })).toBe(true); |
||||
|
expect(isExclusiveOption({ id: "custom", is_exclusive: false })).toBe(false); |
||||
|
expect(isExclusiveOption({ id: "custom" })).toBe(false); |
||||
|
}); |
||||
|
|
||||
|
it("returns false for missing/empty/string-only input (no flag = not exclusive)", () => { |
||||
|
expect(isExclusiveOption("")).toBe(false); |
||||
|
expect(isExclusiveOption("test.none")).toBe(false); |
||||
|
}); |
||||
|
|
||||
|
it("does NOT guess from canonical values or slug suffixes (SSOT: backend flag only)", () => { |
||||
|
expect(isExclusiveOption({ id: "test.none", value: "none" })).toBe(false); |
||||
|
expect(isExclusiveOption({ id: "test.no_pets", value: "no_pets" })).toBe(false); |
||||
|
expect( |
||||
|
isExclusiveOption( |
||||
|
"spouse_criteria.appearance_dealbreakers_aspects.no_appearance_feature_alone_makes_difficult", |
||||
|
), |
||||
|
).toBe(false); |
||||
|
expect( |
||||
|
isExclusiveOption({ |
||||
|
id: "spouse_criteria.appearance_dealbreakers_aspects.no_appearance_feature_alone_makes_difficult", |
||||
|
value: "no_appearance_feature_alone_makes_difficult", |
||||
|
}), |
||||
|
).toBe(false); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe("resolveMultiOptionToggle", () => { |
||||
|
it("adds a regular option when none was selected", () => { |
||||
|
const res = resolveMultiOptionToggle({ |
||||
|
currentSelected: [], |
||||
|
optionId: "opt1", |
||||
|
options, |
||||
|
}); |
||||
|
expect(res).toEqual(["opt1"]); |
||||
|
}); |
||||
|
|
||||
|
it("adds multiple regular options sequentially", () => { |
||||
|
const res1 = resolveMultiOptionToggle({ |
||||
|
currentSelected: ["opt1"], |
||||
|
optionId: "opt2", |
||||
|
options, |
||||
|
}); |
||||
|
expect(res1).toEqual(["opt1", "opt2"]); |
||||
|
|
||||
|
const res2 = resolveMultiOptionToggle({ |
||||
|
currentSelected: ["opt1", "opt2"], |
||||
|
optionId: "opt3", |
||||
|
options, |
||||
|
}); |
||||
|
expect(res2).toEqual(["opt1", "opt2", "opt3"]); |
||||
|
}); |
||||
|
|
||||
|
it("deselects a regular option when toggled again", () => { |
||||
|
const res = resolveMultiOptionToggle({ |
||||
|
currentSelected: ["opt1", "opt2"], |
||||
|
optionId: "opt1", |
||||
|
options, |
||||
|
}); |
||||
|
expect(res).toEqual(["opt2"]); |
||||
|
}); |
||||
|
|
||||
|
it("clears ALL regular options when an exclusive option is selected", () => { |
||||
|
const res = resolveMultiOptionToggle({ |
||||
|
currentSelected: ["opt1", "opt2", "opt3"], |
||||
|
optionId: "opt_none", |
||||
|
options, |
||||
|
}); |
||||
|
expect(res).toEqual(["opt_none"]); |
||||
|
}); |
||||
|
|
||||
|
it("deselects the exclusive option when toggled again", () => { |
||||
|
const res = resolveMultiOptionToggle({ |
||||
|
currentSelected: ["opt_none"], |
||||
|
optionId: "opt_none", |
||||
|
options, |
||||
|
}); |
||||
|
expect(res).toEqual([]); |
||||
|
}); |
||||
|
|
||||
|
it("clears the exclusive option when a regular option is clicked", () => { |
||||
|
const res = resolveMultiOptionToggle({ |
||||
|
currentSelected: ["opt_none"], |
||||
|
optionId: "opt1", |
||||
|
options, |
||||
|
}); |
||||
|
expect(res).toEqual(["opt1"]); |
||||
|
}); |
||||
|
|
||||
|
it("enforces maxSelect for regular options without exclusive conflict", () => { |
||||
|
const res = resolveMultiOptionToggle({ |
||||
|
currentSelected: ["opt1", "opt2"], |
||||
|
optionId: "opt3", |
||||
|
options, |
||||
|
maxSelect: 2, |
||||
|
}); |
||||
|
expect(res).toEqual(["opt1", "opt2"]); // Capped at 2
|
||||
|
}); |
||||
|
|
||||
|
it("allows selecting exclusive option even when regular options reach maxSelect", () => { |
||||
|
const res = resolveMultiOptionToggle({ |
||||
|
currentSelected: ["opt1", "opt2"], |
||||
|
optionId: "opt_none", |
||||
|
options, |
||||
|
maxSelect: 2, |
||||
|
}); |
||||
|
expect(res).toEqual(["opt_none"]); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,76 @@ |
|||||
|
export type MultiSelectOption = { |
||||
|
id: string; |
||||
|
value?: string | number; |
||||
|
label?: string; |
||||
|
is_exclusive?: boolean; |
||||
|
}; |
||||
|
|
||||
|
/** |
||||
|
* Determine if an option is mutually exclusive (مانعةالجمع) with the other |
||||
|
* options of its question. |
||||
|
* |
||||
|
* ⚠️ Single Source of Truth: exclusivity is configured in the live database |
||||
|
* (via the admin dashboard) and delivered by the backend as the |
||||
|
* `is_exclusive` flag on every option. No hardcoded slug/suffix heuristics. |
||||
|
*/ |
||||
|
export function isExclusiveOption(option: MultiSelectOption | string): boolean { |
||||
|
if (!option || typeof option !== "object") return false; |
||||
|
return option.is_exclusive === true; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Resolves toggling an option in a multi-select context with automatic mutual exclusion (مانعةالجمع). |
||||
|
* |
||||
|
* Behavior: |
||||
|
* 1. If an exclusive option is selected: |
||||
|
* - All other selected options are automatically cleared (deselected). |
||||
|
* - Only the exclusive option remains selected. |
||||
|
* 2. If an exclusive option is deselected: |
||||
|
* - It is removed, leaving an empty selection. |
||||
|
* 3. If a regular (non-exclusive) option is selected while an exclusive option was active: |
||||
|
* - The exclusive option is automatically cleared (deselected). |
||||
|
* - The new regular option is selected. |
||||
|
* 4. Respects maxSelect limit for regular options. |
||||
|
*/ |
||||
|
export function resolveMultiOptionToggle({ |
||||
|
currentSelected, |
||||
|
optionId, |
||||
|
options = [], |
||||
|
maxSelect, |
||||
|
}: { |
||||
|
currentSelected: string[]; |
||||
|
optionId: string; |
||||
|
options?: MultiSelectOption[]; |
||||
|
maxSelect?: number; |
||||
|
}): string[] { |
||||
|
const targetOption = options.find((o) => o.id === optionId) || { id: optionId }; |
||||
|
const isExclusive = isExclusiveOption(targetOption); |
||||
|
const isAlreadySelected = currentSelected.includes(optionId); |
||||
|
|
||||
|
// Case 1: Toggling an exclusive option
|
||||
|
if (isExclusive) { |
||||
|
if (isAlreadySelected) { |
||||
|
return []; |
||||
|
} |
||||
|
return [optionId]; |
||||
|
} |
||||
|
|
||||
|
// Case 2: Toggling a regular option that is already selected -> remove it
|
||||
|
if (isAlreadySelected) { |
||||
|
return currentSelected.filter((id) => id !== optionId); |
||||
|
} |
||||
|
|
||||
|
// Case 3: Adding a regular option that was NOT selected:
|
||||
|
// First, filter out any exclusive option(s) from current selection
|
||||
|
const cleanSelected = currentSelected.filter((id) => { |
||||
|
const opt = options.find((o) => o.id === id) || { id }; |
||||
|
return !isExclusiveOption(opt); |
||||
|
}); |
||||
|
|
||||
|
// Check maxSelect limit
|
||||
|
if (maxSelect && cleanSelected.length >= maxSelect) { |
||||
|
return cleanSelected; |
||||
|
} |
||||
|
|
||||
|
return [...cleanSelected, optionId]; |
||||
|
} |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue