Browse Source

feat: add multi-step marriage profile onboarding slider with native navigation handling

master
Muhammad A. Ghorbani 2 weeks ago
parent
commit
bc8cce4b0a
  1. 42
      src/app/questions-list/questions-list-client.tsx
  2. 29
      src/app/terms/page.tsx
  3. 92
      src/components/Componentes/question-birthplace.tsx
  4. 72
      src/components/Componentes/slider-page.tsx
  5. 16
      src/components/Componentes/slider-slide-one.tsx
  6. 89
      src/components/Componentes/ui-config.test.tsx

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

@ -104,6 +104,20 @@ export default function QuestionsListClient({
});
const { dictionary: t, locale } = useI18n();
const router = useRouter();
const handleHeaderBack = useCallback(() => {
if (onClose) {
onClose();
} else if (typeof window !== "undefined" && (window as any).HabibApp) {
(window as any).HabibApp.postMessage(
JSON.stringify({ action: "close_service" }),
);
} else if (typeof window !== "undefined" && window.history.length > 1) {
router.back();
} else {
router.replace(localizePath("/intro", locale));
}
}, [onClose, router, locale]);
const queryClient = useQueryClient();
const { data: profile, isLoading: isProfileLoading, isError: isProfileError, isFetched: isProfileFetched, refetch: refetchProfile } =
useMarriageProfileQuery();
@ -723,15 +737,7 @@ export default function QuestionsListClient({
iconLabel={t["Close questions list"]}
onClick={(e) => {
e.preventDefault();
if (onClose) {
onClose();
} else if (typeof window !== "undefined" && (window as any).HabibApp) {
(window as any).HabibApp.postMessage(
JSON.stringify({ action: "close_service" }),
);
} else {
router.push("/");
}
handleHeaderBack();
}}
/>
<h1 className="group-16 font-semibold text-[#151515]">
@ -805,13 +811,7 @@ export default function QuestionsListClient({
iconLabel={t["Close questions list"]}
onClick={(e) => {
e.preventDefault();
if (typeof window !== "undefined" && (window as any).HabibApp) {
(window as any).HabibApp.postMessage(
JSON.stringify({ action: "close_service" }),
);
} else {
router.push("/");
}
handleHeaderBack();
}}
/>
<h1 className="group-16 font-semibold text-[#151515]">
@ -930,15 +930,7 @@ export default function QuestionsListClient({
iconLabel={t["Close questions list"]}
onClick={(e) => {
e.preventDefault();
if (onClose) {
onClose();
} else if (typeof window !== "undefined" && (window as any).HabibApp) {
(window as any).HabibApp.postMessage(
JSON.stringify({ action: "close_service" }),
);
} else {
router.push("/");
}
handleHeaderBack();
}}
/>
<h1 className="group-16 font-semibold text-[#151515]">

29
src/app/terms/page.tsx

@ -8,6 +8,7 @@ import {
hasCompletedMarriageProfileBasics,
} from "@/lib/get-submit-path";
import { useHabibWebReady } from "@/hooks/use-habib-web-ready";
import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler";
import SliderPage from "@/components/Componentes/slider-page";
import Button from "@/components/Componentes/button";
import { useI18n } from "@/translations/provider";
@ -18,22 +19,26 @@ export default function TermsRoute() {
const router = useRouter();
const { locale, dictionary: t } = useI18n();
// Signal Flutter to lift its loading cover immediately on mount
useHabibWebReady(true);
const isBasicsCompleted = Boolean(
profile &&
(profile.status !== "pending_onboarding" ||
hasCompletedMarriageProfileBasics(profile)),
);
// Signal Flutter to lift its loading cover, but only cache /terms if onboarding is required
useHabibWebReady(!isLoading && !isBasicsCompleted);
// At the root /terms screen, hardware back tells Flutter to close the WebView screen
useHardwareBackHandler(() => false, !isBasicsCompleted);
useEffect(() => {
if (!isLoading && profile) {
if (
profile.status !== "pending_onboarding" ||
hasCompletedMarriageProfileBasics(profile)
) {
const target = getSubmitPath(profile);
if (target !== "/terms") {
router.replace(localizePath(target, locale));
}
if (!isLoading && profile && isBasicsCompleted) {
const target = getSubmitPath(profile);
if (target !== "/terms") {
router.replace(localizePath(target, locale));
}
}
}, [profile, isLoading, router, locale]);
}, [profile, isLoading, isBasicsCompleted, router, locale]);
if (isLoading) {
return (

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

@ -163,6 +163,14 @@ export function QuestionBirthplace({
question.id?.includes("residence") ||
question.id?.includes("living_area") ||
question.id?.includes("location");
const isCurrentResidenceQuestion = Boolean(
question.id === "contact_residence.current_residence_location" ||
question.id?.endsWith(".current_residence_location") ||
question.id?.endsWith(".current_residence") ||
question.ui_config?.read_only_fields === true
);
const storedRegion = isResidence ? getStoredUserGeoRegion() : null;
const initial = parseValue(rawValue);
@ -189,6 +197,13 @@ export function QuestionBirthplace({
const initialCity = hasSavedAnswer ? initial.city || "" : "";
const fallbackCountry = isResidence
? resolveCountryName(storedRegion?.country, locale) ||
storedRegion?.country ||
""
: "";
const fallbackCity = isResidence ? storedRegion?.city || "" : "";
const initialLoc =
localizedInitialCountry || initialCity
? [localizedInitialCountry, initialCity].filter(Boolean).join(", ")
@ -203,12 +218,14 @@ export function QuestionBirthplace({
: "";
const [selectedCountry, setSelectedCountry] = useState(
() => localizedInitialCountry || "",
() => localizedInitialCountry || fallbackCountry,
);
const [cityInput, setCityInput] = useState(() => initialCity);
const [cityInput, setCityInput] = useState(() => initialCity || fallbackCity);
const cityInputStateRef = useRef(initialCity);
const selectedCountryStateRef = useRef(localizedInitialCountry || "");
const cityInputStateRef = useRef(initialCity || fallbackCity);
const selectedCountryStateRef = useRef(
localizedInitialCountry || fallbackCountry,
);
const lastCoordsRef = useRef<{ latitude?: number; longitude?: number } | undefined>(
storedRegion?.latitude && storedRegion?.longitude
@ -642,13 +659,16 @@ export function QuestionBirthplace({
<div className="relative w-full">
<button
type="button"
disabled={disabled}
onClick={openSheet}
disabled={disabled || isCurrentResidenceQuestion}
onClick={isCurrentResidenceQuestion ? undefined : openSheet}
aria-readonly={isCurrentResidenceQuestion}
className={[
"flex h-[54px] w-full items-center justify-between rounded-[16px] border bg-white px-4.5 text-start transition-all cursor-pointer outline-none",
isOpen
? "border-[#6F6F6F] ring-1 ring-[#6F6F6F]"
: "border-[#D0D5DD] hover:border-[#98A2B3]",
"flex h-[54px] w-full items-center justify-between rounded-[16px] border bg-white px-4.5 text-start transition-all outline-none",
isCurrentResidenceQuestion
? "border-[#D0D5DD] cursor-default select-none"
: isOpen
? "border-[#6F6F6F] ring-1 ring-[#6F6F6F] cursor-pointer"
: "border-[#D0D5DD] hover:border-[#98A2B3] cursor-pointer",
].join(" ")}
>
<span
@ -659,24 +679,26 @@ export function QuestionBirthplace({
>
{selectedCountry || selectCountryPlaceholder}
</span>
<svg
width="16"
height="10"
viewBox="0 0 16 10"
fill="none"
className={[
"shrink-0 transition-transform duration-200",
isOpen ? "rotate-180" : "",
].join(" ")}
>
<path
d="M14.75 1.25L7.75 8.25L0.75 1.25"
stroke="#344054"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
{!isCurrentResidenceQuestion && (
<svg
width="16"
height="10"
viewBox="0 0 16 10"
fill="none"
className={[
"shrink-0 transition-transform duration-200",
isOpen ? "rotate-180" : "",
].join(" ")}
>
<path
d="M14.75 1.25L7.75 8.25L0.75 1.25"
stroke="#344054"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</button>
</div>
@ -686,13 +708,19 @@ export function QuestionBirthplace({
ref={cityInputRef}
type="text"
data-no-auto-focus
readOnly={isCurrentResidenceQuestion}
disabled={disabled}
value={cityInput}
onChange={handleCityChange}
onFocus={handleCityFocus}
onBlur={handleCityBlur}
onChange={isCurrentResidenceQuestion ? undefined : handleCityChange}
onFocus={isCurrentResidenceQuestion ? (e) => e.target.blur() : handleCityFocus}
onBlur={isCurrentResidenceQuestion ? undefined : handleCityBlur}
placeholder={cityPlaceholder}
className="h-[54px] w-full rounded-[16px] border border-[#D0D5DD] bg-white px-4.5 text-[15px] font-medium text-[#181818] placeholder:text-[#98A2B3] hover:border-[#98A2B3] focus:border-[#6F6F6F] focus:ring-1 focus:ring-[#6F6F6F] outline-none transition-all"
className={[
"h-[54px] w-full rounded-[16px] border border-[#D0D5DD] bg-white px-4.5 text-[15px] font-medium text-[#181818] placeholder:text-[#98A2B3] outline-none transition-all",
isCurrentResidenceQuestion
? "cursor-default select-none focus:border-[#D0D5DD] focus:ring-0"
: "hover:border-[#98A2B3] focus:border-[#6F6F6F] focus:ring-1 focus:ring-[#6F6F6F]",
].join(" ")}
/>
</div>
</>

72
src/components/Componentes/slider-page.tsx

@ -1,10 +1,12 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useUpdateMarriageProfileBasicMutation } from "@/hooks/marriage/use-profile-basic";
import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler";
import { authBridge } from "@/lib/auth-bridge";
import { setCachedMarriageEntryPath } from "@/lib/entry-route-cache";
import { isInFlutterWebView } from "@/lib/webview-actions";
import {
getSubmitPath,
@ -63,9 +65,9 @@ export default function SliderPage({ onClose }: SliderPageProps = {}) {
setActiveSlide(targetIndex);
};
const goToPreviousSlide = () => {
goToSlide(activeSlide - 1);
};
const goToPreviousSlide = useCallback(() => {
setActiveSlide((prev) => Math.max(0, prev - 1));
}, []);
const goToNextSlide = () => {
if (activeSlide === 0 && !hasReadRules) {
@ -74,6 +76,47 @@ export default function SliderPage({ onClose }: SliderPageProps = {}) {
goToSlide(activeSlide + 1);
};
const handleBackNavigation = useCallback(() => {
if (activeSlide > 0) {
goToPreviousSlide();
return;
}
if (typeof window !== "undefined") {
try {
window.sessionStorage.setItem("skip_intro_redirect", "true");
} catch (e) {
console.warn("sessionStorage is not accessible:", e);
}
}
if (onClose) {
onClose();
return;
}
if (typeof window !== "undefined" && (window as any).HabibApp) {
(window as any).HabibApp.postMessage(
JSON.stringify({ action: "close_service" }),
);
return;
}
if (typeof window !== "undefined" && window.history.length > 1) {
router.back();
} else {
router.replace(localizePath("/intro", locale));
}
}, [activeSlide, goToPreviousSlide, onClose, router, locale]);
useHardwareBackHandler(() => {
if (activeSlide > 0) {
goToPreviousSlide();
return true; // Handled: keep WebView open, move to previous slide
}
if (onClose) {
onClose();
return true; // Handled: closed the overlay
}
return false; // Root: tell Flutter to close WebView screen
});
const completeSlider = async () => {
if (isSubmitting) return;
setIsSubmitting(true);
@ -116,6 +159,7 @@ export default function SliderPage({ onClose }: SliderPageProps = {}) {
const targetPath = getSubmitPath(freshProfile);
const localizedTarget = localizePath(targetPath, locale);
setCachedMarriageEntryPath(targetPath);
router.prefetch?.(localizedTarget);
router.replace(localizedTarget);
} catch (error: any) {
@ -147,24 +191,7 @@ export default function SliderPage({ onClose }: SliderPageProps = {}) {
<NavigationButton
icon="back"
iconLabel="Close slider"
onClick={() => {
if (activeSlide > 0) {
goToPreviousSlide();
return;
}
if (typeof window !== "undefined") {
try {
window.sessionStorage.setItem("skip_intro_redirect", "true");
} catch (e) {
console.warn("sessionStorage is not accessible:", e);
}
}
if (onClose) {
onClose();
} else {
router.back();
}
}}
onClick={handleBackNavigation}
/>
<nav
aria-label={`Slide ${activeSlide + 1} of ${displaySlideCount}`}
@ -237,6 +264,7 @@ export default function SliderPage({ onClose }: SliderPageProps = {}) {
{activeSlide === 0 ? (
<SliderSlideOneActions
onAccept={goToNextSlide}
onBack={handleBackNavigation}
disabled={!hasReadRules}
/>
) : activeSlide === maxSlideIndex ? (

16
src/components/Componentes/slider-slide-one.tsx

@ -123,14 +123,16 @@ export function SliderSlideOne({ index, totalSteps, onScrollToEnd }: SliderSlide
type SliderSlideOneActionsProps = {
onAccept: () => void;
onBack?: () => void;
disabled?: boolean;
};
export function SliderSlideOneActions({
onAccept,
onBack,
disabled,
}: SliderSlideOneActionsProps) {
const { dictionary: t } = useI18n();
const { dictionary: t, locale } = useI18n();
const handleBack = () => {
if (typeof window !== "undefined") {
@ -140,6 +142,17 @@ export function SliderSlideOneActions({
console.warn("sessionStorage is not accessible:", e);
}
}
if (onBack) {
onBack();
} else if (typeof window !== "undefined" && (window as any).HabibApp) {
(window as any).HabibApp.postMessage(
JSON.stringify({ action: "close_service" }),
);
} else if (typeof window !== "undefined" && window.history.length > 1) {
window.history.back();
} else if (typeof window !== "undefined") {
window.location.replace(locale === "en" ? "/intro" : `/${locale}/intro`);
}
};
return (
@ -147,7 +160,6 @@ export function SliderSlideOneActions({
<Button
variant="outlined"
arrowDirection="left"
href="/intro"
onClick={handleBack}
>
{t["Back"] || "Back"}

89
src/components/Componentes/ui-config.test.tsx

@ -563,6 +563,95 @@ describe("UI Config based behavior", () => {
expect(adaptedCustomPhoto.ui_config?.source).toBe("camera");
expect(adaptedCustomPhoto.ui_config?.picker_type).toBe("camera");
});
it("should make country and city fields display-only specifically for current_residence", async () => {
const qResidence = {
id: "contact_residence.current_residence_location",
title: "Current Place of Residence (Country, City / State)",
type: "birthplace",
order: 1,
required: true,
isVisible: true,
description: "",
tooltip: "",
extras: { placeHolder: "City, State" },
options: [],
ui_config: { enable_geoip: true, location_kind: "residence" },
} as any;
const { unmount } = render(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider
slug="test-residence-readonly"
questions={[qResidence]}
initialAnswers={{
"contact_residence.current_residence_location": {
country: "Turkmenistan",
city: "Ashgabat",
},
}}
>
<QuestionBirthplace question={qResidence} />
</QuestionAnswersProvider>
</QueryClientProvider>,
);
// Switch to manual mode to reveal fields
const manualBtn = screen.getByRole("button", { name: /دستی|Manual/i });
fireEvent.click(manualBtn);
// Country button should be read-only / not open sheet on click
const countryBtn = screen.getByRole("button", { name: /ایران|Iran|Turkmenistan/i });
expect(countryBtn.getAttribute("aria-readonly")).toBe("true");
fireEvent.click(countryBtn);
expect(screen.queryByRole("dialog")).toBeNull();
// City input should be readOnly
const cityInput = screen.getByPlaceholderText("City, State");
expect((cityInput as HTMLInputElement).readOnly).toBe(true);
unmount();
// Verify birthplace question remains fully editable
const qBirthplace = {
id: "personal_identity.birthplace",
title: "Place of Birth (Country, City / State)",
type: "birthplace",
order: 2,
required: true,
isVisible: true,
description: "",
tooltip: "",
extras: { placeHolder: "City, State" },
options: [],
ui_config: { enable_geoip: false, location_kind: "birthplace" },
} as any;
render(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider
slug="test-birthplace-editable"
questions={[qBirthplace]}
initialAnswers={{
"personal_identity.birthplace": {
country: "Iran",
city: "Tehran",
},
}}
>
<QuestionBirthplace question={qBirthplace} />
</QuestionAnswersProvider>
</QueryClientProvider>,
);
const bpCountryBtn = screen.getByRole("button", {
name: /انتخاب کشور|Select country/i,
});
expect(bpCountryBtn.getAttribute("aria-readonly")).not.toBe("true");
const bpCityInput = screen.getByPlaceholderText("City, State");
expect((bpCityInput as HTMLInputElement).readOnly).toBe(false);
});
});

Loading…
Cancel
Save