diff --git a/public/assets/images/Ellipse 1210.svg b/public/assets/images/Ellipse 1210.svg index dbf142a..fd124b2 100644 --- a/public/assets/images/Ellipse 1210.svg +++ b/public/assets/images/Ellipse 1210.svg @@ -4,6 +4,6 @@ - + diff --git a/public/assets/images/Group 1597880466.svg b/public/assets/images/Group 1597880466.svg index a7e21f0..0cd0cea 100644 --- a/public/assets/images/Group 1597880466.svg +++ b/public/assets/images/Group 1597880466.svg @@ -22,6 +22,6 @@ - + diff --git a/public/assets/images/Group 15978804fdasf68.svg b/public/assets/images/Group 15978804fdasf68.svg index eb305d0..cddca85 100644 --- a/public/assets/images/Group 15978804fdasf68.svg +++ b/public/assets/images/Group 15978804fdasf68.svg @@ -16,6 +16,6 @@ - + diff --git a/public/assets/images/Group 159788fd0467.svg b/public/assets/images/Group 159788fd0467.svg index 576269e..acc0149 100644 --- a/public/assets/images/Group 159788fd0467.svg +++ b/public/assets/images/Group 159788fd0467.svg @@ -16,6 +16,6 @@ - + diff --git a/public/assets/images/Group 15978fdsa80467.svg b/public/assets/images/Group 15978fdsa80467.svg index a9f7e16..61b6963 100644 --- a/public/assets/images/Group 15978fdsa80467.svg +++ b/public/assets/images/Group 15978fdsa80467.svg @@ -16,6 +16,6 @@ - + diff --git a/public/assets/images/female_avatar.svg b/public/assets/images/female_avatar.svg index 15ee4d6..e5a7c3e 100644 --- a/public/assets/images/female_avatar.svg +++ b/public/assets/images/female_avatar.svg @@ -5,6 +5,6 @@ - + diff --git a/public/assets/images/home-Checkups-List.svg b/public/assets/images/home-Checkups-List.svg index fe1a7b8..4c90975 100644 --- a/public/assets/images/home-Checkups-List.svg +++ b/public/assets/images/home-Checkups-List.svg @@ -44,6 +44,6 @@ - + diff --git a/public/assets/images/islamic_pattern_2_2892_3864.svg b/public/assets/images/islamic_pattern_2_2892_3864.svg index 6e57565..9177107 100644 --- a/public/assets/images/islamic_pattern_2_2892_3864.svg +++ b/public/assets/images/islamic_pattern_2_2892_3864.svg @@ -4,6 +4,6 @@ - + diff --git a/src/app/[lang]/page.tsx b/src/app/[lang]/page.tsx index 35a7ac9..589f042 100644 --- a/src/app/[lang]/page.tsx +++ b/src/app/[lang]/page.tsx @@ -1 +1,31 @@ -export { default } from "@/app/intro/page"; +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import EntryRouteResolver from "@/components/Componentes/entry-route-resolver"; +import { + getAuthenticatedCachedEntryPath, + MARRIAGE_ENTRY_PATH_COOKIE, +} from "@/lib/entry-route-cache"; +import { localizePath } from "@/translations/config"; + +export const dynamic = "force-dynamic"; + +export default async function LocaleEntryPage({ + params, +}: { + params: Promise<{ lang: string }>; +}) { + const [{ lang }, cookieStore] = await Promise.all([params, cookies()]); + const token = + cookieStore.get("HABIB_TOKEN")?.value ?? + cookieStore.get("habib_token")?.value; + const cachedEntryPath = getAuthenticatedCachedEntryPath( + token, + cookieStore.get(MARRIAGE_ENTRY_PATH_COOKIE)?.value, + ); + + if (cachedEntryPath) { + redirect(localizePath(cachedEntryPath, lang)); + } + + return ; +} diff --git a/src/app/api/proxy/route.ts b/src/app/api/proxy/route.ts index 0c60ff4..9195d4e 100644 --- a/src/app/api/proxy/route.ts +++ b/src/app/api/proxy/route.ts @@ -141,15 +141,6 @@ function getRequestHeaders(request: NextRequest, targetUrl: URL) { headers.set("authorization", `Token ${authKey}`); } - // Fall back to the development default token when nothing else provided auth. - if (!headers.has("authorization")) { - const isDevelopment = process.env.NODE_ENV !== "production"; - const defaultToken = process.env.NEXT_PUBLIC_DEFAULT_TOKEN; - if (isDevelopment && defaultToken && defaultToken !== "NO_TOKEN") { - headers.set("authorization", `Token ${defaultToken}`); - } - } - // Dynamically set language headers const requestedLanguages = [ request.headers.get("x-user-language"), diff --git a/src/app/globals.css b/src/app/globals.css index e5c92f1..4e62b46 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -285,43 +285,50 @@ body[data-page-background="custom"] .app-shell { animation-delay: 0.3s; } -/* ─── Premium Shimmer Animations ─── */ +/* ─── Premium Habib Shimmer Animations ─── */ @keyframes shimmer { 0% { - background-position: -200% 0; + background-position: 200% 0; } 100% { - background-position: 200% 0; + background-position: -200% 0; } } + .shimmer-bg { background: linear-gradient( 90deg, - rgba(0, 0, 0, 0.05) 25%, - rgba(0, 0, 0, 0.1) 37%, - rgba(0, 0, 0, 0.05) 63% + rgba(0, 0, 0, 0.04) 0%, + rgba(0, 0, 0, 0.08) 35%, + rgba(0, 0, 0, 0.12) 50%, + rgba(0, 0, 0, 0.08) 65%, + rgba(0, 0, 0, 0.04) 100% ); - background-size: 200% 100%; - animation: shimmer 1.5s infinite linear; + background-size: 250% 100%; + animation: shimmer 1.8s cubic-bezier(0.4, 0, 0.2, 1) infinite; } + .dark .shimmer-bg { background: linear-gradient( 90deg, - rgba(255, 255, 255, 0.04) 25%, - rgba(255, 255, 255, 0.09) 37%, - rgba(255, 255, 255, 0.04) 63% + rgba(255, 255, 255, 0.04) 0%, + rgba(255, 255, 255, 0.09) 35%, + rgba(255, 255, 255, 0.14) 50%, + rgba(255, 255, 255, 0.09) 65%, + rgba(255, 255, 255, 0.04) 100% ); - background-size: 200% 100%; + background-size: 250% 100%; + animation: shimmer 1.8s cubic-bezier(0.4, 0, 0.2, 1) infinite; } .shimmer-white-bg { background: linear-gradient( 90deg, - rgba(255, 255, 255, 0.12) 25%, - rgba(255, 255, 255, 0.24) 37%, - rgba(255, 255, 255, 0.12) 63% + rgba(255, 255, 255, 0.1) 0%, + rgba(255, 255, 255, 0.25) 50%, + rgba(255, 255, 255, 0.1) 100% ); - background-size: 200% 100%; - animation: shimmer 1.5s infinite linear; + background-size: 250% 100%; + animation: shimmer 1.8s cubic-bezier(0.4, 0, 0.2, 1) infinite; } diff --git a/src/app/intro/page.tsx b/src/app/intro/page.tsx index d0444dd..bbfbb0d 100644 --- a/src/app/intro/page.tsx +++ b/src/app/intro/page.tsx @@ -4,6 +4,7 @@ import Image from "next/image"; import { useRouter } from "next/navigation"; import { 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 VideoPlayer from "@/components/Componentes/video-player"; @@ -148,14 +149,11 @@ export default function Intro() { className="mt-14 relative cursor-pointer group rounded-2xl overflow-hidden aspect-[344/221] max-w-[344px] w-full mx-auto" onClick={() => setIsPlayerOpen(true)} > - {t["video"]} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 4129deb..5f53462 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -26,12 +26,6 @@ const amiri = Amiri({ const isDevelopment = process.env.NODE_ENV !== "production"; -// Never ship a fallback token to production: without it a real user whose -// Flutter token has not been injected yet would silently browse a test account. -const developmentFallbackToken = isDevelopment - ? (process.env.NEXT_PUBLIC_DEFAULT_TOKEN ?? "") - : ""; - export const metadata: Metadata = { title: "Habib Marriage", description: "Islamic Marriage Platform", @@ -86,6 +80,7 @@ export default function RootLayout({ var HABIB_TOKEN_COOKIE = 'HABIB_TOKEN'; var HABIB_COINS_COOKIE = 'HABIB_COINS'; + var HABIB_ENTRY_PATH_COOKIE = 'HABIB_MARRIAGE_ENTRY_PATH'; var HABIB_COOKIE_MAX_AGE = 60 * 60 * 24 * 30; function writeCookie(name, value) { @@ -101,6 +96,17 @@ export default function RootLayout({ document.cookie = cookie; } + function clearCookie(name) { + var secure = window.location.protocol === 'https:'; + var cookie = name + '=; Path=/; Max-Age=0; SameSite=Lax'; + + if (secure) { + cookie += '; Secure'; + } + + document.cookie = cookie; + } + function readCookie(name) { var cookies = document.cookie ? document.cookie.split('; ') : []; @@ -118,13 +124,28 @@ export default function RootLayout({ Object.defineProperty(window, 'HABIB_TOKEN', { configurable: true, set: function(value) { - this._habib_token = value; - if (value) { - writeCookie(HABIB_TOKEN_COOKIE, value); + var previousToken = readCookie(HABIB_TOKEN_COOKIE) || readCookie('habib_token'); + var nextToken = value === undefined || value === null ? '' : String(value).trim(); + var hasToken = nextToken !== '' && nextToken !== 'NO_TOKEN'; + + this._habib_token = hasToken ? nextToken : undefined; + if (hasToken) { + if (!previousToken || previousToken !== nextToken) { + clearCookie(HABIB_ENTRY_PATH_COOKIE); + } + writeCookie(HABIB_TOKEN_COOKIE, nextToken); + try { + sessionStorage.setItem(HABIB_TOKEN_COOKIE, nextToken); + } catch (e) {} + } else { + clearCookie(HABIB_TOKEN_COOKIE); + clearCookie('habib_token'); + clearCookie(HABIB_ENTRY_PATH_COOKIE); try { - sessionStorage.setItem(HABIB_TOKEN_COOKIE, value); + sessionStorage.removeItem(HABIB_TOKEN_COOKIE); } catch (e) {} } + window.dispatchEvent(new Event('habib:auth-token-changed')); }, get: function() { var ssVal; diff --git a/src/app/page.tsx b/src/app/page.tsx index 8fb58de..894b332 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,6 +1,14 @@ import { cookies, headers } from "next/headers"; import { redirect } from "next/navigation"; -import { defaultLocale, isLocale } from "@/translations/config"; +import { + getAuthenticatedCachedEntryPath, + MARRIAGE_ENTRY_PATH_COOKIE, +} from "@/lib/entry-route-cache"; +import { + defaultLocale, + isLocale, + localizePath, +} from "@/translations/config"; export const dynamic = "force-dynamic"; @@ -25,5 +33,17 @@ export default async function RootPage() { } } - redirect(`/${targetLocale}`); + const token = + cookieStore.get("HABIB_TOKEN")?.value ?? + cookieStore.get("habib_token")?.value; + const cachedEntryPath = getAuthenticatedCachedEntryPath( + token, + cookieStore.get(MARRIAGE_ENTRY_PATH_COOKIE)?.value, + ); + + redirect( + cachedEntryPath + ? localizePath(cachedEntryPath, targetLocale) + : `/${targetLocale}`, + ); } diff --git a/src/app/questions-list/page.tsx b/src/app/questions-list/page.tsx index f369e36..9fc7216 100644 --- a/src/app/questions-list/page.tsx +++ b/src/app/questions-list/page.tsx @@ -48,6 +48,18 @@ export default function QuestionsListPage() { triggerSilentReload(queryClient); }, [queryClient]); + const profileTargetPath = useMemo( + () => (profile ? getSubmitPath(profile) : null), + [profile], + ); + const isProfileRedirecting = + profileTargetPath !== null && profileTargetPath !== "/questions-list"; + + useEffect(() => { + if (isProfileRedirecting && profileTargetPath) { + router.replace(localizePath(profileTargetPath, locale)); + } + }, [isProfileRedirecting, locale, profileTargetPath, router]); const startMatchMutation = useStartMarriageMatchMutation({ onSuccess: () => { @@ -190,7 +202,12 @@ export default function QuestionsListPage() { } }; - if (isProfileLoading || isSectionsLoading || isSchemaLoading) { + if ( + isProfileLoading || + isSectionsLoading || + isSchemaLoading || + isProfileRedirecting + ) { return ( <> @@ -230,24 +247,38 @@ export default function QuestionsListPage() {
- {/* Required Steps Card Skeleton */} - + {/* Required Steps Card Skeleton (Hosseinieh Card Style) */} +
+
+
+ + +
+
+ + +
+
+
+
+
+
{/* Section Cards Skeletons */}
{Array.from({ length: 6 }).map((_, idx) => (
- -
- - + +
+ +
-
+
- +
))} @@ -255,7 +286,7 @@ export default function QuestionsListPage() {
- + diff --git a/src/app/terms/page.test.tsx b/src/app/terms/page.test.tsx index 2a993ba..d642677 100644 --- a/src/app/terms/page.test.tsx +++ b/src/app/terms/page.test.tsx @@ -72,6 +72,23 @@ describe('TermsRoute Guard', () => { expect(mockReplace).not.toHaveBeenCalled(); }); + it('pending_onboarding with completed profile basics redirects to Questions', async () => { + mockUseMarriageProfileQuery.mockReturnValue({ + isLoading: false, + data: { + status: 'pending_onboarding', + gender: 'female', + is_registering_for_self: true, + } + }); + render(); + + expect(screen.queryByTestId('slider-page')).not.toBeInTheDocument(); + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('/questions-list'); + }); + }); + it('pending_info => redirects to Questions and SliderPage is NOT rendered', async () => { mockUseMarriageProfileQuery.mockReturnValue({ isLoading: false, diff --git a/src/app/terms/page.tsx b/src/app/terms/page.tsx index a76607c..c6fee7d 100644 --- a/src/app/terms/page.tsx +++ b/src/app/terms/page.tsx @@ -3,7 +3,10 @@ import { useEffect } from "react"; import { useRouter } from "next/navigation"; import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; -import { getSubmitPath } from "@/lib/get-submit-path"; +import { + getSubmitPath, + hasCompletedMarriageProfileBasics, +} from "@/lib/get-submit-path"; import SliderPage from "@/components/Componentes/slider-page"; import Button from "@/components/Componentes/button"; import { useI18n } from "@/translations/provider"; @@ -16,7 +19,10 @@ export default function TermsRoute() { useEffect(() => { if (!isLoading && profile) { - if (profile.status !== "pending_onboarding") { + if ( + profile.status !== "pending_onboarding" || + hasCompletedMarriageProfileBasics(profile) + ) { const target = getSubmitPath(profile); if (target !== "/terms") { router.replace(localizePath(target, locale)); @@ -50,7 +56,11 @@ export default function TermsRoute() { ); } - if (profile && profile.status !== "pending_onboarding") { + if ( + profile && + (profile.status !== "pending_onboarding" || + hasCompletedMarriageProfileBasics(profile)) + ) { // If target is /terms but status is not pending_onboarding, we should still not render SliderPage // to prevent showing Onboarding to users who already finished it but have an unknown status. return ( diff --git a/src/components/Componentes/entry-route-resolver.test.tsx b/src/components/Componentes/entry-route-resolver.test.tsx new file mode 100644 index 0000000..7b5dfdf --- /dev/null +++ b/src/components/Componentes/entry-route-resolver.test.tsx @@ -0,0 +1,134 @@ +import { render, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import EntryRouteResolver from "./entry-route-resolver"; + +const mocks = vi.hoisted(() => ({ + getCachedEntryPath: vi.fn(), + isAuthenticated: vi.fn(), + refetchProfile: vi.fn(), + replace: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ replace: mocks.replace }), +})); + +vi.mock("@/lib/auth-bridge", () => ({ + HABIB_AUTH_TOKEN_CHANGED_EVENT: "habib:auth-token-changed", + authBridge: { isAuthenticated: mocks.isAuthenticated }, +})); + +vi.mock("@/lib/entry-route-cache", () => ({ + getCachedMarriageEntryPath: mocks.getCachedEntryPath, +})); + +vi.mock("@/hooks/marriage/use-profile-main", () => ({ + useMarriageProfileQuery: () => ({ refetch: mocks.refetchProfile }), +})); + +vi.mock("@/translations/provider", () => ({ + useI18n: () => ({ locale: "en" }), +})); + +vi.mock("@/translations/config", () => ({ + localizePath: (path: string, locale: string) => `/${locale}${path}`, +})); + +describe("EntryRouteResolver", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getCachedEntryPath.mockReturnValue(null); + delete window.HabibApp; + }); + + it("renders no loading or page content while resolving", () => { + mocks.isAuthenticated.mockReturnValue(true); + mocks.refetchProfile.mockReturnValue(new Promise(() => {})); + + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); + + it("routes a registered pending-info user directly to questions", async () => { + mocks.isAuthenticated.mockReturnValue(true); + mocks.refetchProfile.mockResolvedValue({ + data: { + status: "pending_info", + gender: "female", + is_registering_for_self: true, + }, + }); + + render(); + + await waitFor(() => { + expect(mocks.replace).toHaveBeenCalledWith("/en/questions-list"); + }); + }); + + it("uses the persistent entry path without refetching the profile", async () => { + mocks.isAuthenticated.mockReturnValue(true); + mocks.getCachedEntryPath.mockReturnValue("/questions-list"); + + render(); + + await waitFor(() => { + expect(mocks.replace).toHaveBeenCalledWith("/en/questions-list"); + }); + expect(mocks.refetchProfile).not.toHaveBeenCalled(); + }); + + it("routes a registered user with an advanced status to that status page", async () => { + mocks.isAuthenticated.mockReturnValue(true); + mocks.refetchProfile.mockResolvedValue({ + data: { + status: "waiting", + gender: "male", + is_registering_for_self: false, + }, + }); + + render(); + + await waitFor(() => { + expect(mocks.replace).toHaveBeenCalledWith("/en/finding-match"); + }); + }); + + it("routes an unauthenticated browser user to the real intro route", async () => { + mocks.isAuthenticated.mockReturnValue(false); + + render(); + + await waitFor(() => { + expect(mocks.replace).toHaveBeenCalledWith("/en/intro"); + }); + expect(mocks.refetchProfile).not.toHaveBeenCalled(); + }); + + it("waits for the WebView token event without rendering a loading page", async () => { + let authenticated = false; + mocks.isAuthenticated.mockImplementation(() => authenticated); + mocks.refetchProfile.mockResolvedValue({ + data: { + status: "pending_info", + gender: "female", + is_registering_for_self: true, + }, + }); + window.HabibApp = { postMessage: vi.fn() }; + + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + expect(mocks.replace).not.toHaveBeenCalled(); + + authenticated = true; + window.dispatchEvent(new Event("habib:auth-token-changed")); + + await waitFor(() => { + expect(mocks.replace).toHaveBeenCalledWith("/en/questions-list"); + }); + }); +}); diff --git a/src/components/Componentes/entry-route-resolver.tsx b/src/components/Componentes/entry-route-resolver.tsx new file mode 100644 index 0000000..7c55c88 --- /dev/null +++ b/src/components/Componentes/entry-route-resolver.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect, useRef } from "react"; +import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; +import { + authBridge, + HABIB_AUTH_TOKEN_CHANGED_EVENT, +} from "@/lib/auth-bridge"; +import { getCachedMarriageEntryPath } from "@/lib/entry-route-cache"; +import { + getSubmitPath, + hasCompletedMarriageProfileBasics, +} from "@/lib/get-submit-path"; +import { localizePath } from "@/translations/config"; +import { useI18n } from "@/translations/provider"; + +export default function EntryRouteResolver() { + const router = useRouter(); + const { locale } = useI18n(); + const { refetch } = useMarriageProfileQuery({ + enabled: false, + retry: false, + }); + const entryCheckIdRef = useRef(0); + + useEffect(() => { + let isActive = true; + let bridgeWaitTimeout: number | undefined; + + const goToIntro = () => { + router.replace(localizePath("/intro", locale)); + }; + + const resolveEntryRoute = async () => { + if (!authBridge.isAuthenticated()) { + if (isActive) { + goToIntro(); + } + return; + } + + const cachedEntryPath = getCachedMarriageEntryPath(); + if (cachedEntryPath) { + router.replace(localizePath(cachedEntryPath, locale)); + return; + } + + const checkId = ++entryCheckIdRef.current; + + try { + const { data: profile } = await refetch(); + + if (!isActive || checkId !== entryCheckIdRef.current) { + return; + } + + if (!hasCompletedMarriageProfileBasics(profile)) { + goToIntro(); + return; + } + + router.replace(localizePath(getSubmitPath(profile), locale)); + } catch (error) { + console.warn("Could not resolve entry route", error); + if (isActive && checkId === entryCheckIdRef.current) { + goToIntro(); + } + } + }; + + const handleTokenChanged = () => { + if (bridgeWaitTimeout !== undefined) { + window.clearTimeout(bridgeWaitTimeout); + bridgeWaitTimeout = undefined; + } + void resolveEntryRoute(); + }; + + window.addEventListener(HABIB_AUTH_TOKEN_CHANGED_EVENT, handleTokenChanged); + + if (authBridge.isAuthenticated()) { + void resolveEntryRoute(); + } else if (window.HabibApp) { + bridgeWaitTimeout = window.setTimeout(goToIntro, 4_000); + } else { + goToIntro(); + } + + return () => { + isActive = false; + if (bridgeWaitTimeout !== undefined) { + window.clearTimeout(bridgeWaitTimeout); + } + window.removeEventListener( + HABIB_AUTH_TOKEN_CHANGED_EVENT, + handleTokenChanged, + ); + }; + }, [locale, refetch, router]); + + return null; +} diff --git a/src/components/Componentes/female-outcome-sheet.test.tsx b/src/components/Componentes/female-outcome-sheet.test.tsx index d2a325f..00d812b 100644 --- a/src/components/Componentes/female-outcome-sheet.test.tsx +++ b/src/components/Componentes/female-outcome-sheet.test.tsx @@ -77,11 +77,15 @@ describe("FemaleOutcomeSheet", () => { }); expect(confirmation).toHaveProperty("disabled", true); - await userEvent.type( + fireEvent.change( screen.getByPlaceholderText( "Feel free to briefly explain your decision...", ), - "The families could not agree on the next steps.", + { + target: { + value: "The families could not agree on the next steps.", + }, + }, ); expect(confirmation).toHaveProperty("disabled", false); }); diff --git a/src/components/Componentes/loading-skeleton.tsx b/src/components/Componentes/loading-skeleton.tsx index d38315b..7a2c95f 100644 --- a/src/components/Componentes/loading-skeleton.tsx +++ b/src/components/Componentes/loading-skeleton.tsx @@ -1,7 +1,8 @@ import type { ComponentProps } from "react"; +import { cn } from "@/lib/utils"; -interface LoadingSkeletonProps extends ComponentProps<"div"> { - variant?: "pulse" | "shimmer"; +export interface LoadingSkeletonProps extends ComponentProps<"div"> { + variant?: "pulse" | "shimmer" | "white"; } export function LoadingSkeleton({ @@ -10,20 +11,20 @@ export function LoadingSkeleton({ ...props }: LoadingSkeletonProps) { const baseClasses = - variant === "shimmer" - ? "shimmer-bg" - : "animate-pulse bg-neutral-200/80 dark:bg-neutral-800/80"; - - const hasRoundedClass = className - .split(" ") - .some((c) => c.startsWith("rounded-")); - const roundedClass = hasRoundedClass ? "" : "rounded-md"; + variant === "white" + ? "shimmer-white-bg" + : variant === "pulse" + ? "animate-pulse bg-slate-200/80 dark:bg-white/[0.08]" + : "shimmer-bg"; return (
); } + +export { LoadingSkeleton as Skeleton }; +export default LoadingSkeleton; diff --git a/src/components/Componentes/network-image.tsx b/src/components/Componentes/network-image.tsx new file mode 100644 index 0000000..73eca42 --- /dev/null +++ b/src/components/Componentes/network-image.tsx @@ -0,0 +1,84 @@ +"use client"; + +import Image, { type ImageProps } from "next/image"; +import { useState, useEffect } from "react"; +import { cn } from "@/lib/utils"; + +export interface NetworkImageProps extends Omit { + src?: string | null; + fallbackSrc?: string; + containerClassName?: string; + placeholderClassName?: string; +} + +export function NetworkImage({ + src, + fallbackSrc = "/assets/images/Frame 2095586523.png", + alt = "", + className, + containerClassName, + placeholderClassName, + fill, + width, + height, + priority, + ...props +}: NetworkImageProps) { + const initialSrc = src || fallbackSrc; + const [imgSrc, setImgSrc] = useState(initialSrc); + const [isLoaded, setIsLoaded] = useState(false); + const [hasError, setHasError] = useState(false); + + useEffect(() => { + const target = src || fallbackSrc; + setImgSrc(target); + setHasError(false); + setIsLoaded(false); + }, [src, fallbackSrc]); + + return ( +
+ {!isLoaded && !hasError && ( +
+ )} + {alt} setIsLoaded(true)} + onError={() => { + if (imgSrc !== fallbackSrc) { + setImgSrc(fallbackSrc); + } else { + setHasError(true); + } + }} + className={cn( + "transition-opacity duration-300", + !isLoaded ? "opacity-0" : "opacity-100", + className, + )} + /> +
+ ); +} + +export default NetworkImage; diff --git a/src/components/Componentes/question-answer.test.tsx b/src/components/Componentes/question-answer.test.tsx index fee2389..1a50e0b 100644 --- a/src/components/Componentes/question-answer.test.tsx +++ b/src/components/Componentes/question-answer.test.tsx @@ -28,7 +28,7 @@ function TestComponent({ slug }: { slug: string }) { data-testid="set-radio" onClick={() => setAnswerValue( - { id: "q1", type: "radio", title: "Q1", order: 1, required: true, isVisible: true, description: "", tooltip: "", extras: { placeHolder: "", options: [], range: [0,0] }, options: [{ id: "opt1", value: "A", label: "Option A", order: 1 }] }, + { id: "q1", type: "radio", title: "Q1", order: 1, required: true, baseRequired: true, isVisible: true, description: "", tooltip: "", extras: { placeHolder: "", options: [], range: [0,0] }, options: [{ id: "opt1", value: "A", label: "Option A", order: 1 }] }, "opt1" ) } @@ -40,7 +40,7 @@ function TestComponent({ slug }: { slug: string }) { data-testid="set-checkbox" onClick={() => setAnswerValue( - { id: "q2", type: "checkbox", title: "Q2", order: 2, required: true, isVisible: true, description: "", tooltip: "", extras: { placeHolder: "", options: [], range: [0,0] }, options: [{ id: "opt2", value: "B", label: "Option B", order: 1 }, { id: "opt3", value: "C", label: "Option C", order: 2 }] }, + { id: "q2", type: "checkbox", title: "Q2", order: 2, required: true, baseRequired: true, isVisible: true, description: "", tooltip: "", extras: { placeHolder: "", options: [], range: [0,0] }, options: [{ id: "opt2", value: "B", label: "Option B", order: 1 }, { id: "opt3", value: "C", label: "Option C", order: 2 }] }, ["opt2", "opt3"] ) } @@ -94,7 +94,7 @@ describe("Question Answer & Schema Integration", () => { @@ -121,7 +121,7 @@ describe("Question Answer & Schema Integration", () => { diff --git a/src/components/Componentes/schema-question-flow.integration.test.tsx b/src/components/Componentes/schema-question-flow.integration.test.tsx index fd9b3bb..96f22b6 100644 --- a/src/components/Componentes/schema-question-flow.integration.test.tsx +++ b/src/components/Componentes/schema-question-flow.integration.test.tsx @@ -4,7 +4,7 @@ import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QuestionAnswersProvider, useQuestionAnswers } from "./question-answer-storage"; import { convertSchemaToFrontendItems } from "@/lib/schema-adapter"; -import { useFormSchemaQuery } from "@/hooks/marriage/use-form-schema"; +import { useFormSchemaQuery, type FormSchemaResponse } from "@/hooks/marriage/use-form-schema"; import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; import { useMarriageSectionDataQuery, useUpdateMarriageSectionDataMutation } from "@/hooks/marriage/use-section-data"; import { QuestionRadio } from "./question-radio"; @@ -135,7 +135,10 @@ describe("Schema Question Flow Integration", () => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); // Use convertSchemaToFrontendItems directly (no mock) - const frontendItems = convertSchemaToFrontendItems(mockSchema, "en"); + const frontendItems = convertSchemaToFrontendItems( + mockSchema as unknown as FormSchemaResponse, + "en", + ); // Sort logic validation expect(frontendItems[0].questions[0].id).toBe("q_first"); diff --git a/src/components/Componentes/slider-page.test.tsx b/src/components/Componentes/slider-page.test.tsx index 8b8b317..69f9242 100644 --- a/src/components/Componentes/slider-page.test.tsx +++ b/src/components/Componentes/slider-page.test.tsx @@ -69,6 +69,10 @@ describe('SliderPage', () => { ); + const acceptBtn = screen.getByRole('button', { name: /accept/i }); + if (!acceptBtn.hasAttribute('disabled')) { + fireEvent.click(acceptBtn); + } const dots = screen.getAllByRole('button', { name: /go to slide/i }); fireEvent.click(dots[4]); // Go directly to slide 5 return await screen.findByText('Finish'); @@ -114,7 +118,7 @@ describe('SliderPage', () => { expect(queryClient.setQueryData).toHaveBeenCalled(); const finalRemoveCount = mockRemoveItem.mock.calls.filter(call => call[0] === 'marriage-slider-answers').length; expect(finalRemoveCount).toBe(initialRemoveCount + 1); - expect(mockReplace).toHaveBeenCalledWith('/questions-list/personal_info'); + expect(mockReplace).toHaveBeenCalledWith('/questions-list'); }); }); @@ -151,7 +155,7 @@ describe('SliderPage', () => { fireEvent.click(retryBtn); await waitFor(() => { - expect(mockReplace).toHaveBeenCalledWith('/questions-list/personal_info'); + expect(mockReplace).toHaveBeenCalledWith('/questions-list'); }); const finalRemoveCount = mockRemoveItem.mock.calls.filter(call => call[0] === 'marriage-slider-answers').length; @@ -187,11 +191,11 @@ describe('SliderPage', () => { vi.useRealTimers(); await waitFor(() => { - expect(mockReplace).toHaveBeenCalledWith('/questions-list/personal_info'); + expect(mockReplace).toHaveBeenCalledWith('/questions-list'); }); }); - it('d. Double-check failure: GET returns pending_onboarding -> Error UI', async () => { + it('d. GET with completed profile basics routes even if status still says onboarding', async () => { const finishBtn = await navigateToFinalSlide(); mockMutateAsync.mockResolvedValueOnce({ @@ -209,9 +213,8 @@ describe('SliderPage', () => { fireEvent.click(finishBtn); await waitFor(() => { - expect(screen.getByRole('alert')).toHaveTextContent('Failed to update profile basic details. Please try again.'); + expect(mockReplace).toHaveBeenCalledWith('/questions-list'); }); - expect(mockReplace).not.toHaveBeenCalled(); }); it('e. Double click: only 1 request', async () => { @@ -240,7 +243,7 @@ describe('SliderPage', () => { }); }); - it('f. Double-check failure: GET returns waiting -> Error UI', async () => { + it('f. GET with an advanced status routes to the matching page', async () => { const finishBtn = await navigateToFinalSlide(); mockMutateAsync.mockResolvedValueOnce({ @@ -258,8 +261,50 @@ describe('SliderPage', () => { fireEvent.click(finishBtn); await waitFor(() => { - expect(screen.getByRole('alert')).toHaveTextContent('Failed to update profile basic details. Please try again.'); + expect(mockReplace).toHaveBeenCalledWith('/finding-match'); }); - expect(mockReplace).not.toHaveBeenCalled(); + }); + + it('g. Slide 0 navigation guard: cannot swipe or click dots when rules not read', async () => { + // Render slider page with rules container that has not scrolled to bottom + // We mock element scroll properties before render + const origScrollHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollHeight'); + const origClientHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientHeight'); + const origScrollTop = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollTop'); + + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { configurable: true, value: 2000 }); + Object.defineProperty(HTMLElement.prototype, 'clientHeight', { configurable: true, value: 500 }); + Object.defineProperty(HTMLElement.prototype, 'scrollTop', { configurable: true, value: 0 }); + + try { + render( + + + + ); + + const acceptBtn = screen.getByRole('button', { name: /accept/i }); + expect(acceptBtn).toBeDisabled(); + + // Attempting to click dot 2 (index 1) should be disabled and not navigate + const dots = screen.getAllByRole('button', { name: /go to slide/i }); + expect(dots[1]).toBeDisabled(); + fireEvent.click(dots[1]); + + // We should still be on Slide 1 (terms) + expect(screen.getByText('Terms & Conditions')).toBeInTheDocument(); + + // Swipe touch gesture forward (touchStart at 200, touchEnd at 50 -> deltaX = 150 > 40) + const main = screen.getByRole('main'); + fireEvent.touchStart(main, { touches: [{ clientX: 200 }] }); + fireEvent.touchEnd(main, { changedTouches: [{ clientX: 50 }] }); + + // Still on Slide 1 (terms) + expect(screen.getByText('Terms & Conditions')).toBeInTheDocument(); + } finally { + if (origScrollHeight) Object.defineProperty(HTMLElement.prototype, 'scrollHeight', origScrollHeight); + if (origClientHeight) Object.defineProperty(HTMLElement.prototype, 'clientHeight', origClientHeight); + if (origScrollTop) Object.defineProperty(HTMLElement.prototype, 'scrollTop', origScrollTop); + } }); }); diff --git a/src/components/Componentes/slider-page.tsx b/src/components/Componentes/slider-page.tsx index 888dc6d..8bef188 100644 --- a/src/components/Componentes/slider-page.tsx +++ b/src/components/Componentes/slider-page.tsx @@ -5,6 +5,10 @@ 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"; @@ -47,7 +51,11 @@ export default function SliderPage() { }, []); const goToSlide = (index: number) => { - setActiveSlide(Math.max(0, Math.min(index, maxSlideIndex))); + const targetIndex = Math.max(0, Math.min(index, maxSlideIndex)); + if (targetIndex > 0 && !hasReadRules) { + return; + } + setActiveSlide(targetIndex); }; const goToPreviousSlide = () => { @@ -55,6 +63,9 @@ export default function SliderPage() { }; const goToNextSlide = () => { + if (activeSlide === 0 && !hasReadRules) { + return; + } goToSlide(activeSlide + 1); }; @@ -73,7 +84,7 @@ export default function SliderPage() { }); if ( - patchResponse.status === "pending_onboarding" || + !hasCompletedMarriageProfileBasics(patchResponse) || patchResponse.gender !== genderPayload || patchResponse.is_registering_for_self !== isRegisteringPayload ) { @@ -84,7 +95,7 @@ export default function SliderPage() { const freshProfile = await getMarriageProfile(); if ( - freshProfile.status !== "pending_info" || + !hasCompletedMarriageProfileBasics(freshProfile) || freshProfile.gender !== genderPayload || freshProfile.is_registering_for_self !== isRegisteringPayload ) { @@ -98,7 +109,7 @@ export default function SliderPage() { window.localStorage.removeItem(SLIDER_ANSWERS_STORAGE_KEY); } - router.replace(localizePath("/questions-list/personal_info", locale)); + 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."); @@ -121,6 +132,10 @@ export default function SliderPage() { if (Math.abs(deltaX) >= SWIPE_THRESHOLD) { if (deltaX > 0) { + if (activeSlide === 0 && !hasReadRules) { + setTouchStartX(null); + return; + } goToNextSlide(); } else { goToPreviousSlide(); @@ -154,6 +169,7 @@ export default function SliderPage() { > {Array.from({ length: navDotCount }, (_, index) => { const isActive = index === activeSlide; + const isLocked = index > 0 && !hasReadRules; return (