Browse Source

Merge branch 'master' into Dev

Dev
parent
commit
c8c9b21d24
  1. 22
      src/app/intro/intro-client.tsx
  2. 5
      src/app/layout.tsx
  3. 81
      src/app/questions-list/[slug]/question-detail-client.test.tsx
  4. 120
      src/app/questions-list/[slug]/question-detail-client.tsx
  5. 19
      src/app/questions-list/questions-list-client.tsx
  6. 68
      src/components/Componentes/currency-sheet.test.tsx
  7. 267
      src/components/Componentes/currency-sheet.tsx
  8. 3
      src/components/Componentes/dismiss-reason-sheet.tsx
  9. 3
      src/components/Componentes/female-consent-sheet.tsx
  10. 3
      src/components/Componentes/female-outcome-sheet.tsx
  11. 11
      src/components/Componentes/hardware-back-bridge.tsx
  12. 3
      src/components/Componentes/help-modal.tsx
  13. 6
      src/components/Componentes/information-sheet.tsx
  14. 10
      src/components/Componentes/marriage-advisors-overlay.tsx
  15. 10
      src/components/Componentes/match-profile-overlay.tsx
  16. 3
      src/components/Componentes/outcome-selection-sheet.tsx
  17. 31
      src/components/Componentes/question-answer-storage.tsx
  18. 291
      src/components/Componentes/question-birthplace.tsx
  19. 2
      src/components/Componentes/question-date-sheet.tsx
  20. 814
      src/components/Componentes/question-file.tsx
  21. 80
      src/components/Componentes/question-number.test.tsx
  22. 557
      src/components/Componentes/question-number.tsx
  23. 2
      src/components/Componentes/question-phone.tsx
  24. 42
      src/components/Componentes/question-photo.tsx
  25. 3
      src/components/Componentes/question-progress-tracker.tsx
  26. 13
      src/components/Componentes/question-sheet.test.tsx
  27. 169
      src/components/Componentes/question-snap-list.test.tsx
  28. 159
      src/components/Componentes/question-snap-list.tsx
  29. 35
      src/components/Componentes/report-actions-sheet.tsx
  30. 3
      src/components/Componentes/support-sheet.tsx
  31. 45
      src/components/Componentes/test-questions-flow.tsx
  32. 29
      src/components/Componentes/use-sheet-scroll-lock.ts
  33. 3
      src/components/Componentes/video-player.tsx
  34. 58
      src/data/currencies.test.ts
  35. 381
      src/data/currencies.ts
  36. 15
      src/hooks/marriage/use-upload-tmp-media.ts
  37. 52
      src/hooks/use-hardware-back-handler.ts
  38. 45
      src/lib/geo-region.ts
  39. 97
      src/lib/webview-actions.ts
  40. 10
      src/types/window.d.ts

22
src/app/intro/intro-client.tsx

@ -6,7 +6,6 @@ import { useCallback, useEffect, useState } from "react";
import Button from "@/components/Componentes/button"; import Button from "@/components/Componentes/button";
import NetworkImage from "@/components/Componentes/network-image"; import NetworkImage from "@/components/Componentes/network-image";
import PageHeader from "@/components/Componentes/page-header"; import PageHeader from "@/components/Componentes/page-header";
import ReportActionsSheet from "@/components/Componentes/report-actions-sheet";
import SectionOverlayHost from "@/components/Componentes/section-overlay-host"; import SectionOverlayHost from "@/components/Componentes/section-overlay-host";
import SliderPage from "@/components/Componentes/slider-page"; import SliderPage from "@/components/Componentes/slider-page";
import VideoPlayer from "@/components/Componentes/video-player"; import VideoPlayer from "@/components/Componentes/video-player";
@ -32,7 +31,6 @@ export default function IntroClient() {
enabled: false, enabled: false,
retry: false, retry: false,
}); });
const [isReportSheetOpen, setIsReportSheetOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [isPlayerOpen, setIsPlayerOpen] = useState(false); const [isPlayerOpen, setIsPlayerOpen] = useState(false);
const [isStepsOpen, setIsStepsOpen] = useState(false); const [isStepsOpen, setIsStepsOpen] = useState(false);
@ -60,20 +58,20 @@ export default function IntroClient() {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
const url = new URL(window.location.href); const url = new URL(window.location.href);
url.searchParams.set("steps", "open"); url.searchParams.set("steps", "open");
window.history.pushState({ steps: "open" }, "", url.toString());
window.history.replaceState({ steps: "open" }, "", url.toString());
} }
}, []); }, []);
const handleCloseSteps = useCallback(() => { const handleCloseSteps = useCallback(() => {
setIsStepsOpen(false);
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
if (params.get("steps") === "open") { if (params.get("steps") === "open") {
setIsStepsOpen(false);
window.history.back();
return;
const url = new URL(window.location.href);
url.searchParams.delete("steps");
window.history.replaceState({}, "", url.toString());
} }
} }
setIsStepsOpen(false);
}, []); }, []);
useHardwareBackHandler(() => { useHardwareBackHandler(() => {
@ -128,15 +126,7 @@ export default function IntroClient() {
return ( return (
<> <>
<div className="pt-[max(12px,calc(var(--safe-top)+4px))]"> <div className="pt-[max(12px,calc(var(--safe-top)+4px))]">
{isReportSheetOpen && (
<ReportActionsSheet onClose={() => setIsReportSheetOpen(false)} />
)}
<PageHeader
rightButton={{
icon: "support",
onClick: () => setIsReportSheetOpen(true),
}}
/>
<PageHeader profile={profile} />
<main className="pb-[calc(90px+var(--safe-bottom))]"> <main className="pb-[calc(90px+var(--safe-bottom))]">
<div className="flex flex-col items-center mt-16"> <div className="flex flex-col items-center mt-16">
<Image <Image

5
src/app/layout.tsx

@ -320,6 +320,11 @@ export default async function RootLayout({
return Promise.resolve({ handled: false }); return Promise.resolve({ handled: false });
}; };
} }
if (!window.__habibHandleHardwareBackSync) {
window.__habibHandleHardwareBackSync = function() {
return false;
};
}
})(); })();
`, `,
}} }}

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

@ -138,29 +138,24 @@ describe("QuestionDetailClient Validation", () => {
} }
}; };
it("should render correctly when Cattell API data is completely valid", () => {
it("should render correctly when Cattell API returns string array options", () => {
setupTest( setupTest(
"personality_test", "personality_test",
{ {
questions: [ questions: [
{ {
question_number: 1, question_number: 1,
text: "Valid Question Cattell",
options: [
{ id: "opt_a", label: "Opt1", value: "A" },
{ id: "opt_b", label: "Opt2", value: "B" },
{ id: "opt_c", label: "Opt3", value: "C" },
],
text: "Valid Question Cattell String Options",
options: ["بله", "به اندازه کافی واضح نیست", "نه"],
}, },
], ],
}, },
null, null,
); );
// Retry UI should NOT be present
expect(screen.queryByText("Retry")).toBeNull(); expect(screen.queryByText("Retry")).toBeNull();
// Question text should be visible
expect(screen.getByText("Valid Question Cattell")).toBeDefined();
expect(screen.getByText("Valid Question Cattell String Options")).toBeDefined();
expect(screen.getByText("بله")).toBeDefined();
}); });
it("should render Retry UI when Cattell API data is empty", () => { it("should render Retry UI when Cattell API data is empty", () => {
@ -172,79 +167,19 @@ describe("QuestionDetailClient Validation", () => {
expect(screen.getAllByText("Retry")).toBeDefined(); expect(screen.getAllByText("Retry")).toBeDefined();
}); });
it("should render Retry UI when Cattell options are invalid (schema failure) and trigger refetch on Retry", async () => {
setupTest(
"personality_test",
{
questions: [
{
question_number: 1,
text: "Invalid Question",
options: [{ label: "Opt1", value: "A" }], // Invalid schema
},
],
},
null,
);
expect(
screen.getAllByText("No questions found for this test."),
).toBeDefined();
const retryBtn = screen.getAllByText("Retry")[0];
fireEvent.click(retryBtn);
await waitFor(() => {
expect(mockCattellRefetch).toHaveBeenCalled();
});
});
it("should render correctly when Glasser API data is completely valid", () => {
it("should render correctly when Glasser API returns questions without options using default 5-point scale", () => {
setupTest("glasser_5_needs_test", null, { setupTest("glasser_5_needs_test", null, {
questions: [ questions: [
{ {
question_number: 1, question_number: 1,
text: "Valid Question Glasser",
text: "Valid Question Glasser Default Scale",
factor_code: "SUR", factor_code: "SUR",
options: [
{ id: "o1", label: "O1", value: 1 },
{ id: "o2", label: "O2", value: 2 },
{ id: "o3", label: "O3", value: 3 },
{ id: "o4", label: "O4", value: 4 },
{ id: "o5", label: "O5", value: 5 },
],
}, },
], ],
}); });
expect(screen.queryByText("Retry")).toBeNull(); expect(screen.queryByText("Retry")).toBeNull();
expect(screen.getByText("Valid Question Glasser")).toBeDefined();
});
it("should render Retry UI when Glasser options are invalid (schema failure) and trigger refetch on Retry", async () => {
setupTest("glasser_5_needs_test", null, {
questions: [
{
question_number: 1,
text: "Invalid Question Glasser",
factor_code: "SUR",
options: [
{ label: "O1", value: 1 },
{ label: "O2", value: 2 },
{ label: "O3", value: 3 },
{ label: "O4", value: 4 },
],
},
],
});
expect(
screen.getAllByText("No questions found for this test."),
).toBeDefined();
const retryBtn = screen.getAllByText("Retry")[0];
fireEvent.click(retryBtn);
await waitFor(() => {
expect(mockGlasserRefetch).toHaveBeenCalled();
});
expect(screen.getByText("Valid Question Glasser Default Scale")).toBeDefined();
}); });
it("should render profile questions using ID-based data flow", () => { it("should render profile questions using ID-based data flow", () => {

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

@ -226,6 +226,12 @@ function QuestionFlowWrapper({
} }
} }
if (question.type === "number") {
console.log(
`[DETAIL_LOG] id=${question.id}, answer=${JSON.stringify(answer)}, hasAnswer=${hasAnswer}, isAnswered=${isAnswered}`,
);
}
return ( return (
<div <div
key={question.id} key={question.id}
@ -296,8 +302,10 @@ export default function QuestionDetailClient({
} }
}, [itemSlug, isTestStarted, profileId]); }, [itemSlug, isTestStarted, profileId]);
const isCattellSlug = itemSlug === "personality_test";
const isGlasserSlug = itemSlug === "glasser_5_needs_test";
const isCattellSlug =
itemSlug === "personality_test" || itemSlug === "cattell_test";
const isGlasserSlug =
itemSlug === "glasser_5_needs_test" || itemSlug === "glasser_test";
const isAssessment = isCattellSlug || isGlasserSlug; const isAssessment = isCattellSlug || isGlasserSlug;
const { data: overview, isLoading: isOverviewLoading, isError: isOverviewError, refetch: refetchOverview } = useFormOverviewQuery( const { data: overview, isLoading: isOverviewLoading, isError: isOverviewError, refetch: refetchOverview } = useFormOverviewQuery(
"profile", "profile",
@ -369,62 +377,86 @@ export default function QuestionDetailClient({
const cattellTestQuestions: TestQuestion[] = useMemo(() => { const cattellTestQuestions: TestQuestion[] = useMemo(() => {
const questionsList = cattellQuery.data?.questions || []; const questionsList = cattellQuery.data?.questions || [];
// Strict schema validation for Cattell
const isValidCattell = (q: any) =>
q.question_number &&
q.text &&
q.options &&
q.options.length === 3 &&
q.options.every((o: any) => o.id && o.label && o.value);
if (questionsList.length > 0 && !questionsList.every(isValidCattell)) {
console.error("Invalid Cattell API response schema");
return [];
const OPTION_KEYS = ["A", "B", "C"] as const;
return questionsList
.filter((q: any) => q && (q.question_number || q.id) && q.text)
.map((q: any) => {
const rawOptions = Array.isArray(q.options) ? q.options : [];
const options = rawOptions.map((opt: any, idx: number) => {
const key = OPTION_KEYS[idx] || String(idx);
if (typeof opt === "string") {
return {
id: key,
value: key,
label: opt,
};
} }
return {
id: String(opt.id || opt.value || key),
value: opt.value ?? key,
label: String(opt.label || opt.text || opt.title || opt.name || key),
};
});
return questionsList.map((q) => ({
id: q.question_number,
text: q.text,
options: q.options || [],
}));
return {
id: Number(q.question_number || q.id),
text: String(q.text),
options,
};
});
}, [cattellQuery.data]); }, [cattellQuery.data]);
const glasserTestQuestions: TestQuestion[] = useMemo(() => { const glasserTestQuestions: TestQuestion[] = useMemo(() => {
const questionsList = glasserQuery.data?.questions || []; const questionsList = glasserQuery.data?.questions || [];
// Strict schema validation for Glasser
const isValidGlasser = (q: any) =>
q.question_number &&
q.text &&
q.options &&
q.options.length === 5 &&
q.options.every(
(o: any) =>
o.id &&
o.label &&
typeof o.value === "number" &&
o.value >= 1 &&
o.value <= 5,
);
if (questionsList.length > 0 && !questionsList.every(isValidGlasser)) {
console.error("Invalid Glasser API response schema");
return [];
const DEFAULT_LABELS_FA = ["خیلی کم", "کم", "متوسط", "زیاد", "خیلی زیاد"];
const DEFAULT_LABELS_EN = [
"Very Low",
"Low",
"Moderate",
"High",
"Very High",
];
const defaultLabels = locale === "fa" ? DEFAULT_LABELS_FA : DEFAULT_LABELS_EN;
return questionsList
.filter((q: any) => q && (q.question_number || q.id) && q.text)
.map((q: any) => {
const rawOptions =
Array.isArray(q.options) && q.options.length > 0 ? q.options : null;
const options = rawOptions
? rawOptions.map((opt: any, idx: number) => {
const score = idx + 1;
if (typeof opt === "string") {
return { id: String(score), value: score, label: opt };
} }
return {
id: String(opt.id || opt.value || score),
value: typeof opt.value === "number" ? opt.value : score,
label: String(
opt.label || opt.text || defaultLabels[idx] || String(score),
),
};
})
: [1, 2, 3, 4, 5].map((score, idx) => ({
id: String(score),
value: score,
label: defaultLabels[idx] || String(score),
}));
return questionsList.map((q) => ({
id: q.question_number,
text: q.text,
return {
id: Number(q.question_number || q.id),
text: String(q.text),
info: info:
"factor" in q "factor" in q
? (q.factor as string) ? (q.factor as string)
: "factor_code" in q : "factor_code" in q
? (q.factor_code as string) ? (q.factor_code as string)
: undefined, : undefined,
options: q.options || [],
}));
}, [glasserQuery.data]);
options,
};
});
}, [glasserQuery.data, locale]);

19
src/app/questions-list/questions-list-client.tsx

@ -63,15 +63,14 @@ export default function QuestionsListClient() {
// entries. Flutter calls __habibHandleHardwareBack() and we return false // entries. Flutter calls __habibHandleHardwareBack() and we return false
// (meaning "I didn't handle it — you should close"). // (meaning "I didn't handle it — you should close").
useHardwareBackHandler(() => { useHardwareBackHandler(() => {
if (isOptionalInfoSheetOpen) {
setIsOptionalInfoSheetOpen(false);
return true; // Handled: closed the tips sheet
}
if (activeSectionSlug) { if (activeSectionSlug) {
handleCloseSection(); handleCloseSection();
return true; // Handled: closed the section sheet, do not close WebView return true; // Handled: closed the section sheet, do not close WebView
} }
if (typeof window !== "undefined" && (window as any).HabibApp?.postMessage) {
(window as any).HabibApp.postMessage(
JSON.stringify({ action: "close_service" }),
);
}
return false; // Tell Flutter to close the WebView screen return false; // Tell Flutter to close the WebView screen
}); });
const { dictionary: t, locale } = useI18n(); const { dictionary: t, locale } = useI18n();
@ -147,20 +146,20 @@ export default function QuestionsListClient() {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
const url = new URL(window.location.href); const url = new URL(window.location.href);
url.searchParams.set("section", slug); url.searchParams.set("section", slug);
window.history.pushState({ section: slug }, "", url.toString());
window.history.replaceState({ section: slug }, "", url.toString());
} }
}, []); }, []);
const handleCloseSection = useCallback(() => { const handleCloseSection = useCallback(() => {
setActiveSectionSlug(null);
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
if (params.get("section")) { if (params.get("section")) {
setActiveSectionSlug(null);
window.history.back();
return;
const url = new URL(window.location.href);
url.searchParams.delete("section");
window.history.replaceState({}, "", url.toString());
} }
} }
setActiveSectionSlug(null);
}, []); }, []);
const questionListItems = useMemo( const questionListItems = useMemo(

68
src/components/Componentes/currency-sheet.test.tsx

@ -0,0 +1,68 @@
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { describe, expect, it, vi, afterEach } from "vitest";
import { CurrencySheet } from "./currency-sheet";
describe("CurrencySheet", () => {
afterEach(() => {
cleanup();
});
it("should render currency sheet when isOpen is true", () => {
const handleClose = vi.fn();
const handleSelect = vi.fn();
render(
<CurrencySheet
isOpen={true}
onClose={handleClose}
selectedCurrency="TOMAN"
onSelectCurrency={handleSelect}
title="انتخاب ارز"
/>,
);
expect(screen.getByText("انتخاب ارز")).toBeDefined();
expect(screen.getByText("TOMAN")).toBeDefined();
expect(screen.getAllByText("USD").length).toBeGreaterThan(0);
});
it("should filter currencies when typing in search input", () => {
const handleClose = vi.fn();
const handleSelect = vi.fn();
render(
<CurrencySheet
isOpen={true}
onClose={handleClose}
selectedCurrency="USD"
onSelectCurrency={handleSelect}
/>,
);
const searchInput = screen.getByPlaceholderText("Search currency or country...");
fireEvent.change(searchInput, { target: { value: "EUR" } });
expect(screen.getByText("EUR")).toBeDefined();
expect(screen.queryByText("TOMAN")).toBeNull();
});
it("should call onSelectCurrency when a currency item is clicked", () => {
const handleClose = vi.fn();
const handleSelect = vi.fn();
render(
<CurrencySheet
isOpen={true}
onClose={handleClose}
selectedCurrency="TOMAN"
onSelectCurrency={handleSelect}
/>,
);
const eurButton = screen.getByText("Euro").closest("button");
expect(eurButton).not.toBeNull();
fireEvent.click(eurButton!);
expect(handleSelect).toHaveBeenCalledWith("EUR");
});
});

267
src/components/Componentes/currency-sheet.tsx

@ -0,0 +1,267 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { CURRENCIES, type CurrencyItem } from "@/data/currencies";
import { useI18n } from "@/translations/provider";
import { useSheetScrollLock } from "./use-sheet-scroll-lock";
const EXIT_ANIMATION_MS = 300;
export type CurrencySheetProps = {
isOpen: boolean;
onClose: () => void;
selectedCurrency: string;
onSelectCurrency: (code: string) => void;
title?: string;
};
export function CurrencySheet({
isOpen,
onClose,
selectedCurrency,
onSelectCurrency,
title,
}: CurrencySheetProps) {
const { dictionary: t, locale } = useI18n();
const [isClosing, setIsClosing] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const listRef = useRef<HTMLDivElement>(null);
const sheetRef = useRef<HTMLElement>(null);
const isRtl = locale === "fa" || locale === "ar" || locale === "ur";
const defaultTitle = isRtl ? "انتخاب ارز" : "Select Currency";
const sheetTitle = title || defaultTitle;
const closeSheet = useCallback(() => {
setIsClosing(true);
window.setTimeout(() => {
onClose();
setIsClosing(false);
setSearchQuery("");
}, EXIT_ANIMATION_MS);
}, [onClose]);
useSheetScrollLock(isOpen && !isClosing, { onBack: closeSheet });
// Escape key handler
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
closeSheet();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, closeSheet]);
const filteredCurrencies = useMemo(() => {
const q = searchQuery.toLowerCase().trim();
if (!q) return CURRENCIES;
return CURRENCIES.filter(
(c) =>
c.code.toLowerCase().includes(q) ||
c.nameEn.toLowerCase().includes(q) ||
c.nameFa.toLowerCase().includes(q) ||
(c.symbol && c.symbol.toLowerCase().includes(q)),
);
}, [searchQuery]);
const handleSelect = (code: string) => {
onSelectCurrency(code);
closeSheet();
};
const searchPlaceholder =
locale === "fa"
? "جستجوی ارز یا کشور..."
: locale === "ar"
? "بحث عن العملة..."
: "Search currency or country...";
const noResultsText =
locale === "fa"
? "ارزی یافت نشد"
: locale === "ar"
? "لم يتم العثور على عملات"
: "No currencies found";
if (!isOpen && !isClosing) return null;
return createPortal(
<div
className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 backdrop-blur-[2px] transition-opacity duration-200"
role="dialog"
aria-label={sheetTitle}
onKeyDown={(e) => {
if (e.key === "Escape") closeSheet();
}}
onClick={(event) => {
if (event.target === event.currentTarget) closeSheet();
}}
>
<section
ref={sheetRef}
className={[
"flex w-full flex-col overflow-hidden rounded-t-[24px] bg-white pb-[env(safe-area-inset-bottom)] shadow-[0_-10px_32px_rgba(0,0,0,0.16)] transition-transform duration-[300ms] ease-out sm:max-w-[420px] h-[82svh] min-h-[82svh] max-h-[82svh]",
isClosing ? "translate-y-full" : "translate-y-0",
].join(" ")}
>
{/* Header */}
<div className="flex items-center justify-between px-5 pt-3 pb-3 border-b border-[#F2F4F7]">
<h3 className="text-[17px] font-bold text-[#181818] truncate pr-2">
{sheetTitle}
</h3>
<button
type="button"
onClick={closeSheet}
className="flex size-8 shrink-0 items-center justify-center rounded-full text-[#667085] hover:bg-[#F2F4F7] hover:text-[#181818] transition-colors cursor-pointer"
aria-label="Close"
>
<svg
aria-hidden="true"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
{/* Search Bar */}
<div className="px-5 pt-3.5 pb-2">
<div className="flex h-[46px] w-full items-center gap-2.5 rounded-[14px] bg-[#F2F4F7] px-3.5 transition-colors focus-within:bg-[#EAECF0]">
<svg
aria-hidden="true"
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
className="shrink-0 text-[#667085]"
>
<path
d="M8.25 14.25C11.5637 14.25 14.25 11.5637 14.25 8.25C14.25 4.93629 11.5637 2.25 8.25 2.25C4.93629 2.25 2.25 4.93629 2.25 8.25C2.25 11.5637 4.93629 14.25 8.25 14.25Z"
stroke="#667085"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M15.75 15.75L12.5 12.5"
stroke="#667085"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<input
type="text"
name="currency_sheet_search"
autoComplete="off"
autoCorrect="off"
autoCapitalize="none"
spellCheck="false"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={searchPlaceholder}
className="flex-1 bg-transparent text-[14px] font-medium text-[#181818] outline-none placeholder:text-[#98A2B3]"
/>
{searchQuery ? (
<button
type="button"
onClick={() => setSearchQuery("")}
className="text-[#667085] hover:text-[#181818] text-xs font-semibold p-1 cursor-pointer"
>
</button>
) : null}
</div>
</div>
{/* Currencies List */}
<div
ref={listRef}
onTouchStart={(event) => event.stopPropagation()}
onTouchMove={(event) => event.stopPropagation()}
onTouchEnd={(event) => event.stopPropagation()}
className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto overscroll-contain px-5 py-3"
>
{filteredCurrencies.length > 0 ? (
filteredCurrencies.map((item: CurrencyItem) => {
const isSelected = item.code === selectedCurrency;
const displayName =
locale === "fa" || locale === "fa-ir"
? item.nameFa
: item.nameEn;
return (
<button
key={item.code}
type="button"
onClick={() => handleSelect(item.code)}
className={[
"flex min-h-[52px] w-full items-center justify-between gap-3 rounded-[14px] border px-3.5 py-2.5 text-start transition-all cursor-pointer",
isSelected
? "bg-[#FFF4F5] border-[#F0445B]/40 text-[#181818] shadow-xs"
: "bg-white hover:bg-[#F8F9FA] border-[#EAECF0] text-[#181818]",
].join(" ")}
>
<div className="flex items-center gap-3 min-w-0">
{/* Radio selection circle */}
<div
className={[
"size-[22px] shrink-0 rounded-full transition-all duration-150 flex items-center justify-center",
isSelected
? "border-[6px] border-[#F0445B] bg-white"
: "border-[2px] border-[#98A2B3] bg-white",
].join(" ")}
/>
{/* Currency name */}
<span
className={[
"text-[15px] leading-tight truncate min-w-0",
isSelected
? "font-bold text-[#181818]"
: "font-medium text-[#344054]",
].join(" ")}
>
{displayName}
</span>
</div>
{/* Currency code badge */}
<span
className={[
"shrink-0 px-2.5 py-1 rounded-[8px] text-[13px] font-bold tracking-wider uppercase font-mono",
isSelected
? "bg-[#F0445B] text-white"
: "bg-[#F2F4F7] text-[#475467]",
].join(" ")}
>
{item.code}
</span>
</button>
);
})
) : (
<div className="py-12 text-center text-[14px] text-[#667085]">
{noResultsText}
</div>
)}
</div>
</section>
</div>,
document.body,
);
}
export default CurrencySheet;

3
src/components/Componentes/dismiss-reason-sheet.tsx

@ -2,6 +2,7 @@
import type { HTMLAttributes } from "react"; import type { HTMLAttributes } from "react";
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
import Button from "./button"; import Button from "./button";
@ -77,6 +78,8 @@ export function DismissReasonSheet({
}, EXIT_ANIMATION_MS); }, EXIT_ANIMATION_MS);
}, [isClosing, onClose]); }, [isClosing, onClose]);
useHardwareBackHandler(closeSheet, isVisible && !isClosing);
useEffect(() => { useEffect(() => {
if (!isVisible) { if (!isVisible) {
return; return;

3
src/components/Componentes/female-consent-sheet.tsx

@ -3,6 +3,7 @@
import Image from "next/image"; import Image from "next/image";
import type { HTMLAttributes, ReactNode } from "react"; import type { HTMLAttributes, ReactNode } from "react";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
const EXIT_ANIMATION_MS = 220; const EXIT_ANIMATION_MS = 220;
@ -50,6 +51,8 @@ export function FemaleConsentSheet({
}, EXIT_ANIMATION_MS); }, EXIT_ANIMATION_MS);
}, [isClosing, onClose]); }, [isClosing, onClose]);
useHardwareBackHandler(closeSheet, isVisible && !isClosing);
const controls = { close: closeSheet }; const controls = { close: closeSheet };
const resolvedButtons = const resolvedButtons =
typeof buttons === "function" ? buttons(controls) : buttons; typeof buttons === "function" ? buttons(controls) : buttons;

3
src/components/Componentes/female-outcome-sheet.tsx

@ -1,6 +1,7 @@
"use client"; "use client";
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
import SwipeButton from "./swipe-button"; import SwipeButton from "./swipe-button";
@ -76,6 +77,8 @@ export function FemaleOutcomeSheet({
}, EXIT_ANIMATION_MS); }, EXIT_ANIMATION_MS);
}, [isClosing, onClose]); }, [isClosing, onClose]);
useHardwareBackHandler(closeSheet, isVisible && !isClosing);
useEffect(() => { useEffect(() => {
if (!isVisible) { if (!isVisible) {
return; return;

11
src/components/Componentes/hardware-back-bridge.tsx

@ -1,11 +1,14 @@
"use client"; "use client";
import { useEffect } from "react"; import { useEffect } from "react";
import { handleHardwareBack } from "@/hooks/use-hardware-back-handler";
import {
handleHardwareBack,
handleHardwareBackSync,
} from "@/hooks/use-hardware-back-handler";
/** /**
* Wires the React hardware-back handler stack to the global * Wires the React hardware-back handler stack to the global
* window.__habibHandleHardwareBack function.
* window.__habibHandleHardwareBack and window.__habibHandleHardwareBackSync functions.
* *
* Mount this once in the Providers tree (after React hydration). * Mount this once in the Providers tree (after React hydration).
* It replaces the bootstrap stub with the real handler that walks * It replaces the bootstrap stub with the real handler that walks
@ -14,11 +17,13 @@ import { handleHardwareBack } from "@/hooks/use-hardware-back-handler";
export function HardwareBackBridge() { export function HardwareBackBridge() {
useEffect(() => { useEffect(() => {
window.__habibHandleHardwareBack = handleHardwareBack; window.__habibHandleHardwareBack = handleHardwareBack;
window.__habibHandleHardwareBackSync = handleHardwareBackSync;
return () => { return () => {
// On unmount (shouldn't happen in practice), restore the stub.
// On unmount (shouldn't happen in practice), restore the stubs.
window.__habibHandleHardwareBack = () => window.__habibHandleHardwareBack = () =>
Promise.resolve({ handled: false }); Promise.resolve({ handled: false });
window.__habibHandleHardwareBackSync = () => false;
}; };
}, []); }, []);

3
src/components/Componentes/help-modal.tsx

@ -3,6 +3,7 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler";
import Button from "./button"; import Button from "./button";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
@ -63,6 +64,8 @@ export function HelpModal({
}, EXIT_ANIMATION_MS); }, EXIT_ANIMATION_MS);
}, [isClosing, onClose]); }, [isClosing, onClose]);
useHardwareBackHandler(closeSheet, isOpen && !isClosing);
// Lock body scroll // Lock body scroll
useEffect(() => { useEffect(() => {
if (!isOpen || !mounted) return; if (!isOpen || !mounted) return;

6
src/components/Componentes/information-sheet.tsx

@ -6,6 +6,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import Button from "./button"; import Button from "./button";
import { LoadingSkeleton } from "./loading-skeleton"; import { LoadingSkeleton } from "./loading-skeleton";
import { LoadingThreeDot } from "./loading-three-dot"; import { LoadingThreeDot } from "./loading-three-dot";
import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
const EXIT_ANIMATION_MS = 220; const EXIT_ANIMATION_MS = 220;
@ -171,6 +172,11 @@ export function InformationSheet({
}, EXIT_ANIMATION_MS); }, EXIT_ANIMATION_MS);
}, [isClosing, onClose]); }, [isClosing, onClose]);
useHardwareBackHandler(() => {
closeSheet();
return true;
}, isVisible && !isClosing);
const controls = { close: closeSheet }; const controls = { close: closeSheet };
const resolvedTitle = typeof title === "function" ? title(controls) : title; const resolvedTitle = typeof title === "function" ? title(controls) : title;
const resolvedButtons = const resolvedButtons =

10
src/components/Componentes/marriage-advisors-overlay.tsx

@ -34,20 +34,20 @@ export function useMarriageAdvisorsOverlay() {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
const url = new URL(window.location.href); const url = new URL(window.location.href);
url.searchParams.set("advisors", "open"); url.searchParams.set("advisors", "open");
window.history.pushState({ advisors: "open" }, "", url.toString());
window.history.replaceState({ advisors: "open" }, "", url.toString());
} }
}, []); }, []);
const closeAdvisors = useCallback(() => { const closeAdvisors = useCallback(() => {
setIsAdvisorOpen(false);
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
if (params.get("advisors") === "open") { if (params.get("advisors") === "open") {
setIsAdvisorOpen(false);
window.history.back();
return;
const url = new URL(window.location.href);
url.searchParams.delete("advisors");
window.history.replaceState({}, "", url.toString());
} }
} }
setIsAdvisorOpen(false);
}, []); }, []);
// Intercept hardware back in Flutter WebView when advisor overlay is open // Intercept hardware back in Flutter WebView when advisor overlay is open

10
src/components/Componentes/match-profile-overlay.tsx

@ -34,20 +34,20 @@ export function useMatchProfileOverlay() {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
const url = new URL(window.location.href); const url = new URL(window.location.href);
url.searchParams.set("profile", "open"); url.searchParams.set("profile", "open");
window.history.pushState({ profile: "open" }, "", url.toString());
window.history.replaceState({ profile: "open" }, "", url.toString());
} }
}, []); }, []);
const closeProfile = useCallback(() => { const closeProfile = useCallback(() => {
setIsProfileOpen(false);
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
if (params.get("profile") === "open") { if (params.get("profile") === "open") {
setIsProfileOpen(false);
window.history.back();
return;
const url = new URL(window.location.href);
url.searchParams.delete("profile");
window.history.replaceState({}, "", url.toString());
} }
} }
setIsProfileOpen(false);
}, []); }, []);
// Intercept hardware back in Flutter WebView when profile overlay is open // Intercept hardware back in Flutter WebView when profile overlay is open

3
src/components/Componentes/outcome-selection-sheet.tsx

@ -2,6 +2,7 @@
import type { HTMLAttributes } from "react"; import type { HTMLAttributes } from "react";
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
const EXIT_ANIMATION_MS = 220; const EXIT_ANIMATION_MS = 220;
@ -51,6 +52,8 @@ export function OutcomeSelectionSheet({
}, EXIT_ANIMATION_MS); }, EXIT_ANIMATION_MS);
}, [isClosing, onClose]); }, [isClosing, onClose]);
useHardwareBackHandler(closeSheet, isVisible && !isClosing);
useEffect(() => { useEffect(() => {
if (!isVisible) { if (!isVisible) {
return; return;

31
src/components/Componentes/question-answer-storage.tsx

@ -261,7 +261,18 @@ function createPayload(
function fieldsToAnswers(fields: MarriageField[]) { function fieldsToAnswers(fields: MarriageField[]) {
return fields.reduce<QuestionAnswersByKey>((nextAnswers, field) => { return fields.reduce<QuestionAnswersByKey>((nextAnswers, field) => {
if (field.option_id !== undefined && field.option_id !== null) {
const isChoice =
field.type === "dropdown" ||
field.type === "radio" ||
field.type === "checkbox" ||
field.type === "scale";
if (
isChoice &&
field.option_id !== undefined &&
field.option_id !== null &&
(!Array.isArray(field.option_id) || field.option_id.length > 0 || field.type === "checkbox")
) {
nextAnswers[field.key] = { nextAnswers[field.key] = {
...field, ...field,
value: field.option_id, value: field.option_id,
@ -640,11 +651,27 @@ export function QuestionAnswersProvider({
if (question) { if (question) {
// Only update if there are no newer local dirty edits for this key // Only update if there are no newer local dirty edits for this key
if (!dirtyKeysRef.current.has(key)) { if (!dirtyKeysRef.current.has(key)) {
const isChoice =
question.type === "dropdown" ||
question.type === "radio" ||
question.type === "checkbox" ||
question.type === "scale";
const resolvedVal =
isChoice &&
answer.option_id !== undefined &&
answer.option_id !== null &&
(!Array.isArray(answer.option_id) ||
answer.option_id.length > 0 ||
question.type === "checkbox")
? answer.option_id
: answer.value;
nextAnswers[key] = { nextAnswers[key] = {
key, key,
label: question.title, label: question.title,
type: question.type, type: question.type,
value: answer.option_id ?? answer.value,
value: resolvedVal,
option_id: answer.option_id, option_id: answer.option_id,
} as MarriageField; } as MarriageField;
} }

291
src/components/Componentes/question-birthplace.tsx

@ -2,7 +2,11 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { getCountryList, resolveCountryName, isKnownCountry } from "@/data/countries";
import {
getCountryList,
resolveCountryName,
isKnownCountry,
} from "@/data/countries";
import type { QuestionField } from "@/lib/schema-adapter"; import type { QuestionField } from "@/lib/schema-adapter";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
import { useQuestionAnswers } from "./question-answer-storage"; import { useQuestionAnswers } from "./question-answer-storage";
@ -10,7 +14,16 @@ import QuestionTitle from "./question-title";
import { LoadingThreeDot } from "./loading-three-dot"; import { LoadingThreeDot } from "./loading-three-dot";
import { useSheetScrollLock } from "./use-sheet-scroll-lock"; import { useSheetScrollLock } from "./use-sheet-scroll-lock";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { getUserGeoRegion, getStoredUserGeoRegion } from "@/lib/geo-region";
import {
getUserGeoRegion,
getStoredUserGeoRegion,
subscribeToUserGeoRegion,
} from "@/lib/geo-region";
import {
isInFlutterWebView,
requestAutoLocation,
pickManualLocation,
} from "@/lib/webview-actions";
const EXIT_ANIMATION_MS = 220; const EXIT_ANIMATION_MS = 220;
@ -24,16 +37,25 @@ type BirthplaceValue = {
city?: string; city?: string;
}; };
export function parseValue(rawValue: unknown): { country: string; city: string } {
export function parseValue(rawValue: unknown): {
country: string;
city: string;
} {
if (!rawValue) return { country: "", city: "" }; if (!rawValue) return { country: "", city: "" };
if (typeof rawValue === "object" && rawValue !== null) { if (typeof rawValue === "object" && rawValue !== null) {
const obj = rawValue as BirthplaceValue; const obj = rawValue as BirthplaceValue;
const rawCountry = typeof obj.country === "string" ? obj.country.trim() : "";
const rawCountry =
typeof obj.country === "string" ? obj.country.trim() : "";
const rawCity = typeof obj.city === "string" ? obj.city.trim() : ""; const rawCity = typeof obj.city === "string" ? obj.city.trim() : "";
// If obj has country and city inverted (e.g. { country: "Mashhad", city: "Iran" }) // If obj has country and city inverted (e.g. { country: "Mashhad", city: "Iran" })
if (rawCountry && rawCity && !isKnownCountry(rawCountry) && isKnownCountry(rawCity)) {
if (
rawCountry &&
rawCity &&
!isKnownCountry(rawCountry) &&
isKnownCountry(rawCity)
) {
return { return {
country: rawCity, country: rawCity,
city: rawCountry, city: rawCountry,
@ -146,22 +168,19 @@ export function QuestionBirthplace({
const [mode, setMode] = useState<"auto" | "manual">(() => { const [mode, setMode] = useState<"auto" | "manual">(() => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
const stored = localStorage.getItem(`residence_mode_${question.id}`); const stored = localStorage.getItem(`residence_mode_${question.id}`);
if (stored === "manual" || stored === "auto") return stored;
if (stored === "manual") return "manual";
if (stored === "auto") return "auto";
} }
return "auto"; return "auto";
}); });
const isInitialManual = mode === "manual"; const isInitialManual = mode === "manual";
const localizedInitialCountry =
resolveCountryName(initial.country, locale) ||
initial.country ||
(!hasSavedAnswer && !isInitialManual && storedRegion?.country
? resolveCountryName(storedRegion.country, locale) || storedRegion.country
: "");
const localizedInitialCountry = hasSavedAnswer
? resolveCountryName(initial.country, locale) || initial.country
: "";
const initialCity =
initial.city || (!hasSavedAnswer && !isInitialManual && storedRegion?.city ? storedRegion.city : "");
const initialCity = hasSavedAnswer ? initial.city || "" : "";
const initialLoc = const initialLoc =
localizedInitialCountry || initialCity localizedInitialCountry || initialCity
@ -171,13 +190,17 @@ export function QuestionBirthplace({
const [selectedCountry, setSelectedCountry] = useState( const [selectedCountry, setSelectedCountry] = useState(
() => localizedInitialCountry || "", () => localizedInitialCountry || "",
); );
const [cityInput, setCityInput] = useState(
() => initialCity,
);
const [cityInput, setCityInput] = useState(() => initialCity);
const cityInputStateRef = useRef(initialCity); const cityInputStateRef = useRef(initialCity);
const selectedCountryStateRef = useRef(localizedInitialCountry || ""); const selectedCountryStateRef = useRef(localizedInitialCountry || "");
const lastCoordsRef = useRef<{ latitude?: number; longitude?: number } | undefined>(
storedRegion?.latitude && storedRegion?.longitude
? { latitude: storedRegion.latitude, longitude: storedRegion.longitude }
: undefined,
);
useEffect(() => { useEffect(() => {
cityInputStateRef.current = cityInput; cityInputStateRef.current = cityInput;
}, [cityInput]); }, [cityInput]);
@ -197,7 +220,6 @@ export function QuestionBirthplace({
const [isDetecting, setIsDetecting] = useState(false); const [isDetecting, setIsDetecting] = useState(false);
const [detectedLocation, setDetectedLocation] = useState(initialLoc); const [detectedLocation, setDetectedLocation] = useState(initialLoc);
const hasAutoDetectedRef = useRef(false);
useEffect(() => { useEffect(() => {
isMountedRef.current = true; isMountedRef.current = true;
@ -227,7 +249,7 @@ export function QuestionBirthplace({
setIsClosing(false); setIsClosing(false);
}, [disabled]); }, [disabled]);
useSheetScrollLock(isOpen, { onBack: closeSheet });
useSheetScrollLock(isOpen && !isClosing, { onBack: closeSheet });
// Handle escape key // Handle escape key
useEffect(() => { useEffect(() => {
@ -245,7 +267,8 @@ export function QuestionBirthplace({
const lastInternalAnswerRef = useRef<BirthplaceValue | string | null>(null); const lastInternalAnswerRef = useRef<BirthplaceValue | string | null>(null);
const updateAnswers = (country: string, city: string) => {
const updateAnswers = useCallback(
(country: string, city: string) => {
const cleanCountry = country?.trim() || ""; const cleanCountry = country?.trim() || "";
const cleanCity = city?.trim() || ""; const cleanCity = city?.trim() || "";
const payload = const payload =
@ -254,51 +277,29 @@ export function QuestionBirthplace({
: null; : null;
lastInternalAnswerRef.current = payload; lastInternalAnswerRef.current = payload;
setAnswerValue(question, payload); setAnswerValue(question, payload);
};
},
[question, setAnswerValue],
);
// GeoIP detection logic using unified getUserGeoRegion
const detectLocation = useCallback(
async (force = false) => {
// If there is already a saved answer and we are not forcing, display it
if (rawValue && !force) {
const parsed = parseValue(rawValue);
const cName =
resolveCountryName(parsed.country, locale) || parsed.country;
if (cName || parsed.city) {
const loc = [cName, parsed.city].filter(Boolean).join(", ");
if (cName) {
setSelectedCountry(cName);
selectedCountryStateRef.current = cName;
}
if (parsed.city) {
setCityInput(parsed.city);
cityInputStateRef.current = parsed.city;
const handleAutoClick = async () => {
if (typeof window !== "undefined") {
localStorage.setItem(`residence_mode_${question.id}`, "auto");
} }
setDetectedLocation(loc);
const storedMode =
typeof window !== "undefined"
? localStorage.getItem(`residence_mode_${question.id}`)
: null;
if (storedMode === "manual") {
setMode("manual");
} else {
setMode("auto"); setMode("auto");
}
return;
}
}
setIsDetecting(true); setIsDetecting(true);
try { try {
const region = await getUserGeoRegion(force);
if (isInFlutterWebView()) {
const data = await requestAutoLocation();
if (!isMountedRef.current) return; if (!isMountedRef.current) return;
const city = region.city || "";
const rawCountry = region.country || region.countryCode || "";
const country = resolveCountryName(rawCountry, locale) || rawCountry;
lastCoordsRef.current = {
latitude: data.latitude,
longitude: data.longitude,
};
const rawCountry = data.country || data.country_code || "";
const country =
resolveCountryName(rawCountry, locale) || rawCountry || "";
const city = data.city || "";
if (country || city) { if (country || city) {
setSelectedCountry(country); setSelectedCountry(country);
@ -308,78 +309,74 @@ export function QuestionBirthplace({
const loc = [country, city].filter(Boolean).join(", "); const loc = [country, city].filter(Boolean).join(", ");
setDetectedLocation(loc); setDetectedLocation(loc);
updateAnswers(country, city); updateAnswers(country, city);
setMode("auto");
if (typeof window !== "undefined") {
localStorage.setItem(`residence_mode_${question.id}`, "auto");
} }
} else { } else {
setMode("manual");
if (typeof window !== "undefined") {
localStorage.setItem(`residence_mode_${question.id}`, "manual");
const region = await getUserGeoRegion(true);
if (!isMountedRef.current) return;
const city = region.city || "";
const rawCountry = region.country || region.countryCode || "";
const country =
resolveCountryName(rawCountry, locale) || rawCountry || "";
if (region.latitude && region.longitude) {
lastCoordsRef.current = {
latitude: region.latitude,
longitude: region.longitude,
};
} }
if (country || city) {
setSelectedCountry(country);
selectedCountryStateRef.current = country;
setCityInput(city);
cityInputStateRef.current = city;
const loc = [country, city].filter(Boolean).join(", ");
setDetectedLocation(loc);
updateAnswers(country, city);
} }
} catch {
if (isMountedRef.current) {
setMode("manual");
} }
} catch (err) {
console.warn("Auto location error:", err);
} finally { } finally {
if (isMountedRef.current) { if (isMountedRef.current) {
setIsDetecting(false); setIsDetecting(false);
} }
} }
},
[rawValue, locale, question, setAnswerValue],
);
useEffect(() => {
if (isLoading) return;
if (isResidence && !hasAutoDetectedRef.current) {
hasAutoDetectedRef.current = true;
const storedMode =
typeof window !== "undefined"
? localStorage.getItem(`residence_mode_${question.id}`)
: null;
if (storedMode === "manual") {
setMode("manual");
return;
}
const parsed = parseValue(rawValue);
if (!parsed.country && !parsed.city) {
void detectLocation(false);
} else {
void detectLocation(false);
}
}
}, [isResidence, isLoading, detectLocation, rawValue, question.id]);
const handleAutoClick = () => {
if (typeof window !== "undefined") {
localStorage.setItem(`residence_mode_${question.id}`, "auto");
}
setMode("auto");
detectLocation(true);
}; };
const handleManualClick = () => {
const handleManualClick = async () => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
localStorage.setItem(`residence_mode_${question.id}`, "manual"); localStorage.setItem(`residence_mode_${question.id}`, "manual");
} }
setMode("manual"); setMode("manual");
const parsed = parseValue(rawValue);
const resolvedC =
resolveCountryName(selectedCountry || parsed.country, locale) ||
selectedCountry ||
parsed.country;
const country = resolvedC;
const city = cityInput !== "" ? cityInput : parsed.city;
if (isInFlutterWebView()) {
try {
const data = await pickManualLocation(lastCoordsRef.current);
if (data && isMountedRef.current) {
lastCoordsRef.current = {
latitude: data.latitude,
longitude: data.longitude,
};
const rawCountry = data.country || data.country_code || "";
const country =
resolveCountryName(rawCountry, locale) || rawCountry || "";
const city = data.city || "";
if (country || city) {
setSelectedCountry(country); setSelectedCountry(country);
selectedCountryStateRef.current = country; selectedCountryStateRef.current = country;
setCityInput(city); setCityInput(city);
cityInputStateRef.current = city; cityInputStateRef.current = city;
const loc = [country, city].filter(Boolean).join(", ");
setDetectedLocation(loc);
updateAnswers(country, city); updateAnswers(country, city);
setDetectedLocation([country, city].filter(Boolean).join(", "));
}
}
} catch (err) {
console.warn("Manual map pick error:", err);
}
}
}; };
// Synchronize state ONLY if rawValue changes externally (e.g. draft fetch or reset) // Synchronize state ONLY if rawValue changes externally (e.g. draft fetch or reset)
@ -406,8 +403,9 @@ export function QuestionBirthplace({
} }
} }
const updated = parseValue(rawValue); const updated = parseValue(rawValue);
const resolvedC = resolveCountryName(updated.country, locale) || updated.country;
if (resolvedC !== selectedCountry) {
const resolvedC =
resolveCountryName(updated.country, locale) || updated.country;
if (resolvedC && resolvedC !== selectedCountry) {
setSelectedCountry(resolvedC); setSelectedCountry(resolvedC);
selectedCountryStateRef.current = resolvedC; selectedCountryStateRef.current = resolvedC;
} }
@ -419,7 +417,7 @@ export function QuestionBirthplace({
setDetectedLocation([resolvedC, updated.city].filter(Boolean).join(", ")); setDetectedLocation([resolvedC, updated.city].filter(Boolean).join(", "));
} }
lastInternalAnswerRef.current = rawValue as BirthplaceValue | string | null; lastInternalAnswerRef.current = rawValue as BirthplaceValue | string | null;
}, [rawValue, locale]);
}, [rawValue, locale, selectedCountry, cityInput]);
const options = getCountryList(locale); const options = getCountryList(locale);
const filteredOptions = options.filter((option) => const filteredOptions = options.filter((option) =>
@ -434,7 +432,9 @@ export function QuestionBirthplace({
selectedCountryStateRef.current = country; selectedCountryStateRef.current = country;
closeSheet(); closeSheet();
updateAnswers(country, cityInputStateRef.current); updateAnswers(country, cityInputStateRef.current);
setDetectedLocation([country, cityInputStateRef.current].filter(Boolean).join(", "));
setDetectedLocation(
[country, cityInputStateRef.current].filter(Boolean).join(", "),
);
window.setTimeout(() => { window.setTimeout(() => {
cityInputRef.current?.focus({ preventScroll: true }); cityInputRef.current?.focus({ preventScroll: true });
}, EXIT_ANIMATION_MS); }, EXIT_ANIMATION_MS);
@ -447,7 +447,9 @@ export function QuestionBirthplace({
const newCity = e.target.value; const newCity = e.target.value;
cityInputStateRef.current = newCity; cityInputStateRef.current = newCity;
setCityInput(newCity); setCityInput(newCity);
setDetectedLocation([selectedCountryStateRef.current, newCity].filter(Boolean).join(", "));
setDetectedLocation(
[selectedCountryStateRef.current, newCity].filter(Boolean).join(", "),
);
if (debounceTimerRef.current) { if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current); clearTimeout(debounceTimerRef.current);
@ -608,67 +610,6 @@ export function QuestionBirthplace({
</button> </button>
</div> </div>
</div> </div>
{mode === "manual" && (
<div className="flex flex-col gap-3 w-full animate-in fade-in slide-in-from-top-2 duration-200">
{/* Country Selection Trigger */}
<div className="relative w-full">
<button
type="button"
disabled={disabled}
onClick={openSheet}
className={[
"flex h-[54px] w-full items-center justify-between rounded-[16px] border bg-white px-4.5 text-start transition-all cursor-pointer outline-none",
isOpen
? "border-[#6F6F6F] ring-1 ring-[#6F6F6F]"
: "border-[#D0D5DD] hover:border-[#98A2B3]",
].join(" ")}
>
<span
className={[
"text-[15px] font-medium truncate",
selectedCountry ? "text-[#181818]" : "text-[#667085]",
].join(" ")}
>
{selectedCountry || selectCountryPlaceholder}
</span>
<svg
width="16"
height="10"
viewBox="0 0 16 10"
fill="none"
className={[
"shrink-0 transition-transform duration-200",
isOpen ? "rotate-180" : "",
].join(" ")}
>
<path
d="M14.75 1.25L7.75 8.25L0.75 1.25"
stroke="#344054"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>
{/* City Text Input */}
<div className="w-full">
<Input
ref={cityInputRef}
type="text"
data-no-auto-focus
disabled={disabled}
value={cityInput}
onChange={handleCityChange}
onFocus={handleCityFocus}
onBlur={handleCityBlur}
placeholder={cityPlaceholder}
/>
</div>
</div>
)}
</> </>
) : ( ) : (
<> <>

2
src/components/Componentes/question-date-sheet.tsx

@ -181,7 +181,7 @@ export function QuestionDateSheet({
window.setTimeout(onClose, EXIT_ANIMATION_MS); window.setTimeout(onClose, EXIT_ANIMATION_MS);
}, [onClose]); }, [onClose]);
useSheetScrollLock(true, { onBack: closeSheet });
useSheetScrollLock(!isClosing, { onBack: closeSheet });
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {

814
src/components/Componentes/question-file.tsx
File diff suppressed because it is too large
View File

80
src/components/Componentes/question-number.test.tsx

@ -0,0 +1,80 @@
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { describe, expect, it, afterEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import QuestionNumber, { normalizeNumberString } from "./question-number";
import { QuestionAnswersProvider } from "./question-answer-storage";
import type { QuestionField } from "@/lib/schema-adapter";
describe("normalizeNumberString", () => {
it("should convert Persian digits to English digits", () => {
expect(normalizeNumberString("۱۲۳۴۵۶۷۸۹۰")).toBe("1234567890");
expect(normalizeNumberString("۵")).toBe("5");
expect(normalizeNumberString("۳٫۵")).toBe("3.5");
});
it("should convert Arabic digits to English digits", () => {
expect(normalizeNumberString("١٢٣٤٥٦٧٨٩٠")).toBe("1234567890");
expect(normalizeNumberString("٤")).toBe("4");
});
it("should preserve English digits", () => {
expect(normalizeNumberString("12345")).toBe("12345");
});
});
describe("QuestionNumber Component", () => {
afterEach(() => {
cleanup();
});
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const mockQuestion: QuestionField = {
id: "q1_number_of_siblings",
title: "Number of Siblings",
type: "number",
order: 1,
required: true,
isVisible: true,
description: "",
tooltip: "",
extras: { placeHolder: "e.g. 3", range: [0, 20] },
options: [],
ui_config: {},
};
it("should allow entering Persian digits without clearing input", () => {
render(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="family_background" questions={[mockQuestion]}>
<QuestionNumber question={mockQuestion} />
</QuestionAnswersProvider>
</QueryClientProvider>,
);
const input = screen.getByPlaceholderText("e.g. 3") as HTMLInputElement;
fireEvent.change(input, { target: { value: "۴" } });
expect(input.value).toBe("4");
});
it("should allow entering English digits", () => {
render(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="family_background" questions={[mockQuestion]}>
<QuestionNumber question={mockQuestion} />
</QuestionAnswersProvider>
</QueryClientProvider>,
);
const input = screen.getByPlaceholderText("e.g. 3") as HTMLInputElement;
fireEvent.change(input, { target: { value: "5" } });
expect(input.value).toBe("5");
});
});

557
src/components/Componentes/question-number.tsx

@ -1,12 +1,15 @@
"use client"; "use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import type { QuestionField } from "@/lib/schema-adapter"; import type { QuestionField } from "@/lib/schema-adapter";
import { useI18n } from "@/translations/provider"; 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 { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { isKnownCountry } from "@/data/countries"; import { isKnownCountry } from "@/data/countries";
import { resolveDefaultCurrency } from "@/data/currencies";
import { getStoredUserGeoRegion } from "@/lib/geo-region";
import { CurrencySheet } from "./currency-sheet";
type QuestionNumberProps = { type QuestionNumberProps = {
question: QuestionField; question: QuestionField;
@ -15,6 +18,33 @@ type QuestionNumberProps = {
derivedFromQuestionIndex?: number; derivedFromQuestionIndex?: number;
}; };
const PERSIAN_DIGITS = ["۰", "۱", "۲", "۳", "۴", "۵", "۶", "۷", "۸", "۹"];
const ARABIC_DIGITS = ["٠", "١", "٢", "٣", "٤", "٥", "٦", "٧", "٨", "٩"];
export function normalizeNumberString(val: string): string {
if (!val) return "";
let result = "";
for (let i = 0; i < val.length; i++) {
const char = val[i];
const pIdx = PERSIAN_DIGITS.indexOf(char);
if (pIdx !== -1) {
result += String(pIdx);
continue;
}
const aIdx = ARABIC_DIGITS.indexOf(char);
if (aIdx !== -1) {
result += String(aIdx);
continue;
}
if (char === "٫") {
result += ".";
continue;
}
result += char;
}
return result;
}
const NUMBER_INPUT_PATTERN = /^-?\d*\.?\d*$/; const NUMBER_INPUT_PATTERN = /^-?\d*\.?\d*$/;
export default function QuestionNumber({ export default function QuestionNumber({
@ -48,12 +78,26 @@ export default function QuestionNumber({
]); ]);
useEffect(() => { useEffect(() => {
if (
typeof value === "string" &&
value.length > 0 &&
!NUMBER_INPUT_PATTERN.test(value)
) {
if (typeof value === "string" && value.length > 0) {
const normalized = normalizeNumberString(value);
console.log(
`[NUM_LOG] useEffect normalize: id=${question.id}, value="${value}", normalized="${normalized}"`,
);
if (!NUMBER_INPUT_PATTERN.test(normalized)) {
console.log(
`[NUM_LOG] useEffect clearing invalid value: id=${question.id}, value="${value}"`,
);
setAnswerValue(question, null); setAnswerValue(question, null);
} else if (normalized !== value) {
const parsed = parseFloat(normalized);
console.log(
`[NUM_LOG] useEffect updating normalized: id=${question.id}, parsed=${parsed}`,
);
setAnswerValue(
question,
Number.isNaN(parsed) ? normalized : parsed,
);
}
} }
}, [question, setAnswerValue, value]); }, [question, setAnswerValue, value]);
@ -63,7 +107,7 @@ export default function QuestionNumber({
typeof value === "number" typeof value === "number"
? value ? value
: typeof value === "string" : typeof value === "string"
? parseFloat(value)
? parseFloat(normalizeNumberString(value))
: NaN; : NaN;
const isOutOfRange = useMemo(() => { const isOutOfRange = useMemo(() => {
if (Number.isNaN(numValue)) return false; if (Number.isNaN(numValue)) return false;
@ -73,12 +117,18 @@ export default function QuestionNumber({
}, [numValue, min, max]); }, [numValue, min, max]);
const rawInputValue = value == null ? "" : String(value); const rawInputValue = value == null ? "" : String(value);
const inputValue = NUMBER_INPUT_PATTERN.test(rawInputValue)
? rawInputValue
const normalizedRaw = normalizeNumberString(rawInputValue);
const inputValue = NUMBER_INPUT_PATTERN.test(normalizedRaw)
? normalizedRaw
: ""; : "";
console.log(
`[NUM_LOG] Render: id=${question.id}, value=${JSON.stringify(value)}, inputValue="${inputValue}", isOutOfRange=${isOutOfRange}`,
);
const isMonthlyIncome = question.ui_config?.currency_enabled === true; const isMonthlyIncome = question.ui_config?.currency_enabled === true;
const currencyStorageKey = question.ui_config?.currency_storage_key || "marriage:income:currency";
const currencyStorageKey =
question.ui_config?.currency_storage_key || "marriage:income:currency";
const countryName = useMemo(() => getCountryFromStorage(), []); const countryName = useMemo(() => getCountryFromStorage(), []);
@ -87,13 +137,15 @@ export default function QuestionNumber({
const stored = window.localStorage.getItem(currencyStorageKey); const stored = window.localStorage.getItem(currencyStorageKey);
if (stored) return stored; if (stored) return stored;
} }
return getCurrencyForCountry(countryName);
const geo = getStoredUserGeoRegion();
return resolveDefaultCurrency({
countryCode: geo?.countryCode,
countryName: countryName || geo?.country,
fallbackLocale: locale,
});
}); });
const [isCurrencyDropdownOpen, setIsCurrencyDropdownOpen] = useState(false);
const [currencySearchQuery, setCurrencySearchQuery] = useState("");
const currencyContainerRef = useRef<HTMLDivElement>(null);
const currencySearchInputRef = useRef<HTMLInputElement>(null);
const [isCurrencySheetOpen, setIsCurrencySheetOpen] = useState(false);
useEffect(() => { useEffect(() => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
@ -103,47 +155,18 @@ export default function QuestionNumber({
return; return;
} }
} }
const derived = getCurrencyForCountry(countryName);
const geo = getStoredUserGeoRegion();
const derived = resolveDefaultCurrency({
countryCode: geo?.countryCode,
countryName: countryName || geo?.country,
fallbackLocale: locale,
});
setCurrencyCode(derived); setCurrencyCode(derived);
}, [countryName]);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
currencyContainerRef.current &&
!currencyContainerRef.current.contains(event.target as Node)
) {
setIsCurrencyDropdownOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
useEffect(() => {
if (isCurrencyDropdownOpen) {
setTimeout(() => {
currencySearchInputRef.current?.focus();
}, 50);
}
}, [isCurrencyDropdownOpen]);
const filteredCurrencies = useMemo(() => {
const q = currencySearchQuery.toLowerCase().trim();
if (!q) return CURRENCIES;
return CURRENCIES.filter(
(c) =>
c.code.toLowerCase().includes(q) ||
c.nameEn.toLowerCase().includes(q) ||
c.nameFa.toLowerCase().includes(q),
);
}, [currencySearchQuery]);
}, [countryName, currencyStorageKey, locale]);
const placeholderCurrency = useMemo(() => { const placeholderCurrency = useMemo(() => {
if (currencyCode === "TOMAN") { if (currencyCode === "TOMAN") {
return locale === "fa" ? "تومان" : "TOMAN";
return locale === "fa" || locale === "fa-ir" ? "تومان" : "TOMAN";
} }
return currencyCode; return currencyCode;
}, [currencyCode, locale]); }, [currencyCode, locale]);
@ -152,7 +175,7 @@ export default function QuestionNumber({
if (!isMonthlyIncome) { if (!isMonthlyIncome) {
return question.extras.placeHolder; return question.extras.placeHolder;
} }
return locale === "fa"
return locale === "fa" || locale === "fa-ir"
? `مثال: ۴۰۰۰ ${placeholderCurrency}` ? `مثال: ۴۰۰۰ ${placeholderCurrency}`
: `e.g. 4000 ${placeholderCurrency}`; : `e.g. 4000 ${placeholderCurrency}`;
}, [ }, [
@ -186,7 +209,7 @@ export default function QuestionNumber({
].join(" ")} ].join(" ")}
> >
<QuestionTitle question={question} /> <QuestionTitle question={question} />
<div className="flex gap-3 w-full relative" ref={currencyContainerRef}>
<div className="flex gap-3 w-full relative">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<Input <Input
type="text" type="text"
@ -194,49 +217,47 @@ export default function QuestionNumber({
required={question.required && !disabled} required={question.required && !disabled}
disabled={disabled || Boolean(derivedFromQuestion)} disabled={disabled || Boolean(derivedFromQuestion)}
placeholder={dynamicPlaceholder} placeholder={dynamicPlaceholder}
hasError={isOutOfRange}
value={localTextValue} value={localTextValue}
onChange={(event) => { onChange={(event) => {
const nextValue = event.target.value; const nextValue = event.target.value;
const cleanValue = nextValue.replace(/,/g, "");
const normalized = normalizeNumberString(nextValue);
const cleanValue = normalized.replace(/,/g, "");
if ( if (
cleanValue !== "" && cleanValue !== "" &&
cleanValue !== "-" &&
!NUMBER_INPUT_PATTERN.test(cleanValue) !NUMBER_INPUT_PATTERN.test(cleanValue)
) { ) {
return; return;
} }
const formatted = formatNumberWithCommas(cleanValue); const formatted = formatNumberWithCommas(cleanValue);
const finalFormatted = nextValue.endsWith(".")
const finalFormatted = normalized.endsWith(".")
? `${formatted}.` ? `${formatted}.`
: formatted; : formatted;
setLocalTextValue(finalFormatted); setLocalTextValue(finalFormatted);
if (cleanValue === "") {
if (cleanValue === "" || cleanValue === "-") {
setAnswerValue(question, null); setAnswerValue(question, null);
} else { } else {
const parsed = parseFloat(cleanValue); const parsed = parseFloat(cleanValue);
setAnswerValue( setAnswerValue(
question, Number.isNaN(parsed) ? cleanValue : parsed,
question,
Number.isNaN(parsed) ? cleanValue : parsed,
); );
} }
}} }}
className={[
"h-[54px] w-full rounded-[16px] border px-4.5 text-[15px] font-medium text-[#181818] outline-none transition-all placeholder:text-[#98A2B3] focus:border-[#6F6F6F] focus:ring-1 focus:ring-[#6F6F6F] disabled:bg-[#F5F2F1] disabled:text-[#7C7472]",
isOutOfRange
? "border-[#F2465F] ring-1 ring-[#F2465F]"
: "border-[#D0D5DD] hover:border-[#98A2B3] bg-white",
].join(" ")}
/> />
</div> </div>
<div className="w-[110px] shrink-0 relative"> <div className="w-[110px] shrink-0 relative">
<button <button
type="button" type="button"
disabled={disabled} disabled={disabled}
onClick={() => setIsCurrencyDropdownOpen(!isCurrencyDropdownOpen)}
onClick={() => setIsCurrencySheetOpen(true)}
className={[ className={[
"flex h-[54px] w-full items-center justify-between rounded-[16px] border bg-white px-3.5 text-start transition-all cursor-pointer outline-none", "flex h-[54px] w-full items-center justify-between rounded-[16px] border bg-white px-3.5 text-start transition-all cursor-pointer outline-none",
isCurrencyDropdownOpen
isCurrencySheetOpen
? "border-[#6F6F6F] ring-1 ring-[#6F6F6F]" ? "border-[#6F6F6F] ring-1 ring-[#6F6F6F]"
: "border-[#D0D5DD] hover:border-[#98A2B3]", : "border-[#D0D5DD] hover:border-[#98A2B3]",
].join(" ")} ].join(" ")}
@ -253,7 +274,7 @@ export default function QuestionNumber({
aria-label="Dropdown chevron" aria-label="Dropdown chevron"
className={[ className={[
"shrink-0 transition-transform duration-200 text-[#344054] ml-1", "shrink-0 transition-transform duration-200 text-[#344054] ml-1",
isCurrencyDropdownOpen ? "rotate-180" : "",
isCurrencySheetOpen ? "rotate-180" : "",
].join(" ")} ].join(" ")}
> >
<path <path
@ -265,110 +286,21 @@ export default function QuestionNumber({
/> />
</svg> </svg>
</button> </button>
{isCurrencyDropdownOpen && (
<div className="absolute top-[calc(100%+8px)] right-0 z-50 flex w-[240px] flex-col gap-3 rounded-[20px] bg-white p-4 shadow-[0_12px_36px_rgba(0,0,0,0.09)] border border-[#EAECF0] animate-in fade-in zoom-in-95 duration-150">
{/* Search bar */}
<div className="flex h-[40px] w-full items-center gap-2 rounded-[10px] bg-[#EFEFEF] px-3 transition-colors focus-within:bg-[#E8E8E8]">
<svg
width="16"
height="16"
viewBox="0 0 18 18"
fill="none"
role="img"
aria-label="Search"
className="shrink-0 text-[#667085]"
>
<path
d="M8.25 14.25C11.5637 14.25 14.25 11.5637 14.25 8.25C14.25 4.93629 11.5637 2.25 8.25 2.25C4.93629 2.25 2.25 4.93629 2.25 8.25C2.25 11.5637 4.93629 14.25 8.25 14.25Z"
stroke="#667085"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M15.75 15.75L12.5 12.5"
stroke="#667085"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<input
ref={currencySearchInputRef}
type="text"
name="currency_search_field"
autoComplete="off"
autoCorrect="off"
autoCapitalize="none"
spellCheck="false"
aria-autocomplete="none"
data-lpignore="true"
data-1p-ignore="true"
data-bwignore="true"
data-form-type="other"
value={currencySearchQuery}
onChange={(e) => setCurrencySearchQuery(e.target.value)}
placeholder={locale === "fa" ? "جستجو..." : "Search..."}
className="flex-1 min-w-0 bg-transparent text-[13px] font-medium text-[#181818] outline-none placeholder:text-[#98A2B3]"
/>
{currencySearchQuery ? (
<button
type="button"
onClick={() => setCurrencySearchQuery("")}
className="text-[#667085] hover:text-[#181818] text-xs font-semibold cursor-pointer shrink-0"
>
</button>
) : null}
</div>
</div> </div>
{/* Options list */}
<div className="flex max-h-[180px] flex-col gap-2.5 overflow-y-auto overscroll-contain pr-1">
{filteredCurrencies.length > 0 ? (
filteredCurrencies.map((c) => {
const isSelected = c.code === currencyCode;
return (
<button
key={c.code}
type="button"
onClick={() => {
setCurrencyCode(c.code);
<CurrencySheet
isOpen={isCurrencySheetOpen}
onClose={() => setIsCurrencySheetOpen(false)}
selectedCurrency={currencyCode}
onSelectCurrency={(code) => {
setCurrencyCode(code);
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
window.localStorage.setItem(
currencyStorageKey,
c.code,
);
window.localStorage.setItem(currencyStorageKey, code);
} }
setIsCurrencyDropdownOpen(false);
setCurrencySearchQuery("");
}} }}
className="flex w-full items-center justify-between text-start cursor-pointer group/opt py-1 px-1.5 rounded-lg hover:bg-gray-50 transition-colors"
>
<span className="text-[14px] font-medium text-[#181818]">
{c.code}{" "}
{locale === "fa"
? `(${c.nameFa})`
: `(${c.nameEn})`}
</span>
{isSelected ? (
<div className="size-[6px] rounded-full bg-[#F2465F]" />
) : null}
</button>
);
})
) : (
<span className="py-2 text-[12px] text-[#667085] text-center">
{locale === "fa"
? "ارزی یافت نشد"
: "No currencies found"}
</span>
)}
</div>
</div>
)}
</div>
</div>
/>
{isOutOfRange ? ( {isOutOfRange ? (
<span className="block text-[10px] font-semibold text-[#F2465F]"> <span className="block text-[10px] font-semibold text-[#F2465F]">
{t[ {t[
@ -389,35 +321,43 @@ export default function QuestionNumber({
> >
<QuestionTitle question={question} /> <QuestionTitle question={question} />
<Input <Input
type="number"
type="text"
inputMode="numeric"
required={question.required && !disabled} required={question.required && !disabled}
disabled={disabled || Boolean(derivedFromQuestion)} disabled={disabled || Boolean(derivedFromQuestion)}
min={min || undefined}
max={max || undefined}
placeholder={question.extras.placeHolder} placeholder={question.extras.placeHolder}
hasError={isOutOfRange}
value={inputValue} value={inputValue}
onChange={(event) => { onChange={(event) => {
const nextValue = event.target.value;
if (!NUMBER_INPUT_PATTERN.test(nextValue)) {
event.currentTarget.value = inputValue;
const raw = event.target.value;
const normalized = normalizeNumberString(raw);
const cleaned = normalized.replace(/[^0-9.-]/g, "");
console.log(
`[NUM_LOG] onChange: id=${question.id}, raw="${raw}", normalized="${normalized}", cleaned="${cleaned}"`,
);
if (cleaned === "" || cleaned === "-") {
console.log(`[NUM_LOG] onChange clearing value (empty)`);
setAnswerValue(question, null);
return; return;
} }
if (nextValue === "") {
setAnswerValue(question, null);
} else {
const parsed = parseFloat(nextValue);
if (!NUMBER_INPUT_PATTERN.test(cleaned)) {
console.log(`[NUM_LOG] onChange rejected pattern: "${cleaned}"`);
return;
}
const parsed = parseFloat(cleaned);
console.log(
`[NUM_LOG] onChange calling setAnswerValue with:`,
Number.isNaN(parsed) ? cleaned : parsed,
);
setAnswerValue( setAnswerValue(
question, Number.isNaN(parsed) ? nextValue : parsed,
question,
Number.isNaN(parsed) ? cleaned : parsed,
); );
}
}} }}
className={[
"h-[54px] w-full rounded-[16px] border px-4.5 text-[15px] font-medium text-[#181818] outline-none transition-all placeholder:text-[#98A2B3] focus:border-[#6F6F6F] focus:ring-1 focus:ring-[#6F6F6F] disabled:bg-[#F5F2F1] disabled:text-[#7C7472]",
isOutOfRange
? "border-[#F2465F] ring-1 ring-[#F2465F]"
: "border-[#D0D5DD] hover:border-[#98A2B3] bg-white",
].join(" ")}
/> />
{isOutOfRange ? ( {isOutOfRange ? (
<span className="block text-[10px] font-semibold text-[#F2465F]"> <span className="block text-[10px] font-semibold text-[#F2465F]">
@ -487,252 +427,9 @@ function getCountryFromStorage(): string {
return ""; return "";
} }
function getCurrencyForCountry(countryName: string): string {
const cleanCountry = countryName?.trim();
if (!cleanCountry) return "USD";
const countryMap: Record<string, string> = {
// English
Iran: "TOMAN",
"United States": "USD",
"United Kingdom": "GBP",
Canada: "CAD",
Germany: "EUR",
France: "EUR",
"United Arab Emirates": "AED",
Turkey: "TRY",
Iraq: "IQD",
Afghanistan: "AFN",
Pakistan: "PKR",
"Saudi Arabia": "SAR",
Qatar: "QAR",
Sweden: "SEK",
Netherlands: "EUR",
Norway: "NOK",
Australia: "AUD",
Bulgaria: "BGN",
// Persian
ایران: "TOMAN",
"ایالات متحده": "USD",
بریتانیا: "GBP",
کانادا: "CAD",
آلمان: "EUR",
فرانسه: "EUR",
"امارات متحده عربی": "AED",
ترکیه: "TRY",
عراق: "IQD",
افغانستان: "AFN",
پاکستان: "PKR",
"عربستان سعودی": "SAR",
قطر: "QAR",
سوئد: "SEK",
هلند: "EUR",
نروژ: "NOK",
استرالیا: "AUD",
بلغارستان: "BGN",
};
return countryMap[cleanCountry] || "USD";
}
const CURRENCIES = [
{ code: "AED", nameEn: "UAE Dirham", nameFa: "درهم امارات" },
{ code: "AFN", nameEn: "Afghan Afghani", nameFa: "افغانی افغانستان" },
{ code: "ALL", nameEn: "Albanian Lek", nameFa: "لک آلبانی" },
{ code: "AMD", nameEn: "Armenian Dram", nameFa: "درام ارمنستان" },
{
code: "ANG",
nameEn: "Netherlands Antillean Guilder",
nameFa: "گیلدر آنتیل هلند",
},
{ code: "AOA", nameEn: "Angolan Kwanza", nameFa: "کوانزای آنگولا" },
{ code: "ARS", nameEn: "Argentine Peso", nameFa: "پزو آرژانتین" },
{ code: "AUD", nameEn: "Australian Dollar", nameFa: "دلار استرالیا" },
{ code: "AZN", nameEn: "Azerbaijani Manat", nameFa: "منات آذربایجان" },
{
code: "BAM",
nameEn: "Bosnia-Herzegovina Mark",
nameFa: "مارک بوسنی و هرزگوین",
},
{ code: "BBD", nameEn: "Barbadian Dollar", nameFa: "دلار باربادوس" },
{ code: "BDT", nameEn: "Bangladeshi Taka", nameFa: "تاکای بنگلادش" },
{ code: "BGN", nameEn: "Bulgarian Lev", nameFa: "لو بلغارستان" },
{ code: "BHD", nameEn: "Bahraini Dinar", nameFa: "دینار بحرین" },
{ code: "BIF", nameEn: "Burundian Franc", nameFa: "فرانک بروندی" },
{ code: "BMD", nameEn: "Bermudian Dollar", nameFa: "دلار برمودا" },
{ code: "BND", nameEn: "Brunei Dollar", nameFa: "دلار برونئی" },
{ code: "BOB", nameEn: "Bolivian Boliviano", nameFa: "بولیویانو بولیوی" },
{ code: "BRL", nameEn: "Brazilian Real", nameFa: "رئال برزیل" },
{ code: "BSD", nameEn: "Bahamian Dollar", nameFa: "دلار باهاما" },
{ code: "BTN", nameEn: "Bhutanese Ngultrum", nameFa: "نگولتروم بوتان" },
{ code: "BWP", nameEn: "Botswanan Pula", nameFa: "پولای بوتسوانا" },
{ code: "BYN", nameEn: "Belarusian Ruble", nameFa: "روبل بلاروس" },
{ code: "BZD", nameEn: "Belize Dollar", nameFa: "دلار بلیز" },
{ code: "CAD", nameEn: "Canadian Dollar", nameFa: "دلار کانادا" },
{ code: "CDF", nameEn: "Congolese Franc", nameFa: "فرانک کنگو" },
{ code: "CHF", nameEn: "Swiss Franc", nameFa: "فرانک سوئیس" },
{ code: "CLP", nameEn: "Chilean Peso", nameFa: "پزو شیلی" },
{ code: "CNY", nameEn: "Chinese Yuan", nameFa: "یوان چین" },
{ code: "COP", nameEn: "Colombian Peso", nameFa: "پزو کلمبیا" },
{ code: "CRC", nameEn: "Costa Rican Colón", nameFa: "کولون کاستاریکا" },
{ code: "CUP", nameEn: "Cuban Peso", nameFa: "پزو کوبا" },
{ code: "CVE", nameEn: "Cape Verdean Escudo", nameFa: "اسکودو کیپ ورد" },
{ code: "CZK", nameEn: "Czech Koruna", nameFa: "کرون چک" },
{ code: "DJF", nameEn: "Djiboutian Franc", nameFa: "فرانک جیبوتی" },
{ code: "DKK", nameEn: "Danish Krone", nameFa: "کرون دانمارک" },
{ code: "DOP", nameEn: "Dominican Peso", nameFa: "پزو دومینیکن" },
{ code: "DZD", nameEn: "Algerian Dinar", nameFa: "دینار الجزایر" },
{ code: "EGP", nameEn: "Egyptian Pound", nameFa: "پوند مصر" },
{ code: "ERN", nameEn: "Eritrean Nakfa", nameFa: "ناکفای اریتره" },
{ code: "ETB", nameEn: "Ethiopian Birr", nameFa: "بیر اتیوپی" },
{ code: "EUR", nameEn: "Euro", nameFa: "یورو" },
{ code: "FJD", nameEn: "Fijian Dollar", nameFa: "دلار فیجی" },
{
code: "FKP",
nameEn: "Falkland Islands Pound",
nameFa: "پوند جزایر فالکلند",
},
{ code: "GBP", nameEn: "British Pound", nameFa: "پوند بریتانیا" },
{ code: "GEL", nameEn: "Georgian Lari", nameFa: "لاری گرجستان" },
{ code: "GHS", nameEn: "Ghanaian Cedi", nameFa: "سدی غنا" },
{ code: "GIP", nameEn: "Gibraltar Pound", nameFa: "پوند جبل الطارق" },
{ code: "GMD", nameEn: "Gambian Dalasi", nameFa: "دالاسی گامبیا" },
{ code: "GNF", nameEn: "Guinean Franc", nameFa: "فرانک گینه" },
{ code: "GTQ", nameEn: "Guatemalan Quetzal", nameFa: "کوتزال گواتمالا" },
{ code: "GYD", nameEn: "Guyanese Dollar", nameFa: "دلار گویان" },
{ code: "HKD", nameEn: "Hong Kong Dollar", nameFa: "دلار هنگ کنگ" },
{ code: "HNL", nameEn: "Honduran Lempira", nameFa: "لمپیرای هندوراس" },
{ code: "HRK", nameEn: "Croatian Kuna", nameFa: "کونای کرواسی" },
{ code: "HTG", nameEn: "Haitian Gourde", nameFa: "گورد هائیتی" },
{ code: "HUF", nameEn: "Hungarian Forint", nameFa: "فورینت مجارستان" },
{ code: "IDR", nameEn: "Indonesian Rupiah", nameFa: "روپیه اندونزی" },
{ code: "ILS", nameEn: "Israeli Shekel", nameFa: "شکل اسرائیل" },
{ code: "INR", nameEn: "Indian Rupee", nameFa: "روپیه هند" },
{ code: "IQD", nameEn: "Iraqi Dinar", nameFa: "دینار عراق" },
{ code: "IRR", nameEn: "Iranian Rial", nameFa: "ریال ایران" },
{ code: "ISK", nameEn: "Icelandic Króna", nameFa: "کرون ایسلند" },
{ code: "JMD", nameEn: "Jamaican Dollar", nameFa: "دلار جامائیکا" },
{ code: "JOD", nameEn: "Jordanian Dinar", nameFa: "دینار اردن" },
{ code: "JPY", nameEn: "Japanese Yen", nameFa: "ین ژاپن" },
{ code: "KES", nameEn: "Kenyan Shilling", nameFa: "شیلینگ کنیا" },
{ code: "KGS", nameEn: "Kyrgystani Som", nameFa: "سوم قرقیزستان" },
{ code: "KHR", nameEn: "Cambodian Riel", nameFa: "ریال کامبوج" },
{ code: "KMF", nameEn: "Comorian Franc", nameFa: "فرانک کومور" },
{ code: "KPW", nameEn: "North Korean Won", nameFa: "وون کره شمالی" },
{ code: "KRW", nameEn: "South Korean Won", nameFa: "وون کره جنوبی" },
{ code: "KWD", nameEn: "Kuwaiti Dinar", nameFa: "دینار کویت" },
{ code: "KYD", nameEn: "Cayman Islands Dollar", nameFa: "دلار جزایر کیمن" },
{ code: "KZT", nameEn: "Kazakhstani Tenge", nameFa: "تنگه قزاقستان" },
{ code: "LAK", nameEn: "Laotian Kip", nameFa: "کیپ لائوس" },
{ code: "LBP", nameEn: "Lebanese Pound", nameFa: "پوند لبنان" },
{ code: "LKR", nameEn: "Sri Lankan Rupee", nameFa: "روپیه سریلانکا" },
{ code: "LRD", nameEn: "Liberian Dollar", nameFa: "دلار لیبریا" },
{ code: "LSL", nameEn: "Lesotho Loti", nameFa: "لوتی لسوتو" },
{ code: "LYD", nameEn: "Libyan Dinar", nameFa: "دینار لیبی" },
{ code: "MAD", nameEn: "Moroccan Dirham", nameFa: "درهم مراکش" },
{ code: "MDL", nameEn: "Moldovan Leu", nameFa: "لوی مولداوی" },
{ code: "MGA", nameEn: "Malagasy Ariary", nameFa: "آریاری ماداگاسکار" },
{ code: "MKD", nameEn: "Macedonian Denar", nameFa: "دینار مقدونیه" },
{ code: "MMK", nameEn: "Myanmar Kyat", nameFa: "کیات میانمار" },
{ code: "MNT", nameEn: "Mongolian Tugrik", nameFa: "توگریک مغولستان" },
{ code: "MOP", nameEn: "Macanese Pataca", nameFa: "پاتاکای ماکائو" },
{ code: "MRU", nameEn: "Mauritanian Ouguiya", nameFa: "اوگیای موریتانی" },
{ code: "MUR", nameEn: "Mauritian Rupee", nameFa: "روپیه موریس" },
{ code: "MVR", nameEn: "Maldivian Rufiyaa", nameFa: "روفیای مالدیو" },
{ code: "MWK", nameEn: "Malawian Kwacha", nameFa: "کواچای مالاوی" },
{ code: "MXN", nameEn: "Mexican Peso", nameFa: "پزو مکزیک" },
{ code: "MYR", nameEn: "Malaysian Ringgit", nameFa: "رینگیت مالزی" },
{ code: "MZN", nameEn: "Mozambican Metical", nameFa: "متیکال موزامبیک" },
{ code: "NAD", nameEn: "Namibian Dollar", nameFa: "دلار نامیبیا" },
{ code: "NGN", nameEn: "Nigerian Naira", nameFa: "نایرای نیجریه" },
{ code: "NIO", nameEn: "Nicaraguan Córdoba", nameFa: "کوردوبای نیکاراگوئه" },
{ code: "NOK", nameEn: "Norwegian Krone", nameFa: "کرون نروژ" },
{ code: "NPR", nameEn: "Nepalese Rupee", nameFa: "روپیه نپال" },
{ code: "NZD", nameEn: "New Zealand Dollar", nameFa: "دلار نیوزیلند" },
{ code: "OMR", nameEn: "Omani Rial", nameFa: "ریال عمان" },
{ code: "PAB", nameEn: "Panamanian Balboa", nameFa: "بالبوای پاناما" },
{ code: "PEN", nameEn: "Peruvian Sol", nameFa: "سول پرو" },
{
code: "PGK",
nameEn: "Papua New Guinean Kina",
nameFa: "کینای پاپوآ گینه نو",
},
{ code: "PHP", nameEn: "Philippine Peso", nameFa: "پزو فیلیپین" },
{ code: "PKR", nameEn: "Pakistani Rupee", nameFa: "روپیه پاکستان" },
{ code: "PLN", nameEn: "Polish Zloty", nameFa: "زلوتی لهستان" },
{ code: "PYG", nameEn: "Paraguayan Guarani", nameFa: "گوارانی پاراگوئه" },
{ code: "QAR", nameEn: "Qatari Rial", nameFa: "ریال قطر" },
{ code: "RON", nameEn: "Romanian Leu", nameFa: "لوی رومانی" },
{ code: "RSD", nameEn: "Serbian Dinar", nameFa: "دینار صربستان" },
{ code: "RUB", nameEn: "Russian Ruble", nameFa: "روبل روسیه" },
{ code: "RWF", nameEn: "Rwandan Franc", nameFa: "فرانک رواندا" },
{ code: "SAR", nameEn: "Saudi Riyal", nameFa: "ریال عربستان" },
{
code: "SBD",
nameEn: "Solomon Islands Dollar",
nameFa: "دلار جزایر سلیمان",
},
{ code: "SCR", nameEn: "Seychellois Rupee", nameFa: "روپیه سیشل" },
{ code: "SDG", nameEn: "Sudanese Pound", nameFa: "پوند سودان" },
{ code: "SEK", nameEn: "Swedish Krona", nameFa: "کرون سوئد" },
{ code: "SGD", nameEn: "Singapore Dollar", nameFa: "دلار سنگاپور" },
{ code: "SHP", nameEn: "St. Helena Pound", nameFa: "پوند سنت هلن" },
{ code: "SLL", nameEn: "Sierra Leonean Leone", nameFa: "لئون سیرالئون" },
{ code: "SOS", nameEn: "Somali Shilling", nameFa: "شیلینگ سومالی" },
{ code: "SRD", nameEn: "Surinamese Dollar", nameFa: "دلار سورینام" },
{ code: "SSP", nameEn: "South Sudanese Pound", nameFa: "پوند سودان جنوبی" },
{ code: "STN", nameEn: "São Tomé Dobra", nameFa: "دوبرا سائوتومه" },
{ code: "SVC", nameEn: "Salvadoran Colón", nameFa: "کولون السالوادور" },
{ code: "SYP", nameEn: "Syrian Pound", nameFa: "پوند سوریه" },
{ code: "SZL", nameEn: "Swazi Lilangeni", nameFa: "لیلانگنی سوازیلند" },
{ code: "THB", nameEn: "Thai Baht", nameFa: "بات تایلند" },
{ code: "TJS", nameEn: "Tajikistani Somoni", nameFa: "سامانی تاجیکستان" },
{ code: "TMT", nameEn: "Turkmenistani Manat", nameFa: "منات ترکمنستان" },
{ code: "TND", nameEn: "Tunisian Dinar", nameFa: "دینار تونس" },
{ code: "TOMAN", nameEn: "Iranian Toman", nameFa: "تومان ایران" },
{ code: "TOP", nameEn: "Tongan Paʻanga", nameFa: "پاآنگای تونگا" },
{ code: "TRY", nameEn: "Turkish Lira", nameFa: "لیر ترکیه" },
{
code: "TTD",
nameEn: "Trinidad & Tobago Dollar",
nameFa: "دلار ترینیداد و توباگر",
},
{ code: "TWD", nameEn: "New Taiwan Dollar", nameFa: "دلار جدید تایوان" },
{ code: "TZS", nameEn: "Tanzanian Shilling", nameFa: "شیلینگ تانزانیا" },
{ code: "UAH", nameEn: "Ukrainian Hryvnia", nameFa: "گریونا اوکراین" },
{ code: "UGX", nameEn: "Ugandan Shilling", nameFa: "شیلینگ اوگاندا" },
{ code: "USD", nameEn: "US Dollar", nameFa: "دلار آمریکا" },
{ code: "UYU", nameEn: "Uruguayan Peso", nameFa: "پزو اروگوئه" },
{ code: "UZS", nameEn: "Uzbekistani Som", nameFa: "سوم ازبکستان" },
{ code: "VES", nameEn: "Venezuelan Bolívar", nameFa: "بولیوار ونزوئلا" },
{ code: "VND", nameEn: "Vietnamese Dong", nameFa: "دانگ ویتنام" },
{ code: "VUV", nameEn: "Vanuatu Vatu", nameFa: "واتو وانواتو" },
{ code: "WST", nameEn: "Samoan Tālā", nameFa: "تالای ساموآ" },
{
code: "XAF",
nameEn: "Central African CFA Franc",
nameFa: "فرانک سی‌اف‌آی آفریقای مرکزی",
},
{ code: "XCD", nameEn: "East Caribbean Dollar", nameFa: "دلار کارائیب شرقی" },
{
code: "XOF",
nameEn: "West African CFA Franc",
nameFa: "فرانک سی‌اف‌آی آفریقای غربی",
},
{ code: "XPF", nameEn: "CFP Franc", nameFa: "فرانک اقیانوس آرام" },
{ code: "YER", nameEn: "Yemeni Rial", nameFa: "ریال یمن" },
{ code: "ZAR", nameEn: "South African Rand", nameFa: "راند آفریقای جنوبی" },
{ code: "ZMW", nameEn: "Zambian Kwacha", nameFa: "کواچای زامبیا" },
{ code: "ZWL", nameEn: "Zimbabwean Dollar", nameFa: "دلار زیمبابوه" },
];
function formatNumberWithCommas(
val: string | number | null | undefined,
): string {
if (val === null || val === undefined || val === "") return "";
const cleanStr = String(val).replace(/,/g, "");
const parts = cleanStr.split(".");
function formatNumberWithCommas(val: string): string {
if (!val) return "";
const parts = val.split(".");
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ","); parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
return parts.join("."); return parts.join(".");
} }

2
src/components/Componentes/question-phone.tsx

@ -586,7 +586,7 @@ export function QuestionPhone({
); );
}, [countryList, searchQuery]); }, [countryList, searchQuery]);
useSheetScrollLock(isOpen, { onBack: closeSheet });
useSheetScrollLock(isOpen && !isClosing, { onBack: closeSheet });
useEffect(() => { useEffect(() => {
if (!isOpen) return; if (!isOpen) return;

42
src/components/Componentes/question-photo.tsx

@ -1,7 +1,7 @@
"use client"; "use client";
import Image from "next/image"; import Image from "next/image";
import { type ReactNode, useCallback, useEffect, useId, useState } from "react";
import { type ReactNode, useCallback, useEffect, useId, 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";
@ -23,7 +23,7 @@ export function QuestionPhoto({
}: QuestionPhotoProps) { }: QuestionPhotoProps) {
const inputId = useId(); const inputId = useId();
const [localPreviewUrl, setLocalPreviewUrl] = useState<string | null>(null); const [localPreviewUrl, setLocalPreviewUrl] = useState<string | null>(null);
const [isFlutterPicking, setIsFlutterPicking] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const acceptedFiles = const acceptedFiles =
question.extras?.options && question.extras.options.length > 0 question.extras?.options && question.extras.options.length > 0
@ -34,18 +34,22 @@ export function QuestionPhoto({
const { getAnswerValue, setAnswerValue } = useQuestionAnswers(); const { getAnswerValue, setAnswerValue } = useQuestionAnswers();
const storedValue = getAnswerValue(question); const storedValue = getAnswerValue(question);
const isInitiatorRef = useRef(false);
const uploadTmpMediaMutation = useUploadTmpMediaMutation({ const uploadTmpMediaMutation = useUploadTmpMediaMutation({
onSuccess: (response) => { onSuccess: (response) => {
if (response.path) { if (response.path) {
setAnswerValue(question, response.path); setAnswerValue(question, response.path);
setLocalPreviewUrl(response.path);
} }
setIsUploading(false);
}, },
onError: (error) => { onError: (error) => {
console.error("Photo upload error:", error); console.error("Photo upload error:", error);
setIsUploading(false);
}, },
}); });
const isPending = uploadTmpMediaMutation.isPending || isFlutterPicking;
const isPending = uploadTmpMediaMutation.isPending || isUploading;
// Listen for upload_file responses from Flutter WebView if active // Listen for upload_file responses from Flutter WebView if active
useEffect(() => { useEffect(() => {
@ -53,12 +57,15 @@ export function QuestionPhoto({
const unsubscribe = window.addFlutterResponseListener?.((event) => { const unsubscribe = window.addFlutterResponseListener?.((event) => {
if (event.action !== "upload_file") return; if (event.action !== "upload_file") return;
if (event.requestId && String(event.requestId) !== String(question.id)) return;
if (!event.requestId && !isInitiatorRef.current) return;
switch (event.status) { switch (event.status) {
case "picking": case "picking":
setIsFlutterPicking(true);
break; break;
case "picked": case "picked":
case "progress":
setIsUploading(true);
if (event.data?.files?.[0]?.base64) { if (event.data?.files?.[0]?.base64) {
const b64 = event.data.files[0].base64; const b64 = event.data.files[0].base64;
setLocalPreviewUrl(b64); setLocalPreviewUrl(b64);
@ -66,11 +73,17 @@ export function QuestionPhoto({
} }
break; break;
case "completed": { case "completed": {
setIsFlutterPicking(false);
setIsUploading(false);
isInitiatorRef.current = false;
const file = event.data?.files?.[0]; const file = event.data?.files?.[0];
if (file?.url) {
setAnswerValue(question, file.url);
setLocalPreviewUrl(file.url);
const remoteUrl =
file?.path ||
file?.url ||
(file?.data?.path as string | undefined) ||
(file?.data?.url as string | undefined);
if (remoteUrl) {
setAnswerValue(question, remoteUrl);
setLocalPreviewUrl(remoteUrl);
} else if (file?.base64) { } else if (file?.base64) {
const b64 = file.base64; const b64 = file.base64;
setLocalPreviewUrl(b64); setLocalPreviewUrl(b64);
@ -88,7 +101,8 @@ export function QuestionPhoto({
} }
case "cancelled": case "cancelled":
case "failed": case "failed":
setIsFlutterPicking(false);
setIsUploading(false);
isInitiatorRef.current = false;
break; break;
} }
}); });
@ -103,7 +117,10 @@ export function QuestionPhoto({
o.replace(/^\./, "").toLowerCase(), o.replace(/^\./, "").toLowerCase(),
); );
isInitiatorRef.current = true;
uploadFile({ uploadFile({
requestId: String(question.id),
mediaType: "image", mediaType: "image",
source: "gallery", source: "gallery",
returnAs: "upload", returnAs: "upload",
@ -127,6 +144,7 @@ export function QuestionPhoto({
setAnswerValue(question, objectUrl); setAnswerValue(question, objectUrl);
// Trigger background upload // Trigger background upload
setIsUploading(true);
uploadTmpMediaMutation.mutate(file); uploadTmpMediaMutation.mutate(file);
}; };
@ -225,10 +243,10 @@ export function QuestionPhoto({
</span> </span>
)} )}
{/* Loading spinner during upload */}
{/* Loading spinner during upload matching Flutter ProfileAvatarWidget */}
{isPending && ( {isPending && (
<div className="absolute inset-0 flex items-center justify-center rounded-full bg-black/40 backdrop-blur-[1px]">
<LoadingSkeleton className="size-12 rounded-full" />
<div className="absolute inset-0 flex items-center justify-center rounded-full bg-black/40 backdrop-blur-[1px] z-10 transition-all duration-200">
<div className="h-7 w-7 animate-spin rounded-full border-[2.5px] border-white border-t-transparent shadow-sm" />
</div> </div>
)} )}
</div> </div>

3
src/components/Componentes/question-progress-tracker.tsx

@ -130,6 +130,9 @@ export function QuestionProgressTracker({
setTotal(nextTotal); setTotal(nextTotal);
setAnswered(nextAnswered); setAnswered(nextAnswered);
console.log(
`[PROG_LOG] updateProgress: answered=${nextAnswered}/${nextTotal}, passedIndexes=[${Array.from(passedQuestionIndexes).join(",")}]`,
);
}, [passedQuestionIndexes]); }, [passedQuestionIndexes]);
useEffect(() => { useEffect(() => {

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

@ -192,7 +192,7 @@ describe("QuestionSheet component", () => {
}); });
}); });
it("closes the sheet instead of leaving the page on browser Back", async () => {
it("closes the sheet instead of leaving the page on hardware Back", async () => {
const question = { const question = {
id: "q_back", id: "q_back",
title: "کشور", title: "کشور",
@ -202,7 +202,6 @@ describe("QuestionSheet component", () => {
options: [{ id: "iran", value: "Iran", label: "ایران", order: 1 }], options: [{ id: "iran", value: "Iran", label: "ایران", order: 1 }],
ui_config: {}, ui_config: {},
} as QuestionField; } as QuestionField;
const pushState = vi.spyOn(window.history, "pushState");
render( render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
@ -213,15 +212,17 @@ describe("QuestionSheet component", () => {
); );
fireEvent.click(screen.getByRole("button", { name: /انتخاب کشور/i })); fireEvent.click(screen.getByRole("button", { name: /انتخاب کشور/i }));
await waitFor(() => expect(pushState).toHaveBeenCalledTimes(1));
await waitFor(() => {
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
window.history.replaceState(null, "", window.location.href);
window.dispatchEvent(new PopStateEvent("popstate"));
const { handleHardwareBackSync } = await import("@/hooks/use-hardware-back-handler");
const handled = handleHardwareBackSync();
expect(handled).toBe(true);
await waitFor(() => { await waitFor(() => {
expect(screen.queryByRole("dialog")).toBeNull(); expect(screen.queryByRole("dialog")).toBeNull();
}); });
pushState.mockRestore();
}); });
it("opens a searchable sheet for 7+ options without focusing search", () => { it("opens a searchable sheet for 7+ options without focusing search", () => {

169
src/components/Componentes/question-snap-list.test.tsx

@ -1,4 +1,5 @@
import { import {
act,
cleanup, cleanup,
fireEvent, fireEvent,
render, render,
@ -443,4 +444,172 @@ describe("QuestionSnapList keyboard interaction", () => {
expect(onActiveIndexChange).not.toHaveBeenCalledWith(1); expect(onActiveIndexChange).not.toHaveBeenCalledWith(1);
}); });
}); });
describe("Periodic scroll hint idle cycle", () => {
it("follows 2s idle -> 2s visible -> 2s hidden -> repeat cycle and resets on user interaction", () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
const { container } = render(
<QuestionSnapList
firstQuestionHint={<span data-testid="scroll-hint">Scroll icon</span>}
>
<div>Question 1</div>
<div>Question 2</div>
</QuestionSnapList>,
);
const hintWrapper = container.querySelector(".motion-safe\\:animate-bounce");
expect(hintWrapper).not.toBeNull();
// Initially hidden (waiting 2s)
expect(hintWrapper).toHaveClass("opacity-0");
expect(hintWrapper).not.toHaveClass("opacity-100");
// Advance 1s: still hidden
act(() => {
vi.advanceTimersByTime(1000);
});
expect(hintWrapper).toHaveClass("opacity-0");
// Advance another 1s (total 2s idle): now visible
act(() => {
vi.advanceTimersByTime(1000);
});
expect(hintWrapper).toHaveClass("opacity-100");
// Advance 2s while visible (total 4s): becomes hidden
act(() => {
vi.advanceTimersByTime(2000);
});
expect(hintWrapper).toHaveClass("opacity-0");
// Advance 2s while hidden (total 6s): becomes visible again (cycle repeat)
act(() => {
vi.advanceTimersByTime(2000);
});
expect(hintWrapper).toHaveClass("opacity-100");
// User interacts (touchstart): immediately hides and restarts 2s idle timer
const region = screen.getByRole("region", { name: "Questions" });
act(() => {
fireEvent.touchStart(region);
});
expect(hintWrapper).toHaveClass("opacity-0");
// Advance 1.5s after touch: still hidden
act(() => {
vi.advanceTimersByTime(1500);
});
expect(hintWrapper).toHaveClass("opacity-0");
// Advance another 500ms (total 2s after touch): becomes visible again
act(() => {
vi.advanceTimersByTime(500);
});
expect(hintWrapper).toHaveClass("opacity-100");
vi.useRealTimers();
});
});
describe("Overflowing inline-options vs non-overflowing/sheet questions", () => {
it("allows inner scrolling for overflowing radio questions before snapping to next question", () => {
const onActiveIndexChange = vi.fn();
const { container } = render(
<QuestionSnapList onActiveIndexChange={onActiveIndexChange}>
<div>
<label>
<input type="radio" name="opt" value="1" /> Option 1
</label>
<label>
<input type="radio" name="opt" value="2" /> Option 2
</label>
<label>
<input type="radio" name="opt" value="3" /> Option 3
</label>
<label>
<input type="radio" name="opt" value="4" /> Option 4
</label>
</div>
<div>Question 2</div>
</QuestionSnapList>,
);
const contentEl = container.querySelector<HTMLElement>(".question-snap-content");
expect(contentEl).not.toBeNull();
if (contentEl) {
// Mock overflowing height: scrollHeight 600px > clientHeight 400px (maxScroll = 200px)
Object.defineProperty(contentEl, "scrollHeight", { value: 600, configurable: true });
Object.defineProperty(contentEl, "clientHeight", { value: 400, configurable: true });
contentEl.scrollTop = 0;
}
const region = screen.getByRole("region", { name: "Questions" });
// Drag up while at top (scrollTop = 0 < maxScroll = 200): should NOT flip question
fireEvent.touchStart(region, {
touches: [{ clientX: 100, clientY: 300 }],
});
fireEvent.touchMove(region, {
touches: [{ clientX: 100, clientY: 200 }],
});
fireEvent.touchEnd(region, {
changedTouches: [{ clientX: 100, clientY: 200 }],
});
expect(onActiveIndexChange).not.toHaveBeenCalledWith(1);
// Now simulate user having scrolled to bottom of options (scrollTop = 200)
if (contentEl) {
contentEl.scrollTop = 200;
}
// Drag up at the bottom: now it SHOULD snap to next question
fireEvent.touchStart(region, {
touches: [{ clientX: 100, clientY: 300 }],
});
fireEvent.touchMove(region, {
touches: [{ clientX: 100, clientY: 150 }],
});
fireEvent.touchEnd(region, {
changedTouches: [{ clientX: 100, clientY: 150 }],
});
expect(onActiveIndexChange).toHaveBeenCalledWith(1);
});
it("immediately snaps for non-overflowing questions (e.g. dropdown or short question)", () => {
const onActiveIndexChange = vi.fn();
const { container } = render(
<QuestionSnapList onActiveIndexChange={onActiveIndexChange}>
<div>
<button>Dropdown Trigger</button>
</div>
<div>Question 2</div>
</QuestionSnapList>,
);
const contentEl = container.querySelector<HTMLElement>(".question-snap-content");
if (contentEl) {
// Fits in screen: scrollHeight 300 <= clientHeight 400
Object.defineProperty(contentEl, "scrollHeight", { value: 300, configurable: true });
Object.defineProperty(contentEl, "clientHeight", { value: 400, configurable: true });
}
const region = screen.getByRole("region", { name: "Questions" });
fireEvent.touchStart(region, {
touches: [{ clientX: 100, clientY: 300 }],
});
fireEvent.touchMove(region, {
touches: [{ clientX: 100, clientY: 150 }],
});
fireEvent.touchEnd(region, {
changedTouches: [{ clientX: 100, clientY: 150 }],
});
expect(onActiveIndexChange).toHaveBeenCalledWith(1);
});
});
}); });

159
src/components/Componentes/question-snap-list.tsx

@ -60,6 +60,8 @@ type SnapDragState = {
hardIgnored: boolean; hardIgnored: boolean;
isTextInput: boolean; isTextInput: boolean;
isOptionCard: boolean; isOptionCard: boolean;
isScrollableQuestion: boolean;
scrollContainer: HTMLElement | null;
engaged: boolean; engaged: boolean;
didDrag: boolean; didDrag: boolean;
animating: boolean; animating: boolean;
@ -105,6 +107,7 @@ export function QuestionSnapList({
const previousActiveIndexRef = useRef<number | null>(null); const previousActiveIndexRef = useRef<number | null>(null);
const suppressNextClickRef = useRef(false); const suppressNextClickRef = useRef(false);
const [activeIndex, setActiveIndex] = useState(0); const [activeIndex, setActiveIndex] = useState(0);
const [isHintVisible, setIsHintVisible] = useState(false);
const activeIndexRef = useRef(activeIndex); const activeIndexRef = useRef(activeIndex);
activeIndexRef.current = activeIndex; activeIndexRef.current = activeIndex;
@ -116,6 +119,8 @@ export function QuestionSnapList({
hardIgnored: false, hardIgnored: false,
isTextInput: false, isTextInput: false,
isOptionCard: false, isOptionCard: false,
isScrollableQuestion: false,
scrollContainer: null,
engaged: false, engaged: false,
didDrag: false, didDrag: false,
animating: false, animating: false,
@ -143,6 +148,17 @@ export function QuestionSnapList({
return; return;
} }
const currentEl = questionRefs.current[activeIndexRef.current];
const currentContent = currentEl?.querySelector<HTMLElement>(".question-snap-content");
if (currentContent) {
currentContent.scrollTop = 0;
}
const nextEl = questionRefs.current[nextIndex];
const nextContent = nextEl?.querySelector<HTMLElement>(".question-snap-content");
if (nextContent) {
nextContent.scrollTop = 0;
}
resetQuestionKeyboardState(); resetQuestionKeyboardState();
onQuestionExit?.(activeIndexRef.current, nextIndex); onQuestionExit?.(activeIndexRef.current, nextIndex);
setActiveIndex(nextIndex); setActiveIndex(nextIndex);
@ -247,6 +263,76 @@ export function QuestionSnapList({
previousActiveIndexRef.current = activeIndex; previousActiveIndexRef.current = activeIndex;
}, [activeIndex, onQuestionTransition]); }, [activeIndex, onQuestionTransition]);
useEffect(() => {
let timerId: number | null = null;
let isCancelled = false;
const runCycle = (phase: "wait" | "show" | "hide") => {
if (isCancelled) return;
if (phase === "wait" || phase === "hide") {
setIsHintVisible(false);
timerId = window.setTimeout(() => {
if (isCancelled) return;
setIsHintVisible(true);
runCycle("show");
}, 2000);
} else if (phase === "show") {
setIsHintVisible(true);
timerId = window.setTimeout(() => {
if (isCancelled) return;
setIsHintVisible(false);
runCycle("hide");
}, 2000);
}
};
runCycle("wait");
const handleUserActivity = () => {
if (isCancelled) return;
setIsHintVisible(false);
if (timerId !== null) {
window.clearTimeout(timerId);
timerId = null;
}
runCycle("wait");
};
const container = containerRef.current;
if (container) {
container.addEventListener("touchstart", handleUserActivity, {
passive: true,
capture: true,
});
container.addEventListener("mousedown", handleUserActivity, {
passive: true,
capture: true,
});
container.addEventListener("keydown", handleUserActivity, {
passive: true,
capture: true,
});
container.addEventListener("wheel", handleUserActivity, {
passive: true,
capture: true,
});
}
return () => {
isCancelled = true;
if (timerId !== null) {
window.clearTimeout(timerId);
}
if (container) {
container.removeEventListener("touchstart", handleUserActivity, true);
container.removeEventListener("mousedown", handleUserActivity, true);
container.removeEventListener("keydown", handleUserActivity, true);
container.removeEventListener("wheel", handleUserActivity, true);
}
};
}, [activeIndex]);
useEffect(() => { useEffect(() => {
return () => { return () => {
if (wheelUnlockTimeoutRef.current !== null) { if (wheelUnlockTimeoutRef.current !== null) {
@ -353,6 +439,30 @@ export function QuestionSnapList({
return; return;
} }
const activeEl = questionRefs.current[activeIndexRef.current];
const contentEl = activeEl?.querySelector<HTMLElement>(".question-snap-content");
const hasInlineOptions = Boolean(
activeEl?.querySelector('input[type="radio"], input[type="checkbox"]')
);
const isOverflowing =
hasInlineOptions &&
contentEl != null &&
contentEl.scrollHeight > contentEl.clientHeight + 6;
if (isOverflowing && contentEl) {
const currentScroll = contentEl.scrollTop;
const maxScroll = contentEl.scrollHeight - contentEl.clientHeight;
const isScrollingDown = delta > 0;
const isScrollingUp = delta < 0;
if (isScrollingDown && currentScroll < maxScroll - 4) {
return;
}
if (isScrollingUp && currentScroll > 4) {
return;
}
}
event.preventDefault(); event.preventDefault();
if (!wheelLockedRef.current) { if (!wheelLockedRef.current) {
@ -468,6 +578,20 @@ export function QuestionSnapList({
drag.isOptionCard = isOptionCard; drag.isOptionCard = isOptionCard;
drag.didDrag = false; drag.didDrag = false;
// 3. Detect overflowing inline-options question
const activeEl = questionRefs.current[activeIndexRef.current];
const contentEl = activeEl?.querySelector<HTMLElement>(".question-snap-content");
const hasInlineOptions = Boolean(
activeEl?.querySelector('input[type="radio"], input[type="checkbox"]')
);
const isOverflowing =
hasInlineOptions &&
contentEl != null &&
contentEl.scrollHeight > contentEl.clientHeight + 6;
drag.isScrollableQuestion = Boolean(isOverflowing);
drag.scrollContainer = isOverflowing ? contentEl : null;
drag.height = drag.height =
container.getBoundingClientRect().height || container.getBoundingClientRect().height ||
window.innerHeight || window.innerHeight ||
@ -499,7 +623,7 @@ export function QuestionSnapList({
drag.lastMoveTime = drag.startTime; drag.lastMoveTime = drag.startTime;
touchStartYRef.current = drag.startY; touchStartYRef.current = drag.startY;
console.log(`[Snap] TouchStart at (${drag.startX.toFixed(0)}, ${drag.startY.toFixed(0)}) on <${target?.tagName?.toLowerCase()}> (isOption: ${isOptionCard})`);
console.log(`[Snap] TouchStart at (${drag.startX.toFixed(0)}, ${drag.startY.toFixed(0)}) on <${target?.tagName?.toLowerCase()}> (isOption: ${isOptionCard}, isScrollable: ${drag.isScrollableQuestion})`);
}; };
const onTouchMove = (event: TouchEvent) => { const onTouchMove = (event: TouchEvent) => {
@ -539,6 +663,25 @@ export function QuestionSnapList({
return; return;
} }
// If this question has inline options that overflow the screen:
if (drag.isScrollableQuestion && drag.scrollContainer) {
const scrollEl = drag.scrollContainer;
const currentScroll = scrollEl.scrollTop;
const maxScroll = scrollEl.scrollHeight - scrollEl.clientHeight;
const isDraggingUp = deltaY < 0; // Finger moving up -> scrolling down to view lower options
const isDraggingDown = deltaY > 0; // Finger moving down -> scrolling up towards top
if (isDraggingUp && currentScroll < maxScroll - 4) {
// Not at the bottom yet! Allow internal scroll of options to continue without snapping page.
return;
}
if (isDraggingDown && currentScroll > 4) {
// Not at the top yet! Allow internal scroll back to top without snapping page.
return;
}
}
// Pager claims the gesture! // Pager claims the gesture!
drag.engaged = true; drag.engaged = true;
drag.didDrag = true; drag.didDrag = true;
@ -553,8 +696,8 @@ export function QuestionSnapList({
console.log(`[Snap] Drag Engaged: deltaY=${deltaY.toFixed(0)}px, slop=${slop}px`); console.log(`[Snap] Drag Engaged: deltaY=${deltaY.toFixed(0)}px, slop=${slop}px`);
} }
// Non-passive preventDefault stops native pan and guarantees gesture ownership
if (event.cancelable) {
// Non-passive preventDefault stops native pan only when pager has claimed the gesture
if (drag.engaged && event.cancelable) {
event.preventDefault(); event.preventDefault();
} }
@ -614,8 +757,8 @@ export function QuestionSnapList({
} }
drag.pointerDown = false; drag.pointerDown = false;
// If it was a clean tap on an option card / button, leave it to native click!
if (drag.isOptionCard) {
// If it was a clean tap on an option card / button or an internal scroll on overflowing question, leave it!
if (drag.isOptionCard || drag.isScrollableQuestion) {
touchStartYRef.current = null; touchStartYRef.current = null;
return; return;
} }
@ -742,7 +885,9 @@ export function QuestionSnapList({
containerStyles, containerStyles,
].join(" ")} ].join(" ")}
> >
<div className={`question-snap-content ${wrapperStyles}`}>
<div
className={`question-snap-content ${wrapperStyles} overflow-y-auto max-h-full overscroll-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden p-0.5 pb-2`}
>
{question} {question}
</div> </div>
{isActive && ( {isActive && (
@ -760,7 +905,7 @@ export function QuestionSnapList({
className={[ className={[
"pointer-events-none absolute bottom-24 left-1/2 -translate-x-1/2", "pointer-events-none absolute bottom-24 left-1/2 -translate-x-1/2",
"transition-opacity duration-500 motion-safe:animate-bounce", "transition-opacity duration-500 motion-safe:animate-bounce",
activeIndex === 0 ? "opacity-100" : "opacity-0",
isHintVisible ? "opacity-100" : "opacity-0",
].join(" ")} ].join(" ")}
> >
{firstQuestionHint} {firstQuestionHint}

35
src/components/Componentes/report-actions-sheet.tsx

@ -3,11 +3,14 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Button from "./button"; import Button from "./button";
import { useFlutterBridge } from "@/hooks/useFlutterBridge"; import { useFlutterBridge } from "@/hooks/useFlutterBridge";
import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler";
import { import {
copyToClipboard, copyToClipboard,
downloadFile, downloadFile,
isInFlutterWebView, isInFlutterWebView,
openExternalUrl, openExternalUrl,
requestAutoLocation,
pickManualLocation,
} from "@/lib/webview-actions"; } from "@/lib/webview-actions";
const EXIT_ANIMATION_MS = 220; const EXIT_ANIMATION_MS = 220;
@ -31,6 +34,8 @@ export function ReportActionsSheet({ onClose }: ReportActionsSheetProps) {
setIsClosing(true); setIsClosing(true);
}; };
useHardwareBackHandler(closeSheet, isVisible && !isClosing);
// ✅ دکمه WEB_READY // ✅ دکمه WEB_READY
const handleSendWebReady = () => { const handleSendWebReady = () => {
sendToFlutter("WEB_READY", { sendToFlutter("WEB_READY", {
@ -41,11 +46,28 @@ export function ReportActionsSheet({ onClose }: ReportActionsSheetProps) {
console.log("✅ WEB_READY ارسال شد"); console.log("✅ WEB_READY ارسال شد");
}; };
// ✅ دکمه دریافت موقعیت مکانی
// پل اکنون از کانال واقعی HabibApp استفاده می‌کند، پس یک‌بار ارسال کافی است.
const handleGetLocation = () => {
sendToFlutter("REQUEST_LOCATION");
console.log("📍 REQUEST_LOCATION ارسال شد");
// ✅ دکمه دریافت خودکار موقعیت مکانی GPS (Auto)
const handleAutoLocation = async () => {
try {
const data = await requestAutoLocation();
alert(`📍 Auto Location: ${data.city || ""}, ${data.country || ""} (${data.latitude}, ${data.longitude})`);
} catch (e: any) {
alert(`❌ Auto Location Error: ${e.message}`);
}
};
// ✅ دکمه انتخاب دستی از روی نقشه فلاتر (Manual)
const handleManualLocation = async () => {
try {
const data = await pickManualLocation({ latitude: 35.6892, longitude: 51.3890 });
if (data) {
alert(`🗺️ Selected Location: ${data.city || ""}, ${data.country || ""} (${data.latitude}, ${data.longitude})`);
} else {
alert("⚠️ Map selection cancelled");
}
} catch (e: any) {
alert(`❌ Map Location Error: ${e.message}`);
}
}; };
// ✅ دکمه مشاور // ✅ دکمه مشاور
@ -200,7 +222,8 @@ export function ReportActionsSheet({ onClose }: ReportActionsSheetProps) {
<div className="p-3.5 flex flex-col gap-3"> <div className="p-3.5 flex flex-col gap-3">
{/* Main buttons */} {/* Main buttons */}
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Button onClick={handleGetLocation}>📍 Get Location</Button>
<Button onClick={handleAutoLocation}>📍 Auto Location (GPS)</Button>
<Button onClick={handleManualLocation}>🗺 Manual Location (Map)</Button>
<Button onClick={handleOpenConsultant}> <Button onClick={handleOpenConsultant}>
👨 Habib Consultation 👨 Habib Consultation
</Button> </Button>

3
src/components/Componentes/support-sheet.tsx

@ -4,6 +4,7 @@ import Image from "next/image";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { FiHeadphones } from "react-icons/fi"; import { FiHeadphones } from "react-icons/fi";
import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
const EXIT_ANIMATION_MS = 220; const EXIT_ANIMATION_MS = 220;
@ -45,6 +46,8 @@ export function SupportSheet({ isOpen, onClose }: SupportSheetProps) {
}, EXIT_ANIMATION_MS); }, EXIT_ANIMATION_MS);
}, [isClosing, onClose]); }, [isClosing, onClose]);
useHardwareBackHandler(closeSheet, isOpen && !isClosing);
// Lock body scroll // Lock body scroll
useEffect(() => { useEffect(() => {
if (!isOpen || !mounted) return; if (!isOpen || !mounted) return;

45
src/components/Componentes/test-questions-flow.tsx

@ -198,11 +198,11 @@ export default function TestQuestionsFlow({
[currentQuestion.id]: value, [currentQuestion.id]: value,
})); }));
// Auto advance to next question if not on last question
// Auto advance to next question if not on last question with slide animation
if (!isLastQuestion) { if (!isLastQuestion) {
setTimeout(() => { setTimeout(() => {
setCurrentIndex((prev) => Math.min(prev + 1, totalQuestions - 1)); setCurrentIndex((prev) => Math.min(prev + 1, totalQuestions - 1));
}, 250);
}, 200);
} }
}; };
@ -296,28 +296,44 @@ export default function TestQuestionsFlow({
</div> </div>
</div> </div>
{/* Question Section */}
<div className="flex-1 flex flex-col justify-start overflow-y-auto pt-6 pb-4">
{/* Question Slider Viewport */}
<div className="relative flex-1 w-full overflow-hidden min-h-0">
{questions.map((q, index) => {
const offset = index - currentIndex;
const isNearby = Math.abs(offset) <= 1;
if (!isNearby) return null;
const qSelectedValue = answers[q.id];
const options = q.options || [];
return (
<div
key={q.id}
aria-hidden={offset !== 0}
className={[
"absolute inset-0 flex flex-col justify-start overflow-y-auto pt-9 pb-4 transition-transform duration-250 ease-[cubic-bezier(0.25,1,0.5,1)]",
offset === 0 ? "pointer-events-auto" : "pointer-events-none",
].join(" ")}
style={{
transform: `translate3d(${offset * 100}%, 0, 0)`,
}}
>
{/* Question Title */} {/* Question Title */}
<h2 <h2
className="group-16 font-bold text-[#1B1B1B] leading-[1.45] text-center px-2 mb-8 flex items-center justify-center"
style={{ minHeight: "5.8em" }}
className="text-[17px] sm:text-[18px] font-bold text-[#1F2024] leading-[1.6] text-center px-3 mb-7 flex items-center justify-center shrink-0 min-h-[110px] sm:min-h-[120px]"
> >
<span className="w-full">{currentQuestion.text}</span>
<span className="w-full">{q.text}</span>
</h2> </h2>
{/* Answer Options Stack */} {/* Answer Options Stack */}
{(() => {
const options = currentQuestion.options || [];
return (
<div className="flex flex-col gap-3.5 pt-1"> <div className="flex flex-col gap-3.5 pt-1">
{options.map((option) => { {options.map((option) => {
const isSelected = selectedValue === option.value;
const isSelected = qSelectedValue === option.value;
return ( return (
<button <button
key={`${currentQuestion.id}-${String(option.value)}`}
key={`${q.id}-${String(option.value)}`}
type="button" type="button"
onClick={() => handleOptionSelect(option.value)} onClick={() => handleOptionSelect(option.value)}
className={[ className={[
@ -372,8 +388,9 @@ export default function TestQuestionsFlow({
); );
})} })}
</div> </div>
</div>
); );
})()}
})}
</div> </div>
{/* Bottom Actions Bar */} {/* Bottom Actions Bar */}

29
src/components/Componentes/use-sheet-scroll-lock.ts

@ -10,7 +10,6 @@ let initialHtmlOverflow = "";
let initialAppShellOverflow = ""; let initialAppShellOverflow = "";
let initialAppShellTouchAction = ""; let initialAppShellTouchAction = "";
let lockedAppShell: HTMLElement | null = null; let lockedAppShell: HTMLElement | null = null;
const SHEET_HISTORY_KEY = "__habibQuestionSheet";
type SheetScrollLockOptions = { type SheetScrollLockOptions = {
onBack?: () => void; onBack?: () => void;
@ -25,7 +24,7 @@ export function useSheetScrollLock(
onBackRef.current = onBack; onBackRef.current = onBack;
// Register in the hardware-back handler stack so that Flutter's // Register in the hardware-back handler stack so that Flutter's
// __habibHandleHardwareBack() closes the sheet instead of navigating.
// __habibHandleHardwareBackSync() closes the sheet instead of exiting the screen.
const handleHardwareBack = useCallback(() => { const handleHardwareBack = useCallback(() => {
if (onBackRef.current) { if (onBackRef.current) {
onBackRef.current(); onBackRef.current();
@ -39,18 +38,6 @@ export function useSheetScrollLock(
useEffect(() => { useEffect(() => {
if (!isOpen) return; if (!isOpen) return;
const historyState = window.history.state;
const ownsHistoryEntry =
historyState?.[SHEET_HISTORY_KEY] !== true && activeSheetCount === 0;
if (ownsHistoryEntry) {
window.history.pushState(
{ ...(historyState ?? {}), [SHEET_HISTORY_KEY]: true },
"",
window.location.href,
);
}
if (activeSheetCount === 0) { if (activeSheetCount === 0) {
bodyHadDropdownClass = document.body.classList.contains("dropdown-open"); bodyHadDropdownClass = document.body.classList.contains("dropdown-open");
initialBodyOverflow = document.body.style.overflow; initialBodyOverflow = document.body.style.overflow;
@ -70,23 +57,9 @@ export function useSheetScrollLock(
lockedAppShell.style.touchAction = "none"; lockedAppShell.style.touchAction = "none";
} }
const handlePopState = () => {
if (ownsHistoryEntry) {
onBackRef.current?.();
}
};
window.addEventListener("popstate", handlePopState);
return () => { return () => {
window.removeEventListener("popstate", handlePopState);
activeSheetCount = Math.max(0, activeSheetCount - 1); activeSheetCount = Math.max(0, activeSheetCount - 1);
if (activeSheetCount === 0) { if (activeSheetCount === 0) {
if (
ownsHistoryEntry &&
window.history.state?.[SHEET_HISTORY_KEY] === true
) {
window.history.back();
}
document.body.style.overflow = initialBodyOverflow; document.body.style.overflow = initialBodyOverflow;
document.documentElement.style.overflow = initialHtmlOverflow; document.documentElement.style.overflow = initialHtmlOverflow;
if (lockedAppShell) { if (lockedAppShell) {

3
src/components/Componentes/video-player.tsx

@ -2,6 +2,7 @@
import { useEffect, useState, type FC } from "react"; import { useEffect, useState, type FC } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler";
type VideoPlayerProps = { type VideoPlayerProps = {
isOpen: boolean; isOpen: boolean;
@ -24,6 +25,8 @@ export const VideoPlayer: FC<VideoPlayerProps> = ({
setMounted(true); setMounted(true);
}, []); }, []);
useHardwareBackHandler(onClose, isOpen);
const handleLoadedMetadata = (e: React.SyntheticEvent<HTMLVideoElement>) => { const handleLoadedMetadata = (e: React.SyntheticEvent<HTMLVideoElement>) => {
const video = e.currentTarget; const video = e.currentTarget;
if (video.videoWidth && video.videoHeight) { if (video.videoWidth && video.videoHeight) {

58
src/data/currencies.test.ts

@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import {
COUNTRY_CODE_TO_CURRENCY,
resolveDefaultCurrency,
CURRENCIES,
} from "./currencies";
describe("currencies", () => {
it("should map country code IR to TOMAN", () => {
expect(COUNTRY_CODE_TO_CURRENCY["IR"]).toBe("TOMAN");
expect(resolveDefaultCurrency({ countryCode: "IR" })).toBe("TOMAN");
expect(resolveDefaultCurrency({ countryCode: "ir" })).toBe("TOMAN");
});
it("should map country codes correctly for other countries", () => {
expect(resolveDefaultCurrency({ countryCode: "US" })).toBe("USD");
expect(resolveDefaultCurrency({ countryCode: "GB" })).toBe("GBP");
expect(resolveDefaultCurrency({ countryCode: "DE" })).toBe("EUR");
expect(resolveDefaultCurrency({ countryCode: "AE" })).toBe("AED");
expect(resolveDefaultCurrency({ countryCode: "TR" })).toBe("TRY");
expect(resolveDefaultCurrency({ countryCode: "IQ" })).toBe("IQD");
expect(resolveDefaultCurrency({ countryCode: "AF" })).toBe("AFN");
expect(resolveDefaultCurrency({ countryCode: "CA" })).toBe("CAD");
expect(resolveDefaultCurrency({ countryCode: "TJ" })).toBe("TJS");
});
it("should map country names in English and Persian correctly", () => {
expect(resolveDefaultCurrency({ countryName: "Iran" })).toBe("TOMAN");
expect(resolveDefaultCurrency({ countryName: "ایران" })).toBe("TOMAN");
expect(resolveDefaultCurrency({ countryName: "Iraq" })).toBe("IQD");
expect(resolveDefaultCurrency({ countryName: "عراق" })).toBe("IQD");
expect(resolveDefaultCurrency({ countryName: "Turkey" })).toBe("TRY");
expect(resolveDefaultCurrency({ countryName: "ترکیه" })).toBe("TRY");
expect(resolveDefaultCurrency({ countryName: "United Arab Emirates" })).toBe("AED");
expect(resolveDefaultCurrency({ countryName: "امارات" })).toBe("AED");
});
it("should fallback to TOMAN for Persian locale if no country is detected", () => {
expect(resolveDefaultCurrency({ fallbackLocale: "fa" })).toBe("TOMAN");
expect(resolveDefaultCurrency({ fallbackLocale: "fa-ir" })).toBe("TOMAN");
});
it("should fallback to USD for other locales if no country is detected", () => {
expect(resolveDefaultCurrency({ fallbackLocale: "en" })).toBe("USD");
expect(resolveDefaultCurrency({})).toBe("USD");
});
it("should have valid currency items in CURRENCIES list", () => {
expect(CURRENCIES.length).toBeGreaterThan(50);
const toman = CURRENCIES.find((c) => c.code === "TOMAN");
expect(toman).toBeDefined();
expect(toman?.nameFa).toBe("تومان ایران");
const usd = CURRENCIES.find((c) => c.code === "USD");
expect(usd).toBeDefined();
expect(usd?.nameEn).toBe("US Dollar");
});
});

381
src/data/currencies.ts

@ -0,0 +1,381 @@
export type CurrencyItem = {
code: string;
nameEn: string;
nameFa: string;
symbol?: string;
};
export const COUNTRY_CODE_TO_CURRENCY: Record<string, string> = {
IR: "TOMAN",
US: "USD",
GB: "GBP",
UK: "GBP",
DE: "EUR",
FR: "EUR",
IT: "EUR",
ES: "EUR",
NL: "EUR",
BE: "EUR",
AT: "EUR",
PT: "EUR",
GR: "EUR",
FI: "EUR",
IE: "EUR",
SK: "EUR",
SI: "EUR",
LT: "EUR",
LV: "EUR",
EE: "EUR",
CY: "EUR",
MT: "EUR",
LU: "EUR",
MC: "EUR",
SM: "EUR",
VA: "EUR",
AD: "EUR",
ME: "EUR",
XK: "EUR",
AE: "AED",
TR: "TRY",
IQ: "IQD",
AF: "AFN",
PK: "PKR",
SA: "SAR",
QA: "QAR",
KW: "KWD",
OM: "OMR",
BH: "BHD",
CA: "CAD",
AU: "AUD",
SE: "SEK",
NO: "NOK",
DK: "DKK",
CH: "CHF",
RU: "RUB",
CN: "CNY",
JP: "JPY",
KR: "KRW",
IN: "INR",
MY: "MYR",
ID: "IDR",
TH: "THB",
SG: "SGD",
NZ: "NZD",
ZA: "ZAR",
BR: "BRL",
MX: "MXN",
AR: "ARS",
AZ: "AZN",
TJ: "TJS",
UZ: "UZS",
TM: "TMT",
KZ: "KZT",
KG: "KGS",
GE: "GEL",
AM: "AMD",
LB: "LBP",
SY: "SYP",
JO: "JOD",
EG: "EGP",
YE: "YER",
MA: "MAD",
DZ: "DZD",
TN: "TND",
LY: "LYD",
SD: "SDG",
BD: "BDT",
LK: "LKR",
NP: "NPR",
PH: "PHP",
VN: "VND",
PL: "PLN",
CZ: "CZK",
HU: "HUF",
RO: "RON",
BG: "BGN",
HR: "EUR",
RS: "RSD",
BA: "BAM",
AL: "ALL",
MD: "MDL",
UA: "UAH",
BY: "BYN",
IS: "ISK",
CL: "CLP",
CO: "COP",
PE: "PEN",
VE: "VES",
EC: "USD",
PA: "USD",
CR: "CRC",
DO: "DOP",
GT: "GTQ",
NG: "NGN",
KES: "KES",
GH: "GHS",
ET: "ETB",
TZ: "TZS",
UG: "UGX",
};
export const COUNTRY_NAME_TO_CURRENCY: Record<string, string> = {
// English
Iran: "TOMAN",
"United States": "USD",
"United States of America": "USD",
USA: "USD",
"United Kingdom": "GBP",
UK: "GBP",
Britain: "GBP",
Canada: "CAD",
Germany: "EUR",
France: "EUR",
Italy: "EUR",
Spain: "EUR",
Netherlands: "EUR",
Belgium: "EUR",
Austria: "EUR",
Portugal: "EUR",
Greece: "EUR",
Finland: "EUR",
Ireland: "EUR",
"United Arab Emirates": "AED",
UAE: "AED",
Turkey: "TRY",
Iraq: "IQD",
Afghanistan: "AFN",
Pakistan: "PKR",
"Saudi Arabia": "SAR",
Qatar: "QAR",
Kuwait: "KWD",
Oman: "OMR",
Bahrain: "BHD",
Sweden: "SEK",
Norway: "NOK",
Denmark: "DKK",
Switzerland: "CHF",
Australia: "AUD",
"New Zealand": "NZD",
Russia: "RUB",
China: "CNY",
Japan: "JPY",
"South Korea": "KRW",
India: "INR",
Malaysia: "MYR",
Indonesia: "IDR",
Singapore: "SGD",
Thailand: "THB",
Tajikistan: "TJS",
Azerbaijan: "AZN",
Uzbekistan: "UZS",
Turkmenistan: "TMT",
Kazakhstan: "KZT",
Kyrgyzstan: "KGS",
Georgia: "GEL",
Armenia: "AMD",
Lebanon: "LBP",
Syria: "SYP",
Jordan: "JOD",
Egypt: "EGP",
Yemen: "YER",
Morocco: "MAD",
Algeria: "DZD",
Tunisia: "TND",
Libya: "LYD",
Sudan: "SDG",
Brazil: "BRL",
Mexico: "MXN",
Argentina: "ARS",
Bulgaria: "BGN",
Poland: "PLN",
"Czech Republic": "CZK",
Hungary: "HUF",
Romania: "RON",
Serbia: "RSD",
Ukraine: "UAH",
Belarus: "BYN",
// Persian
ایران: "TOMAN",
"ایالات متحده": "USD",
"ایالات متحده آمریکا": "USD",
آمریکا: "USD",
بریتانیا: "GBP",
انگلیس: "GBP",
کانادا: "CAD",
آلمان: "EUR",
فرانسه: "EUR",
ایتالیا: "EUR",
اسپانیا: "EUR",
هلند: "EUR",
بلژیک: "EUR",
اتریش: "EUR",
پرتغال: "EUR",
یونان: "EUR",
فنلاند: "EUR",
ایرلند: "EUR",
"امارات متحده عربی": "AED",
امارات: "AED",
ترکیه: "TRY",
عراق: "IQD",
افغانستان: "AFN",
پاکستان: "PKR",
"عربستان سعودی": "SAR",
عربستان: "SAR",
قطر: "QAR",
کویت: "KWD",
عمان: "OMR",
بحرین: "BHD",
سوئد: "SEK",
نروژ: "NOK",
دانمارک: "DKK",
سوئیس: "CHF",
استرالیا: "AUD",
نیوزیلند: "NZD",
روسیه: "RUB",
چین: "CNY",
ژاپن: "JPY",
"کره جنوبی": "KRW",
هند: "INR",
مالزی: "MYR",
اندونزی: "IDR",
سنگاپور: "SGD",
تایلند: "THB",
تاجیکستان: "TJS",
آذربایجان: "AZN",
ازبکستان: "UZS",
ترکمنستان: "TMT",
قزاقستان: "KZT",
قرقیزستان: "KGS",
گرجستان: "GEL",
ارمنستان: "AMD",
لبنان: "LBP",
سوریه: "SYP",
اردن: "JOD",
مصر: "EGP",
یمن: "YER",
مراکش: "MAD",
الجزایر: "DZD",
تونس: "TND",
لیبی: "LYD",
سودان: "SDG",
برزیل: "BRL",
مکزیک: "MXN",
آرژانتین: "ARS",
بلغارستان: "BGN",
لهستان: "PLN",
چک: "CZK",
مجارستان: "HUF",
رومانی: "RON",
صربستان: "RSD",
اوکراین: "UAH",
بلاروس: "BYN",
};
export const CURRENCIES: CurrencyItem[] = [
{ code: "TOMAN", nameEn: "Iranian Toman", nameFa: "تومان ایران", symbol: "تومان" },
{ code: "USD", nameEn: "US Dollar", nameFa: "دلار آمریکا", symbol: "$" },
{ code: "EUR", nameEn: "Euro", nameFa: "یورو", symbol: "€" },
{ code: "AED", nameEn: "UAE Dirham", nameFa: "درهم امارات", symbol: "د.إ" },
{ code: "TRY", nameEn: "Turkish Lira", nameFa: "لیر ترکیه", symbol: "₺" },
{ code: "GBP", nameEn: "British Pound", nameFa: "پوند بریتانیا", symbol: "£" },
{ code: "CAD", nameEn: "Canadian Dollar", nameFa: "دلار کانادا", symbol: "CA$" },
{ code: "AUD", nameEn: "Australian Dollar", nameFa: "دلار استرالیا", symbol: "AU$" },
{ code: "IQD", nameEn: "Iraqi Dinar", nameFa: "دینار عراق", symbol: "ع.د" },
{ code: "SAR", nameEn: "Saudi Riyal", nameFa: "ریال سعودی", symbol: "﷼" },
{ code: "QAR", nameEn: "Qatari Riyal", nameFa: "ریال قطر", symbol: "ر.ق" },
{ code: "KWD", nameEn: "Kuwaiti Dinar", nameFa: "دینار کویت", symbol: "د.ك" },
{ code: "OMR", nameEn: "Omani Rial", nameFa: "ریال عمان", symbol: "ر.ع." },
{ code: "BHD", nameEn: "Bahraini Dinar", nameFa: "دینار بحرین", symbol: ".د.ب" },
{ code: "AFN", nameEn: "Afghan Afghani", nameFa: "افغانی افغانستان", symbol: "؋" },
{ code: "PKR", nameEn: "Pakistani Rupee", nameFa: "روپیه پاکستان", symbol: "₨" },
{ code: "TJS", nameEn: "Tajikistani Somoni", nameFa: "سامانی تاجیکستان", symbol: "смн" },
{ code: "AZN", nameEn: "Azerbaijani Manat", nameFa: "منات آذربایجان", symbol: "₼" },
{ code: "RUB", nameEn: "Russian Ruble", nameFa: "روبل روسیه", symbol: "₽" },
{ code: "CNY", nameEn: "Chinese Yuan", nameFa: "یوان چین", symbol: "¥" },
{ code: "JPY", nameEn: "Japanese Yen", nameFa: "ین ژاپن", symbol: "¥" },
{ code: "CHF", nameEn: "Swiss Franc", nameFa: "فرانک سوئیس", symbol: "CHF" },
{ code: "SEK", nameEn: "Swedish Krona", nameFa: "کرون سوئد", symbol: "kr" },
{ code: "NOK", nameEn: "Norwegian Krone", nameFa: "کرون نروژ", symbol: "kr" },
{ code: "DKK", nameEn: "Danish Krone", nameFa: "کرون دانمارک", symbol: "kr" },
{ code: "INR", nameEn: "Indian Rupee", nameFa: "روپیه هند", symbol: "₹" },
{ code: "MYR", nameEn: "Malaysian Ringgit", nameFa: "رینگیت مالزی", symbol: "RM" },
{ code: "SGD", nameEn: "Singapore Dollar", nameFa: "دلار سنگاپور", symbol: "S$" },
{ code: "NZD", nameEn: "New Zealand Dollar", nameFa: "دلار نیوزیلند", symbol: "NZ$" },
{ code: "BRL", nameEn: "Brazilian Real", nameFa: "رئال برزیل", symbol: "R$" },
{ code: "MXN", nameEn: "Mexican Peso", nameFa: "پزو مکزیک", symbol: "Mex$" },
{ code: "ARS", nameEn: "Argentine Peso", nameFa: "پزو آرژانتین", symbol: "ARS$" },
{ code: "EGP", nameEn: "Egyptian Pound", nameFa: "پوند مصر", symbol: "E£" },
{ code: "ZAR", nameEn: "South African Rand", nameFa: "راند آفریقای جنوبی", symbol: "R" },
{ code: "IDR", nameEn: "Indonesian Rupiah", nameFa: "روپیه اندونزی", symbol: "Rp" },
{ code: "THB", nameEn: "Thai Baht", nameFa: "بات تایلند", symbol: "฿" },
{ code: "KRW", nameEn: "South Korean Won", nameFa: "وون کره جنوبی", symbol: "₩" },
{ code: "UZS", nameEn: "Uzbekistani Som", nameFa: "سوم ازبکستان", symbol: "so'm" },
{ code: "TMT", nameEn: "Turkmenistani Manat", nameFa: "منات ترکمنستان", symbol: "TMT" },
{ code: "KZT", nameEn: "Kazakhstani Tenge", nameFa: "تنگه قزاقستان", symbol: "₸" },
{ code: "KGS", nameEn: "Kyrgystani Som", nameFa: "سوم قرقیزستان", symbol: "сом" },
{ code: "GEL", nameEn: "Georgian Lari", nameFa: "لاری گرجستان", symbol: "₾" },
{ code: "AMD", nameEn: "Armenian Dram", nameFa: "درام ارمنستان", symbol: "֏" },
{ code: "LBP", nameEn: "Lebanese Pound", nameFa: "لیره لبنان", symbol: "ل.ل" },
{ code: "SYP", nameEn: "Syrian Pound", nameFa: "لیره سوریه", symbol: "ل.س" },
{ code: "JOD", nameEn: "Jordanian Dinar", nameFa: "دینار اردن", symbol: "د.ا" },
{ code: "YER", nameEn: "Yemeni Rial", nameFa: "ریال یمن", symbol: "﷼" },
{ code: "MAD", nameEn: "Moroccan Dirham", nameFa: "درهم مراکش", symbol: "د.م." },
{ code: "DZD", nameEn: "Algerian Dinar", nameFa: "دینار الجزایر", symbol: "د.ج" },
{ code: "TND", nameEn: "Tunisian Dinar", nameFa: "دینار تونس", symbol: "د.ت" },
{ code: "LYD", nameEn: "Libyan Dinar", nameFa: "دینار لیبی", symbol: "ل.د" },
{ code: "SDG", nameEn: "Sudanese Pound", nameFa: "پوند سودان", symbol: "ج.س." },
{ code: "BDT", nameEn: "Bangladeshi Taka", nameFa: "تاکای بنگلادش", symbol: "৳" },
{ code: "BGN", nameEn: "Bulgarian Lev", nameFa: "لو بلغارستان", symbol: "лв" },
{ code: "PLN", nameEn: "Polish Zloty", nameFa: "زلوتی لهستان", symbol: "zł" },
{ code: "CZK", nameEn: "Czech Koruna", nameFa: "کرون چک", symbol: "Kč" },
{ code: "HUF", nameEn: "Hungarian Forint", nameFa: "فورینت مجارستان", symbol: "Ft" },
{ code: "RON", nameEn: "Romanian Leu", nameFa: "لئو رومانی", symbol: "lei" },
{ code: "RSD", nameEn: "Serbian Dinar", nameFa: "دینار صربستان", symbol: "дин." },
{ code: "BAM", nameEn: "Bosnia Mark", nameFa: "مارک بوسنی", symbol: "KM" },
{ code: "ALL", nameEn: "Albanian Lek", nameFa: "لک آلبانی", symbol: "Lek" },
{ code: "UAH", nameEn: "Ukrainian Hryvnia", nameFa: "گریونا اوکراین", symbol: "₴" },
{ code: "BYN", nameEn: "Belarusian Ruble", nameFa: "روبل بلاروس", symbol: "Br" },
{ code: "ISK", nameEn: "Icelandic Króna", nameFa: "کرون ایسلند", symbol: "kr" },
{ code: "CLP", nameEn: "Chilean Peso", nameFa: "پزو شیلی", symbol: "CLP$" },
{ code: "COP", nameEn: "Colombian Peso", nameFa: "پزو کلمبیا", symbol: "COL$" },
{ code: "PEN", nameEn: "Peruvian Sol", nameFa: "سول پرو", symbol: "S/." },
{ code: "CRC", nameEn: "Costa Rican Colón", nameFa: "کولون کاستاریکا", symbol: "₡" },
{ code: "DOP", nameEn: "Dominican Peso", nameFa: "پزو دومینیکن", symbol: "RD$" },
{ code: "GTQ", nameEn: "Guatemalan Quetzal", nameFa: "کوتزال گواتمالا", symbol: "Q" },
{ code: "NGN", nameEn: "Nigerian Naira", nameFa: "نایرا نیجریه", symbol: "₦" },
{ code: "KES", nameEn: "Kenyan Shilling", nameFa: "شیلینگ کنیا", symbol: "KSh" },
{ code: "GHS", nameEn: "Ghanaian Cedi", nameFa: "سدی غنا", symbol: "GH₵" },
{ code: "ETB", nameEn: "Ethiopian Birr", nameFa: "بیر اتیوپی", symbol: "Br" },
{ code: "IRR", nameEn: "Iranian Rial", nameFa: "ریال ایران", symbol: "﷼" },
];
export function resolveDefaultCurrency({
countryCode,
countryName,
fallbackLocale,
}: {
countryCode?: string | null;
countryName?: string | null;
fallbackLocale?: string | null;
}): string {
if (countryCode && typeof countryCode === "string") {
const cleanCode = countryCode.trim().toUpperCase();
if (COUNTRY_CODE_TO_CURRENCY[cleanCode]) {
return COUNTRY_CODE_TO_CURRENCY[cleanCode];
}
}
if (countryName && typeof countryName === "string") {
const cleanName = countryName.trim();
if (COUNTRY_NAME_TO_CURRENCY[cleanName]) {
return COUNTRY_NAME_TO_CURRENCY[cleanName];
}
}
if (fallbackLocale === "fa" || fallbackLocale === "fa-ir") {
return "TOMAN";
}
return "USD";
}

15
src/hooks/marriage/use-upload-tmp-media.ts

@ -8,7 +8,10 @@ import type { UploadTmpMediaResponse } from "./types";
const CSRF_TOKEN = const CSRF_TOKEN =
"53kqNKySTv3q4K3OolQqLEgaeF9pdPdAEnxrMARaUfvFrIGK57Qje67ifYUDMUQP"; "53kqNKySTv3q4K3OolQqLEgaeF9pdPdAEnxrMARaUfvFrIGK57Qje67ifYUDMUQP";
export async function uploadTmpMedia(file: File) {
export async function uploadTmpMedia(
file: File,
onProgress?: (progressPercent: number) => void,
) {
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);
@ -20,6 +23,14 @@ export async function uploadTmpMedia(file: File) {
Accept: "application/json", Accept: "application/json",
"X-CSRFToken": CSRF_TOKEN, "X-CSRFToken": CSRF_TOKEN,
}, },
onUploadProgress: (progressEvent) => {
if (progressEvent.total && onProgress) {
const percent = Math.round(
(progressEvent.loaded * 100) / progressEvent.total,
);
onProgress(percent);
}
},
}, },
); );
@ -31,6 +42,6 @@ export function useUploadTmpMediaMutation(
) { ) {
return useMutation({ return useMutation({
...options, ...options,
mutationFn: uploadTmpMedia,
mutationFn: (file: File) => uploadTmpMedia(file),
}); });
} }

52
src/hooks/use-hardware-back-handler.ts

@ -13,32 +13,19 @@ import { useEffect, useRef } from "react";
* Pages register themselves with useHardwareBackHandler(). Sheets and modals * Pages register themselves with useHardwareBackHandler(). Sheets and modals
* also register the last one wins, matching the visual stacking order. * also register the last one wins, matching the visual stacking order.
*/ */
const backHandlerStack: Array<() => boolean | Promise<boolean>> = [];
const backHandlerStack: Array<() => void | boolean | Promise<void | boolean>> = [];
/** /**
* Register a hardware-back handler. When the user presses the hardware back * Register a hardware-back handler. When the user presses the hardware back
* button (Android), Flutter calls window.__habibHandleHardwareBack(). The
* root handler walks the stack from top to bottom and calls the first handler.
* button (Android), Flutter calls window.__habibHandleHardwareBackSync() or
* window.__habibHandleHardwareBack().
* *
* @param handler - Return true if you handled the back (e.g. closed a sheet,
* flushed answers and navigated). Return false if you didn't handle it
* (Flutter should close the WebView).
* @param enabled - When false, the handler is not registered. Useful for
* conditionally enabling back handling.
*
* @example
* // In questions-list (root): back = close service
* useHardwareBackHandler(() => false);
*
* // In question-detail: back = flush + navigate
* useHardwareBackHandler(async () => {
* await flushAnswers({ force: true });
* router.replace(questionsListHref);
* return true;
* });
* @param handler - Return true (or void) if you handled the back (e.g. closed a sheet).
* Return false if you didn't handle it (Flutter should close the WebView).
* @param enabled - When false, the handler is not registered.
*/ */
export function useHardwareBackHandler( export function useHardwareBackHandler(
handler: () => boolean | Promise<boolean>,
handler: () => void | boolean | Promise<void | boolean>,
enabled = true, enabled = true,
) { ) {
const handlerRef = useRef(handler); const handlerRef = useRef(handler);
@ -60,6 +47,29 @@ export function useHardwareBackHandler(
}, [enabled]); }, [enabled]);
} }
/**
* Synchronously invokes the topmost hardware-back handler if registered.
* Returns true if a handler consumed the event, or false if Flutter should handle/close.
*/
export function handleHardwareBackSync(): boolean {
if (backHandlerStack.length === 0) {
return false;
}
const handler = backHandlerStack[backHandlerStack.length - 1];
try {
const result = handler();
if (result instanceof Promise) {
// Promise was initiated by invoking the handler; it is handled in JS!
return true;
}
return result === undefined ? true : Boolean(result);
} catch (error) {
console.warn("[HardwareBackSync] Handler threw:", error);
return false;
}
}
/** /**
* Called by the root bootstrap script when Flutter sends a hardware back event. * Called by the root bootstrap script when Flutter sends a hardware back event.
* Returns { handled: true } if a web handler consumed the event, or * Returns { handled: true } if a web handler consumed the event, or
@ -74,7 +84,7 @@ export async function handleHardwareBack(): Promise<{ handled: boolean }> {
const handler = backHandlerStack[backHandlerStack.length - 1]; const handler = backHandlerStack[backHandlerStack.length - 1];
try { try {
const result = await handler(); const result = await handler();
return { handled: result };
return { handled: result === undefined ? true : Boolean(result) };
} catch (error) { } catch (error) {
console.warn("[HardwareBack] Handler threw:", error); console.warn("[HardwareBack] Handler threw:", error);
return { handled: false }; return { handled: false };

45
src/lib/geo-region.ts

@ -9,6 +9,8 @@ export type UserGeoRegion = {
country?: string; country?: string;
countryCode?: string; // e.g. "IR", "US", "GB" countryCode?: string; // e.g. "IR", "US", "GB"
phoneCode?: string; // e.g. "+98", "+1", "+44" phoneCode?: string; // e.g. "+98", "+1", "+44"
latitude?: number;
longitude?: number;
}; };
const phoneUtil = PhoneNumberUtil.getInstance(); const phoneUtil = PhoneNumberUtil.getInstance();
@ -44,7 +46,10 @@ export function getStoredUserGeoRegion(): UserGeoRegion | null {
const parsed = JSON.parse(stored) as UserGeoRegion; const parsed = JSON.parse(stored) as UserGeoRegion;
if ( if (
parsed && parsed &&
(parsed.country || parsed.phoneCode || parsed.city || parsed.countryCode)
(parsed.country ||
parsed.phoneCode ||
parsed.city ||
parsed.countryCode)
) { ) {
cachedRegion = parsed; cachedRegion = parsed;
return parsed; return parsed;
@ -56,17 +61,24 @@ export function getStoredUserGeoRegion(): UserGeoRegion | null {
} }
export function setStoredUserGeoRegion(region: UserGeoRegion) { export function setStoredUserGeoRegion(region: UserGeoRegion) {
cachedRegion = region;
const current =
cachedRegion ||
(typeof window !== "undefined" ? getStoredUserGeoRegion() : null);
const merged: UserGeoRegion = {
...(current || {}),
...region,
};
cachedRegion = merged;
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
try { try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(region));
if (region.phoneCode) {
localStorage.setItem(PHONE_STORAGE_KEY, region.phoneCode);
localStorage.setItem(STORAGE_KEY, JSON.stringify(merged));
if (merged.phoneCode) {
localStorage.setItem(PHONE_STORAGE_KEY, merged.phoneCode);
} }
} catch {} } catch {}
} }
listeners.forEach((fn) => { listeners.forEach((fn) => {
fn(region);
fn(merged);
}); });
} }
@ -87,7 +99,10 @@ function getFallbackGeoRegion(): UserGeoRegion {
const existing = getStoredUserGeoRegion(); const existing = getStoredUserGeoRegion();
if ( if (
existing && existing &&
(existing.country || existing.phoneCode || existing.countryCode || existing.city)
(existing.country ||
existing.phoneCode ||
existing.countryCode ||
existing.city)
) { ) {
console.log( console.log(
"[GEO_BRIDGE_LOG] 📦 Preserving existing stored user geo region:", "[GEO_BRIDGE_LOG] 📦 Preserving existing stored user geo region:",
@ -150,12 +165,14 @@ function fetchFlutterBridgeGeoRegion(): Promise<UserGeoRegion> {
JSON.stringify(event), JSON.stringify(event),
); );
const data = (event.data || (event as any).payload) as {
const data = (event.data || (event as any).payload) as
| {
ip?: string; ip?: string;
country?: string; country?: string;
country_code?: string; country_code?: string;
city?: string; city?: string;
} | undefined;
}
| undefined;
if ( if (
event.success && event.success &&
@ -186,6 +203,8 @@ function fetchFlutterBridgeGeoRegion(): Promise<UserGeoRegion> {
country: countryName, country: countryName,
countryCode: isoCode, countryCode: isoCode,
phoneCode: phoneCode || "+44", phoneCode: phoneCode || "+44",
latitude: (data as any).latitude,
longitude: (data as any).longitude,
}; };
setStoredUserGeoRegion(region); setStoredUserGeoRegion(region);
finish(region); finish(region);
@ -241,12 +260,12 @@ function fetchFlutterBridgeGeoRegion(): Promise<UserGeoRegion> {
* Never performs direct HTTP requests. * Never performs direct HTTP requests.
*/ */
export function getUserGeoRegion(force = false): Promise<UserGeoRegion> { export function getUserGeoRegion(force = false): Promise<UserGeoRegion> {
// If !force: Check cachedRegion or getStoredUserGeoRegion(). If present, return it immediately.
// If !force: Check cachedRegion or getStoredUserGeoRegion(). If present and has location, return it immediately.
if (!force) { if (!force) {
const existing = cachedRegion || getStoredUserGeoRegion(); const existing = cachedRegion || getStoredUserGeoRegion();
if ( if (
existing && existing &&
(existing.city || existing.country || existing.phoneCode || existing.countryCode)
(existing.country || existing.countryCode || existing.city)
) { ) {
console.log( console.log(
"[GEO_BRIDGE_LOG] 📦 Returning cached/stored user geo region:", "[GEO_BRIDGE_LOG] 📦 Returning cached/stored user geo region:",
@ -273,7 +292,9 @@ export function getUserGeoRegion(force = false): Promise<UserGeoRegion> {
console.log( console.log(
"[GEO_BRIDGE_LOG] 📱 Inside Flutter WebView detected, requesting location via Flutter bridge action 'get_location'", "[GEO_BRIDGE_LOG] 📱 Inside Flutter WebView detected, requesting location via Flutter bridge action 'get_location'",
); );
geoRegionPromise = fetchFlutterBridgeGeoRegion();
geoRegionPromise = fetchFlutterBridgeGeoRegion().finally(() => {
geoRegionPromise = null;
});
} else { } else {
console.log( console.log(
"[GEO_BRIDGE_LOG] 🌐 Standard browser / non-Flutter environment: resolving from cached storage or default fallback (no HTTP calls)", "[GEO_BRIDGE_LOG] 🌐 Standard browser / non-Flutter environment: resolving from cached storage or default fallback (no HTTP calls)",

97
src/lib/webview-actions.ts

@ -115,6 +115,7 @@ export function openExternalUrl(options: OpenExternalUrlOptions): boolean {
// ─── upload_file ───────────────────────────────────────── // ─── upload_file ─────────────────────────────────────────
export interface UploadFileOptions { export interface UploadFileOptions {
requestId?: string | number;
mediaType: "image" | "video" | "image+video" | "audio" | "file"; mediaType: "image" | "video" | "image+video" | "audio" | "file";
source?: "gallery" | "camera" | "any"; source?: "gallery" | "camera" | "any";
multiple?: boolean; multiple?: boolean;
@ -180,3 +181,99 @@ export function openConsultantPage(username: string): boolean {
return postActionToFlutter("open_consultant_page", { consultant: username }); return postActionToFlutter("open_consultant_page", { consultant: username });
} }
// ─── Location Actions (Auto GPS & Manual Map) ─────────────
export interface LocationResultData {
latitude: number;
longitude: number;
city?: string;
country?: string;
country_code?: string;
}
/**
* Ask Flutter to request GPS permissions and return precise device location
* with reverse-geocoded city and country.
*/
export function requestAutoLocation(
timeoutMs = 15000,
): Promise<LocationResultData> {
return new Promise((resolve, reject) => {
if (!isInFlutterWebView()) {
reject(new Error("Not in Flutter WebView"));
return;
}
let timer: ReturnType<typeof setTimeout> | null = null;
const cleanup = () => {
if (timer) clearTimeout(timer);
unsubscribe?.();
};
const unsubscribe = window.addFlutterResponseListener?.((event) => {
if (event.action === "get_auto_location") {
cleanup();
if (event.success && event.data) {
resolve(event.data as LocationResultData);
} else {
reject(new Error(event.error || "Failed to get auto location"));
}
}
});
timer = setTimeout(() => {
cleanup();
reject(new Error("Timeout waiting for auto location"));
}, timeoutMs);
postActionToFlutter("get_auto_location");
});
}
/**
* Ask Flutter to open native map dialog for manual location selection.
* Returns selected coordinates and geocoded info, or null if user cancelled.
*/
export function pickManualLocation(
initialCoords?: { latitude?: number; longitude?: number },
timeoutMs = 120000,
): Promise<LocationResultData | null> {
return new Promise((resolve, reject) => {
if (!isInFlutterWebView()) {
reject(new Error("Not in Flutter WebView"));
return;
}
let timer: ReturnType<typeof setTimeout> | null = null;
const cleanup = () => {
if (timer) clearTimeout(timer);
unsubscribe?.();
};
const unsubscribe = window.addFlutterResponseListener?.((event) => {
if (event.action === "pick_manual_location") {
cleanup();
if (event.success && event.data) {
resolve(event.data as LocationResultData);
} else if (event.cancelled) {
resolve(null);
} else {
reject(new Error(event.error || "Failed to pick manual location"));
}
}
});
timer = setTimeout(() => {
cleanup();
reject(new Error("Timeout waiting for manual location pick"));
}, timeoutMs);
postActionToFlutter(
"pick_manual_location",
initialCoords as Record<string, unknown> | undefined,
);
});
}

10
src/types/window.d.ts

@ -9,12 +9,15 @@ declare global {
interface FlutterResponseEvent { interface FlutterResponseEvent {
action: string; action: string;
success: boolean; success: boolean;
requestId?: string | number;
/** Compatibility payload used by the uppercase Flutter event protocol. */ /** Compatibility payload used by the uppercase Flutter event protocol. */
payload?: FlutterResponseEvent["data"]; payload?: FlutterResponseEvent["data"];
/** Top-level status for multi-step actions (download_file, upload_file, …) */ /** Top-level status for multi-step actions (download_file, upload_file, …) */
status?: string; status?: string;
/** Top-level error/info message */ /** Top-level error/info message */
message?: string; message?: string;
error?: string;
cancelled?: boolean;
data?: { data?: {
// get_location // get_location
latitude?: number; latitude?: number;
@ -65,10 +68,13 @@ declare global {
source?: string; source?: string;
files?: Array<{ files?: Array<{
url?: string; url?: string;
path?: string;
apath?: string;
base64?: string; base64?: string;
name?: string; name?: string;
size?: number; size?: number;
mimeType?: string; mimeType?: string;
data?: Record<string, unknown>;
}>; }>;
// copy_to_clipboard // copy_to_clipboard
label?: string; label?: string;
@ -98,6 +104,10 @@ declare global {
* or { handled: false } if Flutter should close the WebView screen. * or { handled: false } if Flutter should close the WebView screen.
*/ */
__habibHandleHardwareBack?: () => Promise<{ handled: boolean }>; __habibHandleHardwareBack?: () => Promise<{ handled: boolean }>;
/**
* Synchronous variant called by Flutter for instant response without Promise serialization issues.
*/
__habibHandleHardwareBackSync?: () => boolean;
/** /**
* Unique ID per document load. If this changes on back navigation, * Unique ID per document load. If this changes on back navigation,
* it proves a hard reload / WebView recreation happened. * it proves a hard reload / WebView recreation happened.

Loading…
Cancel
Save