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)}
>
-
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 && (
+
+ )}
+
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 (