You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
303 lines
9.9 KiB
303 lines
9.9 KiB
"use client";
|
|
|
|
import { useRouter } from "next/navigation";
|
|
import type { TouchEvent } from "react";
|
|
import { useEffect, useState } from "react";
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
import { useUpdateMarriageProfileBasicMutation } from "@/hooks/marriage/use-profile-basic";
|
|
import {
|
|
getSubmitPath,
|
|
hasCompletedMarriageProfileBasics,
|
|
} from "@/lib/get-submit-path";
|
|
import { localizePath } from "@/translations/config";
|
|
import { useI18n } from "@/translations/provider";
|
|
import Button from "./button";
|
|
import NavigationButton from "./navigation-button";
|
|
import type { GenderAnswer, RegistrationAnswer } from "./slider-slide";
|
|
import { SliderSlideFive } from "./slider-slide-five";
|
|
import { SliderSlideFour } from "./slider-slide-four";
|
|
import { SliderSlideOne, SliderSlideOneActions } from "./slider-slide-one";
|
|
import { SliderSlideThree } from "./slider-slide-three";
|
|
import { SliderSlideTwo } from "./slider-slide-two";
|
|
|
|
const FINAL_SLIDE_COUNT = 5;
|
|
const SWIPE_THRESHOLD = 40;
|
|
const SLIDER_ANSWERS_STORAGE_KEY = "marriage-slider-answers";
|
|
|
|
export default function SliderPage() {
|
|
const router = useRouter();
|
|
const { locale } = useI18n();
|
|
const [activeSlide, setActiveSlide] = useState(0);
|
|
const [selectedGender, setSelectedGender] = useState<GenderAnswer>("woman");
|
|
const [selectedRegistration, setSelectedRegistration] =
|
|
useState<RegistrationAnswer>("self");
|
|
const [hasReadRules, setHasReadRules] = useState(false);
|
|
const [touchStartX, setTouchStartX] = useState<number | null>(null);
|
|
const updateProfileBasicMutation = useUpdateMarriageProfileBasicMutation();
|
|
const queryClient = useQueryClient();
|
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const { dictionary: t } = useI18n();
|
|
const hasFinalNotice = true;
|
|
const maxSlideIndex = FINAL_SLIDE_COUNT - 1;
|
|
const displaySlideCount = FINAL_SLIDE_COUNT;
|
|
const navDotCount = displaySlideCount;
|
|
|
|
useEffect(() => {
|
|
// Clear any previous slider answers on mount so abandoned onboarding starts clean
|
|
if (typeof window !== "undefined") {
|
|
window.localStorage.removeItem(SLIDER_ANSWERS_STORAGE_KEY);
|
|
}
|
|
}, []);
|
|
|
|
const goToSlide = (index: number) => {
|
|
const targetIndex = Math.max(0, Math.min(index, maxSlideIndex));
|
|
if (targetIndex > 0 && !hasReadRules) {
|
|
return;
|
|
}
|
|
setActiveSlide(targetIndex);
|
|
};
|
|
|
|
const goToPreviousSlide = () => {
|
|
goToSlide(activeSlide - 1);
|
|
};
|
|
|
|
const goToNextSlide = () => {
|
|
if (activeSlide === 0 && !hasReadRules) {
|
|
return;
|
|
}
|
|
goToSlide(activeSlide + 1);
|
|
};
|
|
|
|
const completeSlider = async () => {
|
|
if (isSubmitting) return;
|
|
setIsSubmitting(true);
|
|
setSubmitError(null);
|
|
|
|
const genderPayload = selectedGender === "man" ? "male" : "female";
|
|
const isRegisteringPayload = selectedRegistration === "self";
|
|
|
|
try {
|
|
const patchResponse = await updateProfileBasicMutation.mutateAsync({
|
|
gender: genderPayload,
|
|
is_registering_for_self: isRegisteringPayload,
|
|
});
|
|
|
|
if (
|
|
!hasCompletedMarriageProfileBasics(patchResponse) ||
|
|
patchResponse.gender !== genderPayload ||
|
|
patchResponse.is_registering_for_self !== isRegisteringPayload
|
|
) {
|
|
throw new Error("Invalid PATCH response");
|
|
}
|
|
|
|
const { getMarriageProfile } = await import("@/hooks/marriage/use-profile-main");
|
|
const freshProfile = await getMarriageProfile();
|
|
|
|
if (
|
|
!hasCompletedMarriageProfileBasics(freshProfile) ||
|
|
freshProfile.gender !== genderPayload ||
|
|
freshProfile.is_registering_for_self !== isRegisteringPayload
|
|
) {
|
|
throw new Error("Invalid GET double-check response");
|
|
}
|
|
|
|
const { marriageQueryKeys } = await import("@/hooks/marriage/query-keys");
|
|
queryClient.setQueryData(marriageQueryKeys.profile(), freshProfile);
|
|
|
|
if (typeof window !== "undefined") {
|
|
window.localStorage.removeItem(SLIDER_ANSWERS_STORAGE_KEY);
|
|
}
|
|
|
|
router.replace(localizePath(getSubmitPath(freshProfile), locale));
|
|
} catch (error) {
|
|
console.error("Failed to complete onboarding:", error);
|
|
setSubmitError(t["Failed to update profile basic details. Please try again."] || "Failed to update profile basic details. Please try again.");
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handleTouchStart = (event: TouchEvent<HTMLDivElement>) => {
|
|
setTouchStartX(event.touches[0]?.clientX ?? null);
|
|
};
|
|
|
|
const handleTouchEnd = (event: TouchEvent<HTMLDivElement>) => {
|
|
if (touchStartX === null) {
|
|
return;
|
|
}
|
|
|
|
const touchEndX = event.changedTouches[0]?.clientX ?? touchStartX;
|
|
const deltaX = touchStartX - touchEndX;
|
|
|
|
if (Math.abs(deltaX) >= SWIPE_THRESHOLD) {
|
|
if (deltaX > 0) {
|
|
if (activeSlide === 0 && !hasReadRules) {
|
|
setTouchStartX(null);
|
|
return;
|
|
}
|
|
goToNextSlide();
|
|
} else {
|
|
goToPreviousSlide();
|
|
}
|
|
}
|
|
|
|
setTouchStartX(null);
|
|
};
|
|
|
|
return (
|
|
<div className="relative flex h-[100dvh] max-h-[100dvh] flex-col overflow-hidden">
|
|
<header className="relative -mx-[17px] rounded-b-[32px] px-[17px] pt-[max(16px,calc(var(--safe-top)+8px))] pb-3.5">
|
|
<div className="relative flex items-center justify-between">
|
|
<NavigationButton
|
|
icon="back"
|
|
iconLabel="Close slider"
|
|
onClick={() => {
|
|
if (typeof window !== "undefined") {
|
|
try {
|
|
window.sessionStorage.setItem("skip_intro_redirect", "true");
|
|
} catch (e) {
|
|
console.warn("sessionStorage is not accessible:", e);
|
|
}
|
|
}
|
|
router.back();
|
|
}}
|
|
/>
|
|
<nav
|
|
aria-label={`Slide ${activeSlide + 1} of ${displaySlideCount}`}
|
|
className="absolute left-1/2 flex -translate-x-1/2 items-center gap-2"
|
|
>
|
|
{Array.from({ length: navDotCount }, (_, index) => {
|
|
const isActive = index === activeSlide;
|
|
const isLocked = index > 0 && !hasReadRules;
|
|
|
|
return (
|
|
<button
|
|
key={`slide-${index + 1}`}
|
|
type="button"
|
|
aria-label={`Go to slide ${index + 1}`}
|
|
aria-pressed={isActive}
|
|
disabled={isLocked}
|
|
className={[
|
|
"h-2.5 rounded-full border-1 border-[#F14B46] transition-all duration-300 ease-out",
|
|
isActive ? "w-6 bg-[#F14B46]" : "w-2.5 bg-white/80",
|
|
isLocked ? "opacity-40 cursor-not-allowed" : "cursor-pointer",
|
|
].join(" ")}
|
|
onClick={() => goToSlide(index)}
|
|
/>
|
|
);
|
|
})}
|
|
</nav>
|
|
</div>
|
|
</header>
|
|
|
|
<main
|
|
className="flex min-h-0 flex-1 touch-pan-y overflow-hidden pt-6"
|
|
onTouchEnd={handleTouchEnd}
|
|
onTouchStart={handleTouchStart}
|
|
>
|
|
<div
|
|
className="flex h-full w-full transition-transform duration-300 ease-out"
|
|
style={{ transform: `translateX(-${activeSlide * 100}%)` }}
|
|
>
|
|
<SliderSlideOne
|
|
index={0}
|
|
onScrollToEnd={() => setHasReadRules(true)}
|
|
/>
|
|
<SliderSlideTwo index={1} />
|
|
<SliderSlideThree
|
|
index={2}
|
|
selectedGender={selectedGender}
|
|
onGenderChange={setSelectedGender}
|
|
/>
|
|
<SliderSlideFour
|
|
index={3}
|
|
selectedRegistration={selectedRegistration}
|
|
onRegistrationChange={setSelectedRegistration}
|
|
/>
|
|
{hasFinalNotice ? <SliderSlideFive index={4} /> : null}
|
|
</div>
|
|
</main>
|
|
|
|
<div className="pointer-events-none fixed bottom-0 left-1/2 z-20 w-full sm:max-w-[375px] -translate-x-1/2 bg-[#F5F5F5]">
|
|
<div
|
|
style={{ paddingBottom: `calc(1rem + var(--safe-bottom))` }}
|
|
className="mx-auto w-full sm:max-w-[375px] px-[17px] pt-4"
|
|
>
|
|
<div className="pointer-events-auto">
|
|
{activeSlide === 0 ? (
|
|
<SliderSlideOneActions
|
|
onAccept={goToNextSlide}
|
|
disabled={!hasReadRules}
|
|
/>
|
|
) : activeSlide === maxSlideIndex ? (
|
|
<div className="flex flex-col gap-3">
|
|
{submitError && (
|
|
<div role="alert" aria-live="assertive" className="text-sm text-red-500 bg-red-50 p-3 rounded-xl border border-red-200">
|
|
{submitError}
|
|
</div>
|
|
)}
|
|
<SliderFinalActions
|
|
onBack={goToPreviousSlide}
|
|
isFinishing={isSubmitting}
|
|
onFinish={completeSlider}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<SliderStepActions
|
|
onBack={goToPreviousSlide}
|
|
onNext={goToNextSlide}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type SliderStepActionsProps = {
|
|
onBack: () => void;
|
|
onNext: () => void;
|
|
};
|
|
|
|
function SliderStepActions({ onBack, onNext }: SliderStepActionsProps) {
|
|
return (
|
|
<div className="flex items-center gap-3 [&>button]:flex-1">
|
|
<Button variant="outlined" arrowDirection="left" onClick={onBack}>
|
|
Back
|
|
</Button>
|
|
<Button arrowDirection="right" onClick={onNext}>
|
|
Next
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type SliderFinalActionsProps = {
|
|
isFinishing?: boolean;
|
|
onBack: () => void;
|
|
onFinish: () => void | Promise<void>;
|
|
};
|
|
|
|
function SliderFinalActions({
|
|
isFinishing = false,
|
|
onBack,
|
|
onFinish,
|
|
}: SliderFinalActionsProps) {
|
|
return (
|
|
<div className="flex items-center gap-3 [&>button]:flex-1">
|
|
<Button
|
|
variant="outlined"
|
|
arrowDirection="left"
|
|
disabled={isFinishing}
|
|
onClick={onBack}
|
|
>
|
|
Back
|
|
</Button>
|
|
<Button disabled={isFinishing} isLoading={isFinishing} onClick={onFinish}>
|
|
Finish
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|