From 8f6934468fae97222353b6221080c6e4f9695a92 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Sat, 22 Aug 2026 14:44:46 +0330 Subject: [PATCH 01/22] fix(assessments): robust options parsing for Cattell and Glasser --- .../[slug]/question-detail-client.test.tsx | 81 +-------- .../[slug]/question-detail-client.tsx | 134 ++++++++------ .../Componentes/question-birthplace.tsx | 169 +++++++++++++----- src/lib/geo-region.ts | 49 +++-- 4 files changed, 248 insertions(+), 185 deletions(-) diff --git a/src/app/questions-list/[slug]/question-detail-client.test.tsx b/src/app/questions-list/[slug]/question-detail-client.test.tsx index 4b1bdbd..b51bcde 100644 --- a/src/app/questions-list/[slug]/question-detail-client.test.tsx +++ b/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( "personality_test", { questions: [ { 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, ); - // Retry UI should NOT be present 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", () => { @@ -172,79 +167,19 @@ describe("QuestionDetailClient Validation", () => { 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, { questions: [ { question_number: 1, - text: "Valid Question Glasser", + text: "Valid Question Glasser Default Scale", 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.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", () => { diff --git a/src/app/questions-list/[slug]/question-detail-client.tsx b/src/app/questions-list/[slug]/question-detail-client.tsx index 4d5a043..74ed8aa 100644 --- a/src/app/questions-list/[slug]/question-detail-client.tsx +++ b/src/app/questions-list/[slug]/question-detail-client.tsx @@ -275,8 +275,10 @@ export default function QuestionDetailClient({ } }, [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 { data: overview, isLoading: isOverviewLoading, isError: isOverviewError, refetch: refetchOverview } = useFormOverviewQuery( "profile", @@ -348,62 +350,86 @@ export default function QuestionDetailClient({ const cattellTestQuestions: TestQuestion[] = useMemo(() => { 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 []; - } - - return questionsList.map((q) => ({ - id: q.question_number, - text: q.text, - options: q.options || [], - })); + 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 { + id: Number(q.question_number || q.id), + text: String(q.text), + options, + }; + }); }, [cattellQuery.data]); const glasserTestQuestions: TestQuestion[] = useMemo(() => { 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 []; - } - - return questionsList.map((q) => ({ - id: q.question_number, - text: q.text, - info: - "factor" in q - ? (q.factor as string) - : "factor_code" in q - ? (q.factor_code as string) - : undefined, - options: q.options || [], - })); - }, [glasserQuery.data]); + 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 { + id: Number(q.question_number || q.id), + text: String(q.text), + info: + "factor" in q + ? (q.factor as string) + : "factor_code" in q + ? (q.factor_code as string) + : undefined, + options, + }; + }); + }, [glasserQuery.data, locale]); diff --git a/src/components/Componentes/question-birthplace.tsx b/src/components/Componentes/question-birthplace.tsx index 2593dd6..5073486 100644 --- a/src/components/Componentes/question-birthplace.tsx +++ b/src/components/Componentes/question-birthplace.tsx @@ -2,7 +2,11 @@ import { useCallback, useEffect, useRef, useState } from "react"; 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 { useI18n } from "@/translations/provider"; import { useQuestionAnswers } from "./question-answer-storage"; @@ -10,7 +14,11 @@ import QuestionTitle from "./question-title"; import { LoadingThreeDot } from "./loading-three-dot"; import { useSheetScrollLock } from "./use-sheet-scroll-lock"; import { Input } from "@/components/ui/input"; -import { getUserGeoRegion, getStoredUserGeoRegion } from "@/lib/geo-region"; +import { + getUserGeoRegion, + getStoredUserGeoRegion, + subscribeToUserGeoRegion, +} from "@/lib/geo-region"; const EXIT_ANIMATION_MS = 220; @@ -24,16 +32,25 @@ type BirthplaceValue = { 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 (typeof rawValue === "object" && rawValue !== null) { 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() : ""; // 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 { country: rawCity, city: rawCountry, @@ -146,22 +163,31 @@ export function QuestionBirthplace({ const [mode, setMode] = useState<"auto" | "manual">(() => { if (typeof window !== "undefined") { 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"; }); const isInitialManual = mode === "manual"; + const defaultCountryFallback = + isResidence && locale === "fa" + ? resolveCountryName("IR", "fa") || "ایران" + : ""; + const localizedInitialCountry = resolveCountryName(initial.country, locale) || initial.country || (!hasSavedAnswer && !isInitialManual && storedRegion?.country ? resolveCountryName(storedRegion.country, locale) || storedRegion.country - : ""); + : defaultCountryFallback); const initialCity = - initial.city || (!hasSavedAnswer && !isInitialManual && storedRegion?.city ? storedRegion.city : ""); + initial.city || + (!hasSavedAnswer && !isInitialManual && storedRegion?.city + ? storedRegion.city + : ""); const initialLoc = localizedInitialCountry || initialCity @@ -171,9 +197,7 @@ export function QuestionBirthplace({ const [selectedCountry, setSelectedCountry] = useState( () => localizedInitialCountry || "", ); - const [cityInput, setCityInput] = useState( - () => initialCity, - ); + const [cityInput, setCityInput] = useState(() => initialCity); const cityInputStateRef = useRef(initialCity); const selectedCountryStateRef = useRef(localizedInitialCountry || ""); @@ -245,16 +269,63 @@ export function QuestionBirthplace({ const lastInternalAnswerRef = useRef(null); - const updateAnswers = (country: string, city: string) => { - const cleanCountry = country?.trim() || ""; - const cleanCity = city?.trim() || ""; - const payload = - cleanCountry || cleanCity - ? { country: cleanCountry, city: cleanCity } - : null; - lastInternalAnswerRef.current = payload; - setAnswerValue(question, payload); - }; + const updateAnswers = useCallback( + (country: string, city: string) => { + const cleanCountry = country?.trim() || ""; + const cleanCity = city?.trim() || ""; + const payload = + cleanCountry || cleanCity + ? { country: cleanCountry, city: cleanCity } + : null; + lastInternalAnswerRef.current = payload; + setAnswerValue(question, payload); + }, + [question, setAnswerValue], + ); + + // Subscribe to live geo region updates (e.g. when Flutter bridge responds asynchronously) + useEffect(() => { + if (!isResidence) return; + + const unsubscribe = subscribeToUserGeoRegion((region) => { + if (!isMountedRef.current) return; + // If user has already switched to manual mode, do not overwrite manual edits + const currentStoredMode = + typeof window !== "undefined" + ? localStorage.getItem(`residence_mode_${question.id}`) + : null; + if (currentStoredMode === "manual" || mode === "manual") return; + + const rawCountry = region.country || region.countryCode || ""; + const country = + resolveCountryName(rawCountry, locale) || + rawCountry || + defaultCountryFallback; + const city = region.city || ""; + + 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); + setIsDetecting(false); + } + }); + + return () => { + unsubscribe(); + }; + }, [ + isResidence, + mode, + locale, + question.id, + defaultCountryFallback, + updateAnswers, + ]); // GeoIP detection logic using unified getUserGeoRegion const detectLocation = useCallback( @@ -298,7 +369,10 @@ export function QuestionBirthplace({ const city = region.city || ""; const rawCountry = region.country || region.countryCode || ""; - const country = resolveCountryName(rawCountry, locale) || rawCountry; + const country = + resolveCountryName(rawCountry, locale) || + rawCountry || + defaultCountryFallback; if (country || city) { setSelectedCountry(country); @@ -308,29 +382,19 @@ export function QuestionBirthplace({ const loc = [country, city].filter(Boolean).join(", "); setDetectedLocation(loc); updateAnswers(country, city); - setMode("auto"); - if (typeof window !== "undefined") { - localStorage.setItem(`residence_mode_${question.id}`, "auto"); - } - } else { - setMode("manual"); - if (typeof window !== "undefined") { - localStorage.setItem(`residence_mode_${question.id}`, "manual"); - } } } catch { - if (isMountedRef.current) { - setMode("manual"); - } + // Keep in auto mode on error, do not force manual } finally { if (isMountedRef.current) { setIsDetecting(false); } } }, - [rawValue, locale, question, setAnswerValue], + [rawValue, locale, question.id, defaultCountryFallback, updateAnswers], ); + // Auto-detect and pre-fill on initial mount useEffect(() => { if (isLoading) return; if (isResidence && !hasAutoDetectedRef.current) { @@ -345,6 +409,11 @@ export function QuestionBirthplace({ return; } + // Pre-fill answer immediately if initial values exist and no answer recorded yet + if (!hasSavedAnswer && (localizedInitialCountry || initialCity)) { + updateAnswers(localizedInitialCountry, initialCity); + } + const parsed = parseValue(rawValue); if (!parsed.country && !parsed.city) { void detectLocation(false); @@ -352,7 +421,17 @@ export function QuestionBirthplace({ void detectLocation(false); } } - }, [isResidence, isLoading, detectLocation, rawValue, question.id]); + }, [ + isResidence, + isLoading, + detectLocation, + rawValue, + question.id, + hasSavedAnswer, + localizedInitialCountry, + initialCity, + updateAnswers, + ]); const handleAutoClick = () => { if (typeof window !== "undefined") { @@ -371,7 +450,8 @@ export function QuestionBirthplace({ const resolvedC = resolveCountryName(selectedCountry || parsed.country, locale) || selectedCountry || - parsed.country; + parsed.country || + defaultCountryFallback; const country = resolvedC; const city = cityInput !== "" ? cityInput : parsed.city; setSelectedCountry(country); @@ -406,8 +486,9 @@ export function QuestionBirthplace({ } } 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); selectedCountryStateRef.current = resolvedC; } @@ -419,7 +500,7 @@ export function QuestionBirthplace({ setDetectedLocation([resolvedC, updated.city].filter(Boolean).join(", ")); } lastInternalAnswerRef.current = rawValue as BirthplaceValue | string | null; - }, [rawValue, locale]); + }, [rawValue, locale, selectedCountry, cityInput]); const options = getCountryList(locale); const filteredOptions = options.filter((option) => @@ -434,7 +515,9 @@ export function QuestionBirthplace({ selectedCountryStateRef.current = country; closeSheet(); updateAnswers(country, cityInputStateRef.current); - setDetectedLocation([country, cityInputStateRef.current].filter(Boolean).join(", ")); + setDetectedLocation( + [country, cityInputStateRef.current].filter(Boolean).join(", "), + ); window.setTimeout(() => { cityInputRef.current?.focus({ preventScroll: true }); }, EXIT_ANIMATION_MS); @@ -447,7 +530,9 @@ export function QuestionBirthplace({ const newCity = e.target.value; cityInputStateRef.current = newCity; setCityInput(newCity); - setDetectedLocation([selectedCountryStateRef.current, newCity].filter(Boolean).join(", ")); + setDetectedLocation( + [selectedCountryStateRef.current, newCity].filter(Boolean).join(", "), + ); if (debounceTimerRef.current) { clearTimeout(debounceTimerRef.current); diff --git a/src/lib/geo-region.ts b/src/lib/geo-region.ts index 7f3c110..c7e23db 100644 --- a/src/lib/geo-region.ts +++ b/src/lib/geo-region.ts @@ -44,7 +44,10 @@ export function getStoredUserGeoRegion(): UserGeoRegion | null { const parsed = JSON.parse(stored) as UserGeoRegion; if ( parsed && - (parsed.country || parsed.phoneCode || parsed.city || parsed.countryCode) + (parsed.country || + parsed.phoneCode || + parsed.city || + parsed.countryCode) ) { cachedRegion = parsed; return parsed; @@ -56,17 +59,24 @@ export function getStoredUserGeoRegion(): UserGeoRegion | null { } 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") { 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 {} } listeners.forEach((fn) => { - fn(region); + fn(merged); }); } @@ -87,7 +97,10 @@ function getFallbackGeoRegion(): UserGeoRegion { const existing = getStoredUserGeoRegion(); if ( existing && - (existing.country || existing.phoneCode || existing.countryCode || existing.city) + (existing.country || + existing.phoneCode || + existing.countryCode || + existing.city) ) { console.log( "[GEO_BRIDGE_LOG] 📦 Preserving existing stored user geo region:", @@ -150,12 +163,14 @@ function fetchFlutterBridgeGeoRegion(): Promise { JSON.stringify(event), ); - const data = (event.data || (event as any).payload) as { - ip?: string; - country?: string; - country_code?: string; - city?: string; - } | undefined; + const data = (event.data || (event as any).payload) as + | { + ip?: string; + country?: string; + country_code?: string; + city?: string; + } + | undefined; if ( event.success && @@ -241,12 +256,12 @@ function fetchFlutterBridgeGeoRegion(): Promise { * Never performs direct HTTP requests. */ export function getUserGeoRegion(force = false): Promise { - // 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) { const existing = cachedRegion || getStoredUserGeoRegion(); if ( existing && - (existing.city || existing.country || existing.phoneCode || existing.countryCode) + (existing.country || existing.countryCode || existing.city) ) { console.log( "[GEO_BRIDGE_LOG] 📦 Returning cached/stored user geo region:", @@ -273,7 +288,9 @@ export function getUserGeoRegion(force = false): Promise { console.log( "[GEO_BRIDGE_LOG] 📱 Inside Flutter WebView detected, requesting location via Flutter bridge action 'get_location'", ); - geoRegionPromise = fetchFlutterBridgeGeoRegion(); + geoRegionPromise = fetchFlutterBridgeGeoRegion().finally(() => { + geoRegionPromise = null; + }); } else { console.log( "[GEO_BRIDGE_LOG] 🌐 Standard browser / non-Flutter environment: resolving from cached storage or default fallback (no HTTP calls)", From e6ef2291b19fd95b4af4e1eabe4b34ec842e4519 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Sat, 22 Aug 2026 15:03:55 +0330 Subject: [PATCH 02/22] feat(assessments): add native-like smooth horizontal slide transition to test questions --- .../Componentes/test-questions-flow.tsx | 158 ++++++++++-------- 1 file changed, 88 insertions(+), 70 deletions(-) diff --git a/src/components/Componentes/test-questions-flow.tsx b/src/components/Componentes/test-questions-flow.tsx index 9cbd01d..1c74335 100644 --- a/src/components/Componentes/test-questions-flow.tsx +++ b/src/components/Componentes/test-questions-flow.tsx @@ -198,11 +198,11 @@ export default function TestQuestionsFlow({ [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) { setTimeout(() => { setCurrentIndex((prev) => Math.min(prev + 1, totalQuestions - 1)); - }, 250); + }, 200); } }; @@ -296,84 +296,102 @@ export default function TestQuestionsFlow({ - {/* Question Section */} -
- {/* Question Title */} -

- {currentQuestion.text} -

+ {/* Question Slider Viewport */} +
+ {questions.map((q, index) => { + const offset = index - currentIndex; + const isNearby = Math.abs(offset) <= 1; + + if (!isNearby) return null; - {/* Answer Options Stack */} - {(() => { - const options = currentQuestion.options || []; + const qSelectedValue = answers[q.id]; + const options = q.options || []; return ( -
- {options.map((option) => { - const isSelected = selectedValue === option.value; - - return ( - - ); - })} + + ); + })} +
); - })()} + })}
{/* Bottom Actions Bar */} From 5f033fc822065e4f42834cf1f46c46680439eaf8 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Sat, 22 Aug 2026 15:19:35 +0330 Subject: [PATCH 03/22] feat: add bottom sheet for currency selection with auto-mapping based on location country code --- .../Componentes/currency-sheet.test.tsx | 68 +++ src/components/Componentes/currency-sheet.tsx | 274 +++++++++++ .../Componentes/question-number.tsx | 445 ++---------------- src/data/currencies.test.ts | 58 +++ src/data/currencies.ts | 381 +++++++++++++++ 5 files changed, 827 insertions(+), 399 deletions(-) create mode 100644 src/components/Componentes/currency-sheet.test.tsx create mode 100644 src/components/Componentes/currency-sheet.tsx create mode 100644 src/data/currencies.test.ts create mode 100644 src/data/currencies.ts diff --git a/src/components/Componentes/currency-sheet.test.tsx b/src/components/Componentes/currency-sheet.test.tsx new file mode 100644 index 0000000..705489a --- /dev/null +++ b/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( + , + ); + + 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( + , + ); + + 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( + , + ); + + const eurButton = screen.getByText("Euro").closest("button"); + expect(eurButton).not.toBeNull(); + fireEvent.click(eurButton!); + + expect(handleSelect).toHaveBeenCalledWith("EUR"); + }); +}); diff --git a/src/components/Componentes/currency-sheet.tsx b/src/components/Componentes/currency-sheet.tsx new file mode 100644 index 0000000..0815350 --- /dev/null +++ b/src/components/Componentes/currency-sheet.tsx @@ -0,0 +1,274 @@ +"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(null); + const sheetRef = useRef(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( +
{ + if (e.key === "Escape") closeSheet(); + }} + onClick={(event) => { + if (event.target === event.currentTarget) closeSheet(); + }} + > +
+ {/* Header */} +
+

+ {sheetTitle} +

+ +
+ + {/* Search Bar */} +
+
+ + setSearchQuery(e.target.value)} + placeholder={searchPlaceholder} + className="flex-1 bg-transparent text-[14px] font-medium text-[#181818] outline-none placeholder:text-[#98A2B3]" + /> + {searchQuery ? ( + + ) : null} +
+
+ + {/* Currencies List */} +
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 ( + + ); + }) + ) : ( +
+ {noResultsText} +
+ )} +
+
+
, + document.body, + ); +} + +export default CurrencySheet; diff --git a/src/components/Componentes/question-number.tsx b/src/components/Componentes/question-number.tsx index 86e7b0a..5baf1bf 100644 --- a/src/components/Componentes/question-number.tsx +++ b/src/components/Componentes/question-number.tsx @@ -1,12 +1,15 @@ "use client"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import type { QuestionField } from "@/lib/schema-adapter"; import { useI18n } from "@/translations/provider"; import { useQuestionAnswers } from "./question-answer-storage"; import QuestionTitle from "./question-title"; import { Input } from "@/components/ui/input"; import { isKnownCountry } from "@/data/countries"; +import { resolveDefaultCurrency } from "@/data/currencies"; +import { getStoredUserGeoRegion } from "@/lib/geo-region"; +import { CurrencySheet } from "./currency-sheet"; type QuestionNumberProps = { question: QuestionField; @@ -78,7 +81,8 @@ export default function QuestionNumber({ : ""; 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(), []); @@ -87,13 +91,15 @@ export default function QuestionNumber({ const stored = window.localStorage.getItem(currencyStorageKey); 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(null); - const currencySearchInputRef = useRef(null); + const [isCurrencySheetOpen, setIsCurrencySheetOpen] = useState(false); useEffect(() => { if (typeof window !== "undefined") { @@ -103,47 +109,18 @@ export default function QuestionNumber({ return; } } - const derived = getCurrencyForCountry(countryName); + const geo = getStoredUserGeoRegion(); + const derived = resolveDefaultCurrency({ + countryCode: geo?.countryCode, + countryName: countryName || geo?.country, + fallbackLocale: locale, + }); 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(() => { if (currencyCode === "TOMAN") { - return locale === "fa" ? "تومان" : "TOMAN"; + return locale === "fa" || locale === "fa-ir" ? "تومان" : "TOMAN"; } return currencyCode; }, [currencyCode, locale]); @@ -152,7 +129,7 @@ export default function QuestionNumber({ if (!isMonthlyIncome) { return question.extras.placeHolder; } - return locale === "fa" + return locale === "fa" || locale === "fa-ir" ? `مثال: ۴۰۰۰ ${placeholderCurrency}` : `e.g. 4000 ${placeholderCurrency}`; }, [ @@ -186,7 +163,7 @@ export default function QuestionNumber({ ].join(" ")} > -
+
setIsCurrencyDropdownOpen(!isCurrencyDropdownOpen)} + onClick={() => setIsCurrencySheetOpen(true)} 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", - isCurrencyDropdownOpen + isCurrencySheetOpen ? "border-[#6F6F6F] ring-1 ring-[#6F6F6F]" : "border-[#D0D5DD] hover:border-[#98A2B3]", ].join(" ")} @@ -253,7 +231,7 @@ export default function QuestionNumber({ aria-label="Dropdown chevron" className={[ "shrink-0 transition-transform duration-200 text-[#344054] ml-1", - isCurrencyDropdownOpen ? "rotate-180" : "", + isCurrencySheetOpen ? "rotate-180" : "", ].join(" ")} > - - {isCurrencyDropdownOpen && ( -
- {/* Search bar */} -
- - - - - 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 ? ( - - ) : null} -
- - {/* Options list */} -
- {filteredCurrencies.length > 0 ? ( - filteredCurrencies.map((c) => { - const isSelected = c.code === currencyCode; - return ( - - ); - }) - ) : ( - - {locale === "fa" - ? "ارزی یافت نشد" - : "No currencies found"} - - )} -
-
- )}
+ + setIsCurrencySheetOpen(false)} + selectedCurrency={currencyCode} + onSelectCurrency={(code) => { + setCurrencyCode(code); + if (typeof window !== "undefined") { + window.localStorage.setItem(currencyStorageKey, code); + } + }} + /> + {isOutOfRange ? ( {t[ @@ -408,7 +297,8 @@ export default function QuestionNumber({ } else { const parsed = parseFloat(nextValue); setAnswerValue( - question, Number.isNaN(parsed) ? nextValue : parsed, + question, + Number.isNaN(parsed) ? nextValue : parsed, ); } }} @@ -487,252 +377,9 @@ function getCountryFromStorage(): string { return ""; } -function getCurrencyForCountry(countryName: string): string { - const cleanCountry = countryName?.trim(); - if (!cleanCountry) return "USD"; - - const countryMap: Record = { - // 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, ","); return parts.join("."); } diff --git a/src/data/currencies.test.ts b/src/data/currencies.test.ts new file mode 100644 index 0000000..c0db105 --- /dev/null +++ b/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"); + }); +}); diff --git a/src/data/currencies.ts b/src/data/currencies.ts new file mode 100644 index 0000000..e0c5298 --- /dev/null +++ b/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 = { + 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 = { + // 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"; +} From 3d21bd72acdd8d97826b32bffe95a3879b1ce650 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Sat, 22 Aug 2026 15:26:09 +0330 Subject: [PATCH 04/22] style: adjust question text position, height, and spacing to match checkup --- src/components/Componentes/test-questions-flow.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/components/Componentes/test-questions-flow.tsx b/src/components/Componentes/test-questions-flow.tsx index 1c74335..6a4e3e3 100644 --- a/src/components/Componentes/test-questions-flow.tsx +++ b/src/components/Componentes/test-questions-flow.tsx @@ -312,7 +312,7 @@ export default function TestQuestionsFlow({ key={q.id} aria-hidden={offset !== 0} className={[ - "absolute inset-0 flex flex-col justify-start overflow-y-auto pt-6 pb-4 transition-transform duration-250 ease-[cubic-bezier(0.25,1,0.5,1)]", + "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={{ @@ -321,8 +321,7 @@ export default function TestQuestionsFlow({ > {/* Question Title */}

{q.text}

From aafc61d205bd1440e123108ad545c5639d03e540 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Sat, 22 Aug 2026 15:33:59 +0330 Subject: [PATCH 05/22] fix(currency-sheet): remove symbol subtitle under currency name --- src/components/Componentes/currency-sheet.tsx | 29 +++++++------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/src/components/Componentes/currency-sheet.tsx b/src/components/Componentes/currency-sheet.tsx index 0815350..bd375aa 100644 --- a/src/components/Componentes/currency-sheet.tsx +++ b/src/components/Componentes/currency-sheet.tsx @@ -225,24 +225,17 @@ export function CurrencySheet({ ].join(" ")} /> - {/* Currency name and symbol */} -
- - {displayName} - - {item.symbol && item.symbol !== item.code && ( - - {item.symbol} - - )} -
+ {/* Currency name */} + + {displayName} +
{/* Currency code badge */} From 7df253acd75613cba3206be6d29d84cacd708873 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Sat, 22 Aug 2026 16:04:10 +0330 Subject: [PATCH 06/22] fix(question-number): normalize Persian/Arabic digits and use text inputMode numeric --- .../Componentes/question-number.test.tsx | 80 +++++++++++++++++ .../Componentes/question-number.tsx | 89 +++++++++++++------ 2 files changed, 143 insertions(+), 26 deletions(-) create mode 100644 src/components/Componentes/question-number.test.tsx diff --git a/src/components/Componentes/question-number.test.tsx b/src/components/Componentes/question-number.test.tsx new file mode 100644 index 0000000..c9f56e6 --- /dev/null +++ b/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( + + + + + , + ); + + 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( + + + + + , + ); + + const input = screen.getByPlaceholderText("e.g. 3") as HTMLInputElement; + fireEvent.change(input, { target: { value: "5" } }); + + expect(input.value).toBe("5"); + }); +}); diff --git a/src/components/Componentes/question-number.tsx b/src/components/Componentes/question-number.tsx index 5baf1bf..ddb07c6 100644 --- a/src/components/Componentes/question-number.tsx +++ b/src/components/Componentes/question-number.tsx @@ -18,6 +18,33 @@ type QuestionNumberProps = { 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*$/; export default function QuestionNumber({ @@ -51,12 +78,17 @@ export default function QuestionNumber({ ]); useEffect(() => { - if ( - typeof value === "string" && - value.length > 0 && - !NUMBER_INPUT_PATTERN.test(value) - ) { - setAnswerValue(question, null); + if (typeof value === "string" && value.length > 0) { + const normalized = normalizeNumberString(value); + if (!NUMBER_INPUT_PATTERN.test(normalized)) { + setAnswerValue(question, null); + } else if (normalized !== value) { + const parsed = parseFloat(normalized); + setAnswerValue( + question, + Number.isNaN(parsed) ? normalized : parsed, + ); + } } }, [question, setAnswerValue, value]); @@ -66,7 +98,7 @@ export default function QuestionNumber({ typeof value === "number" ? value : typeof value === "string" - ? parseFloat(value) + ? parseFloat(normalizeNumberString(value)) : NaN; const isOutOfRange = useMemo(() => { if (Number.isNaN(numValue)) return false; @@ -76,8 +108,9 @@ export default function QuestionNumber({ }, [numValue, min, max]); 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 : ""; const isMonthlyIncome = question.ui_config?.currency_enabled === true; @@ -174,22 +207,24 @@ export default function QuestionNumber({ value={localTextValue} onChange={(event) => { const nextValue = event.target.value; - const cleanValue = nextValue.replace(/,/g, ""); + const normalized = normalizeNumberString(nextValue); + const cleanValue = normalized.replace(/,/g, ""); if ( cleanValue !== "" && + cleanValue !== "-" && !NUMBER_INPUT_PATTERN.test(cleanValue) ) { return; } const formatted = formatNumberWithCommas(cleanValue); - const finalFormatted = nextValue.endsWith(".") + const finalFormatted = normalized.endsWith(".") ? `${formatted}.` : formatted; setLocalTextValue(finalFormatted); - if (cleanValue === "") { + if (cleanValue === "" || cleanValue === "-") { setAnswerValue(question, null); } else { const parsed = parseFloat(cleanValue); @@ -278,29 +313,31 @@ export default function QuestionNumber({ > { - 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, ""); + + if (cleaned === "" || cleaned === "-") { + setAnswerValue(question, null); return; } - if (nextValue === "") { - setAnswerValue(question, null); - } else { - const parsed = parseFloat(nextValue); - setAnswerValue( - question, - Number.isNaN(parsed) ? nextValue : parsed, - ); + if (!NUMBER_INPUT_PATTERN.test(cleaned)) { + return; } + + const parsed = parseFloat(cleaned); + setAnswerValue( + 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]", From 47f884517d722577947ead676812128258a130c3 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Sat, 22 Aug 2026 16:10:00 +0330 Subject: [PATCH 07/22] feat(debug): add comprehensive logging for number inputs, answers, and progress tracker --- .../[slug]/question-detail-client.tsx | 6 +++++ .../Componentes/question-number.tsx | 23 +++++++++++++++++++ .../Componentes/question-progress-tracker.tsx | 3 +++ 3 files changed, 32 insertions(+) diff --git a/src/app/questions-list/[slug]/question-detail-client.tsx b/src/app/questions-list/[slug]/question-detail-client.tsx index 74ed8aa..eb7c626 100644 --- a/src/app/questions-list/[slug]/question-detail-client.tsx +++ b/src/app/questions-list/[slug]/question-detail-client.tsx @@ -205,6 +205,12 @@ function QuestionFlowWrapper({ } } + if (question.type === "number") { + console.log( + `[DETAIL_LOG] id=${question.id}, answer=${JSON.stringify(answer)}, hasAnswer=${hasAnswer}, isAnswered=${isAnswered}`, + ); + } + return (
{ 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); } 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, @@ -113,6 +122,10 @@ export default function QuestionNumber({ ? 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 currencyStorageKey = question.ui_config?.currency_storage_key || "marriage:income:currency"; @@ -324,16 +337,26 @@ export default function QuestionNumber({ 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; } 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( question, Number.isNaN(parsed) ? cleaned : parsed, diff --git a/src/components/Componentes/question-progress-tracker.tsx b/src/components/Componentes/question-progress-tracker.tsx index e13bbff..9823c76 100644 --- a/src/components/Componentes/question-progress-tracker.tsx +++ b/src/components/Componentes/question-progress-tracker.tsx @@ -130,6 +130,9 @@ export function QuestionProgressTracker({ setTotal(nextTotal); setAnswered(nextAnswered); + console.log( + `[PROG_LOG] updateProgress: answered=${nextAnswered}/${nextTotal}, passedIndexes=[${Array.from(passedQuestionIndexes).join(",")}]`, + ); }, [passedQuestionIndexes]); useEffect(() => { From 954cc10c60a9162365a1455d10d0b64952de07cc Mon Sep 17 00:00:00 2001 From: mortezaei Date: Sat, 22 Aug 2026 16:15:45 +0330 Subject: [PATCH 08/22] fix(question-storage): only use option_id for choice questions so scalar number questions preserve numeric values --- .../Componentes/question-answer-storage.tsx | 31 +++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/src/components/Componentes/question-answer-storage.tsx b/src/components/Componentes/question-answer-storage.tsx index b8be8f9..eba104c 100644 --- a/src/components/Componentes/question-answer-storage.tsx +++ b/src/components/Componentes/question-answer-storage.tsx @@ -261,7 +261,18 @@ function createPayload( function fieldsToAnswers(fields: MarriageField[]) { return fields.reduce((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] = { ...field, value: field.option_id, @@ -640,11 +651,27 @@ export function QuestionAnswersProvider({ if (question) { // Only update if there are no newer local dirty edits for this 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] = { key, label: question.title, type: question.type, - value: answer.option_id ?? answer.value, + value: resolvedVal, option_id: answer.option_id, } as MarriageField; } From fdc37a38ae27a241e7fedf0aef698009dd684bb1 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Sat, 22 Aug 2026 16:22:17 +0330 Subject: [PATCH 09/22] fix(intro): replace test report action sheet with standard support sheet on home intro page --- src/app/intro/intro-client.tsx | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/src/app/intro/intro-client.tsx b/src/app/intro/intro-client.tsx index b3d2a7c..000fcdf 100644 --- a/src/app/intro/intro-client.tsx +++ b/src/app/intro/intro-client.tsx @@ -6,7 +6,6 @@ import { useCallback, useEffect, useState } from "react"; import Button from "@/components/Componentes/button"; import NetworkImage from "@/components/Componentes/network-image"; import PageHeader from "@/components/Componentes/page-header"; -import ReportActionsSheet from "@/components/Componentes/report-actions-sheet"; import SectionOverlayHost from "@/components/Componentes/section-overlay-host"; import SliderPage from "@/components/Componentes/slider-page"; import VideoPlayer from "@/components/Componentes/video-player"; @@ -32,7 +31,6 @@ export default function IntroClient() { enabled: false, retry: false, }); - const [isReportSheetOpen, setIsReportSheetOpen] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const [isPlayerOpen, setIsPlayerOpen] = useState(false); const [isStepsOpen, setIsStepsOpen] = useState(false); @@ -128,15 +126,7 @@ export default function IntroClient() { return ( <>
- {isReportSheetOpen && ( - setIsReportSheetOpen(false)} /> - )} - setIsReportSheetOpen(true), - }} - /> +
Date: Sat, 22 Aug 2026 16:49:26 +0330 Subject: [PATCH 10/22] feat(location): add auto GPS and manual map picker bridge integration with initial empty state --- .../Componentes/question-birthplace.tsx | 253 ++++++------------ .../Componentes/question-snap-list.test.tsx | 68 +++++ .../Componentes/question-snap-list.tsx | 75 +++++- .../Componentes/report-actions-sheet.tsx | 32 ++- src/lib/geo-region.ts | 4 + src/lib/webview-actions.ts | 96 +++++++ src/types/window.d.ts | 2 + 7 files changed, 354 insertions(+), 176 deletions(-) diff --git a/src/components/Componentes/question-birthplace.tsx b/src/components/Componentes/question-birthplace.tsx index 5073486..f939416 100644 --- a/src/components/Componentes/question-birthplace.tsx +++ b/src/components/Componentes/question-birthplace.tsx @@ -19,6 +19,11 @@ import { getStoredUserGeoRegion, subscribeToUserGeoRegion, } from "@/lib/geo-region"; +import { + isInFlutterWebView, + requestAutoLocation, + pickManualLocation, +} from "@/lib/webview-actions"; const EXIT_ANIMATION_MS = 220; @@ -171,23 +176,11 @@ export function QuestionBirthplace({ const isInitialManual = mode === "manual"; - const defaultCountryFallback = - isResidence && locale === "fa" - ? resolveCountryName("IR", "fa") || "ایران" - : ""; - - const localizedInitialCountry = - resolveCountryName(initial.country, locale) || - initial.country || - (!hasSavedAnswer && !isInitialManual && storedRegion?.country - ? resolveCountryName(storedRegion.country, locale) || storedRegion.country - : defaultCountryFallback); + 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 = localizedInitialCountry || initialCity @@ -202,6 +195,12 @@ export function QuestionBirthplace({ const cityInputStateRef = useRef(initialCity); const selectedCountryStateRef = useRef(localizedInitialCountry || ""); + const lastCoordsRef = useRef<{ latitude?: number; longitude?: number } | undefined>( + storedRegion?.latitude && storedRegion?.longitude + ? { latitude: storedRegion.latitude, longitude: storedRegion.longitude } + : undefined, + ); + useEffect(() => { cityInputStateRef.current = cityInput; }, [cityInput]); @@ -221,7 +220,6 @@ export function QuestionBirthplace({ const [isDetecting, setIsDetecting] = useState(false); const [detectedLocation, setDetectedLocation] = useState(initialLoc); - const hasAutoDetectedRef = useRef(false); useEffect(() => { isMountedRef.current = true; @@ -283,96 +281,49 @@ export function QuestionBirthplace({ [question, setAnswerValue], ); - // Subscribe to live geo region updates (e.g. when Flutter bridge responds asynchronously) - useEffect(() => { - if (!isResidence) return; - - const unsubscribe = subscribeToUserGeoRegion((region) => { - if (!isMountedRef.current) return; - // If user has already switched to manual mode, do not overwrite manual edits - const currentStoredMode = - typeof window !== "undefined" - ? localStorage.getItem(`residence_mode_${question.id}`) - : null; - if (currentStoredMode === "manual" || mode === "manual") return; - - const rawCountry = region.country || region.countryCode || ""; - const country = - resolveCountryName(rawCountry, locale) || - rawCountry || - defaultCountryFallback; - const city = region.city || ""; - - 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); - setIsDetecting(false); - } - }); - - return () => { - unsubscribe(); - }; - }, [ - isResidence, - mode, - locale, - question.id, - defaultCountryFallback, - updateAnswers, - ]); - - // 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; - } - setDetectedLocation(loc); + const handleAutoClick = async () => { + if (typeof window !== "undefined") { + localStorage.setItem(`residence_mode_${question.id}`, "auto"); + } + setMode("auto"); + setIsDetecting(true); - const storedMode = - typeof window !== "undefined" - ? localStorage.getItem(`residence_mode_${question.id}`) - : null; + try { + if (isInFlutterWebView()) { + const data = await requestAutoLocation(); + if (!isMountedRef.current) return; + 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 (storedMode === "manual") { - setMode("manual"); - } else { - setMode("auto"); - } - return; + 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); } - } - - setIsDetecting(true); - - try { - const region = await getUserGeoRegion(force); + } else { + 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 || - defaultCountryFallback; + resolveCountryName(rawCountry, locale) || rawCountry || ""; + + if (region.latitude && region.longitude) { + lastCoordsRef.current = { + latitude: region.latitude, + longitude: region.longitude, + }; + } if (country || city) { setSelectedCountry(country); @@ -383,83 +334,49 @@ export function QuestionBirthplace({ setDetectedLocation(loc); updateAnswers(country, city); } - } catch { - // Keep in auto mode on error, do not force manual - } finally { - if (isMountedRef.current) { - setIsDetecting(false); - } } - }, - [rawValue, locale, question.id, defaultCountryFallback, updateAnswers], - ); - - // Auto-detect and pre-fill on initial mount - 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; - } - - // Pre-fill answer immediately if initial values exist and no answer recorded yet - if (!hasSavedAnswer && (localizedInitialCountry || initialCity)) { - updateAnswers(localizedInitialCountry, initialCity); - } - - const parsed = parseValue(rawValue); - if (!parsed.country && !parsed.city) { - void detectLocation(false); - } else { - void detectLocation(false); + } catch (err) { + console.warn("Auto location error:", err); + } finally { + if (isMountedRef.current) { + setIsDetecting(false); } } - }, [ - isResidence, - isLoading, - detectLocation, - rawValue, - question.id, - hasSavedAnswer, - localizedInitialCountry, - initialCity, - updateAnswers, - ]); - - 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") { localStorage.setItem(`residence_mode_${question.id}`, "manual"); } setMode("manual"); - const parsed = parseValue(rawValue); - const resolvedC = - resolveCountryName(selectedCountry || parsed.country, locale) || - selectedCountry || - parsed.country || - defaultCountryFallback; - const country = resolvedC; - const city = cityInput !== "" ? cityInput : parsed.city; - setSelectedCountry(country); - selectedCountryStateRef.current = country; - setCityInput(city); - cityInputStateRef.current = city; - updateAnswers(country, city); - setDetectedLocation([country, city].filter(Boolean).join(", ")); + + 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); + selectedCountryStateRef.current = country; + setCityInput(city); + cityInputStateRef.current = city; + const loc = [country, city].filter(Boolean).join(", "); + setDetectedLocation(loc); + updateAnswers(country, city); + } + } + } catch (err) { + console.warn("Manual map pick error:", err); + } + } }; // Synchronize state ONLY if rawValue changes externally (e.g. draft fetch or reset) diff --git a/src/components/Componentes/question-snap-list.test.tsx b/src/components/Componentes/question-snap-list.test.tsx index a3b5d09..6d9fd24 100644 --- a/src/components/Componentes/question-snap-list.test.tsx +++ b/src/components/Componentes/question-snap-list.test.tsx @@ -1,4 +1,5 @@ import { + act, cleanup, fireEvent, render, @@ -443,4 +444,71 @@ describe("QuestionSnapList keyboard interaction", () => { 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( + Scroll icon} + > +
Question 1
+
Question 2
+
, + ); + + 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(); + }); + }); }); diff --git a/src/components/Componentes/question-snap-list.tsx b/src/components/Componentes/question-snap-list.tsx index b73a6e5..5467931 100644 --- a/src/components/Componentes/question-snap-list.tsx +++ b/src/components/Componentes/question-snap-list.tsx @@ -105,6 +105,7 @@ export function QuestionSnapList({ const previousActiveIndexRef = useRef(null); const suppressNextClickRef = useRef(false); const [activeIndex, setActiveIndex] = useState(0); + const [isHintVisible, setIsHintVisible] = useState(false); const activeIndexRef = useRef(activeIndex); activeIndexRef.current = activeIndex; @@ -247,6 +248,76 @@ export function QuestionSnapList({ previousActiveIndexRef.current = activeIndex; }, [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(() => { return () => { if (wheelUnlockTimeoutRef.current !== null) { @@ -759,8 +830,8 @@ export function QuestionSnapList({ aria-hidden="true" className={[ "pointer-events-none absolute bottom-24 left-1/2 -translate-x-1/2", - "transition-opacity duration-500 motion-safe:animate-bounce", - activeIndex === 0 ? "opacity-100" : "opacity-0", + "transition-opacity duration-500 motion-safe:animate-bounce", + isHintVisible ? "opacity-100" : "opacity-0", ].join(" ")} > {firstQuestionHint} diff --git a/src/components/Componentes/report-actions-sheet.tsx b/src/components/Componentes/report-actions-sheet.tsx index 0e968ae..62317d4 100644 --- a/src/components/Componentes/report-actions-sheet.tsx +++ b/src/components/Componentes/report-actions-sheet.tsx @@ -8,6 +8,8 @@ import { downloadFile, isInFlutterWebView, openExternalUrl, + requestAutoLocation, + pickManualLocation, } from "@/lib/webview-actions"; const EXIT_ANIMATION_MS = 220; @@ -41,11 +43,28 @@ export function ReportActionsSheet({ onClose }: ReportActionsSheetProps) { 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 +219,8 @@ export function ReportActionsSheet({ onClose }: ReportActionsSheetProps) {
{/* Main buttons */}
- + + diff --git a/src/lib/geo-region.ts b/src/lib/geo-region.ts index c7e23db..c038b49 100644 --- a/src/lib/geo-region.ts +++ b/src/lib/geo-region.ts @@ -9,6 +9,8 @@ export type UserGeoRegion = { country?: string; countryCode?: string; // e.g. "IR", "US", "GB" phoneCode?: string; // e.g. "+98", "+1", "+44" + latitude?: number; + longitude?: number; }; const phoneUtil = PhoneNumberUtil.getInstance(); @@ -201,6 +203,8 @@ function fetchFlutterBridgeGeoRegion(): Promise { country: countryName, countryCode: isoCode, phoneCode: phoneCode || "+44", + latitude: (data as any).latitude, + longitude: (data as any).longitude, }; setStoredUserGeoRegion(region); finish(region); diff --git a/src/lib/webview-actions.ts b/src/lib/webview-actions.ts index d7c1050..7ef7f5a 100644 --- a/src/lib/webview-actions.ts +++ b/src/lib/webview-actions.ts @@ -180,3 +180,99 @@ export function openConsultantPage(username: string): boolean { 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 { + return new Promise((resolve, reject) => { + if (!isInFlutterWebView()) { + reject(new Error("Not in Flutter WebView")); + return; + } + + let timer: ReturnType | 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 { + return new Promise((resolve, reject) => { + if (!isInFlutterWebView()) { + reject(new Error("Not in Flutter WebView")); + return; + } + + let timer: ReturnType | 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 | undefined, + ); + }); +} + diff --git a/src/types/window.d.ts b/src/types/window.d.ts index a72dd9d..6798bbe 100644 --- a/src/types/window.d.ts +++ b/src/types/window.d.ts @@ -15,6 +15,8 @@ declare global { status?: string; /** Top-level error/info message */ message?: string; + error?: string; + cancelled?: boolean; data?: { // get_location latitude?: number; From ac0968f1bc78a7b19fca11d0723951a591c171f2 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Sat, 22 Aug 2026 16:55:39 +0330 Subject: [PATCH 11/22] fix(location): remove manual country dropdown and city inputs from residence question --- .../Componentes/question-birthplace.tsx | 61 ------------------- 1 file changed, 61 deletions(-) diff --git a/src/components/Componentes/question-birthplace.tsx b/src/components/Componentes/question-birthplace.tsx index f939416..5ca8b53 100644 --- a/src/components/Componentes/question-birthplace.tsx +++ b/src/components/Componentes/question-birthplace.tsx @@ -610,67 +610,6 @@ export function QuestionBirthplace({
- - {mode === "manual" && ( -
- {/* Country Selection Trigger */} -
- -
- - {/* City Text Input */} -
- -
-
- )} ) : ( <> From b1e75845f14f644bc3c3efa566d983ad3f69ca05 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Sat, 22 Aug 2026 17:31:20 +0330 Subject: [PATCH 12/22] fix(navigation): implement synchronous hardware back handler to prevent accidental webview exit on section back --- src/app/layout.tsx | 5 ++++ .../questions-list/questions-list-client.tsx | 8 +++--- .../Componentes/hardware-back-bridge.tsx | 11 +++++--- src/hooks/use-hardware-back-handler.ts | 25 ++++++++++++++++++- src/types/window.d.ts | 4 +++ 5 files changed, 45 insertions(+), 8 deletions(-) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 7f9c933..d90d076 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -320,6 +320,11 @@ export default async function RootLayout({ return Promise.resolve({ handled: false }); }; } + if (!window.__habibHandleHardwareBackSync) { + window.__habibHandleHardwareBackSync = function() { + return false; + }; + } })(); `, }} diff --git a/src/app/questions-list/questions-list-client.tsx b/src/app/questions-list/questions-list-client.tsx index 4aa2ba8..9a5f6a3 100644 --- a/src/app/questions-list/questions-list-client.tsx +++ b/src/app/questions-list/questions-list-client.tsx @@ -152,15 +152,15 @@ export default function QuestionsListClient() { }, []); const handleCloseSection = useCallback(() => { + setActiveSectionSlug(null); if (typeof window !== "undefined") { const params = new URLSearchParams(window.location.search); 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( diff --git a/src/components/Componentes/hardware-back-bridge.tsx b/src/components/Componentes/hardware-back-bridge.tsx index 5f71810..05c4610 100644 --- a/src/components/Componentes/hardware-back-bridge.tsx +++ b/src/components/Componentes/hardware-back-bridge.tsx @@ -1,11 +1,14 @@ "use client"; 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 - * window.__habibHandleHardwareBack function. + * window.__habibHandleHardwareBack and window.__habibHandleHardwareBackSync functions. * * Mount this once in the Providers tree (after React hydration). * 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() { useEffect(() => { window.__habibHandleHardwareBack = handleHardwareBack; + window.__habibHandleHardwareBackSync = handleHardwareBackSync; return () => { - // On unmount (shouldn't happen in practice), restore the stub. + // On unmount (shouldn't happen in practice), restore the stubs. window.__habibHandleHardwareBack = () => Promise.resolve({ handled: false }); + window.__habibHandleHardwareBackSync = () => false; }; }, []); diff --git a/src/hooks/use-hardware-back-handler.ts b/src/hooks/use-hardware-back-handler.ts index 1b6f5f7..148ea45 100644 --- a/src/hooks/use-hardware-back-handler.ts +++ b/src/hooks/use-hardware-back-handler.ts @@ -60,6 +60,29 @@ export function useHardwareBackHandler( }, [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 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. * Returns { handled: true } if a web handler consumed the event, or @@ -74,7 +97,7 @@ export async function handleHardwareBack(): Promise<{ handled: boolean }> { const handler = backHandlerStack[backHandlerStack.length - 1]; try { const result = await handler(); - return { handled: result }; + return { handled: Boolean(result) }; } catch (error) { console.warn("[HardwareBack] Handler threw:", error); return { handled: false }; diff --git a/src/types/window.d.ts b/src/types/window.d.ts index 6798bbe..6b3eacf 100644 --- a/src/types/window.d.ts +++ b/src/types/window.d.ts @@ -100,6 +100,10 @@ declare global { * or { handled: false } if Flutter should close the WebView screen. */ __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, * it proves a hard reload / WebView recreation happened. From 3317ed3f06b4b33c964b63b6a4555e8243f820b7 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Sat, 22 Aug 2026 17:35:24 +0330 Subject: [PATCH 13/22] feat(question-snap-list): enable vertical scroll for overflowing inline options questions before page snap --- .../Componentes/question-snap-list.test.tsx | 101 ++++++++++++++++++ .../Componentes/question-snap-list.tsx | 86 +++++++++++++-- 2 files changed, 181 insertions(+), 6 deletions(-) diff --git a/src/components/Componentes/question-snap-list.test.tsx b/src/components/Componentes/question-snap-list.test.tsx index 6d9fd24..fc25a4a 100644 --- a/src/components/Componentes/question-snap-list.test.tsx +++ b/src/components/Componentes/question-snap-list.test.tsx @@ -511,4 +511,105 @@ describe("QuestionSnapList keyboard interaction", () => { 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( + +
+ + + + +
+
Question 2
+
, + ); + + const contentEl = container.querySelector(".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( + +
+ +
+
Question 2
+
, + ); + + const contentEl = container.querySelector(".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); + }); + }); }); diff --git a/src/components/Componentes/question-snap-list.tsx b/src/components/Componentes/question-snap-list.tsx index 5467931..d2323b7 100644 --- a/src/components/Componentes/question-snap-list.tsx +++ b/src/components/Componentes/question-snap-list.tsx @@ -60,6 +60,8 @@ type SnapDragState = { hardIgnored: boolean; isTextInput: boolean; isOptionCard: boolean; + isScrollableQuestion: boolean; + scrollContainer: HTMLElement | null; engaged: boolean; didDrag: boolean; animating: boolean; @@ -117,6 +119,8 @@ export function QuestionSnapList({ hardIgnored: false, isTextInput: false, isOptionCard: false, + isScrollableQuestion: false, + scrollContainer: null, engaged: false, didDrag: false, animating: false, @@ -144,6 +148,17 @@ export function QuestionSnapList({ return; } + const currentEl = questionRefs.current[activeIndexRef.current]; + const currentContent = currentEl?.querySelector(".question-snap-content"); + if (currentContent) { + currentContent.scrollTop = 0; + } + const nextEl = questionRefs.current[nextIndex]; + const nextContent = nextEl?.querySelector(".question-snap-content"); + if (nextContent) { + nextContent.scrollTop = 0; + } + resetQuestionKeyboardState(); onQuestionExit?.(activeIndexRef.current, nextIndex); setActiveIndex(nextIndex); @@ -424,6 +439,30 @@ export function QuestionSnapList({ return; } + const activeEl = questionRefs.current[activeIndexRef.current]; + const contentEl = activeEl?.querySelector(".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(); if (!wheelLockedRef.current) { @@ -539,6 +578,20 @@ export function QuestionSnapList({ drag.isOptionCard = isOptionCard; drag.didDrag = false; + // 3. Detect overflowing inline-options question + const activeEl = questionRefs.current[activeIndexRef.current]; + const contentEl = activeEl?.querySelector(".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 = container.getBoundingClientRect().height || window.innerHeight || @@ -570,7 +623,7 @@ export function QuestionSnapList({ drag.lastMoveTime = drag.startTime; 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) => { @@ -610,6 +663,25 @@ export function QuestionSnapList({ 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! drag.engaged = true; drag.didDrag = true; @@ -624,8 +696,8 @@ export function QuestionSnapList({ 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(); } @@ -685,8 +757,8 @@ export function QuestionSnapList({ } 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; return; } @@ -813,7 +885,9 @@ export function QuestionSnapList({ containerStyles, ].join(" ")} > -
+
{question}
{isActive && ( From 962f7e9bf6500e9dfbac2fcad895106b8faebe0a Mon Sep 17 00:00:00 2001 From: mortezaei Date: Sat, 22 Aug 2026 17:44:11 +0330 Subject: [PATCH 14/22] feat(navigation): register all modals and sheets to hardware back stack to cleanly close on Android back --- .../Componentes/dismiss-reason-sheet.tsx | 3 ++ .../Componentes/female-consent-sheet.tsx | 3 ++ .../Componentes/female-outcome-sheet.tsx | 3 ++ src/components/Componentes/help-modal.tsx | 3 ++ .../Componentes/information-sheet.tsx | 3 ++ .../Componentes/outcome-selection-sheet.tsx | 3 ++ .../Componentes/report-actions-sheet.tsx | 3 ++ src/components/Componentes/support-sheet.tsx | 3 ++ src/components/Componentes/video-player.tsx | 3 ++ src/hooks/use-hardware-back-handler.ts | 31 ++++++------------- 10 files changed, 36 insertions(+), 22 deletions(-) diff --git a/src/components/Componentes/dismiss-reason-sheet.tsx b/src/components/Componentes/dismiss-reason-sheet.tsx index ca217f1..6ab7f51 100644 --- a/src/components/Componentes/dismiss-reason-sheet.tsx +++ b/src/components/Componentes/dismiss-reason-sheet.tsx @@ -2,6 +2,7 @@ import type { HTMLAttributes } 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 Button from "./button"; @@ -77,6 +78,8 @@ export function DismissReasonSheet({ }, EXIT_ANIMATION_MS); }, [isClosing, onClose]); + useHardwareBackHandler(closeSheet, isVisible && !isClosing); + useEffect(() => { if (!isVisible) { return; diff --git a/src/components/Componentes/female-consent-sheet.tsx b/src/components/Componentes/female-consent-sheet.tsx index f6758f9..356ed62 100644 --- a/src/components/Componentes/female-consent-sheet.tsx +++ b/src/components/Componentes/female-consent-sheet.tsx @@ -3,6 +3,7 @@ import Image from "next/image"; import type { HTMLAttributes, ReactNode } from "react"; import { useCallback, useEffect, useRef, useState } from "react"; +import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler"; import { useI18n } from "@/translations/provider"; const EXIT_ANIMATION_MS = 220; @@ -50,6 +51,8 @@ export function FemaleConsentSheet({ }, EXIT_ANIMATION_MS); }, [isClosing, onClose]); + useHardwareBackHandler(closeSheet, isVisible && !isClosing); + const controls = { close: closeSheet }; const resolvedButtons = typeof buttons === "function" ? buttons(controls) : buttons; diff --git a/src/components/Componentes/female-outcome-sheet.tsx b/src/components/Componentes/female-outcome-sheet.tsx index 6b49395..4a37a47 100644 --- a/src/components/Componentes/female-outcome-sheet.tsx +++ b/src/components/Componentes/female-outcome-sheet.tsx @@ -1,6 +1,7 @@ "use client"; import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; +import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler"; import { useI18n } from "@/translations/provider"; import SwipeButton from "./swipe-button"; @@ -76,6 +77,8 @@ export function FemaleOutcomeSheet({ }, EXIT_ANIMATION_MS); }, [isClosing, onClose]); + useHardwareBackHandler(closeSheet, isVisible && !isClosing); + useEffect(() => { if (!isVisible) { return; diff --git a/src/components/Componentes/help-modal.tsx b/src/components/Componentes/help-modal.tsx index b27c41f..c9722b3 100644 --- a/src/components/Componentes/help-modal.tsx +++ b/src/components/Componentes/help-modal.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from "react"; import { useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; +import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler"; import Button from "./button"; import { useI18n } from "@/translations/provider"; @@ -63,6 +64,8 @@ export function HelpModal({ }, EXIT_ANIMATION_MS); }, [isClosing, onClose]); + useHardwareBackHandler(closeSheet, isOpen && !isClosing); + // Lock body scroll useEffect(() => { if (!isOpen || !mounted) return; diff --git a/src/components/Componentes/information-sheet.tsx b/src/components/Componentes/information-sheet.tsx index 6d7c5a4..a435feb 100644 --- a/src/components/Componentes/information-sheet.tsx +++ b/src/components/Componentes/information-sheet.tsx @@ -6,6 +6,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import Button from "./button"; import { LoadingSkeleton } from "./loading-skeleton"; import { LoadingThreeDot } from "./loading-three-dot"; +import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler"; import { useI18n } from "@/translations/provider"; const EXIT_ANIMATION_MS = 220; @@ -171,6 +172,8 @@ export function InformationSheet({ }, EXIT_ANIMATION_MS); }, [isClosing, onClose]); + useHardwareBackHandler(closeSheet, isVisible && !isClosing); + const controls = { close: closeSheet }; const resolvedTitle = typeof title === "function" ? title(controls) : title; const resolvedButtons = diff --git a/src/components/Componentes/outcome-selection-sheet.tsx b/src/components/Componentes/outcome-selection-sheet.tsx index f8c5648..b95a0de 100644 --- a/src/components/Componentes/outcome-selection-sheet.tsx +++ b/src/components/Componentes/outcome-selection-sheet.tsx @@ -2,6 +2,7 @@ import type { HTMLAttributes } from "react"; import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; +import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler"; import { useI18n } from "@/translations/provider"; const EXIT_ANIMATION_MS = 220; @@ -51,6 +52,8 @@ export function OutcomeSelectionSheet({ }, EXIT_ANIMATION_MS); }, [isClosing, onClose]); + useHardwareBackHandler(closeSheet, isVisible && !isClosing); + useEffect(() => { if (!isVisible) { return; diff --git a/src/components/Componentes/report-actions-sheet.tsx b/src/components/Componentes/report-actions-sheet.tsx index 62317d4..2ba7b78 100644 --- a/src/components/Componentes/report-actions-sheet.tsx +++ b/src/components/Componentes/report-actions-sheet.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import Button from "./button"; import { useFlutterBridge } from "@/hooks/useFlutterBridge"; +import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler"; import { copyToClipboard, downloadFile, @@ -33,6 +34,8 @@ export function ReportActionsSheet({ onClose }: ReportActionsSheetProps) { setIsClosing(true); }; + useHardwareBackHandler(closeSheet, isVisible && !isClosing); + // ✅ دکمه WEB_READY const handleSendWebReady = () => { sendToFlutter("WEB_READY", { diff --git a/src/components/Componentes/support-sheet.tsx b/src/components/Componentes/support-sheet.tsx index 9084959..6fe9a07 100644 --- a/src/components/Componentes/support-sheet.tsx +++ b/src/components/Componentes/support-sheet.tsx @@ -4,6 +4,7 @@ import Image from "next/image"; import { useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { FiHeadphones } from "react-icons/fi"; +import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler"; import { useI18n } from "@/translations/provider"; const EXIT_ANIMATION_MS = 220; @@ -45,6 +46,8 @@ export function SupportSheet({ isOpen, onClose }: SupportSheetProps) { }, EXIT_ANIMATION_MS); }, [isClosing, onClose]); + useHardwareBackHandler(closeSheet, isOpen && !isClosing); + // Lock body scroll useEffect(() => { if (!isOpen || !mounted) return; diff --git a/src/components/Componentes/video-player.tsx b/src/components/Componentes/video-player.tsx index d1ceeee..2cc536c 100644 --- a/src/components/Componentes/video-player.tsx +++ b/src/components/Componentes/video-player.tsx @@ -2,6 +2,7 @@ import { useEffect, useState, type FC } from "react"; import { createPortal } from "react-dom"; +import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler"; type VideoPlayerProps = { isOpen: boolean; @@ -24,6 +25,8 @@ export const VideoPlayer: FC = ({ setMounted(true); }, []); + useHardwareBackHandler(onClose, isOpen); + const handleLoadedMetadata = (e: React.SyntheticEvent) => { const video = e.currentTarget; if (video.videoWidth && video.videoHeight) { diff --git a/src/hooks/use-hardware-back-handler.ts b/src/hooks/use-hardware-back-handler.ts index 148ea45..059f66d 100644 --- a/src/hooks/use-hardware-back-handler.ts +++ b/src/hooks/use-hardware-back-handler.ts @@ -13,32 +13,19 @@ import { useEffect, useRef } from "react"; * Pages register themselves with useHardwareBackHandler(). Sheets and modals * also register — the last one wins, matching the visual stacking order. */ -const backHandlerStack: Array<() => boolean | Promise> = []; +const backHandlerStack: Array<() => void | boolean | Promise> = []; /** * 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( - handler: () => boolean | Promise, + handler: () => void | boolean | Promise, enabled = true, ) { const handlerRef = useRef(handler); @@ -76,7 +63,7 @@ export function handleHardwareBackSync(): boolean { // Promise was initiated by invoking the handler; it is handled in JS! return true; } - return Boolean(result); + return result === undefined ? true : Boolean(result); } catch (error) { console.warn("[HardwareBackSync] Handler threw:", error); return false; @@ -97,7 +84,7 @@ export async function handleHardwareBack(): Promise<{ handled: boolean }> { const handler = backHandlerStack[backHandlerStack.length - 1]; try { const result = await handler(); - return { handled: Boolean(result) }; + return { handled: result === undefined ? true : Boolean(result) }; } catch (error) { console.warn("[HardwareBack] Handler threw:", error); return { handled: false }; From 233077006297d625dcd55b9da8b3e9a3f6675d5a Mon Sep 17 00:00:00 2001 From: mortezaei Date: Sat, 22 Aug 2026 17:58:00 +0330 Subject: [PATCH 15/22] fix(ui): add bottom padding to snap content and use hasError on number input to prevent border-bottom clipping --- src/components/Componentes/question-number.tsx | 14 ++------------ src/components/Componentes/question-snap-list.tsx | 2 +- 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/src/components/Componentes/question-number.tsx b/src/components/Componentes/question-number.tsx index 06c3b90..a6b222a 100644 --- a/src/components/Componentes/question-number.tsx +++ b/src/components/Componentes/question-number.tsx @@ -217,6 +217,7 @@ export default function QuestionNumber({ required={question.required && !disabled} disabled={disabled || Boolean(derivedFromQuestion)} placeholder={dynamicPlaceholder} + hasError={isOutOfRange} value={localTextValue} onChange={(event) => { const nextValue = event.target.value; @@ -247,12 +248,6 @@ export default function QuestionNumber({ ); } }} - 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(" ")} />
@@ -331,6 +326,7 @@ export default function QuestionNumber({ required={question.required && !disabled} disabled={disabled || Boolean(derivedFromQuestion)} placeholder={question.extras.placeHolder} + hasError={isOutOfRange} value={inputValue} onChange={(event) => { const raw = event.target.value; @@ -362,12 +358,6 @@ export default function QuestionNumber({ 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 ? ( diff --git a/src/components/Componentes/question-snap-list.tsx b/src/components/Componentes/question-snap-list.tsx index d2323b7..d82279f 100644 --- a/src/components/Componentes/question-snap-list.tsx +++ b/src/components/Componentes/question-snap-list.tsx @@ -886,7 +886,7 @@ export function QuestionSnapList({ ].join(" ")} >
{question}
From 83bc7cbf510cf13365b97884303866d684e653e0 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Sat, 22 Aug 2026 18:20:21 +0330 Subject: [PATCH 16/22] feat(upload): isolate photo and file uploads via requestId and connect to tmp-media service --- src/components/Componentes/question-file.tsx | 30 ++++++++++++++----- src/components/Componentes/question-photo.tsx | 23 +++++++++++--- src/lib/webview-actions.ts | 1 + src/types/window.d.ts | 4 +++ 4 files changed, 46 insertions(+), 12 deletions(-) diff --git a/src/components/Componentes/question-file.tsx b/src/components/Componentes/question-file.tsx index 48e8d8d..011b42d 100644 --- a/src/components/Componentes/question-file.tsx +++ b/src/components/Componentes/question-file.tsx @@ -1,7 +1,7 @@ "use client"; import Image from "next/image"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import type { QuestionField } from "@/lib/schema-adapter"; import { useUploadTmpMediaMutation } from "@/hooks/marriage/use-upload-tmp-media"; import { getApiRequestUrl } from "@/lib/http"; @@ -83,6 +83,7 @@ export function QuestionFile({ const [filePreviewUrl, setFilePreviewUrl] = useState( initialFileUrl, ); + const isInitiatorRef = useRef(false); const [isFlutterPicking, setIsFlutterPicking] = useState(false); const acceptedFiles = (question.extras?.options ?? []) @@ -93,6 +94,8 @@ export function QuestionFile({ onSuccess: (response) => { if (response.path) { setAnswerValue(question, response.path); + setSelectedFileName(response.name ?? response.path.split("/").pop() ?? "uploaded"); + setFilePreviewUrl(response.path); } }, onError: (error) => { @@ -108,6 +111,8 @@ export function QuestionFile({ const unsubscribe = window.addFlutterResponseListener?.((event) => { 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) { case "picking": @@ -117,20 +122,23 @@ export function QuestionFile({ if (event.data?.files?.[0]) { const fileName = event.data.files[0].name ?? null; setSelectedFileName(fileName); - if (fileName) { - setAnswerValue(question, fileName); - } } break; case "progress": break; case "completed": { setIsFlutterPicking(false); + isInitiatorRef.current = false; const file = event.data?.files?.[0]; - if (file?.url) { - setAnswerValue(question, file.url); - setSelectedFileName(file.name ?? "uploaded"); - setFilePreviewUrl(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); + setSelectedFileName(file?.name ?? remoteUrl.split("/").pop() ?? "uploaded"); + setFilePreviewUrl(remoteUrl); } else if (file?.base64) { const b64 = file.base64; setSelectedFileName(file.name ?? "upload"); @@ -148,9 +156,11 @@ export function QuestionFile({ } case "cancelled": setIsFlutterPicking(false); + isInitiatorRef.current = false; break; case "failed": setIsFlutterPicking(false); + isInitiatorRef.current = false; console.error("upload_file failed:", event.message); break; } @@ -168,7 +178,11 @@ export function QuestionFile({ ); const mediaType = resolveMediaType(extensions); + isInitiatorRef.current = true; + setIsFlutterPicking(true); + uploadFile({ + requestId: String(question.id), mediaType, source: "gallery", returnAs: "upload", diff --git a/src/components/Componentes/question-photo.tsx b/src/components/Componentes/question-photo.tsx index 374b127..0a965de 100644 --- a/src/components/Componentes/question-photo.tsx +++ b/src/components/Componentes/question-photo.tsx @@ -1,7 +1,7 @@ "use client"; 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 { useUploadTmpMediaMutation } from "@/hooks/marriage/use-upload-tmp-media"; import { getApiRequestUrl } from "@/lib/http"; @@ -34,10 +34,12 @@ export function QuestionPhoto({ const { getAnswerValue, setAnswerValue } = useQuestionAnswers(); const storedValue = getAnswerValue(question); + const isInitiatorRef = useRef(false); const uploadTmpMediaMutation = useUploadTmpMediaMutation({ onSuccess: (response) => { if (response.path) { setAnswerValue(question, response.path); + setLocalPreviewUrl(response.path); } }, onError: (error) => { @@ -53,6 +55,8 @@ export function QuestionPhoto({ const unsubscribe = window.addFlutterResponseListener?.((event) => { 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) { case "picking": @@ -67,10 +71,16 @@ export function QuestionPhoto({ break; case "completed": { setIsFlutterPicking(false); + isInitiatorRef.current = false; 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) { const b64 = file.base64; setLocalPreviewUrl(b64); @@ -89,6 +99,7 @@ export function QuestionPhoto({ case "cancelled": case "failed": setIsFlutterPicking(false); + isInitiatorRef.current = false; break; } }); @@ -103,7 +114,11 @@ export function QuestionPhoto({ o.replace(/^\./, "").toLowerCase(), ); + isInitiatorRef.current = true; + setIsFlutterPicking(true); + uploadFile({ + requestId: String(question.id), mediaType: "image", source: "gallery", returnAs: "upload", diff --git a/src/lib/webview-actions.ts b/src/lib/webview-actions.ts index 7ef7f5a..057192c 100644 --- a/src/lib/webview-actions.ts +++ b/src/lib/webview-actions.ts @@ -115,6 +115,7 @@ export function openExternalUrl(options: OpenExternalUrlOptions): boolean { // ─── upload_file ───────────────────────────────────────── export interface UploadFileOptions { + requestId?: string | number; mediaType: "image" | "video" | "image+video" | "audio" | "file"; source?: "gallery" | "camera" | "any"; multiple?: boolean; diff --git a/src/types/window.d.ts b/src/types/window.d.ts index 6b3eacf..e5f6cad 100644 --- a/src/types/window.d.ts +++ b/src/types/window.d.ts @@ -9,6 +9,7 @@ declare global { interface FlutterResponseEvent { action: string; success: boolean; + requestId?: string | number; /** Compatibility payload used by the uppercase Flutter event protocol. */ payload?: FlutterResponseEvent["data"]; /** Top-level status for multi-step actions (download_file, upload_file, …) */ @@ -67,10 +68,13 @@ declare global { source?: string; files?: Array<{ url?: string; + path?: string; + apath?: string; base64?: string; name?: string; size?: number; mimeType?: string; + data?: Record; }>; // copy_to_clipboard label?: string; From 120a753efd3da5d3ff63a510dab8fe3e4376d88d Mon Sep 17 00:00:00 2001 From: mortezaei Date: Sat, 22 Aug 2026 18:32:23 +0330 Subject: [PATCH 17/22] fix(photo-upload): implement smooth circular spinner loading matching Flutter ProfileAvatarWidget --- src/components/Componentes/question-photo.tsx | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/components/Componentes/question-photo.tsx b/src/components/Componentes/question-photo.tsx index 0a965de..77c110e 100644 --- a/src/components/Componentes/question-photo.tsx +++ b/src/components/Componentes/question-photo.tsx @@ -23,7 +23,7 @@ export function QuestionPhoto({ }: QuestionPhotoProps) { const inputId = useId(); const [localPreviewUrl, setLocalPreviewUrl] = useState(null); - const [isFlutterPicking, setIsFlutterPicking] = useState(false); + const [isUploading, setIsUploading] = useState(false); const acceptedFiles = question.extras?.options && question.extras.options.length > 0 @@ -41,13 +41,15 @@ export function QuestionPhoto({ setAnswerValue(question, response.path); setLocalPreviewUrl(response.path); } + setIsUploading(false); }, onError: (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 useEffect(() => { @@ -60,9 +62,10 @@ export function QuestionPhoto({ switch (event.status) { case "picking": - setIsFlutterPicking(true); break; case "picked": + case "progress": + setIsUploading(true); if (event.data?.files?.[0]?.base64) { const b64 = event.data.files[0].base64; setLocalPreviewUrl(b64); @@ -70,7 +73,7 @@ export function QuestionPhoto({ } break; case "completed": { - setIsFlutterPicking(false); + setIsUploading(false); isInitiatorRef.current = false; const file = event.data?.files?.[0]; const remoteUrl = @@ -98,7 +101,7 @@ export function QuestionPhoto({ } case "cancelled": case "failed": - setIsFlutterPicking(false); + setIsUploading(false); isInitiatorRef.current = false; break; } @@ -115,7 +118,6 @@ export function QuestionPhoto({ ); isInitiatorRef.current = true; - setIsFlutterPicking(true); uploadFile({ requestId: String(question.id), @@ -142,6 +144,7 @@ export function QuestionPhoto({ setAnswerValue(question, objectUrl); // Trigger background upload + setIsUploading(true); uploadTmpMediaMutation.mutate(file); }; @@ -240,10 +243,10 @@ export function QuestionPhoto({
)} - {/* Loading spinner during upload */} + {/* Loading spinner during upload matching Flutter ProfileAvatarWidget */} {isPending && ( -
- +
+
)}
From dd8ea52037fb725b4023f3110dc3c8a0eb45bae7 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Sat, 22 Aug 2026 18:50:49 +0330 Subject: [PATCH 18/22] feat(question-file): add multi-document upload support with tmp-media and Flutter-style spinner while preserving card frame --- src/components/Componentes/question-file.tsx | 500 ++++++++++++------- 1 file changed, 313 insertions(+), 187 deletions(-) diff --git a/src/components/Componentes/question-file.tsx b/src/components/Componentes/question-file.tsx index 011b42d..f9e6fbe 100644 --- a/src/components/Componentes/question-file.tsx +++ b/src/components/Componentes/question-file.tsx @@ -3,7 +3,10 @@ import Image from "next/image"; import { useCallback, useEffect, useRef, useState } from "react"; import type { QuestionField } from "@/lib/schema-adapter"; -import { useUploadTmpMediaMutation } from "@/hooks/marriage/use-upload-tmp-media"; +import { + uploadTmpMedia, + useUploadTmpMediaMutation, +} from "@/hooks/marriage/use-upload-tmp-media"; import { getApiRequestUrl } from "@/lib/http"; import { isInFlutterWebView, uploadFile } from "@/lib/webview-actions"; import { useQuestionAnswers } from "./question-answer-storage"; @@ -56,6 +59,12 @@ function isImageFile( return /\.(jpg|jpeg|png|webp|gif|svg|bmp|avif)$/i.test(nameToCheck); } +export type UploadedDoc = { + url: string; + name: string; + previewUrl?: string; +}; + export function QuestionFile({ question, disabled, @@ -63,28 +72,60 @@ export function QuestionFile({ const { getAnswerValue, setAnswerValue } = useQuestionAnswers(); const storedValue = getAnswerValue(question); - const initialFileName = - typeof storedValue === "string" && storedValue.trim().length > 0 - ? (storedValue.split("/").pop() ?? storedValue) - : null; - - const initialFileUrl = - typeof storedValue === "string" && storedValue.trim().length > 0 - ? storedValue.startsWith("http") || - storedValue.startsWith("blob:") || - storedValue.startsWith("data:") - ? storedValue - : getApiRequestUrl(storedValue) - : null; - - const [selectedFileName, setSelectedFileName] = useState( - initialFileName, - ); - const [filePreviewUrl, setFilePreviewUrl] = useState( - initialFileUrl, + const parseInitialDocs = useCallback((stored: unknown): UploadedDoc[] => { + if (!stored) return []; + if (Array.isArray(stored)) { + return stored + .filter((item): item is string | Record => Boolean(item)) + .map((item) => { + if (typeof item === "string") { + const resolvedUrl = + item.startsWith("http") || + item.startsWith("blob:") || + item.startsWith("data:") + ? item + : getApiRequestUrl(item); + return { + url: item, + previewUrl: resolvedUrl, + name: item.split("/").pop() || "document", + }; + } + const url = (item.url || item.path || "") as string; + const name = (item.name || url.split("/").pop() || "document") as string; + const resolvedUrl = + url.startsWith("http") || + url.startsWith("blob:") || + url.startsWith("data:") + ? url + : getApiRequestUrl(url); + return { url, name, previewUrl: resolvedUrl }; + }); + } + if (typeof stored === "string" && stored.trim().length > 0) { + const resolvedUrl = + stored.startsWith("http") || + stored.startsWith("blob:") || + stored.startsWith("data:") + ? stored + : getApiRequestUrl(stored); + return [ + { + url: stored, + name: stored.split("/").pop() || "document", + previewUrl: resolvedUrl, + }, + ]; + } + return []; + }, []); + + const [uploadedDocs, setUploadedDocs] = useState(() => + parseInitialDocs(storedValue), ); + const [isUploading, setIsUploading] = useState(false); const isInitiatorRef = useRef(false); - const [isFlutterPicking, setIsFlutterPicking] = useState(false); + const fileInputRef = useRef(null); const acceptedFiles = (question.extras?.options ?? []) .map((option) => option.replace(/^\./, "")) @@ -93,17 +134,32 @@ export function QuestionFile({ const uploadTmpMediaMutation = useUploadTmpMediaMutation({ onSuccess: (response) => { if (response.path) { - setAnswerValue(question, response.path); - setSelectedFileName(response.name ?? response.path.split("/").pop() ?? "uploaded"); - setFilePreviewUrl(response.path); + const resolved = response.path.startsWith("http") + ? response.path + : getApiRequestUrl(response.path); + const newDoc: UploadedDoc = { + url: response.path, + name: response.name ?? response.path.split("/").pop() ?? "uploaded", + previewUrl: resolved, + }; + setUploadedDocs((prev) => { + const nextDocs = [...prev, newDoc]; + setAnswerValue( + question, + nextDocs.length === 1 ? nextDocs[0].url : nextDocs.map((d) => d.url), + ); + return nextDocs; + }); } + setIsUploading(false); }, onError: (error) => { console.error("File upload error:", error); + setIsUploading(false); }, }); - const isPending = uploadTmpMediaMutation.isPending || isFlutterPicking; + const isPending = uploadTmpMediaMutation.isPending || isUploading; // Listen for upload_file responses from Flutter useEffect(() => { @@ -116,52 +172,66 @@ export function QuestionFile({ switch (event.status) { case "picking": - setIsFlutterPicking(true); break; case "picked": - if (event.data?.files?.[0]) { - const fileName = event.data.files[0].name ?? null; - setSelectedFileName(fileName); - } - break; case "progress": + setIsUploading(true); break; case "completed": { - setIsFlutterPicking(false); + setIsUploading(false); isInitiatorRef.current = false; - const file = event.data?.files?.[0]; - const remoteUrl = - file?.path || - file?.url || - (file?.data?.path as string | undefined) || - (file?.data?.url as string | undefined); - if (remoteUrl) { - setAnswerValue(question, remoteUrl); - setSelectedFileName(file?.name ?? remoteUrl.split("/").pop() ?? "uploaded"); - setFilePreviewUrl(remoteUrl); - } else if (file?.base64) { - const b64 = file.base64; - setSelectedFileName(file.name ?? "upload"); - setFilePreviewUrl(b64); - fetch(b64) - .then((res) => res.blob()) - .then((blob) => { - const f = new File([blob], file.name ?? "upload", { - type: blob.type, - }); - uploadTmpMediaMutation.mutate(f); + const incomingFiles = event.data?.files || []; + const newDocs: UploadedDoc[] = []; + + for (const f of incomingFiles) { + const remoteUrl = + f?.path || + f?.url || + (f?.data?.path as string | undefined) || + (f?.data?.url as string | undefined); + if (remoteUrl) { + const resolved = + remoteUrl.startsWith("http") || + remoteUrl.startsWith("blob:") || + remoteUrl.startsWith("data:") + ? remoteUrl + : getApiRequestUrl(remoteUrl); + newDocs.push({ + url: remoteUrl, + name: f?.name || remoteUrl.split("/").pop() || "document", + previewUrl: resolved, }); + } else if (f?.base64) { + const b64 = f.base64; + fetch(b64) + .then((res) => res.blob()) + .then((blob) => { + const fileObj = new File([blob], f.name ?? "document", { + type: blob.type, + }); + uploadTmpMediaMutation.mutate(fileObj); + }); + } + } + + if (newDocs.length > 0) { + setUploadedDocs((prev) => { + const nextDocs = [...prev, ...newDocs]; + setAnswerValue( + question, + nextDocs.length === 1 + ? nextDocs[0].url + : nextDocs.map((d) => d.url), + ); + return nextDocs; + }); } break; } case "cancelled": - setIsFlutterPicking(false); - isInitiatorRef.current = false; - break; case "failed": - setIsFlutterPicking(false); + setIsUploading(false); isInitiatorRef.current = false; - console.error("upload_file failed:", event.message); break; } }); @@ -179,11 +249,11 @@ export function QuestionFile({ const mediaType = resolveMediaType(extensions); isInitiatorRef.current = true; - setIsFlutterPicking(true); uploadFile({ requestId: String(question.id), mediaType, + multiple: true, source: "gallery", returnAs: "upload", uploadUrl: `${process.env.NEXT_PUBLIC_API_BASE_URL ?? ""}/upload-tmp-media/`, @@ -195,180 +265,236 @@ export function QuestionFile({ }); }, [question]); - /** Handle file pick via browser (fallback). */ - function handleBrowserFileChange(files: FileList | null) { - const file = files?.[0]; - - if (!file) { - setSelectedFileName(null); - setFilePreviewUrl(null); - setAnswerValue(question, null); - return; + /** Handle file pick via browser (fallback). */ + async function handleBrowserFileChange(files: FileList | null) { + if (!files || files.length === 0) return; + + setIsUploading(true); + const newDocs: UploadedDoc[] = []; + + for (let i = 0; i < files.length; i++) { + const file = files[i]; + try { + const res = await uploadTmpMedia(file); + if (res.path) { + const resolved = res.path.startsWith("http") + ? res.path + : getApiRequestUrl(res.path); + newDocs.push({ + url: res.path, + name: res.name || file.name, + previewUrl: resolved, + }); + } + } catch (err) { + console.error("Failed to upload tmp file:", file.name, err); + } } - setSelectedFileName(file.name); - setAnswerValue(question, file.name); - - if (file.type.startsWith("image/")) { - const objectUrl = URL.createObjectURL(file); - setFilePreviewUrl(objectUrl); - } else { - setFilePreviewUrl(null); + setIsUploading(false); + + if (newDocs.length > 0) { + setUploadedDocs((prev) => { + const nextDocs = [...prev, ...newDocs]; + setAnswerValue( + question, + nextDocs.length === 1 ? nextDocs[0].url : nextDocs.map((d) => d.url), + ); + return nextDocs; + }); } - uploadTmpMediaMutation.mutate(file); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } } - const handleRemoveFile = (e: React.MouseEvent) => { + const handleRemoveDoc = (indexToRemove: number, e: React.MouseEvent) => { e.stopPropagation(); e.preventDefault(); - setSelectedFileName(null); - setFilePreviewUrl(null); - setAnswerValue(question, null); + setUploadedDocs((prev) => { + const nextDocs = prev.filter((_, idx) => idx !== indexToRemove); + if (nextDocs.length === 0) { + setAnswerValue(question, null); + } else if (nextDocs.length === 1) { + setAnswerValue(question, nextDocs[0].url); + } else { + setAnswerValue(question, nextDocs.map((d) => d.url)); + } + return nextDocs; + }); + }; + + const handleTriggerPick = (e?: React.MouseEvent) => { + if (e) { + e.stopPropagation(); + } + if (isInFlutterWebView()) { + handleFlutterPick(); + } else { + fileInputRef.current?.click(); + } }; const inWebView = isInFlutterWebView(); - const isUploaded = Boolean(selectedFileName || storedValue); - const currentFileName = - selectedFileName ?? - (typeof storedValue === "string" ? storedValue.split("/").pop() : null); - const currentFileUrl = - filePreviewUrl ?? - (typeof storedValue === "string" && storedValue.trim().length > 0 - ? storedValue.startsWith("http") || - storedValue.startsWith("blob:") || - storedValue.startsWith("data:") - ? storedValue - : getApiRequestUrl(storedValue) - : null); - - const isImg = isImageFile(currentFileName, currentFileUrl); + const hasDocuments = uploadedDocs.length > 0; return (
- { - if (e.key === "Enter" || e.key === " ") handleFlutterPick(); - } - : undefined - } - > - {/* Fallback: browser file input (hidden in WebView or when uploaded) */} - {!inWebView && !isUploaded && ( - handleBrowserFileChange(event.target.files)} - className="absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0" - /> - )} - {isUploaded ? ( - /* ────── UPLOADED STATE ────── */ -
- {isImg && currentFileUrl ? ( - /* Image Preview (Left design in screenshot) */ - {currentFileName - ) : ( - /* Document / PDF Preview (Right design in screenshot) */ -
-
-
+ {hasDocuments ? ( + /* ────── UPLOADED MULTI-DOCUMENT STATE ────── */ +
+
+ {uploadedDocs.map((doc, index) => { + const isImg = isImageFile(doc.name, doc.previewUrl ?? doc.url); + return ( +
- - - - - PDF - -
- - {currentFileName ?? "document"} - -
- )} - - {/* Trash Button in Bottom-Right */} + {/* Left: Thumbnail or PDF Icon */} +
+ {isImg && doc.previewUrl ? ( + {doc.name} + ) : ( +
+ + + + + + PDF + +
+ )} + + {/* Middle: File Name */} + + {doc.name} + +
+ + {/* Right: Individual Trash Button */} + +
+ ); + })} +
+ + {/* Add Another Document Button */} - - {isPending && ( -
- -
- )}
- ) : /* ────── DEFAULT EMPTY STATE ────── */ - isPending ? ( - ) : ( - <> + /* ────── DEFAULT EMPTY STATE ────── */ +
handleTriggerPick()} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") handleTriggerPick(); + }} + className="flex w-full cursor-pointer flex-col items-center justify-center py-4" + > Upload - - {selectedFileName ?? "upload certificates"} + + بارگذاری مدارک (کارت شناسایی، پاسپورت و...) - {uploadTmpMediaMutation.isError ? ( - - Upload failed. Please try again. - - ) : acceptedFiles ? ( + {acceptedFiles ? ( {acceptedFiles} ) : null} - +
+ )} + + {/* Smooth Circular Loading Spinner Overlay (Matching Flutter ProfileAvatar) */} + {isPending && ( +
+
+ در حال آپلود در تمپ... +
)} - +
); } From 192546a02aa6c842b943c7b2cb732d1e4982f040 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Sat, 22 Aug 2026 18:58:23 +0330 Subject: [PATCH 19/22] fix(question-file): add full multi-language i18n support and fix border interaction states --- src/components/Componentes/question-file.tsx | 251 ++++++++++++++----- 1 file changed, 187 insertions(+), 64 deletions(-) diff --git a/src/components/Componentes/question-file.tsx b/src/components/Componentes/question-file.tsx index f9e6fbe..b40521c 100644 --- a/src/components/Componentes/question-file.tsx +++ b/src/components/Componentes/question-file.tsx @@ -9,6 +9,7 @@ import { } from "@/hooks/marriage/use-upload-tmp-media"; import { getApiRequestUrl } from "@/lib/http"; import { isInFlutterWebView, uploadFile } from "@/lib/webview-actions"; +import { useI18n } from "@/translations/provider"; import { useQuestionAnswers } from "./question-answer-storage"; import QuestionTitle from "./question-title"; import { LoadingSkeleton } from "./loading-skeleton"; @@ -18,6 +19,107 @@ type QuestionFileProps = { disabled?: boolean; }; +const FILE_I18N: Record< + string, + { + uploadPrompt: string; + addMore: string; + uploading: string; + remove: string; + uploadFailed: string; + defaultDoc: string; + } +> = { + fa: { + uploadPrompt: "بارگذاری مدارک (کارت شناسایی، پاسپورت و...)", + addMore: "افزودن مدرک دیگر", + uploading: "در حال آپلود در تمپ...", + remove: "حذف مدرک", + uploadFailed: "خطا در آپلود. لطفاً دوباره تلاش کنید.", + defaultDoc: "مدرک", + }, + ar: { + uploadPrompt: "تحميل المستندات (بطاقة الهوية، جواز السفر...)", + addMore: "إضافة مستند آخر", + uploading: "جاري التحميل...", + remove: "حذف المستند", + uploadFailed: "فشل التحميل. يرجى المحاولة مرة أخرى.", + defaultDoc: "مستند", + }, + en: { + uploadPrompt: "Upload documents (ID card, passport...)", + addMore: "Add another document", + uploading: "Uploading to temporary storage...", + remove: "Remove document", + uploadFailed: "Upload failed. Please try again.", + defaultDoc: "document", + }, + tr: { + uploadPrompt: "Belgeleri yükleyin (Kimlik kartı, pasaport...)", + addMore: "Başka bir belge ekle", + uploading: "Yükleniyor...", + remove: "Belgeyi kaldır", + uploadFailed: "Yükleme başarısız oldu. Lütfen tekrar deneyin.", + defaultDoc: "belge", + }, + ur: { + uploadPrompt: "دستاویزات اپ لوڈ کریں (شناختی کارڈ، پاسپورٹ...)", + addMore: "ایک اور دستاویز شامل کریں", + uploading: "اپ لوڈ ہو رہا ہے...", + remove: "دستاویز حذف کریں", + uploadFailed: "اپ لوڈ ناکام ہو گیا۔ براہ کرم دوبارہ کوشش کریں۔", + defaultDoc: "دستاویز", + }, + ru: { + uploadPrompt: "Загрузить документы (удостоверение личности, паспорт...)", + addMore: "Добавить еще документ", + uploading: "Загрузка...", + remove: "Удалить документ", + uploadFailed: "Ошибка загрузки. Пожалуйста, попробуйте снова.", + defaultDoc: "документ", + }, + de: { + uploadPrompt: "Dokumente hochladen (Personalausweis, Reisepass...)", + addMore: "Weitere Datei hinzufügen", + uploading: "Wird hochgeladen...", + remove: "Dokument entfernen", + uploadFailed: "Upload fehlgeschlagen. Bitte erneut versuchen.", + defaultDoc: "Dokument", + }, + fr: { + uploadPrompt: "Télécharger des documents (carte d'identité, passeport...)", + addMore: "Ajouter un autre document", + uploading: "Téléchargement en cours...", + remove: "Supprimer le document", + uploadFailed: "Échec du téléchargement. Veuillez réessayer.", + defaultDoc: "document", + }, + es: { + uploadPrompt: "Subir documentos (DNI, pasaporte...)", + addMore: "Agregar otro documento", + uploading: "Subiendo...", + remove: "Eliminar documento", + uploadFailed: "Error al subir. Inténtalo de nuevo.", + defaultDoc: "documento", + }, + zh: { + uploadPrompt: "上传证件(身份证、护照等)", + addMore: "添加其他文件", + uploading: "上传中...", + remove: "删除文件", + uploadFailed: "上传失败,请重试。", + defaultDoc: "文件", + }, + id: { + uploadPrompt: "Unggah dokumen (KTP, paspor...)", + addMore: "Tambah dokumen lain", + uploading: "Mengunggah...", + remove: "Hapus dokumen", + uploadFailed: "Unggah gagal. Silakan coba lagi.", + defaultDoc: "dokumen", + }, +}; + /** Map question file extensions to upload_file mediaType. */ function resolveMediaType( extensions: string[], @@ -69,56 +171,64 @@ export function QuestionFile({ question, disabled, }: QuestionFileProps) { + const { locale } = useI18n(); + const t = + FILE_I18N[locale] ?? FILE_I18N[locale?.slice(0, 2)] ?? FILE_I18N.fa; + const { getAnswerValue, setAnswerValue } = useQuestionAnswers(); const storedValue = getAnswerValue(question); - const parseInitialDocs = useCallback((stored: unknown): UploadedDoc[] => { - if (!stored) return []; - if (Array.isArray(stored)) { - return stored - .filter((item): item is string | Record => Boolean(item)) - .map((item) => { - if (typeof item === "string") { + const parseInitialDocs = useCallback( + (stored: unknown): UploadedDoc[] => { + if (!stored) return []; + if (Array.isArray(stored)) { + return stored + .filter((item): item is string | Record => Boolean(item)) + .map((item) => { + if (typeof item === "string") { + const resolvedUrl = + item.startsWith("http") || + item.startsWith("blob:") || + item.startsWith("data:") + ? item + : getApiRequestUrl(item); + return { + url: item, + previewUrl: resolvedUrl, + name: item.split("/").pop() || t.defaultDoc, + }; + } + const url = (item.url || item.path || "") as string; + const name = + (item.name || url.split("/").pop() || t.defaultDoc) as string; const resolvedUrl = - item.startsWith("http") || - item.startsWith("blob:") || - item.startsWith("data:") - ? item - : getApiRequestUrl(item); - return { - url: item, - previewUrl: resolvedUrl, - name: item.split("/").pop() || "document", - }; - } - const url = (item.url || item.path || "") as string; - const name = (item.name || url.split("/").pop() || "document") as string; - const resolvedUrl = - url.startsWith("http") || - url.startsWith("blob:") || - url.startsWith("data:") - ? url - : getApiRequestUrl(url); - return { url, name, previewUrl: resolvedUrl }; - }); - } - if (typeof stored === "string" && stored.trim().length > 0) { - const resolvedUrl = - stored.startsWith("http") || - stored.startsWith("blob:") || - stored.startsWith("data:") - ? stored - : getApiRequestUrl(stored); - return [ - { - url: stored, - name: stored.split("/").pop() || "document", - previewUrl: resolvedUrl, - }, - ]; - } - return []; - }, []); + url.startsWith("http") || + url.startsWith("blob:") || + url.startsWith("data:") + ? url + : getApiRequestUrl(url); + return { url, name, previewUrl: resolvedUrl }; + }); + } + if (typeof stored === "string" && stored.trim().length > 0) { + const resolvedUrl = + stored.startsWith("http") || + stored.startsWith("blob:") || + stored.startsWith("data:") + ? stored + : getApiRequestUrl(stored); + return [ + { + url: stored, + name: stored.split("/").pop() || t.defaultDoc, + previewUrl: resolvedUrl, + }, + ]; + } + return []; + }, + [t.defaultDoc], + ); const [uploadedDocs, setUploadedDocs] = useState(() => parseInitialDocs(storedValue), @@ -139,7 +249,7 @@ export function QuestionFile({ : getApiRequestUrl(response.path); const newDoc: UploadedDoc = { url: response.path, - name: response.name ?? response.path.split("/").pop() ?? "uploaded", + name: response.name ?? response.path.split("/").pop() ?? t.defaultDoc, previewUrl: resolved, }; setUploadedDocs((prev) => { @@ -198,7 +308,7 @@ export function QuestionFile({ : getApiRequestUrl(remoteUrl); newDocs.push({ url: remoteUrl, - name: f?.name || remoteUrl.split("/").pop() || "document", + name: f?.name || remoteUrl.split("/").pop() || t.defaultDoc, previewUrl: resolved, }); } else if (f?.base64) { @@ -239,7 +349,7 @@ export function QuestionFile({ return () => { unsubscribe?.(); }; - }, [question, setAnswerValue, uploadTmpMediaMutation]); + }, [question, setAnswerValue, t.defaultDoc, uploadTmpMediaMutation]); /** Handle file pick in Flutter WebView via upload_file action. */ const handleFlutterPick = useCallback(() => { @@ -363,7 +473,22 @@ export function QuestionFile({ )}
handleTriggerPick() : undefined} + onKeyDown={ + !hasDocuments + ? (e) => { + if (e.key === "Enter" || e.key === " ") handleTriggerPick(); + } + : undefined + } + className={[ + "relative flex w-full flex-col items-center justify-center rounded-[29px] border-2 border-dashed border-[#8D8D8D] bg-[#F7F7F7] text-center transition-all duration-200 focus-within:outline-2 focus-within:outline-offset-4 focus-within:outline-[#6F6F6F] hover:border-[#777777] active:border-[#111111] overflow-hidden p-4", + hasDocuments + ? "min-h-[156px]" + : "aspect-[727/330] min-h-[156px] cursor-pointer", + ].join(" ")} > {hasDocuments ? ( /* ────── UPLOADED MULTI-DOCUMENT STATE ────── */ @@ -415,7 +540,7 @@ export function QuestionFile({
) : ( /* ────── DEFAULT EMPTY STATE ────── */ -
handleTriggerPick()} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") handleTriggerPick(); - }} - className="flex w-full cursor-pointer flex-col items-center justify-center py-4" - > +
Upload - بارگذاری مدارک (کارت شناسایی، پاسپورت و...) + {t.uploadPrompt} - {acceptedFiles ? ( + {uploadTmpMediaMutation.isError ? ( + + {t.uploadFailed} + + ) : acceptedFiles ? ( {acceptedFiles} @@ -491,7 +612,9 @@ export function QuestionFile({ {isPending && (
- در حال آپلود در تمپ... + + {t.uploading} +
)}
From ceb2c885931c5da2863a4c4666d0b8cedccdd4bb Mon Sep 17 00:00:00 2001 From: mortezaei Date: Sat, 22 Aug 2026 19:08:41 +0330 Subject: [PATCH 20/22] fix(question-file): add upload progress percentage, fix border jumping on tap, sync initial stored files, and fix hardware back history handling --- .../questions-list/questions-list-client.tsx | 2 +- src/components/Componentes/question-file.tsx | 49 +++++++++++++++---- src/hooks/marriage/use-upload-tmp-media.ts | 15 +++++- 3 files changed, 54 insertions(+), 12 deletions(-) diff --git a/src/app/questions-list/questions-list-client.tsx b/src/app/questions-list/questions-list-client.tsx index 9a5f6a3..2cec762 100644 --- a/src/app/questions-list/questions-list-client.tsx +++ b/src/app/questions-list/questions-list-client.tsx @@ -147,7 +147,7 @@ export default function QuestionsListClient() { if (typeof window !== "undefined") { const url = new URL(window.location.href); url.searchParams.set("section", slug); - window.history.pushState({ section: slug }, "", url.toString()); + window.history.replaceState({ section: slug }, "", url.toString()); } }, []); diff --git a/src/components/Componentes/question-file.tsx b/src/components/Componentes/question-file.tsx index b40521c..63c0015 100644 --- a/src/components/Componentes/question-file.tsx +++ b/src/components/Componentes/question-file.tsx @@ -234,9 +234,17 @@ export function QuestionFile({ parseInitialDocs(storedValue), ); const [isUploading, setIsUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(null); const isInitiatorRef = useRef(false); const fileInputRef = useRef(null); + // Sync state when answers load asynchronously from server/cache + useEffect(() => { + if (storedValue !== undefined && storedValue !== null) { + setUploadedDocs(parseInitialDocs(storedValue)); + } + }, [storedValue, parseInitialDocs]); + const acceptedFiles = (question.extras?.options ?? []) .map((option) => option.replace(/^\./, "")) .join(", "); @@ -262,10 +270,12 @@ export function QuestionFile({ }); } setIsUploading(false); + setUploadProgress(null); }, onError: (error) => { console.error("File upload error:", error); setIsUploading(false); + setUploadProgress(null); }, }); @@ -284,11 +294,20 @@ export function QuestionFile({ case "picking": break; case "picked": - case "progress": setIsUploading(true); + setUploadProgress(0); + break; + case "progress": { + setIsUploading(true); + const p = (event.data as any)?.progress; + if (typeof p === "number") { + setUploadProgress(Math.round(p)); + } break; + } case "completed": { setIsUploading(false); + setUploadProgress(null); isInitiatorRef.current = false; const incomingFiles = event.data?.files || []; const newDocs: UploadedDoc[] = []; @@ -341,6 +360,7 @@ export function QuestionFile({ case "cancelled": case "failed": setIsUploading(false); + setUploadProgress(null); isInitiatorRef.current = false; break; } @@ -380,12 +400,13 @@ export function QuestionFile({ if (!files || files.length === 0) return; setIsUploading(true); + setUploadProgress(0); const newDocs: UploadedDoc[] = []; for (let i = 0; i < files.length; i++) { const file = files[i]; try { - const res = await uploadTmpMedia(file); + const res = await uploadTmpMedia(file, (p) => setUploadProgress(p)); if (res.path) { const resolved = res.path.startsWith("http") ? res.path @@ -402,6 +423,7 @@ export function QuestionFile({ } setIsUploading(false); + setUploadProgress(null); if (newDocs.length > 0) { setUploadedDocs((prev) => { @@ -484,10 +506,10 @@ export function QuestionFile({ : undefined } className={[ - "relative flex w-full flex-col items-center justify-center rounded-[29px] border-2 border-dashed border-[#8D8D8D] bg-[#F7F7F7] text-center transition-all duration-200 focus-within:outline-2 focus-within:outline-offset-4 focus-within:outline-[#6F6F6F] hover:border-[#777777] active:border-[#111111] overflow-hidden p-4", + "relative flex w-full flex-col items-center justify-center rounded-[29px] border-2 border-dashed border-[#8D8D8D] bg-[#F7F7F7] text-center transition-colors duration-200 hover:border-[#6F6F6F] overflow-hidden select-none", hasDocuments - ? "min-h-[156px]" - : "aspect-[727/330] min-h-[156px] cursor-pointer", + ? "min-h-[156px] p-3.5" + : "aspect-[727/330] min-h-[156px] cursor-pointer p-4", ].join(" ")} > {hasDocuments ? ( @@ -608,12 +630,21 @@ export function QuestionFile({
)} - {/* Smooth Circular Loading Spinner Overlay (Matching Flutter ProfileAvatar) */} + {/* Smooth Circular Loading Spinner Overlay with Upload Progress */} {isPending && ( -
-
+
+
+
+ {uploadProgress !== null && uploadProgress > 0 && ( + + {uploadProgress}% + + )} +
- {t.uploading} + {uploadProgress !== null && uploadProgress > 0 + ? `${t.uploading} (${uploadProgress}%)` + : t.uploading}
)} diff --git a/src/hooks/marriage/use-upload-tmp-media.ts b/src/hooks/marriage/use-upload-tmp-media.ts index 78294ca..50ad895 100644 --- a/src/hooks/marriage/use-upload-tmp-media.ts +++ b/src/hooks/marriage/use-upload-tmp-media.ts @@ -8,7 +8,10 @@ import type { UploadTmpMediaResponse } from "./types"; const CSRF_TOKEN = "53kqNKySTv3q4K3OolQqLEgaeF9pdPdAEnxrMARaUfvFrIGK57Qje67ifYUDMUQP"; -export async function uploadTmpMedia(file: File) { +export async function uploadTmpMedia( + file: File, + onProgress?: (progressPercent: number) => void, +) { const formData = new FormData(); formData.append("file", file); @@ -20,6 +23,14 @@ export async function uploadTmpMedia(file: File) { Accept: "application/json", "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({ ...options, - mutationFn: uploadTmpMedia, + mutationFn: (file: File) => uploadTmpMedia(file), }); } From a8423a2d89affc312b18e83ecd1fdd2c171e9c3f Mon Sep 17 00:00:00 2001 From: mortezaei Date: Sat, 22 Aug 2026 19:23:51 +0330 Subject: [PATCH 21/22] fix(navigation): remove history stack pollution in useSheetScrollLock and overlays to fix hardware back sheet closing --- src/app/intro/intro-client.tsx | 10 +++---- .../Componentes/marriage-advisors-overlay.tsx | 10 +++---- .../Componentes/match-profile-overlay.tsx | 10 +++---- .../Componentes/question-birthplace.tsx | 2 +- .../Componentes/question-date-sheet.tsx | 2 +- src/components/Componentes/question-phone.tsx | 2 +- .../Componentes/question-sheet.test.tsx | 13 +++++---- .../Componentes/use-sheet-scroll-lock.ts | 29 +------------------ 8 files changed, 26 insertions(+), 52 deletions(-) diff --git a/src/app/intro/intro-client.tsx b/src/app/intro/intro-client.tsx index 000fcdf..a2dc838 100644 --- a/src/app/intro/intro-client.tsx +++ b/src/app/intro/intro-client.tsx @@ -58,20 +58,20 @@ export default function IntroClient() { if (typeof window !== "undefined") { const url = new URL(window.location.href); url.searchParams.set("steps", "open"); - window.history.pushState({ steps: "open" }, "", url.toString()); + window.history.replaceState({ steps: "open" }, "", url.toString()); } }, []); const handleCloseSteps = useCallback(() => { + setIsStepsOpen(false); if (typeof window !== "undefined") { const params = new URLSearchParams(window.location.search); 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(() => { diff --git a/src/components/Componentes/marriage-advisors-overlay.tsx b/src/components/Componentes/marriage-advisors-overlay.tsx index beb93e5..54e4753 100644 --- a/src/components/Componentes/marriage-advisors-overlay.tsx +++ b/src/components/Componentes/marriage-advisors-overlay.tsx @@ -34,20 +34,20 @@ export function useMarriageAdvisorsOverlay() { if (typeof window !== "undefined") { const url = new URL(window.location.href); url.searchParams.set("advisors", "open"); - window.history.pushState({ advisors: "open" }, "", url.toString()); + window.history.replaceState({ advisors: "open" }, "", url.toString()); } }, []); const closeAdvisors = useCallback(() => { + setIsAdvisorOpen(false); if (typeof window !== "undefined") { const params = new URLSearchParams(window.location.search); 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 diff --git a/src/components/Componentes/match-profile-overlay.tsx b/src/components/Componentes/match-profile-overlay.tsx index 39c14cd..f39d578 100644 --- a/src/components/Componentes/match-profile-overlay.tsx +++ b/src/components/Componentes/match-profile-overlay.tsx @@ -34,20 +34,20 @@ export function useMatchProfileOverlay() { if (typeof window !== "undefined") { const url = new URL(window.location.href); url.searchParams.set("profile", "open"); - window.history.pushState({ profile: "open" }, "", url.toString()); + window.history.replaceState({ profile: "open" }, "", url.toString()); } }, []); const closeProfile = useCallback(() => { + setIsProfileOpen(false); if (typeof window !== "undefined") { const params = new URLSearchParams(window.location.search); 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 diff --git a/src/components/Componentes/question-birthplace.tsx b/src/components/Componentes/question-birthplace.tsx index 5ca8b53..a6c484f 100644 --- a/src/components/Componentes/question-birthplace.tsx +++ b/src/components/Componentes/question-birthplace.tsx @@ -249,7 +249,7 @@ export function QuestionBirthplace({ setIsClosing(false); }, [disabled]); - useSheetScrollLock(isOpen, { onBack: closeSheet }); + useSheetScrollLock(isOpen && !isClosing, { onBack: closeSheet }); // Handle escape key useEffect(() => { diff --git a/src/components/Componentes/question-date-sheet.tsx b/src/components/Componentes/question-date-sheet.tsx index 7ecde83..9a1e250 100644 --- a/src/components/Componentes/question-date-sheet.tsx +++ b/src/components/Componentes/question-date-sheet.tsx @@ -181,7 +181,7 @@ export function QuestionDateSheet({ window.setTimeout(onClose, EXIT_ANIMATION_MS); }, [onClose]); - useSheetScrollLock(true, { onBack: closeSheet }); + useSheetScrollLock(!isClosing, { onBack: closeSheet }); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { diff --git a/src/components/Componentes/question-phone.tsx b/src/components/Componentes/question-phone.tsx index 0617f7b..580c316 100644 --- a/src/components/Componentes/question-phone.tsx +++ b/src/components/Componentes/question-phone.tsx @@ -586,7 +586,7 @@ export function QuestionPhone({ ); }, [countryList, searchQuery]); - useSheetScrollLock(isOpen, { onBack: closeSheet }); + useSheetScrollLock(isOpen && !isClosing, { onBack: closeSheet }); useEffect(() => { if (!isOpen) return; diff --git a/src/components/Componentes/question-sheet.test.tsx b/src/components/Componentes/question-sheet.test.tsx index 497c82b..cf07d00 100644 --- a/src/components/Componentes/question-sheet.test.tsx +++ b/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 = { id: "q_back", title: "کشور", @@ -202,7 +202,6 @@ describe("QuestionSheet component", () => { options: [{ id: "iran", value: "Iran", label: "ایران", order: 1 }], ui_config: {}, } as QuestionField; - const pushState = vi.spyOn(window.history, "pushState"); render( @@ -213,15 +212,17 @@ describe("QuestionSheet component", () => { ); 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(() => { expect(screen.queryByRole("dialog")).toBeNull(); }); - pushState.mockRestore(); }); it("opens a searchable sheet for 7+ options without focusing search", () => { diff --git a/src/components/Componentes/use-sheet-scroll-lock.ts b/src/components/Componentes/use-sheet-scroll-lock.ts index 72d4854..aa9baff 100644 --- a/src/components/Componentes/use-sheet-scroll-lock.ts +++ b/src/components/Componentes/use-sheet-scroll-lock.ts @@ -10,7 +10,6 @@ let initialHtmlOverflow = ""; let initialAppShellOverflow = ""; let initialAppShellTouchAction = ""; let lockedAppShell: HTMLElement | null = null; -const SHEET_HISTORY_KEY = "__habibQuestionSheet"; type SheetScrollLockOptions = { onBack?: () => void; @@ -25,7 +24,7 @@ export function useSheetScrollLock( onBackRef.current = onBack; // 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(() => { if (onBackRef.current) { onBackRef.current(); @@ -39,18 +38,6 @@ export function useSheetScrollLock( useEffect(() => { 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) { bodyHadDropdownClass = document.body.classList.contains("dropdown-open"); initialBodyOverflow = document.body.style.overflow; @@ -70,23 +57,9 @@ export function useSheetScrollLock( lockedAppShell.style.touchAction = "none"; } - const handlePopState = () => { - if (ownsHistoryEntry) { - onBackRef.current?.(); - } - }; - window.addEventListener("popstate", handlePopState); - return () => { - window.removeEventListener("popstate", handlePopState); activeSheetCount = Math.max(0, activeSheetCount - 1); if (activeSheetCount === 0) { - if ( - ownsHistoryEntry && - window.history.state?.[SHEET_HISTORY_KEY] === true - ) { - window.history.back(); - } document.body.style.overflow = initialBodyOverflow; document.documentElement.style.overflow = initialHtmlOverflow; if (lockedAppShell) { From cf71fc9f6514e2c611c949d1a4ce8235b347b08f Mon Sep 17 00:00:00 2001 From: mortezaei Date: Sat, 22 Aug 2026 19:29:42 +0330 Subject: [PATCH 22/22] fix(questions-list): handle optional tips sheet in hardware back stack cleanly --- src/app/questions-list/questions-list-client.tsx | 9 ++++----- src/components/Componentes/information-sheet.tsx | 5 ++++- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/app/questions-list/questions-list-client.tsx b/src/app/questions-list/questions-list-client.tsx index 2cec762..ffb38ea 100644 --- a/src/app/questions-list/questions-list-client.tsx +++ b/src/app/questions-list/questions-list-client.tsx @@ -63,15 +63,14 @@ export default function QuestionsListClient() { // entries. Flutter calls __habibHandleHardwareBack() and we return false // (meaning "I didn't handle it — you should close"). useHardwareBackHandler(() => { + if (isOptionalInfoSheetOpen) { + setIsOptionalInfoSheetOpen(false); + return true; // Handled: closed the tips sheet + } if (activeSectionSlug) { handleCloseSection(); 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 }); const { dictionary: t, locale } = useI18n(); diff --git a/src/components/Componentes/information-sheet.tsx b/src/components/Componentes/information-sheet.tsx index a435feb..53b72d8 100644 --- a/src/components/Componentes/information-sheet.tsx +++ b/src/components/Componentes/information-sheet.tsx @@ -172,7 +172,10 @@ export function InformationSheet({ }, EXIT_ANIMATION_MS); }, [isClosing, onClose]); - useHardwareBackHandler(closeSheet, isVisible && !isClosing); + useHardwareBackHandler(() => { + closeSheet(); + return true; + }, isVisible && !isClosing); const controls = { close: closeSheet }; const resolvedTitle = typeof title === "function" ? title(controls) : title;