Browse Source
feat: integrate reviewed Dev branch UI enhancements, test sheets, and conditional form logic
master
feat: integrate reviewed Dev branch UI enhancements, test sheets, and conditional form logic
master
47 changed files with 2513 additions and 527 deletions
-
77public/assets/images/diamond-color.svg
-
655src/app/new-match/new-match-client.tsx
-
88src/app/questions-list/[slug]/question-detail-client.tsx
-
58src/app/questions-list/questions-list-client.tsx
-
23src/app/questions-list/sections-request.tsx
-
61src/app/request-accepted/request-accepted-client.tsx
-
49src/app/request-sent/request-sent-client.tsx
-
54src/components/Componentes/advisor-actions-card.tsx
-
111src/components/Componentes/information-sheet.tsx
-
6src/components/Componentes/navigation-button.tsx
-
8src/components/Componentes/question-answer-storage.tsx
-
224src/components/Componentes/question-file.test.tsx
-
50src/components/Componentes/question-sheet.test.tsx
-
98src/components/Componentes/question-sheet.tsx
-
19src/components/Componentes/swipe-button.tsx
-
67src/components/Componentes/test-completed-sheet.test.tsx
-
61src/components/Componentes/test-completed-sheet.tsx
-
92src/components/Componentes/test-exit-sheet.test.tsx
-
78src/components/Componentes/test-exit-sheet.tsx
-
156src/components/Componentes/test-questions-flow.tsx
-
639src/lib/conditional-rules.test.ts
-
41src/lib/conditional-rules.ts
-
86src/lib/schema-adapter.ts
-
9src/translations/locales/ar.json
-
9src/translations/locales/az.json
-
9src/translations/locales/bn.json
-
9src/translations/locales/da.json
-
9src/translations/locales/de.json
-
20src/translations/locales/en.json
-
9src/translations/locales/es.json
-
21src/translations/locales/fa.json
-
9src/translations/locales/fr.json
-
9src/translations/locales/gu.json
-
9src/translations/locales/ha.json
-
9src/translations/locales/he.json
-
9src/translations/locales/hi.json
-
9src/translations/locales/id.json
-
9src/translations/locales/ks.json
-
9src/translations/locales/pt.json
-
9src/translations/locales/ru.json
-
9src/translations/locales/sw.json
-
9src/translations/locales/tg.json
-
9src/translations/locales/tr.json
-
9src/translations/locales/ul.json
-
9src/translations/locales/ur.json
-
9src/translations/locales/uz.json
-
9src/translations/locales/zh.json
@ -0,0 +1,77 @@ |
|||
<svg width="64" height="64" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg"> |
|||
<defs> |
|||
<!-- Facet Gradients matching the exact vibrant spectrum --> |
|||
<linearGradient id="g_top_left" x1="6" y1="42" x2="22" y2="18" gradientUnits="userSpaceOnUse"> |
|||
<stop offset="0%" stop-color="#00C2FF"/> |
|||
<stop offset="100%" stop-color="#6366F1"/> |
|||
</linearGradient> |
|||
|
|||
<linearGradient id="g_top_mid_left" x1="22" y1="18" x2="32" y2="42" gradientUnits="userSpaceOnUse"> |
|||
<stop offset="0%" stop-color="#8B5CF6"/> |
|||
<stop offset="100%" stop-color="#D946EF"/> |
|||
</linearGradient> |
|||
|
|||
<linearGradient id="g_top_center" x1="32" y1="42" x2="68" y2="18" gradientUnits="userSpaceOnUse"> |
|||
<stop offset="0%" stop-color="#C084FC"/> |
|||
<stop offset="50%" stop-color="#F472B6"/> |
|||
<stop offset="100%" stop-color="#FDA4AF"/> |
|||
</linearGradient> |
|||
|
|||
<linearGradient id="g_top_mid_right" x1="50" y1="18" x2="78" y2="42" gradientUnits="userSpaceOnUse"> |
|||
<stop offset="0%" stop-color="#F43F5E"/> |
|||
<stop offset="100%" stop-color="#FB7185"/> |
|||
</linearGradient> |
|||
|
|||
<linearGradient id="g_top_right" x1="68" y1="42" x2="94" y2="42" gradientUnits="userSpaceOnUse"> |
|||
<stop offset="0%" stop-color="#FB7185"/> |
|||
<stop offset="100%" stop-color="#FB923C"/> |
|||
</linearGradient> |
|||
|
|||
<linearGradient id="g_bot_left" x1="6" y1="42" x2="50" y2="88" gradientUnits="userSpaceOnUse"> |
|||
<stop offset="0%" stop-color="#00BAFF"/> |
|||
<stop offset="60%" stop-color="#3B82F6"/> |
|||
<stop offset="100%" stop-color="#6366F1"/> |
|||
</linearGradient> |
|||
|
|||
<linearGradient id="g_bot_mid_left" x1="32" y1="42" x2="50" y2="88" gradientUnits="userSpaceOnUse"> |
|||
<stop offset="0%" stop-color="#6366F1"/> |
|||
<stop offset="50%" stop-color="#8B5CF6"/> |
|||
<stop offset="100%" stop-color="#A855F7"/> |
|||
</linearGradient> |
|||
|
|||
<linearGradient id="g_bot_mid_right" x1="50" y1="42" x2="50" y2="88" gradientUnits="userSpaceOnUse"> |
|||
<stop offset="0%" stop-color="#EC4899"/> |
|||
<stop offset="60%" stop-color="#D946EF"/> |
|||
<stop offset="100%" stop-color="#A855F7"/> |
|||
</linearGradient> |
|||
|
|||
<linearGradient id="g_bot_right" x1="94" y1="42" x2="50" y2="88" gradientUnits="userSpaceOnUse"> |
|||
<stop offset="0%" stop-color="#F43F5E"/> |
|||
<stop offset="50%" stop-color="#FB7185"/> |
|||
<stop offset="100%" stop-color="#FB923C"/> |
|||
</linearGradient> |
|||
|
|||
<filter id="subtle_glow" x="0" y="0" width="100" height="100" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> |
|||
<feGaussianBlur stdDeviation="1.5" result="blur"/> |
|||
<feComposite in="SourceGraphic" in2="blur" operator="over"/> |
|||
</filter> |
|||
</defs> |
|||
|
|||
<g stroke="white" stroke-width="2.2" stroke-linejoin="round" stroke-linecap="round"> |
|||
<!-- Top Row Facets --> |
|||
<polygon points="22,18 6,42 32,42" fill="url(#g_top_left)" /> |
|||
<polygon points="22,18 50,18 32,42" fill="url(#g_top_mid_left)" /> |
|||
<polygon points="32,42 50,18 68,42" fill="url(#g_top_center)" /> |
|||
<polygon points="50,18 78,18 68,42" fill="url(#g_top_mid_right)" /> |
|||
<polygon points="78,18 68,42 94,42" fill="url(#g_top_right)" /> |
|||
|
|||
<!-- Bottom Row Facets --> |
|||
<polygon points="6,42 32,42 50,88" fill="url(#g_bot_left)" /> |
|||
<polygon points="32,42 50,42 50,88" fill="url(#g_bot_mid_left)" /> |
|||
<polygon points="50,42 68,42 50,88" fill="url(#g_bot_mid_right)" /> |
|||
<polygon points="68,42 94,42 50,88" fill="url(#g_bot_right)" /> |
|||
</g> |
|||
|
|||
<!-- Outer highlight border for extra crispness --> |
|||
<polygon points="22,18 78,18 94,42 50,88 6,42" fill="none" stroke="white" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round" /> |
|||
</svg> |
|||
@ -0,0 +1,224 @@ |
|||
import { |
|||
act, |
|||
cleanup, |
|||
fireEvent, |
|||
render, |
|||
screen, |
|||
waitFor, |
|||
} from "@testing-library/react"; |
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
|||
import type { QuestionField } from "@/lib/schema-adapter"; |
|||
import { QuestionFile } from "./question-file"; |
|||
|
|||
let answerMap: Record<string, unknown> = {}; |
|||
const mockSetAnswerValue = vi.fn((q: QuestionField, val: unknown) => { |
|||
answerMap[q.id] = val; |
|||
}); |
|||
|
|||
const mockMutateAsync = vi.fn(async (file: File) => ({ |
|||
path: `/media/tmp/${file.name}`, |
|||
})); |
|||
|
|||
vi.mock("@/hooks/marriage/use-upload-tmp-media", () => ({ |
|||
useUploadTmpMediaMutation: () => ({ |
|||
mutate: vi.fn(), |
|||
mutateAsync: mockMutateAsync, |
|||
isPending: false, |
|||
isError: false, |
|||
}), |
|||
})); |
|||
|
|||
vi.mock("@/translations/provider", () => ({ |
|||
useI18n: () => ({ |
|||
locale: "en", |
|||
dictionary: { |
|||
upload_certificates: "upload certificates", |
|||
add_another_document: "Add another document", |
|||
max_files_reached: "Maximum of 4 files uploaded", |
|||
remove_document: "Remove document", |
|||
upload_failed: "Upload failed. Please try again.", |
|||
}, |
|||
}), |
|||
})); |
|||
|
|||
vi.mock("./question-answer-storage", () => ({ |
|||
useQuestionAnswers: () => ({ |
|||
getAnswerValue: (q: QuestionField) => answerMap[q.id] ?? null, |
|||
setAnswerValue: mockSetAnswerValue, |
|||
isLoading: false, |
|||
}), |
|||
})); |
|||
|
|||
const mockFileQuestion: QuestionField = { |
|||
id: "documents_verification.valid_identification_document", |
|||
title: "Valid Identification Document", |
|||
type: "file", |
|||
order: 1, |
|||
required: true, |
|||
baseRequired: true, |
|||
isVisible: true, |
|||
description: "Passport, National ID, or Driver's License", |
|||
tooltip: "", |
|||
extras: { |
|||
placeHolder: "Upload document", |
|||
range: [0, 0], |
|||
options: [".pdf", ".jpg", ".jpeg", ".png"], |
|||
}, |
|||
options: [], |
|||
}; |
|||
|
|||
describe("QuestionFile Component (Multiple Upload)", () => { |
|||
beforeEach(() => { |
|||
answerMap = {}; |
|||
mockSetAnswerValue.mockClear(); |
|||
mockMutateAsync.mockClear(); |
|||
vi.clearAllMocks(); |
|||
}); |
|||
|
|||
afterEach(() => { |
|||
cleanup(); |
|||
}); |
|||
|
|||
it("renders empty dropzone when no files are uploaded without formats text", () => { |
|||
render(<QuestionFile question={mockFileQuestion} />); |
|||
|
|||
expect(screen.getByText(/Valid Identification/)).toBeInTheDocument(); |
|||
expect(screen.getByText(/Passport, National ID, or Driver's License/)).toBeInTheDocument(); |
|||
expect(screen.getByText("upload certificates")).toBeInTheDocument(); |
|||
expect(screen.queryByText("pdf, jpg, jpeg, png")).not.toBeInTheDocument(); |
|||
}); |
|||
|
|||
it("renders legacy single file stored as string correctly", () => { |
|||
answerMap[mockFileQuestion.id] = "/media/marriage/media/passport.pdf"; |
|||
|
|||
render(<QuestionFile question={mockFileQuestion} />); |
|||
|
|||
expect(screen.getByText("passport.pdf")).toBeInTheDocument(); |
|||
expect(screen.getByText(/Add another document/)).toBeInTheDocument(); |
|||
expect(screen.getByText(/(1\/4)/)).toBeInTheDocument(); |
|||
}); |
|||
|
|||
it("renders multiple stored files correctly", () => { |
|||
answerMap[mockFileQuestion.id] = [ |
|||
"/media/marriage/media/doc1.pdf", |
|||
"/media/marriage/media/doc2.jpg", |
|||
"/media/marriage/media/doc3.png", |
|||
]; |
|||
|
|||
render(<QuestionFile question={mockFileQuestion} />); |
|||
|
|||
expect(screen.getByText("doc1.pdf")).toBeInTheDocument(); |
|||
expect(screen.getByAltText("doc2.jpg")).toBeInTheDocument(); |
|||
expect(screen.getByAltText("doc3.png")).toBeInTheDocument(); |
|||
expect(screen.getByText(/Add another document/)).toBeInTheDocument(); |
|||
expect(screen.getByText(/(3\/4)/)).toBeInTheDocument(); |
|||
}); |
|||
|
|||
it("uploads a new file and transitions from empty state to list with Add button", async () => { |
|||
render(<QuestionFile question={mockFileQuestion} />); |
|||
|
|||
const input = screen.getByLabelText("Upload files"); |
|||
expect(input).toBeInTheDocument(); |
|||
|
|||
const file = new File(["dummy content"], "national_id.pdf", { |
|||
type: "application/pdf", |
|||
}); |
|||
|
|||
await act(async () => { |
|||
fireEvent.change(input, { target: { files: [file] } }); |
|||
}); |
|||
|
|||
await waitFor(() => { |
|||
expect(mockMutateAsync).toHaveBeenCalledWith(file); |
|||
expect(mockSetAnswerValue).toHaveBeenCalledWith(mockFileQuestion, [ |
|||
"/media/tmp/national_id.pdf", |
|||
]); |
|||
}); |
|||
|
|||
expect(screen.getByText("national_id.pdf")).toBeInTheDocument(); |
|||
expect(screen.getByText(/Add another document/)).toBeInTheDocument(); |
|||
}); |
|||
|
|||
it("allows adding 2nd, 3rd, 4th file and disables upload at 4 items", async () => { |
|||
answerMap[mockFileQuestion.id] = [ |
|||
"/media/marriage/media/file1.pdf", |
|||
"/media/marriage/media/file2.pdf", |
|||
"/media/marriage/media/file3.pdf", |
|||
]; |
|||
|
|||
render(<QuestionFile question={mockFileQuestion} />); |
|||
|
|||
expect(screen.getByText("file1.pdf")).toBeInTheDocument(); |
|||
expect(screen.getByText("file2.pdf")).toBeInTheDocument(); |
|||
expect(screen.getByText("file3.pdf")).toBeInTheDocument(); |
|||
expect(screen.getByText(/(3\/4)/)).toBeInTheDocument(); |
|||
|
|||
// Add 4th file
|
|||
const addInput = screen.getByLabelText("Add file"); |
|||
const fourthFile = new File(["content"], "file4.jpg", { type: "image/jpeg" }); |
|||
|
|||
await act(async () => { |
|||
fireEvent.change(addInput, { target: { files: [fourthFile] } }); |
|||
}); |
|||
|
|||
await waitFor(() => { |
|||
expect(mockSetAnswerValue).toHaveBeenCalledWith(mockFileQuestion, [ |
|||
"/media/marriage/media/file1.pdf", |
|||
"/media/marriage/media/file2.pdf", |
|||
"/media/marriage/media/file3.pdf", |
|||
"/media/tmp/file4.jpg", |
|||
]); |
|||
}); |
|||
|
|||
// Now 4 items are present, max files reached badge should be displayed
|
|||
expect(screen.getByText("Maximum of 4 files uploaded")).toBeInTheDocument(); |
|||
expect(screen.queryByText(/Add another document/)).not.toBeInTheDocument(); |
|||
}); |
|||
|
|||
it("deleting an item removes it from list, preserves remaining items, and restores Add button", async () => { |
|||
answerMap[mockFileQuestion.id] = [ |
|||
"/media/marriage/media/file1.pdf", |
|||
"/media/marriage/media/file2.jpg", |
|||
"/media/marriage/media/file3.png", |
|||
"/media/marriage/media/file4.pdf", |
|||
]; |
|||
|
|||
render(<QuestionFile question={mockFileQuestion} />); |
|||
|
|||
expect(screen.getByText("Maximum of 4 files uploaded")).toBeInTheDocument(); |
|||
|
|||
const deleteButtons = screen.getAllByTitle("Remove document"); |
|||
expect(deleteButtons).toHaveLength(4); |
|||
|
|||
// Delete 2nd file (file2.jpg)
|
|||
await act(async () => { |
|||
fireEvent.click(deleteButtons[1]); |
|||
}); |
|||
|
|||
expect(mockSetAnswerValue).toHaveBeenCalledWith(mockFileQuestion, [ |
|||
"/media/marriage/media/file1.pdf", |
|||
"/media/marriage/media/file3.png", |
|||
"/media/marriage/media/file4.pdf", |
|||
]); |
|||
|
|||
// Now 3 items remain, add button should be visible again
|
|||
expect(screen.getByText(/Add another document/)).toBeInTheDocument(); |
|||
expect(screen.getByText(/(3\/4)/)).toBeInTheDocument(); |
|||
}); |
|||
|
|||
it("deleting the only item clears answer back to null and returns to empty dropzone", async () => { |
|||
answerMap[mockFileQuestion.id] = ["/media/marriage/media/only_file.pdf"]; |
|||
|
|||
render(<QuestionFile question={mockFileQuestion} />); |
|||
|
|||
expect(screen.getByText("only_file.pdf")).toBeInTheDocument(); |
|||
|
|||
const deleteButton = screen.getByTitle("Remove document"); |
|||
await act(async () => { |
|||
fireEvent.click(deleteButton); |
|||
}); |
|||
|
|||
expect(mockSetAnswerValue).toHaveBeenCalledWith(mockFileQuestion, null); |
|||
expect(screen.getByText("upload certificates")).toBeInTheDocument(); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,67 @@ |
|||
import { cleanup, fireEvent, render, screen } from "@testing-library/react"; |
|||
import { afterEach, describe, expect, it, vi } from "vitest"; |
|||
import { TestCompletedSheet } from "./test-completed-sheet"; |
|||
|
|||
vi.mock("@/translations/provider", () => ({ |
|||
useI18n: () => ({ |
|||
locale: "fa", |
|||
dictionary: {}, |
|||
}), |
|||
})); |
|||
|
|||
vi.mock("@/hooks/use-hardware-back-handler", () => ({ |
|||
useHardwareBackHandler: vi.fn(), |
|||
})); |
|||
|
|||
describe("TestCompletedSheet", () => { |
|||
afterEach(() => { |
|||
cleanup(); |
|||
}); |
|||
|
|||
it("renders completion message and 'متوجه شدم' button when open", () => { |
|||
render( |
|||
<TestCompletedSheet |
|||
isOpen={true} |
|||
onClose={vi.fn()} |
|||
title="تست گلاسر" |
|||
/>, |
|||
); |
|||
|
|||
expect(screen.getByRole("heading", { name: "تست گلاسر" })).toBeInTheDocument(); |
|||
expect( |
|||
screen.getByText( |
|||
"شما این آزمون را قبلاً تکمیل کردهاید و امکان شرکت مجدد در آن وجود ندارد.", |
|||
), |
|||
).toBeInTheDocument(); |
|||
expect(screen.getByRole("button", { name: "متوجه شدم" })).toBeInTheDocument(); |
|||
}); |
|||
|
|||
it("calls onClose when clicking 'متوجه شدم' button", async () => { |
|||
const handleClose = vi.fn(); |
|||
|
|||
render( |
|||
<TestCompletedSheet |
|||
isOpen={true} |
|||
onClose={handleClose} |
|||
title="تست شخصیتشناسی" |
|||
/>, |
|||
); |
|||
|
|||
const gotItBtn = screen.getByRole("button", { name: "متوجه شدم" }); |
|||
fireEvent.click(gotItBtn); |
|||
|
|||
await new Promise((r) => setTimeout(r, 260)); |
|||
expect(handleClose).toHaveBeenCalled(); |
|||
}); |
|||
|
|||
it("returns null when isOpen is false", () => { |
|||
const { container } = render( |
|||
<TestCompletedSheet |
|||
isOpen={false} |
|||
onClose={vi.fn()} |
|||
/>, |
|||
); |
|||
|
|||
expect(container.firstChild).toBeNull(); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,61 @@ |
|||
"use client"; |
|||
|
|||
import { useI18n } from "@/translations/provider"; |
|||
import InformationSheet from "./information-sheet"; |
|||
import SwipeButton from "./swipe-button"; |
|||
|
|||
export type TestCompletedSheetProps = { |
|||
isOpen: boolean; |
|||
onClose: () => void; |
|||
title?: string; |
|||
closeOnOutside?: boolean; |
|||
}; |
|||
|
|||
export function TestCompletedSheet({ |
|||
isOpen, |
|||
onClose, |
|||
title, |
|||
closeOnOutside = true, |
|||
}: TestCompletedSheetProps) { |
|||
const { dictionary: t, locale } = useI18n(); |
|||
|
|||
if (!isOpen) { |
|||
return null; |
|||
} |
|||
|
|||
const isFa = locale === "fa"; |
|||
|
|||
const sheetTitle = |
|||
title || |
|||
(t as Record<string, string>)["Test Completed"] || |
|||
(isFa ? "آزمون تکمیل شده است" : "Test Completed"); |
|||
|
|||
const message = isFa |
|||
? "شما این آزمون را قبلاً تکمیل کردهاید و امکان شرکت مجدد در آن وجود ندارد." |
|||
: (t as Record<string, string>)["You have already completed this test, and it cannot be retaken."] || |
|||
"You have already completed this test, and it cannot be retaken."; |
|||
|
|||
const buttonLabel = (t as Record<string, string>)["Got it"] || (isFa ? "متوجه شدم" : "Got it"); |
|||
|
|||
return ( |
|||
<InformationSheet |
|||
icon="check" |
|||
title={sheetTitle} |
|||
description={ |
|||
<p className="text-center mt-2 group-12 text-[#4D4D4D] leading-relaxed font-medium"> |
|||
{message} |
|||
</p> |
|||
} |
|||
buttons={({ close }) => ( |
|||
<SwipeButton |
|||
text={buttonLabel} |
|||
onSuccess={close} |
|||
/> |
|||
)} |
|||
closeOnOutside={closeOnOutside} |
|||
onClose={onClose} |
|||
/> |
|||
); |
|||
} |
|||
|
|||
export default TestCompletedSheet; |
|||
@ -0,0 +1,92 @@ |
|||
import { cleanup, fireEvent, render, screen } from "@testing-library/react"; |
|||
import { afterEach, describe, expect, it, vi } from "vitest"; |
|||
import { TestExitSheet } from "./test-exit-sheet"; |
|||
|
|||
vi.mock("@/translations/provider", () => ({ |
|||
useI18n: () => ({ |
|||
locale: "fa", |
|||
dictionary: {}, |
|||
}), |
|||
})); |
|||
|
|||
vi.mock("@/hooks/use-hardware-back-handler", () => ({ |
|||
useHardwareBackHandler: vi.fn(), |
|||
})); |
|||
|
|||
describe("TestExitSheet", () => { |
|||
afterEach(() => { |
|||
cleanup(); |
|||
}); |
|||
|
|||
it("renders warning title and explanation points when open", () => { |
|||
render( |
|||
<TestExitSheet |
|||
isOpen={true} |
|||
onClose={vi.fn()} |
|||
onConfirmExit={vi.fn()} |
|||
/>, |
|||
); |
|||
|
|||
expect(screen.getByRole("heading", { name: "خروج از آزمون" })).toBeInTheDocument(); |
|||
expect( |
|||
screen.getByText("در صورت خروج از تست، ادامه فعلی حفظ نمیشود."), |
|||
).toBeInTheDocument(); |
|||
expect( |
|||
screen.getByText("پاسخهای واردشده ذخیره نخواهند شد."), |
|||
).toBeInTheDocument(); |
|||
expect( |
|||
screen.getByText("برای انجام دوباره تست باید از ابتدا شروع کنید."), |
|||
).toBeInTheDocument(); |
|||
expect(screen.getByText("ادامه آزمون")).toBeInTheDocument(); |
|||
expect(screen.getByRole("button", { name: "خروج از آزمون" })).toBeInTheDocument(); |
|||
}); |
|||
|
|||
it("calls onClose when clicking continue test (cancel)", async () => { |
|||
const handleClose = vi.fn(); |
|||
const handleConfirm = vi.fn(); |
|||
|
|||
render( |
|||
<TestExitSheet |
|||
isOpen={true} |
|||
onClose={handleClose} |
|||
onConfirmExit={handleConfirm} |
|||
/>, |
|||
); |
|||
|
|||
const continueBtn = screen.getByText("ادامه آزمون"); |
|||
fireEvent.click(continueBtn); |
|||
|
|||
await new Promise((r) => setTimeout(r, 260)); |
|||
expect(handleClose).toHaveBeenCalled(); |
|||
}); |
|||
|
|||
it("calls onConfirmExit when confirming exit", () => { |
|||
const handleClose = vi.fn(); |
|||
const handleConfirm = vi.fn(); |
|||
|
|||
render( |
|||
<TestExitSheet |
|||
isOpen={true} |
|||
onClose={handleClose} |
|||
onConfirmExit={handleConfirm} |
|||
/>, |
|||
); |
|||
|
|||
const exitBtn = screen.getByRole("button", { name: "خروج از آزمون" }); |
|||
fireEvent.click(exitBtn); |
|||
|
|||
expect(handleConfirm).toHaveBeenCalled(); |
|||
}); |
|||
|
|||
it("returns null when isOpen is false", () => { |
|||
const { container } = render( |
|||
<TestExitSheet |
|||
isOpen={false} |
|||
onClose={vi.fn()} |
|||
onConfirmExit={vi.fn()} |
|||
/>, |
|||
); |
|||
|
|||
expect(container.firstChild).toBeNull(); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,78 @@ |
|||
"use client"; |
|||
|
|||
import { useI18n } from "@/translations/provider"; |
|||
import InformationSheet from "./information-sheet"; |
|||
import SwipeButton from "./swipe-button"; |
|||
|
|||
export type TestExitSheetProps = { |
|||
isOpen: boolean; |
|||
onClose: () => void; |
|||
onConfirmExit: () => void; |
|||
closeOnOutside?: boolean; |
|||
}; |
|||
|
|||
export function TestExitSheet({ |
|||
isOpen, |
|||
onClose, |
|||
onConfirmExit, |
|||
closeOnOutside = true, |
|||
}: TestExitSheetProps) { |
|||
const { dictionary: t, locale } = useI18n(); |
|||
|
|||
if (!isOpen) { |
|||
return null; |
|||
} |
|||
|
|||
const isFa = locale === "fa"; |
|||
|
|||
const tr = t as Record<string, string>; |
|||
const title = tr["Exit Test"] || (isFa ? "خروج از آزمون" : "Exit Test"); |
|||
|
|||
const points = isFa |
|||
? [ |
|||
"در صورت خروج از تست، ادامه فعلی حفظ نمیشود.", |
|||
"پاسخهای واردشده ذخیره نخواهند شد.", |
|||
"برای انجام دوباره تست باید از ابتدا شروع کنید.", |
|||
] |
|||
: [ |
|||
tr["If you exit the test, your current progress will not be saved."] || |
|||
"If you exit the test, your current progress will not be saved.", |
|||
tr["Your entered answers will not be saved."] || |
|||
"Your entered answers will not be saved.", |
|||
tr["To take the test again, you must start from the beginning."] || |
|||
"To take the test again, you must start from the beginning.", |
|||
]; |
|||
|
|||
const cancelLabel = |
|||
tr["Continue Test"] || (isFa ? "ادامه آزمون" : "Continue Test"); |
|||
const exitLabel = |
|||
tr["Exit Test"] || (isFa ? "خروج از آزمون" : "Exit Test"); |
|||
|
|||
return ( |
|||
<InformationSheet |
|||
icon="warning" |
|||
title={title} |
|||
description={ |
|||
<div className="flex flex-col gap-2 text-center mt-2 group-12 text-[#4D4D4D] leading-relaxed"> |
|||
{points.map((pt, idx) => ( |
|||
<p key={idx} className="font-medium"> |
|||
{pt} |
|||
</p> |
|||
))} |
|||
</div> |
|||
} |
|||
buttons={({ close }) => ( |
|||
<SwipeButton |
|||
text={exitLabel} |
|||
cancelText={cancelLabel} |
|||
onCancel={close} |
|||
onSuccess={onConfirmExit} |
|||
/> |
|||
)} |
|||
closeOnOutside={closeOnOutside} |
|||
onClose={onClose} |
|||
/> |
|||
); |
|||
} |
|||
|
|||
export default TestExitSheet; |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue