Browse Source

feat: add multi-language support, implement conditional logic components, and update question schemas

Dev
parent
commit
97d4b19d68
  1. 2
      api_questions.json
  2. 2
      api_questions_marital.json
  3. 23
      conditional-rules.js
  4. 33
      src/app/questions-list/[slug]/question-detail-client.tsx
  5. 224
      src/components/Componentes/question-file.test.tsx
  6. 688
      src/components/Componentes/question-file.tsx
  7. 50
      src/components/Componentes/question-sheet.test.tsx
  8. 98
      src/components/Componentes/question-sheet.tsx
  9. 71
      src/lib/conditional-rules.test.ts
  10. 41
      src/lib/conditional-rules.ts
  11. 7
      src/translations/locales/ar.json
  12. 7
      src/translations/locales/az.json
  13. 7
      src/translations/locales/bn.json
  14. 7
      src/translations/locales/da.json
  15. 7
      src/translations/locales/de.json
  16. 7
      src/translations/locales/en.json
  17. 7
      src/translations/locales/es.json
  18. 7
      src/translations/locales/fa.json
  19. 7
      src/translations/locales/fr.json
  20. 7
      src/translations/locales/gu.json
  21. 7
      src/translations/locales/ha.json
  22. 7
      src/translations/locales/he.json
  23. 7
      src/translations/locales/hi.json
  24. 7
      src/translations/locales/id.json
  25. 7
      src/translations/locales/ks.json
  26. 7
      src/translations/locales/pt.json
  27. 7
      src/translations/locales/ru.json
  28. 7
      src/translations/locales/sw.json
  29. 7
      src/translations/locales/tg.json
  30. 7
      src/translations/locales/tr.json
  31. 7
      src/translations/locales/ul.json
  32. 7
      src/translations/locales/ur.json
  33. 7
      src/translations/locales/uz.json
  34. 7
      src/translations/locales/zh.json

2
api_questions.json
File diff suppressed because it is too large
View File

2
api_questions_marital.json
File diff suppressed because it is too large
View File

23
conditional-rules.js

@ -105,7 +105,7 @@ function matchesAudience(audience, context) {
return true; return true;
} }
if (audience.genders && audience.genders.length > 0) { if (audience.genders && audience.genders.length > 0) {
if ((context === null || context === void 0 ? void 0 : context.gender) && !audience.genders.includes(context.gender)) {
if (!context || !context.gender || !audience.genders.map(function(g) { return g.toLowerCase(); }).includes(context.gender.toLowerCase())) {
return false; return false;
} }
} }
@ -139,8 +139,8 @@ function ruleMatches(rawRule, answers, context) {
var expectedValues = (rule.dependsOn.values || []).map(function (v) { var expectedValues = (rule.dependsOn.values || []).map(function (v) {
return String(v).toLowerCase().trim(); return String(v).toLowerCase().trim();
}); });
var actualTokens_1 = getSelectedOptionTokens(answer);
var hasMatch = expectedValues.some(function (v) { return actualTokens_1.has(v); });
var actualTokens = getSelectedOptionTokens(answer);
var hasMatch = expectedValues.some(function (v) { return actualTokens.has(v); });
return hasMatch; return hasMatch;
} }
var mainMatches = true; var mainMatches = true;
@ -155,17 +155,17 @@ function ruleMatches(rawRule, answers, context) {
mainMatches = false; mainMatches = false;
} }
else { else {
var actualTokens_2 = getSelectedOptionTokens(answer);
var actualTokens_1 = getSelectedOptionTokens(answer);
var expectedIds = (rule.trigger_option_ids || []).map(function (id) { var expectedIds = (rule.trigger_option_ids || []).map(function (id) {
return String(id).toLowerCase().trim(); return String(id).toLowerCase().trim();
}); });
var isTokenMatched = function (expectedId) { var isTokenMatched = function (expectedId) {
if (actualTokens_2.has(expectedId))
if (actualTokens_1.has(expectedId))
return true; return true;
var lastDot = expectedId.lastIndexOf("."); var lastDot = expectedId.lastIndexOf(".");
if (lastDot !== -1 && lastDot < expectedId.length - 1) { if (lastDot !== -1 && lastDot < expectedId.length - 1) {
var suffix = expectedId.slice(lastDot + 1); var suffix = expectedId.slice(lastDot + 1);
if (actualTokens_2.has(suffix))
if (actualTokens_1.has(suffix))
return true; return true;
} }
return false; return false;
@ -177,7 +177,7 @@ function ruleMatches(rawRule, answers, context) {
mainMatches = mainMatches =
expectedIds.length > 0 && expectedIds.length > 0 &&
expectedIds.every(isTokenMatched) && expectedIds.every(isTokenMatched) &&
actualTokens_2.size <= expectedIds.length * 2;
actualTokens_1.size <= expectedIds.length * 2;
} }
else { else {
// default: "any_of" // default: "any_of"
@ -201,7 +201,7 @@ function ruleMatches(rawRule, answers, context) {
? mainMatches || conditionsResult ? mainMatches || conditionsResult
: mainMatches && conditionsResult; : mainMatches && conditionsResult;
} }
return mainMatches;
return parentId ? mainMatches : Boolean(!rule.audience || matchesAudience(rule.audience, context));
} }
function isQuestionVisible(question, answers, context) { function isQuestionVisible(question, answers, context) {
// 1. Audience check // 1. Audience check
@ -224,14 +224,11 @@ function isQuestionRequired(question, answers, context) {
if (!isQuestionVisible(question, answers, context)) { if (!isQuestionVisible(question, answers, context)) {
return false; return false;
} }
if (question.required || question.baseRequired) {
return true;
}
if (question.requiredWhen) { if (question.requiredWhen) {
if (question.requiredWhen.genders || question.requiredWhen.minAge || question.requiredWhen.maxAge) {
if (question.requiredWhen.genders || question.requiredWhen.minAge !== undefined || question.requiredWhen.maxAge !== undefined) {
return matchesAudience(question.requiredWhen, context); return matchesAudience(question.requiredWhen, context);
} }
return ruleMatches(question.requiredWhen, answers, context); return ruleMatches(question.requiredWhen, answers, context);
} }
return false;
return Boolean(question.baseRequired !== undefined ? question.baseRequired : question.required);
} }

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

@ -137,13 +137,34 @@ function QuestionFlowWrapper({
return undefined; return undefined;
}, [profile?.age, answers]); }, [profile?.age, answers]);
const userContext = useMemo(
() => ({
gender: profile?.gender,
const userContext = useMemo(() => {
let gender = profile?.gender;
if (!gender) {
const genderAns =
answers["personal_identity.gender"] ||
answers["personal_info.gender"] ||
answers["gender"] ||
Object.entries(answers).find(([k]) => k.includes("gender"))?.[1];
const gVal =
typeof genderAns === "object" &&
genderAns !== null &&
"value" in genderAns
? genderAns.value
: genderAns;
if (typeof gVal === "string" && gVal) {
gender =
gVal.toLowerCase().includes("female") ||
gVal.toLowerCase().includes("woman") ||
gVal.toLowerCase().includes("زن")
? "female"
: "male";
}
}
return {
gender,
age: computedAge, age: computedAge,
}),
[profile?.gender, computedAge],
);
};
}, [profile?.gender, answers, computedAge]);
const dynamicQuestions = useMemo(() => { const dynamicQuestions = useMemo(() => {

224
src/components/Componentes/question-file.test.tsx

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

688
src/components/Componentes/question-file.tsx

@ -1,20 +1,30 @@
"use client"; "use client";
import Image from "next/image"; import Image from "next/image";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { QuestionField } from "@/lib/schema-adapter"; import type { QuestionField } from "@/lib/schema-adapter";
import { useUploadTmpMediaMutation } from "@/hooks/marriage/use-upload-tmp-media"; import { useUploadTmpMediaMutation } from "@/hooks/marriage/use-upload-tmp-media";
import { getApiRequestUrl } from "@/lib/http"; import { getApiRequestUrl } from "@/lib/http";
import { isInFlutterWebView, uploadFile } from "@/lib/webview-actions"; import { isInFlutterWebView, uploadFile } from "@/lib/webview-actions";
import { useI18n } from "@/translations/provider";
import { useQuestionAnswers } from "./question-answer-storage"; import { useQuestionAnswers } from "./question-answer-storage";
import QuestionTitle from "./question-title"; import QuestionTitle from "./question-title";
import { LoadingSkeleton } from "./loading-skeleton"; import { LoadingSkeleton } from "./loading-skeleton";
const MAX_FILES = 4;
type QuestionFileProps = { type QuestionFileProps = {
question: QuestionField; question: QuestionField;
disabled?: boolean; disabled?: boolean;
}; };
export type UploadedFileItem = {
id: string;
url: string;
name: string;
isUploading?: boolean;
};
/** Map question file extensions to upload_file mediaType. */ /** Map question file extensions to upload_file mediaType. */
function resolveMediaType( function resolveMediaType(
extensions: string[], extensions: string[],
@ -53,56 +63,115 @@ function isImageFile(
return true; return true;
} }
const nameToCheck = fileName || fileUrl || ""; const nameToCheck = fileName || fileUrl || "";
return /\.(jpg|jpeg|png|webp|gif|svg|bmp|avif)$/i.test(nameToCheck);
return /\.(jpg|jpeg|png|webp|gif|svg|bmp|avif)($|\?)/i.test(nameToCheck);
} }
export function QuestionFile({
question,
disabled,
}: QuestionFileProps) {
function getDisplayFileName(url: string, name?: string): string {
if (name && name.trim().length > 0) return name;
if (!url) return "document";
try {
const clean = url.split("?")[0].split("#")[0];
const extracted = clean.split("/").pop();
return extracted ? decodeURIComponent(extracted) : "document";
} catch {
return url.split("/").pop() ?? "document";
}
}
function parseStoredFiles(storedValue: unknown): UploadedFileItem[] {
if (!storedValue) return [];
if (Array.isArray(storedValue)) {
return storedValue
.filter((v): v is string => typeof v === "string" && v.trim().length > 0)
.slice(0, MAX_FILES)
.map((url, idx) => ({
id: `stored-${idx}-${url}`,
url,
name: getDisplayFileName(url),
}));
}
if (typeof storedValue === "string" && storedValue.trim().length > 0) {
return [
{
id: `stored-0-${storedValue}`,
url: storedValue,
name: getDisplayFileName(storedValue),
},
];
}
return [];
}
function extractFilePath(res: unknown): string | null {
if (!res || typeof res !== "object") return null;
const r = res as Record<string, unknown>;
if (typeof r.path === "string" && r.path.trim().length > 0) return r.path;
if (typeof r.file === "string" && r.file.trim().length > 0) return r.file;
if (typeof r.url === "string" && r.url.trim().length > 0) return r.url;
if (typeof r.apath === "string" && r.apath.trim().length > 0) return r.apath;
return null;
}
export function QuestionFile({ question, disabled }: QuestionFileProps) {
const { dictionary: t } = useI18n();
const { getAnswerValue, setAnswerValue } = useQuestionAnswers(); const { getAnswerValue, setAnswerValue } = useQuestionAnswers();
const storedValue = getAnswerValue(question); const storedValue = getAnswerValue(question);
const initialFileName =
typeof storedValue === "string" && storedValue.trim().length > 0
? (storedValue.split("/").pop() ?? storedValue)
: null;
const initialFileUrl =
typeof storedValue === "string" && storedValue.trim().length > 0
? storedValue.startsWith("http") ||
storedValue.startsWith("blob:") ||
storedValue.startsWith("data:")
? storedValue
: getApiRequestUrl(storedValue)
: null;
const [selectedFileName, setSelectedFileName] = useState<string | null>(
initialFileName,
);
const [filePreviewUrl, setFilePreviewUrl] = useState<string | null>(
initialFileUrl,
const initialItems = useMemo(
() => parseStoredFiles(storedValue),
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
); );
const [files, setFiles] = useState<UploadedFileItem[]>(initialItems);
const [isFlutterPicking, setIsFlutterPicking] = useState(false); const [isFlutterPicking, setIsFlutterPicking] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const addFileInputRef = useRef<HTMLInputElement>(null);
const filesRef = useRef(files);
filesRef.current = files;
// Sync state if storedValue changes externally
useEffect(() => {
const parsed = parseStoredFiles(storedValue);
const currentUrls = filesRef.current
.filter((f) => !f.isUploading)
.map((f) => f.url);
const parsedUrls = parsed.map((p) => p.url);
const isDifferent =
currentUrls.length !== parsedUrls.length ||
currentUrls.some((u, i) => u !== parsedUrls[i]);
if (isDifferent) {
setFiles(parsed);
}
}, [storedValue]);
const acceptedFiles = (question.extras?.options ?? []) const acceptedFiles = (question.extras?.options ?? [])
.map((option) => option.replace(/^\./, "")) .map((option) => option.replace(/^\./, ""))
.join(", "); .join(", ");
const uploadTmpMediaMutation = useUploadTmpMediaMutation({
onSuccess: (response) => {
if (response.path) {
setAnswerValue(question, response.path);
const uploadTmpMediaMutation = useUploadTmpMediaMutation();
const persistFiles = useCallback(
(newFiles: UploadedFileItem[]) => {
setFiles(newFiles);
const validUrls = newFiles
.filter((f) => !f.isUploading && f.url)
.map((f) => f.url);
if (validUrls.length === 0) {
setAnswerValue(question, null);
} else {
setAnswerValue(question, validUrls);
} }
}, },
onError: (error) => {
console.error("File upload error:", error);
},
});
const isPending = uploadTmpMediaMutation.isPending || isFlutterPicking;
[question, setAnswerValue],
);
// Listen for upload_file responses from Flutter
// Listen for upload_file responses from Flutter WebView
useEffect(() => { useEffect(() => {
if (!isInFlutterWebView()) return; if (!isInFlutterWebView()) return;
@ -112,37 +181,72 @@ export function QuestionFile({
switch (event.status) { switch (event.status) {
case "picking": case "picking":
setIsFlutterPicking(true); setIsFlutterPicking(true);
setUploadError(null);
break; break;
case "picked": case "picked":
if (event.data?.files?.[0]) {
const fileName = event.data.files[0].name ?? null;
setSelectedFileName(fileName);
if (fileName) {
setAnswerValue(question, fileName);
}
}
break; break;
case "progress": case "progress":
break; break;
case "completed": { case "completed": {
setIsFlutterPicking(false); setIsFlutterPicking(false);
setUploadError(null);
const file = event.data?.files?.[0]; const file = event.data?.files?.[0];
if (file?.url) {
setAnswerValue(question, file.url);
setSelectedFileName(file.name ?? "uploaded");
setFilePreviewUrl(file.url);
const fileUrl = file?.url || file?.path || (file as any)?.apath;
if (fileUrl) {
const current = filesRef.current.filter(
(f) => !f.isUploading && f.url,
);
if (current.length < MAX_FILES) {
const newItem: UploadedFileItem = {
id: `flutter-${Date.now()}-${fileUrl}`,
url: fileUrl,
name: file?.name ?? getDisplayFileName(fileUrl),
};
persistFiles([...current, newItem]);
}
} else if (file?.base64) { } else if (file?.base64) {
const b64 = file.base64; const b64 = file.base64;
setSelectedFileName(file.name ?? "upload");
setFilePreviewUrl(b64);
fetch(b64)
.then((res) => res.blob())
.then((blob) => {
const f = new File([blob], file.name ?? "upload", {
type: blob.type,
const tempId = `flutter-uploading-${Date.now()}`;
const tempItem: UploadedFileItem = {
id: tempId,
url: b64,
name: file.name ?? "document",
isUploading: true,
};
const current = filesRef.current.filter((f) => f.url);
if (current.length < MAX_FILES) {
setFiles([...current, tempItem]);
fetch(b64)
.then((res) => res.blob())
.then((blob) => {
const f = new File([blob], file.name ?? "document", {
type: blob.type,
});
return uploadTmpMediaMutation.mutateAsync(f);
})
.then((res) => {
const extracted = extractFilePath(res);
if (extracted) {
const finalItem: UploadedFileItem = {
id: `uploaded-${Date.now()}-${extracted}`,
url: extracted,
name: file.name ?? getDisplayFileName(extracted),
};
const next = filesRef.current
.filter((item) => item.id !== tempId)
.concat(finalItem);
persistFiles(next.slice(0, MAX_FILES));
} else {
setFiles((prev) => prev.filter((item) => item.id !== tempId));
setUploadError((t?.upload_failed as string) ?? "Upload failed. Please try again.");
}
})
.catch((err) => {
console.error("Flutter base64 upload error:", err);
setFiles((prev) => prev.filter((item) => item.id !== tempId));
setUploadError((t?.upload_failed as string) ?? "Upload failed. Please try again.");
}); });
uploadTmpMediaMutation.mutate(f);
});
}
} }
break; break;
} }
@ -151,6 +255,7 @@ export function QuestionFile({
break; break;
case "failed": case "failed":
setIsFlutterPicking(false); setIsFlutterPicking(false);
setUploadError((t?.upload_failed as string) ?? "Upload failed. Please try again.");
console.error("upload_file failed:", event.message); console.error("upload_file failed:", event.message);
break; break;
} }
@ -159,10 +264,13 @@ export function QuestionFile({
return () => { return () => {
unsubscribe?.(); unsubscribe?.();
}; };
}, [question, setAnswerValue, uploadTmpMediaMutation]);
}, [persistFiles, uploadTmpMediaMutation, t]);
/** Handle file pick in Flutter WebView via upload_file action. */ /** Handle file pick in Flutter WebView via upload_file action. */
const handleFlutterPick = useCallback(() => { const handleFlutterPick = useCallback(() => {
if (filesRef.current.length >= MAX_FILES || disabled) return;
setUploadError(null);
const extensions = (question.extras?.options ?? []).map((o) => const extensions = (question.extras?.options ?? []).map((o) =>
o.replace(/^\./, "").toLowerCase(), o.replace(/^\./, "").toLowerCase(),
); );
@ -179,182 +287,364 @@ export function QuestionFile({
title: title:
typeof question.title === "string" ? question.title : "Upload File", typeof question.title === "string" ? question.title : "Upload File",
}); });
}, [question]);
/** Handle file pick via browser <input type="file"> (fallback). */
function handleBrowserFileChange(files: FileList | null) {
const file = files?.[0];
if (!file) {
setSelectedFileName(null);
setFilePreviewUrl(null);
setAnswerValue(question, null);
return;
}
}, [question, disabled]);
/** Process multiple files uploaded via browser input */
async function handleBrowserFiles(fileList: FileList | null) {
if (!fileList || fileList.length === 0 || disabled) return;
setUploadError(null);
const currentValid = filesRef.current.filter((f) => !f.isUploading);
const availableSlots = MAX_FILES - currentValid.length;
if (availableSlots <= 0) return;
const filesToUpload = Array.from(fileList).slice(0, availableSlots);
// Create temporary placeholders for visual feedback
const tempItems: { item: UploadedFileItem; file: File }[] =
filesToUpload.map((file, i) => {
const isImg = file.type.startsWith("image/");
const previewUrl = isImg ? URL.createObjectURL(file) : "";
return {
item: {
id: `temp-${Date.now()}-${i}-${file.name}`,
url: previewUrl,
name: file.name,
isUploading: true,
},
file,
};
});
setFiles([...currentValid, ...tempItems.map((t) => t.item)]);
// Upload files
try {
const results = await Promise.all(
tempItems.map(async ({ item, file }) => {
try {
const res = await uploadTmpMediaMutation.mutateAsync(file);
const extracted = extractFilePath(res);
if (!extracted) {
return { id: item.id, finalItem: null };
}
return {
id: item.id,
finalItem: {
id: `uploaded-${Date.now()}-${extracted}`,
url: extracted,
name: file.name,
} as UploadedFileItem,
};
} catch (err) {
console.error(`Upload error for ${file.name}:`, err);
return { id: item.id, finalItem: null };
}
}),
);
const successfulUploads = results
.map((r) => r.finalItem)
.filter((item): item is UploadedFileItem => item !== null);
if (successfulUploads.length === 0 && tempItems.length > 0) {
setUploadError(
(t?.upload_failed as string) ?? "Upload failed. Please try again.",
);
persistFiles(currentValid);
return;
}
setSelectedFileName(file.name);
setAnswerValue(question, file.name);
if (successfulUploads.length < tempItems.length) {
setUploadError(
(t?.upload_failed as string) ?? "Upload failed. Please try again.",
);
} else {
setUploadError(null);
}
if (file.type.startsWith("image/")) {
const objectUrl = URL.createObjectURL(file);
setFilePreviewUrl(objectUrl);
} else {
setFilePreviewUrl(null);
const nextFiles = [
...currentValid,
...successfulUploads,
].slice(0, MAX_FILES);
persistFiles(nextFiles);
} catch {
setUploadError(
(t?.upload_failed as string) ?? "Upload failed. Please try again.",
);
persistFiles(currentValid);
} }
uploadTmpMediaMutation.mutate(file);
} }
const handleRemoveFile = (e: React.MouseEvent) => {
const handleRemoveFile = (indexToRemove: number, e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
setSelectedFileName(null);
setFilePreviewUrl(null);
setAnswerValue(question, null);
setUploadError(null);
const updated = filesRef.current.filter((_, idx) => idx !== indexToRemove);
persistFiles(updated);
}; };
const inWebView = isInFlutterWebView(); const inWebView = isInFlutterWebView();
const isUploaded = Boolean(selectedFileName || storedValue);
const currentFileName =
selectedFileName ??
(typeof storedValue === "string" ? storedValue.split("/").pop() : null);
const currentFileUrl =
filePreviewUrl ??
(typeof storedValue === "string" && storedValue.trim().length > 0
? storedValue.startsWith("http") ||
storedValue.startsWith("blob:") ||
storedValue.startsWith("data:")
? storedValue
: getApiRequestUrl(storedValue)
: null);
const isImg = isImageFile(currentFileName, currentFileUrl);
const hasFiles = files.length > 0;
const isMaxReached = files.length >= MAX_FILES;
const isAnyPending =
isFlutterPicking || files.some((item) => item.isUploading);
const getFullFileUrl = (url: string) => {
if (!url) return "";
if (
url.startsWith("http://") ||
url.startsWith("https://") ||
url.startsWith("blob:") ||
url.startsWith("data:")
) {
return url;
}
return getApiRequestUrl(url);
};
return ( return (
<div <div
data-question-answered={isUploaded ? "true" : "false"}
data-question-answered={hasFiles ? "true" : "false"}
className={[ className={[
"flex w-full flex-col gap-2 transition-opacity duration-200", "flex w-full flex-col gap-2 transition-opacity duration-200",
disabled ? "pointer-events-none opacity-30" : "", disabled ? "pointer-events-none opacity-30" : "",
].join(" ")} ].join(" ")}
> >
<QuestionTitle question={question} /> <QuestionTitle question={question} />
<span
className="relative flex aspect-[727/330] min-h-[156px] w-full cursor-pointer flex-col items-center justify-center rounded-[29px] border-2 border-dashed border-[#8D8D8D] bg-[#F7F7F7] text-center transition-colors duration-200 focus-within:outline-2 focus-within:outline-offset-4 focus-within:outline-[#6F6F6F] hover:border-[#777777] overflow-hidden p-4"
role={inWebView ? "button" : undefined}
tabIndex={inWebView ? 0 : undefined}
onClick={inWebView && !isUploaded ? handleFlutterPick : undefined}
onKeyDown={
inWebView && !isUploaded
? (e) => {
if (e.key === "Enter" || e.key === " ") handleFlutterPick();
}
: undefined
}
>
{/* Fallback: browser file input (hidden in WebView or when uploaded) */}
{!inWebView && !isUploaded && (
<input
type="file"
accept={acceptedFiles ? acceptedFiles : "*/*"}
disabled={disabled}
onChange={(event) => handleBrowserFileChange(event.target.files)}
className="absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0"
/>
)}
{isUploaded ? (
/* ────── UPLOADED STATE ────── */
<div className="relative flex h-full w-full flex-col items-center justify-center">
{isImg && currentFileUrl ? (
/* Image Preview (Left design in screenshot) */
<img
src={currentFileUrl}
alt={currentFileName ?? "Uploaded image"}
className="max-h-[120px] max-w-[85%] rounded-[12px] object-contain shadow-xs"
{!hasFiles ? (
/* ────── EMPTY STATE (0 FILES) ────── */
<div
className="relative flex aspect-[727/330] min-h-[156px] w-full cursor-pointer flex-col items-center justify-center rounded-[29px] border-2 border-dashed border-[#8D8D8D] bg-[#F7F7F7] text-center transition-colors duration-200 focus-within:outline-2 focus-within:outline-offset-4 focus-within:outline-[#6F6F6F] hover:border-[#777777] overflow-hidden p-4 select-none"
role="button"
tabIndex={0}
onClick={
inWebView
? handleFlutterPick
: () => fileInputRef.current?.click()
}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (inWebView) handleFlutterPick();
else fileInputRef.current?.click();
}
}}
>
{!inWebView && (
<input
ref={fileInputRef}
type="file"
multiple
accept={acceptedFiles ? acceptedFiles : "*/*"}
disabled={disabled || isAnyPending}
onChange={(e) => {
handleBrowserFiles(e.target.files);
if (e.target) e.target.value = "";
}}
className="sr-only"
aria-label="Upload files"
/>
)}
{isAnyPending ? (
<LoadingSkeleton className="h-24 w-full rounded-[24px]" />
) : (
<>
<Image
src="/assets/images/Image.svg"
alt="Upload"
width={24}
height={24}
/> />
) : (
/* Document / PDF Preview (Right design in screenshot) */
<div className="flex flex-col items-center justify-center gap-2">
<div className="relative flex h-14 w-11 items-center justify-center rounded-[6px] border border-[#D1D5DB] bg-white shadow-xs">
<svg
width="28"
height="32"
viewBox="0 0 32 36"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4 0C1.79086 0 0 1.79086 0 4V32C0 34.2091 1.79086 36 4 36H28C30.2091 36 32 34.2091 32 32V10L22 0H4Z"
fill="#E5E7EB"
/>
<path d="M22 0V10H32L22 0Z" fill="#9CA3AF" />
</svg>
<span className="absolute bottom-1 rounded-[3px] bg-[#F0445B] px-1 py-[1px] text-[8px] font-bold text-white leading-none">
PDF
</span>
</div>
<span className="max-w-[240px] truncate text-xs font-semibold text-[#111111]">
{currentFileName ?? "document"}
<span className="mt-3 block text-sm font-normal text-[#111111] leading-snug">
{(t?.upload_certificates as string) ?? "upload certificates"}
</span>
{uploadError ? (
<span className="mt-2 block text-xs font-bold text-[#D44747]">
{uploadError}
</span> </span>
</div>
)}
{/* Trash Button in Bottom-Right */}
<button
type="button"
onClick={handleRemoveFile}
title="Remove file"
className="absolute bottom-0 right-0 z-20 flex h-8 w-8 items-center justify-center rounded-full bg-[#EAEAEA] text-[#36363C] transition-colors hover:bg-[#DDD] active:scale-95 cursor-pointer shadow-xs"
) : null}
</>
)}
</div>
) : (
/* ────── MULTIPLE FILES LIST / GRID ────── */
<div className="flex w-full flex-col gap-3">
<div
className={[
"grid w-full gap-3",
files.length === 1
? "grid-cols-1"
: "grid-cols-1 sm:grid-cols-2",
].join(" ")}
>
{files.map((fileItem, index) => {
const fullUrl = getFullFileUrl(fileItem.url);
const isImg = isImageFile(fileItem.name, fullUrl);
return (
<div
key={fileItem.id}
className="relative flex min-h-[120px] w-full flex-col items-center justify-center rounded-[20px] border border-[#E5E7EB] bg-[#F7F7F7] p-3 shadow-3xs overflow-hidden transition-all duration-200 hover:border-[#D1D5DB]"
>
{isImg && fullUrl ? (
<div className="relative flex h-full max-h-[110px] w-full items-center justify-center">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={fullUrl}
alt={fileItem.name}
className="max-h-[100px] max-w-[85%] rounded-[12px] object-contain shadow-xs"
/>
</div>
) : (
<div className="flex flex-col items-center justify-center gap-2 py-2">
<div className="relative flex h-12 w-10 items-center justify-center rounded-[6px] border border-[#D1D5DB] bg-white shadow-xs">
<svg
width="24"
height="28"
viewBox="0 0 32 36"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4 0C1.79086 0 0 1.79086 0 4V32C0 34.2091 1.79086 36 4 36H28C30.2091 36 32 34.2091 32 32V10L22 0H4Z"
fill="#E5E7EB"
/>
<path d="M22 0V10H32L22 0Z" fill="#9CA3AF" />
</svg>
<span className="absolute bottom-1 rounded-[3px] bg-[#F0445B] px-1 py-[1px] text-[7.5px] font-bold text-white leading-none">
PDF
</span>
</div>
<span className="max-w-[180px] sm:max-w-[200px] truncate text-xs font-semibold text-[#111111]">
{fileItem.name}
</span>
</div>
)}
{/* Individual Delete / Trash Button */}
{!fileItem.isUploading && (
<button
type="button"
onClick={(e) => handleRemoveFile(index, e)}
title={(t?.remove_document as string) ?? "Remove document"}
aria-label={`${(t?.remove_document as string) ?? "Remove"} ${fileItem.name}`}
className="absolute bottom-2 right-2 z-20 flex h-7 w-7 items-center justify-center rounded-full bg-[#EAEAEA] text-[#36363C] transition-all hover:bg-[#DDD] hover:text-[#D44747] active:scale-95 cursor-pointer shadow-xs"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 6h18" />
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
</svg>
</button>
)}
{/* Loading overlay for item */}
{fileItem.isUploading && (
<div className="absolute inset-0 z-30 flex items-center justify-center rounded-[20px] bg-black/40 backdrop-blur-[1px]">
<LoadingSkeleton className="h-full w-full rounded-[20px]" />
</div>
)}
</div>
);
})}
</div>
{/* ────── ADD ANOTHER DOCUMENT ACTION / STATUS ────── */}
{!isMaxReached ? (
<div
role="button"
tabIndex={0}
onClick={
inWebView
? handleFlutterPick
: () => addFileInputRef.current?.click()
}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (inWebView) handleFlutterPick();
else addFileInputRef.current?.click();
}
}}
className="flex min-h-[48px] w-full cursor-pointer items-center justify-center gap-2 rounded-[16px] border-2 border-dashed border-[#8D8D8D]/70 bg-[#F9F9F9] px-4 py-3 text-center transition-all hover:border-[#6F6F6F] hover:bg-[#F2F2F2] active:scale-[0.99] select-none"
> >
{!inWebView && (
<input
ref={addFileInputRef}
type="file"
multiple
accept={acceptedFiles ? acceptedFiles : "*/*"}
disabled={disabled || isAnyPending}
onChange={(e) => {
handleBrowserFiles(e.target.files);
if (e.target) e.target.value = "";
}}
className="sr-only"
aria-label="Add file"
/>
)}
<svg <svg
width="16" width="16"
height="16" height="16"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
strokeWidth="2"
strokeWidth="2.5"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
className="text-[#6F6F6F]"
> >
<path d="M3 6h18" />
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg> </svg>
</button>
{isPending && (
<div className="absolute inset-0 z-30 flex items-center justify-center rounded-[29px] bg-black/40 backdrop-blur-[1px]">
<LoadingSkeleton className="h-full w-full rounded-[29px]" />
</div>
)}
</div>
) : /* ────── DEFAULT EMPTY STATE ────── */
isPending ? (
<LoadingSkeleton className="h-24 w-full rounded-[24px]" />
) : (
<>
<Image
src="/assets/images/Image.svg"
alt="Upload"
width={24}
height={24}
/>
<span className="mt-3 block group-12 leading-none font-normal text-[#111111]">
{selectedFileName ?? "upload certificates"}
</span>
{uploadTmpMediaMutation.isError ? (
<span className="mt-2 block group-10 leading-none font-bold text-[#D44747]">
Upload failed. Please try again.
<span className="text-xs font-semibold text-[#36363C]">
{(t?.add_another_document as string) ?? "Add another document"}{" "}
<span className="text-[#8D8D8D]">
({files.length}/{MAX_FILES})
</span>
</span> </span>
) : acceptedFiles ? (
<span className="mt-2 block group-10 leading-none font-bold text-[#8B8B8B]">
{acceptedFiles}
</div>
) : (
<div className="flex w-full items-center justify-center gap-1.5 rounded-[14px] bg-[#E8F5E9] dark:bg-[#1E293B] py-2 px-3 text-center text-xs font-semibold text-[#2E7D32] dark:text-[#86EFAC]">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M20 6L9 17l-5-5" />
</svg>
<span>
{(t?.max_files_reached as string) ??
"Maximum of 4 files uploaded"}
</span> </span>
) : null}
</>
)}
</span>
</div>
)}
{uploadError && (
<span className="block text-center text-xs font-bold text-[#D44747]">
{uploadError}
</span>
)}
</div>
)}
</div> </div>
); );
} }

50
src/components/Componentes/question-sheet.test.tsx

@ -382,4 +382,54 @@ describe("QuestionSheet component", () => {
expect(section).not.toHaveClass("h-auto"); expect(section).not.toHaveClass("h-auto");
expect(screen.getByPlaceholderText("جستجو...")).toBeDefined(); expect(screen.getByPlaceholderText("جستجو...")).toBeDefined();
}); });
it("should render relationship persons and NOT countries for contact_residence.relationship_to_representative", () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const relQuestion = {
id: "contact_residence.relationship_to_representative",
title: "نسبت رابط با شما",
type: "dropdown",
required: false,
extras: {
placeHolder: "انتخاب کنید",
noSearch: true,
},
options: [
{ id: "contact_residence.relationship_to_representative.father", value: "father", label: "پدر", order: 1 },
{ id: "contact_residence.relationship_to_representative.mother", value: "mother", label: "مادر", order: 2 },
{ id: "contact_residence.relationship_to_representative.brother", value: "brother", label: "برادر", order: 3 },
{ id: "contact_residence.relationship_to_representative.sister", value: "sister", label: "خواهر", order: 4 },
{ id: "contact_residence.relationship_to_representative.paternal_maternal_uncle", value: "paternal_maternal_uncle", label: "عمو / دایی", order: 5 },
{ id: "contact_residence.relationship_to_representative.paternal_maternal_aunt", value: "paternal_maternal_aunt", label: "خاله / عمه", order: 6 },
{ id: "contact_residence.relationship_to_representative.trusted_family_friend", value: "trusted_family_friend", label: "دوست خانوادگی معتمد", order: 7 },
{ id: "contact_residence.relationship_to_representative.religious_clerical_sponsor", value: "religious_clerical_sponsor", label: "معرف مذهبی / روحانی", order: 8 },
{ id: "contact_residence.relationship_to_representative.trusted_social_sponsor", value: "trusted_social_sponsor", label: "معرف اجتماعی معتمد", order: 9 },
],
ui_config: {},
} as QuestionField;
render(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test" questions={[relQuestion]}>
<QuestionSheet question={relQuestion} />
</QuestionAnswersProvider>
</QueryClientProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "انتخاب کنید" }));
// Should contain person relationships
expect(screen.getByText("پدر")).toBeDefined();
expect(screen.getByText("مادر")).toBeDefined();
expect(screen.getByText("برادر")).toBeDefined();
expect(screen.getByText("عمو / دایی")).toBeDefined();
// Should NOT contain countries
expect(screen.queryByText("Afghanistan")).toBeNull();
expect(screen.queryByText("Antigua and Barbuda")).toBeNull();
expect(screen.queryByText("افغانستان")).toBeNull();
});
}); });

98
src/components/Componentes/question-sheet.tsx

@ -97,30 +97,38 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
return () => window.removeEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, closeSheet]); }, [isOpen, closeSheet]);
const isExcludedFromAutoDatasets =
question.id?.toLowerCase().includes("representative") ||
question.id?.toLowerCase().includes("relationship") ||
question.id?.toLowerCase().includes("residence_status") ||
question.id?.toLowerCase().includes("status") ||
question.id?.toLowerCase().includes("responsibility") ||
question.extras?.noSearch === true ||
question.ui_config?.noSearch === true;
const isLanguageQuestion = const isLanguageQuestion =
question.id?.toLowerCase().includes("language") ||
question.id?.toLowerCase().includes("mother_tongue") ||
question.id?.toLowerCase().includes("other_languages") ||
question.title?.toLowerCase().includes("language") ||
question.title?.toLowerCase().includes("tongue") ||
question.title?.includes("زبان") ||
question.ui_config?.dataset === "languages";
!isExcludedFromAutoDatasets &&
(question.ui_config?.dataset === "languages" ||
question.id?.endsWith(".mother_tongue") ||
question.id?.endsWith(".native_language") ||
question.id?.endsWith(".other_languages") ||
(Boolean(question.title?.toLowerCase().includes("language")) &&
!question.options?.length));
const isCountryQuestion = const isCountryQuestion =
question.id?.toLowerCase().includes("nationality") ||
question.id?.toLowerCase().includes("citizenship") ||
question.id?.toLowerCase().includes("country") ||
question.id?.toLowerCase().includes("birthplace") ||
question.id?.toLowerCase().includes("residence") ||
question.title?.toLowerCase().includes("nationality") ||
question.title?.toLowerCase().includes("citizenship") ||
question.title?.toLowerCase().includes("country") ||
question.title?.includes("ملیت") ||
question.title?.includes("تابعیت") ||
question.title?.includes("کشور") ||
question.title?.includes("سکونت") ||
question.ui_config?.dataset === "countries" ||
question.ui_config?.dataset === "nationalities";
!isExcludedFromAutoDatasets &&
(question.ui_config?.dataset === "countries" ||
question.ui_config?.dataset === "nationalities" ||
question.id?.endsWith(".nationality") ||
question.id?.endsWith(".citizenship") ||
question.id?.endsWith(".second_nationality") ||
(Boolean(
question.id?.endsWith(".birthplace") ||
question.id?.endsWith(".current_residence") ||
question.title?.toLowerCase().includes("nationality") ||
question.title?.toLowerCase().includes("citizenship"),
) &&
!question.options?.length));
const options = useMemo(() => { const options = useMemo(() => {
const rawOptions = question.options || []; const rawOptions = question.options || [];
@ -142,8 +150,8 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
LANGUAGES_EN.forEach((enLang, idx) => { LANGUAGES_EN.forEach((enLang, idx) => {
const localizedLabel = const localizedLabel =
(locale === "fa" || locale === "fa-ir" (locale === "fa" || locale === "fa-ir"
? (LANGUAGE_EN_TO_FA[enLang] || LANGUAGES_FA[idx])
: (t as any)[enLang]) || enLang;
? LANGUAGES_FA[idx]
: (t as any)[LANGUAGE_EN_TO_FA[enLang] || enLang]) || enLang;
const cleanSlug = enLang const cleanSlug = enLang
.toLowerCase() .toLowerCase()
.replace(/[^a-z0-9]+/g, "_") .replace(/[^a-z0-9]+/g, "_")
@ -162,7 +170,7 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
const isPersian = locale === "fa" || locale === "fa-ir"; const isPersian = locale === "fa" || locale === "fa-ir";
const displayLabel = isPersian const displayLabel = isPersian
? (LANGUAGE_EN_TO_FA[enLang] || localizedLabel)
? (LANGUAGES_FA[idx] || localizedLabel)
: enLang; : enLang;
mergedOptions.push({ mergedOptions.push({
@ -241,14 +249,22 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
return mergedOptions; return mergedOptions;
} }
return rawOptions;
return rawOptions.map((opt) => ({
...opt,
label: (t as any)[opt.label] || opt.label,
}));
}, [question, isLanguageQuestion, isCountryQuestion, locale, t]); }, [question, isLanguageQuestion, isCountryQuestion, locale, t]);
const noSearch = Boolean(
question.extras?.noSearch === true ||
question.ui_config?.noSearch === true ||
question.id?.toLowerCase().includes("responsibility"),
);
const COMPACT_OPTIONS_MAX = 6; const COMPACT_OPTIONS_MAX = 6;
const isCompact = options.length <= COMPACT_OPTIONS_MAX;
const showSearch =
!question.extras?.noSearch && options.length > COMPACT_OPTIONS_MAX;
const isCompact = options.length <= COMPACT_OPTIONS_MAX || noSearch;
const showSearch = !noSearch && options.length > COMPACT_OPTIONS_MAX;
useEffect(() => { useEffect(() => {
if (!isOpen || isClosing || !isCompact) return; if (!isOpen || isClosing || !isCompact) return;
@ -411,7 +427,7 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
ref={sheetRef} ref={sheetRef}
className={[ className={[
"flex w-full flex-col overflow-hidden rounded-t-[22px] bg-white pb-[env(safe-area-inset-bottom)] shadow-[0_-10px_32px_rgba(0,0,0,0.12)] transition-transform duration-[300ms] ease-out sm:max-w-[375px] animate-in slide-in-from-bottom", "flex w-full flex-col overflow-hidden rounded-t-[22px] bg-white pb-[env(safe-area-inset-bottom)] shadow-[0_-10px_32px_rgba(0,0,0,0.12)] transition-transform duration-[300ms] ease-out sm:max-w-[375px] animate-in slide-in-from-bottom",
isCompact && !showSearch
!showSearch
? "h-auto max-h-[82svh]" ? "h-auto max-h-[82svh]"
: "h-[82svh] min-h-[82svh] max-h-[82svh]", : "h-[82svh] min-h-[82svh] max-h-[82svh]",
isClosing ? "translate-y-full" : "translate-y-0", isClosing ? "translate-y-full" : "translate-y-0",
@ -527,9 +543,9 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
} }
}} }}
className={[ className={[
"flex min-h-12 w-full items-start gap-3 rounded-lg border px-3 py-3 text-start transition-colors cursor-pointer",
"flex min-h-[52px] w-full items-start gap-3 rounded-[12px] border px-3.5 py-3 text-start transition-colors cursor-pointer",
isSelected isSelected
? "bg-[#FFF4F5] border-[#F0445B]/30 text-[#181818]"
? "bg-[#FFF4F5] border-[#F0445B]/40 text-[#181818]"
: "bg-white hover:bg-[#F2F4F7] border-[#EAECF0] text-[#181818]", : "bg-white hover:bg-[#F2F4F7] border-[#EAECF0] text-[#181818]",
].join(" ")} ].join(" ")}
> >
@ -537,7 +553,7 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
{isMulti ? ( {isMulti ? (
<div <div
className={[ className={[
"size-[22px] shrink-0 rounded-[6px] transition-all duration-150 mt-0.5 flex items-center justify-center",
"size-[20px] shrink-0 rounded-[6px] transition-all duration-150 mt-[2px] flex items-center justify-center",
isSelected isSelected
? "bg-[#F0445B] text-white shadow-xs" ? "bg-[#F0445B] text-white shadow-xs"
: "border-[2px] border-[#98A2B3] bg-white", : "border-[2px] border-[#98A2B3] bg-white",
@ -564,15 +580,19 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
) : ( ) : (
<div <div
className={[ className={[
"size-[22px] shrink-0 rounded-full transition-all duration-150 mt-0.5 flex items-center justify-center",
"size-[20px] shrink-0 rounded-full border-[2px] transition-all flex items-center justify-center mt-[2px]",
isSelected isSelected
? "border-[6px] border-[#F0445B] bg-white"
: "border-[2px] border-[#98A2B3] bg-white",
? "border-[#F0445B] bg-white text-[#F0445B]"
: "border-[#98A2B3] bg-white text-transparent",
].join(" ")} ].join(" ")}
/>
>
{isSelected && (
<div className="size-[10px] rounded-full bg-[#F0445B]" />
)}
</div>
)} )}
<span className="text-[15px] leading-snug flex-1">
<span className="text-[15px] leading-[1.45] flex-1 text-start break-words">
{option.label.includes(" - ") ? ( {option.label.includes(" - ") ? (
(() => { (() => {
const parts = option.label.split(" - "); const parts = option.label.split(" - ");
@ -593,8 +613,8 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
<span <span
className={ className={
isSelected isSelected
? "font-bold text-[#181818]"
: "font-semibold text-[#344054]"
? "font-bold text-[#181818] block"
: "font-semibold text-[#344054] block"
} }
> >
{option.label} {option.label}

71
src/lib/conditional-rules.test.ts

@ -201,6 +201,77 @@ describe("Conditional Rules Evaluator", () => {
expect(isQuestionRequired(q, matchingAnswers)).toBe(true); expect(isQuestionRequired(q, matchingAnswers)).toBe(true);
}); });
it("should evaluate representative questions as optional for men and conditional for women based on age", () => {
const repNameQuestion: QuestionField = {
...dummyQuestion,
id: "contact_residence.representative_s_full_name",
title: "Representative's Full Name",
required: false,
baseRequired: false,
requiredWhen: {
genders: ["female"],
maxAge: 26,
},
};
const repPhoneQuestion: QuestionField = {
...dummyQuestion,
id: "contact_residence.representative_s_contact_number",
title: "Representative's Contact Number",
type: "phone",
required: false,
baseRequired: false,
requiredWhen: {
genders: ["female"],
maxAge: 26,
},
};
const repRelQuestion: QuestionField = {
...dummyQuestion,
id: "contact_residence.relationship_to_representative",
title: "Relationship to Representative",
type: "dropdown",
required: false,
baseRequired: false,
requiredWhen: {
genders: ["female"],
maxAge: 26,
},
};
const repQuestions = [repNameQuestion, repPhoneQuestion, repRelQuestion];
// For Male (regardless of age: 20, 26, 30): ALWAYS OPTIONAL
for (const age of [18, 20, 25, 26, 27, 35]) {
const maleContext = { gender: "male", age };
for (const question of repQuestions) {
expect(isQuestionRequired(question, {}, maleContext)).toBe(false);
}
}
// For Female <= 26: REQUIRED
for (const age of [18, 20, 25, 26]) {
const youngFemaleContext = { gender: "female", age };
for (const question of repQuestions) {
expect(isQuestionRequired(question, {}, youngFemaleContext)).toBe(true);
}
}
// For Female >= 27: OPTIONAL
for (const age of [27, 30, 35]) {
const olderFemaleContext = { gender: "female", age };
for (const question of repQuestions) {
expect(isQuestionRequired(question, {}, olderFemaleContext)).toBe(false);
}
}
// When gender is unknown: OPTIONAL
for (const question of repQuestions) {
expect(isQuestionRequired(question, {}, {})).toBe(false);
}
});
it("should correctly handle physical health 4-option visibility and medication requirement", () => { it("should correctly handle physical health 4-option visibility and medication requirement", () => {
// 1. Physical Health Description // 1. Physical Health Description
const physicalHealthDescription: QuestionField = { const physicalHealthDescription: QuestionField = {

41
src/lib/conditional-rules.ts

@ -58,6 +58,16 @@ export function canonicalRule(rule: any): CanonicalRule | null {
if (rule.audience && typeof rule.audience === "object") { if (rule.audience && typeof rule.audience === "object") {
result.audience = rule.audience; result.audience = rule.audience;
} else if (
rule.genders ||
rule.minAge !== undefined ||
rule.maxAge !== undefined
) {
result.audience = {
genders: rule.genders,
minAge: rule.minAge,
maxAge: rule.maxAge,
};
} }
if (Array.isArray(rule.conditions)) { if (Array.isArray(rule.conditions)) {
@ -140,19 +150,24 @@ export function matchesAudience(
} }
if (audience.genders && audience.genders.length > 0) { if (audience.genders && audience.genders.length > 0) {
if (context?.gender && !audience.genders.includes(context.gender)) {
if (
!context?.gender ||
!audience.genders
.map((g) => g.toLowerCase())
.includes(context.gender.toLowerCase())
) {
return false; return false;
} }
} }
if (audience.minAge !== undefined && context?.age !== undefined && context.age !== null) {
if (context.age < audience.minAge) {
if (audience.minAge !== undefined) {
if (context?.age === undefined || context.age === null || context.age < audience.minAge) {
return false; return false;
} }
} }
if (audience.maxAge !== undefined && context?.age !== undefined && context.age !== null) {
if (context.age > audience.maxAge) {
if (audience.maxAge !== undefined) {
if (context?.age === undefined || context.age === null || context.age > audience.maxAge) {
return false; return false;
} }
} }
@ -250,7 +265,9 @@ export function ruleMatches(
: mainMatches && conditionsResult; : mainMatches && conditionsResult;
} }
return mainMatches;
return parentId
? mainMatches
: Boolean(!rule.audience || matchesAudience(rule.audience, context));
} }
export function isQuestionVisible( export function isQuestionVisible(
@ -289,16 +306,16 @@ export function isQuestionRequired(
return false; return false;
} }
if (question.required || question.baseRequired) {
return true;
}
if (question.requiredWhen) { if (question.requiredWhen) {
if (question.requiredWhen.genders || question.requiredWhen.minAge || question.requiredWhen.maxAge) {
if (
question.requiredWhen.genders ||
question.requiredWhen.minAge !== undefined ||
question.requiredWhen.maxAge !== undefined
) {
return matchesAudience(question.requiredWhen, context); return matchesAudience(question.requiredWhen, context);
} }
return ruleMatches(question.requiredWhen, answers, context); return ruleMatches(question.requiredWhen, answers, context);
} }
return false;
return Boolean(question.baseRequired ?? question.required);
} }

7
src/translations/locales/ar.json

@ -808,5 +808,10 @@
"نام دارو و دلیل مصرف را وارد نمایید...": "أدخل اسم الدواء وسبب الاستخدام...", "نام دارو و دلیل مصرف را وارد نمایید...": "أدخل اسم الدواء وسبب الاستخدام...",
"وضعیت سلامت جسمانی": "حالة الصحة الجسدية", "وضعیت سلامت جسمانی": "حالة الصحة الجسدية",
"توضیحات وضعیت جسمانی": "وصف الصحة الجسدية", "توضیحات وضعیت جسمانی": "وصف الصحة الجسدية",
"Name": "الاسم"
"Name": "الاسم",
"upload_certificates": "تحميل الشهادات والوثائق",
"add_another_document": "إضافة وثيقة أخرى",
"max_files_reached": "تم تحميل الحد الأقصى (4 ملفات)",
"remove_document": "حذف الوثيقة",
"upload_failed": "فشل التحميل. يرجى المحاولة مرة أخرى."
} }

7
src/translations/locales/az.json

@ -808,5 +808,10 @@
"نام دارو و دلیل مصرف را وارد نمایید...": "Dərmanın adını və istifadə səbəbini daxil edin...", "نام دارو و دلیل مصرف را وارد نمایید...": "Dərmanın adını və istifadə səbəbini daxil edin...",
"وضعیت سلامت جسمانی": "Fiziki Sağlamlıq Vəziyyəti", "وضعیت سلامت جسمانی": "Fiziki Sağlamlıq Vəziyyəti",
"توضیحات وضعیت جسمانی": "Fiziki Sağlamlıq Təsviri", "توضیحات وضعیت جسمانی": "Fiziki Sağlamlıq Təsviri",
"Name": "Ad"
"Name": "Ad",
"upload_certificates": "Sənədləri yükləyin",
"add_another_document": "Başqa sənəd əlavə edin",
"max_files_reached": "Maksimum 4 fayl yükləndi",
"remove_document": "Sənədi sil",
"upload_failed": "Yükləmə uğursuz oldu. Yenidən cəhd edin."
} }

7
src/translations/locales/bn.json

@ -808,5 +808,10 @@
"نام دارو و دلیل مصرف را وارد نمایید...": "ওষুধের নাম এবং কারণ লিখুন...", "نام دارو و دلیل مصرف را وارد نمایید...": "ওষুধের নাম এবং কারণ লিখুন...",
"وضعیت سلامت جسمانی": "শারীরিক স্বাস্থ্যের অবস্থা", "وضعیت سلامت جسمانی": "শারীরিক স্বাস্থ্যের অবস্থা",
"توضیحات وضعیت جسمانی": "শারীরিক স্বাস্থ্যের বিবরণ", "توضیحات وضعیت جسمانی": "শারীরিক স্বাস্থ্যের বিবরণ",
"Name": "নাম"
"Name": "নাম",
"upload_certificates": "নথিপত্র আপলোড করুন",
"add_another_document": "অন্য নথি যোগ করুন",
"max_files_reached": "সর্বোচ্চ ৪টি ফাইল আপলোড করা হয়েছে",
"remove_document": "নথি মুছুন",
"upload_failed": "আপলোড ব্যর্থ হয়েছে। আবার চেষ্টা করুন।"
} }

7
src/translations/locales/da.json

@ -808,5 +808,10 @@
"نام دارو و دلیل مصرف را وارد نمایید...": "Indtast medicinnavn og årsag...", "نام دارو و دلیل مصرف را وارد نمایید...": "Indtast medicinnavn og årsag...",
"وضعیت سلامت جسمانی": "Fysisk helbredstilstand", "وضعیت سلامت جسمانی": "Fysisk helbredstilstand",
"توضیحات وضعیت جسمانی": "Beskrivelse af fysisk helbred", "توضیحات وضعیت جسمانی": "Beskrivelse af fysisk helbred",
"Name": "Navn"
"Name": "Navn",
"upload_certificates": "Upload certifikater",
"add_another_document": "Tilføj et andet dokument",
"max_files_reached": "Maksimalt 4 filer uploadet",
"remove_document": "Fjern dokument",
"upload_failed": "Upload mislykkedes. Prøv igen."
} }

7
src/translations/locales/de.json

@ -808,5 +808,10 @@
"نام دارو و دلیل مصرف را وارد نمایید...": "Geben Sie den Medikamentennamen und den Grund ein...", "نام دارو و دلیل مصرف را وارد نمایید...": "Geben Sie den Medikamentennamen und den Grund ein...",
"وضعیت سلامت جسمانی": "Körperlicher Gesundheitszustand", "وضعیت سلامت جسمانی": "Körperlicher Gesundheitszustand",
"توضیحات وضعیت جسمانی": "Beschreibung des körperlichen Zustands", "توضیحات وضعیت جسمانی": "Beschreibung des körperlichen Zustands",
"Name": "Name"
"Name": "Name",
"upload_certificates": "Zertifikate hochladen",
"add_another_document": "Ein weiteres Dokument hinzufügen",
"max_files_reached": "Maximal 4 Dateien hochgeladen",
"remove_document": "Dokument entfernen",
"upload_failed": "Upload fehlgeschlagen. Bitte versuchen Sie es erneut."
} }

7
src/translations/locales/en.json

@ -845,5 +845,10 @@
"Medication Name and Reason for Use": "Medication Name and Reason for Use", "Medication Name and Reason for Use": "Medication Name and Reason for Use",
"Enter medication name and reason for use...": "Enter medication name and reason for use...", "Enter medication name and reason for use...": "Enter medication name and reason for use...",
"Enter a valid phone number with country code.": "Enter a valid phone number with country code.", "Enter a valid phone number with country code.": "Enter a valid phone number with country code.",
"Name": "Name"
"Name": "Name",
"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."
} }

7
src/translations/locales/es.json

@ -808,5 +808,10 @@
"نام دارو و دلیل مصرف را وارد نمایید...": "Ingrese el nombre del medicamento y el motivo...", "نام دارو و دلیل مصرف را وارد نمایید...": "Ingrese el nombre del medicamento y el motivo...",
"وضعیت سلامت جسمانی": "Estado de salud física", "وضعیت سلامت جسمانی": "Estado de salud física",
"توضیحات وضعیت جسمانی": "Descripción de la salud física", "توضیحات وضعیت جسمانی": "Descripción de la salud física",
"Name": "Nombre"
"Name": "Nombre",
"upload_certificates": "Subir certificados",
"add_another_document": "Agregar otro documento",
"max_files_reached": "Máximo de 4 archivos subidos",
"remove_document": "Eliminar documento",
"upload_failed": "Error al subir. Por favor, inténtelo de nuevo."
} }

7
src/translations/locales/fa.json

@ -856,5 +856,10 @@
"نام دارو و دلیل مصرف را وارد نمایید...": "نام دارو و دلیل مصرف را وارد نمایید...", "نام دارو و دلیل مصرف را وارد نمایید...": "نام دارو و دلیل مصرف را وارد نمایید...",
"وضعیت سلامت جسمانی": "وضعیت سلامت جسمانی", "وضعیت سلامت جسمانی": "وضعیت سلامت جسمانی",
"توضیحات وضعیت جسمانی": "توضیحات وضعیت جسمانی", "توضیحات وضعیت جسمانی": "توضیحات وضعیت جسمانی",
"Name": "نام"
"Name": "نام",
"upload_certificates": "بارگذاری مدارک",
"add_another_document": "افزودن مدرک جدید",
"max_files_reached": "حداکثر ۴ فایل بارگذاری شده است",
"remove_document": "حذف مدرک",
"upload_failed": "بارگذاری با خطا مواجه شد. لطفاً دوباره تلاش کنید."
} }

7
src/translations/locales/fr.json

@ -808,5 +808,10 @@
"نام دارو و دلیل مصرف را وارد نمایید...": "Entrez le nom du médicament et le motif...", "نام دارو و دلیل مصرف را وارد نمایید...": "Entrez le nom du médicament et le motif...",
"وضعیت سلامت جسمانی": "État de santé physique", "وضعیت سلامت جسمانی": "État de santé physique",
"توضیحات وضعیت جسمانی": "Description de la santé physique", "توضیحات وضعیت جسمانی": "Description de la santé physique",
"Name": "Nom"
"Name": "Nom",
"upload_certificates": "Télécharger les certificats",
"add_another_document": "Ajouter un autre document",
"max_files_reached": "Maximum de 4 fichiers téléchargés",
"remove_document": "Supprimer le document",
"upload_failed": "Échec du téléchargement. Veuillez réessayer."
} }

7
src/translations/locales/gu.json

@ -808,5 +808,10 @@
"نام دارو و دلیل مصرف را وارد نمایید...": "દવાનું નામ અને કારણ દાખલ કરો...", "نام دارو و دلیل مصرف را وارد نمایید...": "દવાનું નામ અને કારણ દાખલ કરો...",
"وضعیت سلامت جسمانی": "શારીરિક સ્વાસ્થ્ય સ્થિતિ", "وضعیت سلامت جسمانی": "શારીરિક સ્વાસ્થ્ય સ્થિતિ",
"توضیحات وضعیت جسمانی": "શારીરિક સ્વાસ્થ્ય વર્ણન", "توضیحات وضعیت جسمانی": "શારીરિક સ્વાસ્થ્ય વર્ણન",
"Name": "નામ"
"Name": "નામ",
"upload_certificates": "પ્રમાણપત્રો અપલોડ કરો",
"add_another_document": "બીજો દસ્તાવેજ ઉમેરો",
"max_files_reached": "મહત્તમ 4 ફાઇલો અપલોડ કરવામાં આવી છે",
"remove_document": "દસ્તાવેજ દૂર કરો",
"upload_failed": "અપલોડ નિષ્ફળ ગયું. કૃપા કરીને ફરી પ્રયાસ કરો."
} }

7
src/translations/locales/ha.json

@ -808,5 +808,10 @@
"نام دارو و دلیل مصرف را وارد نمایید...": "Shigar da sunan magani da dalilin sha...", "نام دارو و دلیل مصرف را وارد نمایید...": "Shigar da sunan magani da dalilin sha...",
"وضعیت سلامت جسمانی": "Yanayin Lafiyar Jiki", "وضعیت سلامت جسمانی": "Yanayin Lafiyar Jiki",
"توضیحات وضعیت جسمانی": "Bayanin Lafiyar Jiki", "توضیحات وضعیت جسمانی": "Bayanin Lafiyar Jiki",
"Name": "Suna"
"Name": "Suna",
"upload_certificates": "Loda takardun shaida",
"add_another_document": "Ƙara wata takarda",
"max_files_reached": "An loda matsakaicin fayiloli 4",
"remove_document": "Cire takarda",
"upload_failed": "Loda ya faskara. Da fatan za a sake gwadawa."
} }

7
src/translations/locales/he.json

@ -355,5 +355,10 @@
"Job Title": "תואר התפקיד", "Job Title": "תואר התפקיד",
"Employment Status": "מצב תעסוקתי", "Employment Status": "מצב תעסוקתי",
"Your Hobbies and Main Interests": "התחביבים ותחומי העניין העיקריים שלך", "Your Hobbies and Main Interests": "התחביבים ותחומי העניין העיקריים שלך",
"View more details": "הצג פרטים נוספים"
"View more details": "הצג פרטים נוספים",
"upload_certificates": "העלאת תעודות ומסמכים",
"add_another_document": "הוסף מסמך נוסף",
"max_files_reached": "הועלו מקסימום 4 קבצים",
"remove_document": "הסר מסמך",
"upload_failed": "ההעלאה נכשלה. אנא נסה שוב."
} }

7
src/translations/locales/hi.json

@ -808,5 +808,10 @@
"نام دارو و دلیل مصرف را وارد نمایید...": "दवा का नाम और उपयोग का कारण दर्ज करें...", "نام دارو و دلیل مصرف را وارد نمایید...": "दवा का नाम और उपयोग का कारण दर्ज करें...",
"وضعیت سلامت جسمانی": "शारीरिक स्वास्थ्य की स्थिति", "وضعیت سلامت جسمانی": "शारीरिक स्वास्थ्य की स्थिति",
"توضیحات وضعیت جسمانی": "शारीरिक स्वास्थ्य का विवरण", "توضیحات وضعیت جسمانی": "शारीरिक स्वास्थ्य का विवरण",
"Name": "नाम"
"Name": "नाम",
"upload_certificates": "प्रमाणपत्र अपलोड करें",
"add_another_document": "दूसरा दस्तावेज़ जोड़ें",
"max_files_reached": "अधिकतम 4 फ़ाइलें अपलोड की गईं",
"remove_document": "दस्तावेज़ हटाएं",
"upload_failed": "अपलोड विफल रहा। कृपया पुन: प्रयास करें।"
} }

7
src/translations/locales/id.json

@ -355,5 +355,10 @@
"Job Title": "Jabatan / Pekerjaan", "Job Title": "Jabatan / Pekerjaan",
"Employment Status": "Status Pekerjaan", "Employment Status": "Status Pekerjaan",
"Your Hobbies and Main Interests": "Hobi dan Minat Utama Anda", "Your Hobbies and Main Interests": "Hobi dan Minat Utama Anda",
"View more details": "Lihat detail selengkapnya"
"View more details": "Lihat detail selengkapnya",
"upload_certificates": "Unggah sertifikat",
"add_another_document": "Tambah dokumen lain",
"max_files_reached": "Maksimum 4 file diunggah",
"remove_document": "Hapus dokumen",
"upload_failed": "Pengunggahan gagal. Silakan coba lagi."
} }

7
src/translations/locales/ks.json

@ -355,5 +355,10 @@
"Job Title": "کٲم ہُنٛد ناو", "Job Title": "کٲم ہُنٛد ناو",
"Employment Status": "مُلازمتٕچ حالت", "Employment Status": "مُلازمتٕچ حالت",
"Your Hobbies and Main Interests": "تُہنٛدؠ شۄق تہٕ اَہَم دِلچسپی", "Your Hobbies and Main Interests": "تُہنٛدؠ شۄق تہٕ اَہَم دِلچسپی",
"View more details": "مزید تفصیل وُچھِو"
"View more details": "مزید تفصیل وُچھِو",
"upload_certificates": "دستاویز اپ لوڈ کریو",
"add_another_document": "بیٛاکھ دستاویز جمع کریو",
"max_files_reached": "زیاد کھوتہ زیاد ۴ فائل اپ لوڈ کرنہ آمژٕ",
"remove_document": "دستاویز ہٹاوِیو",
"upload_failed": "اپ لوڈ ناکام۔ مہربانی کرتھ دوبارہ کوشش کریو۔"
} }

7
src/translations/locales/pt.json

@ -355,5 +355,10 @@
"Job Title": "Cargo / Título profissional", "Job Title": "Cargo / Título profissional",
"Employment Status": "Situação profissional", "Employment Status": "Situação profissional",
"Your Hobbies and Main Interests": "Seus hobbies e principais interesses", "Your Hobbies and Main Interests": "Seus hobbies e principais interesses",
"View more details": "Ver mais detalhes"
"View more details": "Ver mais detalhes",
"upload_certificates": "Enviar certificados",
"add_another_document": "Adicionar outro documento",
"max_files_reached": "Máximo de 4 arquivos enviados",
"remove_document": "Remover documento",
"upload_failed": "Falha no envio. Por favor, tente novamente."
} }

7
src/translations/locales/ru.json

@ -812,5 +812,10 @@
"نام دارو و دلیل مصرف را وارد نمایید...": "Введите название препарата и причину приема...", "نام دارو و دلیل مصرف را وارد نمایید...": "Введите название препарата и причину приема...",
"وضعیت سلامت جسمانی": "Физическое состояние здоровья", "وضعیت سلامت جسمانی": "Физическое состояние здоровья",
"توضیحات وضعیت جسمانی": "Описание физического состояния", "توضیحات وضعیت جسمانی": "Описание физического состояния",
"Name": "Имя"
"Name": "Имя",
"upload_certificates": "Загрузить сертификаты",
"add_another_document": "Добавить еще один документ",
"max_files_reached": "Загружено максимум 4 файла",
"remove_document": "Удалить документ",
"upload_failed": "Ошибка загрузки. Пожалуйста, повторите попытку."
} }

7
src/translations/locales/sw.json

@ -355,5 +355,10 @@
"Job Title": "Wadhifa wa Kazi", "Job Title": "Wadhifa wa Kazi",
"Employment Status": "Hali ya Ajira", "Employment Status": "Hali ya Ajira",
"Your Hobbies and Main Interests": "Mambo unayopenda na Maslahi Kuu", "Your Hobbies and Main Interests": "Mambo unayopenda na Maslahi Kuu",
"View more details": "Angalia maelezo zaidi"
"View more details": "Angalia maelezo zaidi",
"upload_certificates": "Pakia vyeti",
"add_another_document": "Ongeza hati nyingine",
"max_files_reached": "Upeo wa faili 4 zimepakiwa",
"remove_document": "Ondoa hati",
"upload_failed": "Upakiaji umeshindwa. Tafadhali jaribu tena."
} }

7
src/translations/locales/tg.json

@ -355,5 +355,10 @@
"Job Title": "Унвони вазифа", "Job Title": "Унвони вазифа",
"Employment Status": "Вазъи шуғл", "Employment Status": "Вазъи шуғл",
"Your Hobbies and Main Interests": "Машғулиятҳо ва манфиатҳои асосии шумо", "Your Hobbies and Main Interests": "Машғулиятҳо ва манфиатҳои асосии шумо",
"View more details": "Дидани тафсилоти бештар"
"View more details": "Дидани тафсилоти бештар",
"upload_certificates": "Боргузории ҳуҷҷатҳо",
"add_another_document": "Ҳуҷҷати дигар илова кунед",
"max_files_reached": "Ҳадди аксар 4 файл боргузорӣ шудааст",
"remove_document": "Ҳуҷҷатро нест кунед",
"upload_failed": "Боргузорӣ ноком шуд. Лутфан бори дигар кӯшиш кунед."
} }

7
src/translations/locales/tr.json

@ -355,5 +355,10 @@
"Job Title": "Meslek / Unvan", "Job Title": "Meslek / Unvan",
"Employment Status": "Çalışma Durumu", "Employment Status": "Çalışma Durumu",
"Your Hobbies and Main Interests": "Hobileriniz ve Temel İlgi Alanlarınız", "Your Hobbies and Main Interests": "Hobileriniz ve Temel İlgi Alanlarınız",
"View more details": "Daha fazla ayrıntı gör"
"View more details": "Daha fazla ayrıntı gör",
"upload_certificates": "Belgeleri yükle",
"add_another_document": "Başka bir belge ekle",
"max_files_reached": "Maksimum 4 dosya yüklendi",
"remove_document": "Belgeyi kaldır",
"upload_failed": "Yükleme başarısız oldu. Lütfen tekrar deneyin."
} }

7
src/translations/locales/ul.json

@ -355,5 +355,10 @@
"Job Title": "Job Title", "Job Title": "Job Title",
"Employment Status": "Employment Status", "Employment Status": "Employment Status",
"Your Hobbies and Main Interests": "Your Hobbies and Main Interests", "Your Hobbies and Main Interests": "Your Hobbies and Main Interests",
"View more details": "View more details"
"View more details": "View more details",
"upload_certificates": "دستاویزات اپ لوڈ کریں",
"add_another_document": "مزید دستاویز شامل کریں",
"max_files_reached": "زیادہ سے زیادہ 4 فائلیں اپ لوڈ کی گئیں",
"remove_document": "دستاویز ہٹائیں",
"upload_failed": "اپ لوڈ ناکام ہو گیا۔ براہ کرم دوبارہ کوشش کریں۔"
} }

7
src/translations/locales/ur.json

@ -355,5 +355,10 @@
"Job Title": "عہدہ / ملازمت کا عنوان", "Job Title": "عہدہ / ملازمت کا عنوان",
"Employment Status": "ملازمت کی صورتحال", "Employment Status": "ملازمت کی صورتحال",
"Your Hobbies and Main Interests": "آپ کے مشاغل اور اہم دلچسپیاں", "Your Hobbies and Main Interests": "آپ کے مشاغل اور اہم دلچسپیاں",
"View more details": "مزید تفصیلات دیکھیں"
"View more details": "مزید تفصیلات دیکھیں",
"upload_certificates": "دستاویزات اپ لوڈ کریں",
"add_another_document": "مزید دستاویز شامل کریں",
"max_files_reached": "زیادہ سے زیادہ 4 فائلیں اپ لوڈ کی گئیں",
"remove_document": "دستاویز ہٹائیں",
"upload_failed": "اپ لوڈ ناکام ہو گیا۔ براہ کرم دوبارہ کوشش کریں۔"
} }

7
src/translations/locales/uz.json

@ -355,5 +355,10 @@
"Job Title": "Kasb / Lavozim", "Job Title": "Kasb / Lavozim",
"Employment Status": "Bandlik holati", "Employment Status": "Bandlik holati",
"Your Hobbies and Main Interests": "Qiziqishlaringiz va asosiy mashgʻulotlaringiz", "Your Hobbies and Main Interests": "Qiziqishlaringiz va asosiy mashgʻulotlaringiz",
"View more details": "Batafsil maʼlumotni koʻrish"
"View more details": "Batafsil maʼlumotni koʻrish",
"upload_certificates": "Hujjatlarni yuklash",
"add_another_document": "Boshqa hujjat qo'shish",
"max_files_reached": "Maksimal 4 ta fayl yuklandi",
"remove_document": "Hujjatni o'chirish",
"upload_failed": "Yuklab bo'lmadi. Qayta urinib ko'ring."
} }

7
src/translations/locales/zh.json

@ -808,5 +808,10 @@
"نام دارو و دلیل مصرف را وارد نمایید...": "输入药物名称和使用原因...", "نام دارو و دلیل مصرف را وارد نمایید...": "输入药物名称和使用原因...",
"وضعیت سلامت جسمانی": "身体健康状况", "وضعیت سلامت جسمانی": "身体健康状况",
"توضیحات وضعیت جسمانی": "身体健康说明", "توضیحات وضعیت جسمانی": "身体健康说明",
"Name": "姓名"
"Name": "姓名",
"upload_certificates": "上传证书和文件",
"add_another_document": "添加其他文件",
"max_files_reached": "最多已上传 4 个文件",
"remove_document": "删除文件",
"upload_failed": "上传失败,请重试。"
} }
Loading…
Cancel
Save