Browse Source

refactor: implement entry route caching and improve loading skeletons for profile redirection.

Dev
mortezaei 1 week ago
parent
commit
8cf42bcd14
  1. 2
      public/assets/images/Ellipse 1210.svg
  2. 2
      public/assets/images/Group 1597880466.svg
  3. 2
      public/assets/images/Group 15978804fdasf68.svg
  4. 2
      public/assets/images/Group 159788fd0467.svg
  5. 2
      public/assets/images/Group 15978fdsa80467.svg
  6. 2
      public/assets/images/female_avatar.svg
  7. 2
      public/assets/images/home-Checkups-List.svg
  8. 2
      public/assets/images/islamic_pattern_2_2892_3864.svg
  9. 32
      src/app/[lang]/page.tsx
  10. 9
      src/app/api/proxy/route.ts
  11. 41
      src/app/globals.css
  12. 10
      src/app/intro/page.tsx
  13. 41
      src/app/layout.tsx
  14. 24
      src/app/page.tsx
  15. 53
      src/app/questions-list/page.tsx
  16. 17
      src/app/terms/page.test.tsx
  17. 16
      src/app/terms/page.tsx
  18. 134
      src/components/Componentes/entry-route-resolver.test.tsx
  19. 103
      src/components/Componentes/entry-route-resolver.tsx
  20. 8
      src/components/Componentes/female-outcome-sheet.test.tsx
  21. 23
      src/components/Componentes/loading-skeleton.tsx
  22. 84
      src/components/Componentes/network-image.tsx
  23. 8
      src/components/Componentes/question-answer.test.tsx
  24. 7
      src/components/Componentes/schema-question-flow.integration.test.tsx
  25. 63
      src/components/Componentes/slider-page.test.tsx
  26. 26
      src/components/Componentes/slider-page.tsx
  27. 11
      src/components/Componentes/slider-slide-two.tsx
  28. 6
      src/components/Componentes/token-switcher.tsx
  29. 2
      src/hooks/marriage/use-match-start.ts
  30. 48
      src/hooks/marriage/use-profile-main.test.ts
  31. 9
      src/hooks/marriage/use-profile-main.ts
  32. 39
      src/lib/auth-bridge.test.ts
  33. 26
      src/lib/auth-bridge.ts
  34. 46
      src/lib/entry-route-cache.test.ts
  35. 65
      src/lib/entry-route-cache.ts
  36. 20
      src/lib/get-submit-path.test.ts
  37. 19
      src/lib/get-submit-path.ts
  38. 14
      src/lib/utils.ts

2
public/assets/images/Ellipse 1210.svg
File diff suppressed because it is too large
View File

2
public/assets/images/Group 1597880466.svg
File diff suppressed because it is too large
View File

2
public/assets/images/Group 15978804fdasf68.svg
File diff suppressed because it is too large
View File

2
public/assets/images/Group 159788fd0467.svg
File diff suppressed because it is too large
View File

2
public/assets/images/Group 15978fdsa80467.svg
File diff suppressed because it is too large
View File

2
public/assets/images/female_avatar.svg
File diff suppressed because it is too large
View File

2
public/assets/images/home-Checkups-List.svg
File diff suppressed because it is too large
View File

2
public/assets/images/islamic_pattern_2_2892_3864.svg
File diff suppressed because it is too large
View File

32
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 <EntryRouteResolver />;
}

9
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"),

41
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;
}

10
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)}
>
<Image
src={
config?.intro_video_thumbnail_url ||
"/assets/images/Frame 2095586523.png"
}
<NetworkImage
src={config?.intro_video_thumbnail_url}
fallbackSrc="/assets/images/Frame 2095586523.png"
alt={t["video"]}
fill
sizes="344px"
className="object-cover transition-transform duration-300 group-hover:scale-105"
priority
/>

41
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;

24
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}`,
);
}

53
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 (
<>
<PageBackground disabled />
@ -230,24 +247,38 @@ export default function QuestionsListPage() {
</header>
<div className="relative mt-4 space-y-5">
{/* Required Steps Card Skeleton */}
<LoadingSkeleton className="h-[96px] w-full rounded-[15px]" />
{/* Required Steps Card Skeleton (Hosseinieh Card Style) */}
<div className="rounded-[15px] bg-[#40506A] p-4 text-white shadow-[0_18px_34px_rgba(38,52,73,0.16)] flex items-center justify-between gap-5">
<div className="min-w-0 flex-1 space-y-2.5">
<div className="flex items-center gap-3">
<span className="size-6 shrink-0 rounded-full shimmer-white-bg" />
<span className="h-4 w-32 rounded-md shimmer-white-bg block" />
</div>
<div className="space-y-1.5 pt-0.5">
<span className="h-3 w-4/5 rounded-md shimmer-white-bg block" />
<span className="h-3 w-3/5 rounded-md shimmer-white-bg block" />
</div>
</div>
<div className="size-[60px] shrink-0 rounded-full shimmer-white-bg flex items-center justify-center p-1.5">
<div className="size-full rounded-full bg-[#40506A]" />
</div>
</div>
{/* Section Cards Skeletons */}
<div className="space-y-3">
{Array.from({ length: 6 }).map((_, idx) => (
<div
key={idx}
className="flex items-center gap-3 rounded-[20px] border border-white/80 bg-white p-3 shadow-[0_12px_28px_rgba(15,23,42,0.05)]"
className="flex items-center gap-3.5 rounded-[20px] border border-white/80 bg-white p-3 shadow-[0_12px_28px_rgba(15,23,42,0.04)]"
>
<LoadingSkeleton className="h-[44px] w-[44px] rounded-[12px]" />
<div className="flex-1 space-y-2">
<LoadingSkeleton className="h-4 w-[60%] rounded-md" />
<LoadingSkeleton className="h-3 w-[30%] rounded-md" />
<LoadingSkeleton className="h-[46px] w-[46px] shrink-0 rounded-[14px]" />
<div className="flex-1 space-y-2 py-0.5">
<LoadingSkeleton className="h-4 w-[55%] rounded-md" />
<LoadingSkeleton className="h-3 w-[35%] rounded-md" />
</div>
<div className="flex flex-col items-end gap-3">
<div className="flex flex-col items-end gap-2 shrink-0">
<LoadingSkeleton className="h-5 w-14 rounded-full" />
<LoadingSkeleton className="h-[22px] w-[22px] rounded-full" />
<LoadingSkeleton className="size-[22px] rounded-full" />
</div>
</div>
))}
@ -255,7 +286,7 @@ export default function QuestionsListPage() {
</div>
<FixToTheEnd>
<LoadingSkeleton className="h-[52px] w-full rounded-[11px]" />
<LoadingSkeleton className="h-[52px] w-full rounded-[14px]" />
</FixToTheEnd>
</main>
</>

17
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(<TermsRoute />);
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,

16
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 (

134
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(<EntryRouteResolver />);
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(<EntryRouteResolver />);
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(<EntryRouteResolver />);
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(<EntryRouteResolver />);
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(<EntryRouteResolver />);
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(<EntryRouteResolver />);
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");
});
});
});

103
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;
}

8
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);
});

23
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 (
<div
data-slot="loading-skeleton"
className={`${baseClasses} ${roundedClass} ${className}`.trim()}
className={cn("rounded-md", baseClasses, className)}
{...props}
/>
);
}
export { LoadingSkeleton as Skeleton };
export default LoadingSkeleton;

84
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<ImageProps, "src"> {
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<string>(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 (
<div
className={cn(
fill
? "absolute inset-0 size-full overflow-hidden"
: "relative overflow-hidden inline-block",
containerClassName,
)}
style={!fill && width && height ? { width, height } : undefined}
>
{!isLoaded && !hasError && (
<div
className={cn(
"absolute inset-0 animate-pulse bg-slate-200/60 dark:bg-white/[0.08] z-0",
placeholderClassName,
)}
aria-hidden
/>
)}
<Image
{...props}
src={imgSrc}
alt={alt}
fill={fill}
width={!fill ? width : undefined}
height={!fill ? height : undefined}
priority={priority}
onLoad={() => setIsLoaded(true)}
onError={() => {
if (imgSrc !== fallbackSrc) {
setImgSrc(fallbackSrc);
} else {
setHasError(true);
}
}}
className={cn(
"transition-opacity duration-300",
!isLoaded ? "opacity-0" : "opacity-100",
className,
)}
/>
</div>
);
}
export default NetworkImage;

8
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", () => {
<QuestionAnswersProvider
slug="test_slug"
questions={[
{ 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 }] },
]}
>
<TestComponent slug="test_slug" />
@ -121,7 +121,7 @@ describe("Question Answer & Schema Integration", () => {
<QuestionAnswersProvider
slug="test_slug"
questions={[
{ 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 }] },
]}
>
<TestComponent slug="test_slug" />

7
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");

63
src/components/Componentes/slider-page.test.tsx

@ -69,6 +69,10 @@ describe('SliderPage', () => {
<SliderPage />
</QueryClientProvider>
);
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(
<QueryClientProvider client={queryClient}>
<SliderPage />
</QueryClientProvider>
);
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);
}
});
});

26
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 (
<button
@ -161,9 +177,11 @@ export default function SliderPage() {
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)}
/>

11
src/components/Componentes/slider-slide-two.tsx

@ -6,6 +6,7 @@ import { useMarriageConfigQuery } from "@/hooks/marriage/use-marriage-config";
import { SliderHeader } from "./slider-header";
import type { SliderSlideProps } from "./slider-slide";
import VideoPlayer from "./video-player";
import NetworkImage from "./network-image";
export function SliderSlideTwo({ index }: SliderSlideProps) {
const [isPlayerOpen, setIsPlayerOpen] = useState(false);
@ -22,12 +23,10 @@ export function SliderSlideTwo({ index }: SliderSlideProps) {
className="mt-8 flex min-h-0 justify-center cursor-pointer group"
onClick={() => setIsPlayerOpen(true)}
>
<div className="relative w-full max-w-[344px]">
<Image
src={
config?.intro_video_thumbnail_url ||
"/assets/images/Frame 20953586523.png"
}
<div className="relative w-full max-w-[344px] overflow-hidden rounded-2xl">
<NetworkImage
src={config?.intro_video_thumbnail_url}
fallbackSrc="/assets/images/Frame 20953586523.png"
alt="video"
width={344}
height={488}

6
src/components/Componentes/token-switcher.tsx

@ -3,6 +3,7 @@
import { useEffect, useState } from "react";
import { MdOutlineSwitchAccount } from "react-icons/md";
import { getClientCookie, setClientCookie } from "@/lib/cookies";
import { setCachedMarriageEntryPath } from "@/lib/entry-route-cache";
export const FEMALE_TOKEN = "545f61bb3e061ccb9b19f84715eb1b1ed4740331";
export const MALE_TOKEN = "f3a7543b44ef0a713d1ee0d4f7866b3825cf1308";
@ -35,8 +36,7 @@ export function TokenSwitcher({
getClientCookie(TOKEN_COOKIE_NAME) ??
getClientCookie("habib_token") ??
sessionStorage.getItem(TOKEN_COOKIE_NAME) ??
process.env.NEXT_PUBLIC_DEFAULT_TOKEN ??
MALE_TOKEN;
"";
setCurrentToken(token);
}
}, []);
@ -60,6 +60,7 @@ export function TokenSwitcher({
console.error("Failed to clear storage", e);
}
setCachedMarriageEntryPath(null);
setClientCookie(TOKEN_COOKIE_NAME, trimmed);
setClientCookie("habib_token", trimmed);
try {
@ -124,6 +125,7 @@ export function TokenSwitcher({
? MALE_2_TOKEN
: "NO_TOKEN";
setCachedMarriageEntryPath(null);
setClientCookie(TOKEN_COOKIE_NAME, targetToken);
setClientCookie("habib_token", targetToken);
if (typeof window !== "undefined") {

2
src/hooks/marriage/use-match-start.ts

@ -1,6 +1,7 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { setCachedMarriageEntryPath } from "@/lib/entry-route-cache";
import { http } from "@/lib/http";
import type { MutationOptions } from "./options";
import { marriageQueryKeys } from "./query-keys";
@ -23,6 +24,7 @@ export function useStartMarriageMatchMutation(
...options,
mutationFn: startMarriageMatch,
onSuccess: async (data, variables, onMutateResult, context) => {
setCachedMarriageEntryPath("/finding-match");
Promise.all([
queryClient.invalidateQueries({
queryKey: marriageQueryKeys.profile(),

48
src/hooks/marriage/use-profile-main.test.ts

@ -0,0 +1,48 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getMarriageProfile } from "./use-profile-main";
const mocks = vi.hoisted(() => ({
get: vi.fn(),
setCachedEntryPath: vi.fn(),
}));
vi.mock("@/lib/http", () => ({
http: { get: mocks.get },
}));
vi.mock("@/lib/entry-route-cache", () => ({
setCachedMarriageEntryPath: mocks.setCachedEntryPath,
}));
describe("getMarriageProfile", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("persists the resolved route for a completed profile", async () => {
const profile = {
status: "pending_info",
gender: "female",
is_registering_for_self: true,
};
mocks.get.mockResolvedValue({ data: profile });
await expect(getMarriageProfile()).resolves.toBe(profile);
expect(mocks.setCachedEntryPath).toHaveBeenCalledWith("/questions-list");
});
it("clears the route for an incomplete onboarding profile", async () => {
mocks.get.mockResolvedValue({
data: {
status: "pending_onboarding",
gender: null,
is_registering_for_self: null,
},
});
await getMarriageProfile();
expect(mocks.setCachedEntryPath).toHaveBeenCalledWith(null);
});
});

9
src/hooks/marriage/use-profile-main.ts

@ -1,6 +1,11 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { setCachedMarriageEntryPath } from "@/lib/entry-route-cache";
import {
getSubmitPath,
hasCompletedMarriageProfileBasics,
} from "@/lib/get-submit-path";
import { http } from "@/lib/http";
import type { QueryOptions } from "./options";
import { marriageQueryKeys } from "./query-keys";
@ -11,6 +16,10 @@ export async function getMarriageProfile() {
"/api/marriage/profile/main/",
);
setCachedMarriageEntryPath(
hasCompletedMarriageProfileBasics(data) ? getSubmitPath(data) : null,
);
return data;
}

39
src/lib/auth-bridge.test.ts

@ -0,0 +1,39 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
getClientCookie: vi.fn(),
setCachedEntryPath: vi.fn(),
}));
vi.mock("./cookies", () => ({
getClientCookie: mocks.getClientCookie,
}));
vi.mock("./entry-route-cache", () => ({
setCachedMarriageEntryPath: mocks.setCachedEntryPath,
}));
describe("authBridge", () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
mocks.getClientCookie.mockReturnValue(null);
window.HABIB_TOKEN = undefined;
window.addFlutterResponseListener = vi.fn(() => vi.fn());
window.sessionStorage.clear();
});
it("treats a missing token as anonymous and clears the entry cache", async () => {
const { authBridge } = await import("./auth-bridge");
expect(authBridge.isAuthenticated()).toBe(false);
expect(mocks.setCachedEntryPath).toHaveBeenCalledWith(null);
});
it("treats an empty token as anonymous", async () => {
window.HABIB_TOKEN = "";
const { authBridge } = await import("./auth-bridge");
expect(authBridge.isAuthenticated()).toBe(false);
});
});

26
src/lib/auth-bridge.ts

@ -1,8 +1,10 @@
import { getClientCookie } from "./cookies";
import { setCachedMarriageEntryPath } from "./entry-route-cache";
const TOKEN_COOKIE_NAME = "HABIB_TOKEN";
const COINS_COOKIE_NAME = "HABIB_COINS";
const REDIRECT_SESSION_KEY = "redirect";
export const HABIB_AUTH_TOKEN_CHANGED_EVENT = "habib:auth-token-changed";
function postFlutterMessage(payload: Record<string, unknown>) {
if (typeof window === "undefined") {
@ -51,19 +53,13 @@ class AuthBridge {
if (token === "NO_TOKEN") {
this.token = "NO_TOKEN";
setCachedMarriageEntryPath(null);
return false;
}
if (!token || token.trim() === "") {
const isDevelopment = process.env.NODE_ENV !== "production";
if (isDevelopment) {
this.token =
process.env.NEXT_PUBLIC_DEFAULT_TOKEN ??
"f3a7543b44ef0a713d1ee0d4f7866b3825cf1308";
this.coins = coinsValue ?? Number(coinsCookie ?? 0);
return true;
}
this.token = null;
setCachedMarriageEntryPath(null);
return false;
}
@ -190,6 +186,12 @@ class AuthBridge {
this.setupFlutterResponseListener();
window.addEventListener(HABIB_AUTH_TOKEN_CHANGED_EVENT, () => {
this.loginRequested = false;
const hasToken = this.syncFromStorage();
this.markReady(hasToken ? this.token : null);
});
if (this.syncFromStorage()) {
this.markReady(this.token);
return;
@ -259,14 +261,6 @@ class AuthBridge {
return effectiveCookie;
}
const isDevelopment = process.env.NODE_ENV !== "production";
if (isDevelopment) {
return (
process.env.NEXT_PUBLIC_DEFAULT_TOKEN ??
"f3a7543b44ef0a713d1ee0d4f7866b3825cf1308"
);
}
return null;
}

46
src/lib/entry-route-cache.test.ts

@ -0,0 +1,46 @@
import { afterEach, describe, expect, it } from "vitest";
import {
getAuthenticatedCachedEntryPath,
getCachedMarriageEntryPath,
MARRIAGE_ENTRY_PATH_COOKIE,
normalizeCachedMarriageEntryPath,
setCachedMarriageEntryPath,
} from "./entry-route-cache";
describe("entry route cache", () => {
afterEach(() => {
document.cookie = `${MARRIAGE_ENTRY_PATH_COOKIE}=; Path=/; Max-Age=0`;
});
it("accepts only known authenticated routes", () => {
expect(normalizeCachedMarriageEntryPath("/questions-list")).toBe(
"/questions-list",
);
expect(normalizeCachedMarriageEntryPath("%2Ffinding-match")).toBe(
"/finding-match",
);
expect(normalizeCachedMarriageEntryPath("https://example.com")).toBeNull();
expect(normalizeCachedMarriageEntryPath("//example.com")).toBeNull();
expect(normalizeCachedMarriageEntryPath("/intro")).toBeNull();
});
it("requires a real token before trusting the cached route", () => {
expect(
getAuthenticatedCachedEntryPath("token", "/questions-list"),
).toBe("/questions-list");
expect(
getAuthenticatedCachedEntryPath("NO_TOKEN", "/questions-list"),
).toBeNull();
expect(
getAuthenticatedCachedEntryPath("", "/questions-list"),
).toBeNull();
});
it("persists and clears a safe client entry route", () => {
setCachedMarriageEntryPath("/questions-list");
expect(getCachedMarriageEntryPath()).toBe("/questions-list");
setCachedMarriageEntryPath(null);
expect(getCachedMarriageEntryPath()).toBeNull();
});
});

65
src/lib/entry-route-cache.ts

@ -0,0 +1,65 @@
import {
deleteClientCookie,
getClientCookie,
setClientCookie,
} from "./cookies";
export const MARRIAGE_ENTRY_PATH_COOKIE = "HABIB_MARRIAGE_ENTRY_PATH";
const AUTHENTICATED_ENTRY_PATHS = new Set([
"/terms",
"/questions-list",
"/finding-match",
"/request-accepted",
"/new-match",
"/request-sent",
]);
export function isAuthenticatedToken(token: string | null | undefined) {
return Boolean(token && token !== "NO_TOKEN" && token.trim() !== "");
}
export function normalizeCachedMarriageEntryPath(
value: string | null | undefined,
) {
if (!value) {
return null;
}
let decodedValue: string;
try {
decodedValue = decodeURIComponent(value);
} catch {
return null;
}
return AUTHENTICATED_ENTRY_PATHS.has(decodedValue) ? decodedValue : null;
}
export function getAuthenticatedCachedEntryPath(
token: string | null | undefined,
cachedPath: string | null | undefined,
) {
if (!isAuthenticatedToken(token)) {
return null;
}
return normalizeCachedMarriageEntryPath(cachedPath);
}
export function getCachedMarriageEntryPath() {
return normalizeCachedMarriageEntryPath(
getClientCookie(MARRIAGE_ENTRY_PATH_COOKIE),
);
}
export function setCachedMarriageEntryPath(path: string | null) {
const safePath = normalizeCachedMarriageEntryPath(path);
if (!safePath) {
deleteClientCookie(MARRIAGE_ENTRY_PATH_COOKIE);
return;
}
setClientCookie(MARRIAGE_ENTRY_PATH_COOKIE, safePath);
}

20
src/lib/get-submit-path.test.ts

@ -20,15 +20,24 @@ describe('getSubmitPath', () => {
vi.mocked(matchStartGrace.isWithinMatchStartGrace).mockReturnValue(false);
});
const baseProfile: MarriageProfileResponse = {
const baseProfile = {
status: 'pending_onboarding',
id: 1,
gender: 'male',
is_registering_for_self: true,
};
} as unknown as MarriageProfileResponse;
it('pending_onboarding with completed profile basics returns questions path', () => {
expect(getSubmitPath({ ...baseProfile, status: 'pending_onboarding' })).toBe('/questions-list');
});
it('pending_onboarding returns /terms', () => {
expect(getSubmitPath({ ...baseProfile, status: 'pending_onboarding' })).toBe('/terms');
it('pending_onboarding with incomplete profile basics returns /terms', () => {
expect(getSubmitPath({
...baseProfile,
status: 'pending_onboarding',
gender: null,
is_registering_for_self: null,
})).toBe('/terms');
});
it('waiting returns /finding-match', () => {
@ -37,9 +46,6 @@ describe('getSubmitPath', () => {
it('pending_info returns questions path', () => {
expect(getSubmitPath({ ...baseProfile, status: 'pending_info' })).toBe('/questions-list');
vi.mocked(firstEntryHelper.isFirstEntryCompleted).mockReturnValue(false);
expect(getSubmitPath({ ...baseProfile, status: 'pending_info' })).toBe('/questions-list/personal_info');
});
it('matched returns /request-accepted', () => {

19
src/lib/get-submit-path.ts

@ -1,10 +1,19 @@
import type { MarriageProfileResponse } from "@/hooks/marriage/types";
import { isFirstEntryCompleted } from "./first-entry-helper";
import {
clearLegacyMatchSubmittedFlag,
isWithinMatchStartGrace,
} from "./match-start-grace";
export function hasCompletedMarriageProfileBasics(
profile: MarriageProfileResponse | undefined,
) {
return (
profile?.gender !== null &&
profile?.gender !== undefined &&
typeof profile.is_registering_for_self === "boolean"
);
}
export function getSubmitPath(
profile: MarriageProfileResponse | undefined,
): string {
@ -52,7 +61,9 @@ export function getSubmitPath(
}
if (profile.status === "pending_onboarding") {
return "/terms";
return hasCompletedMarriageProfileBasics(profile)
? "/questions-list"
: "/terms";
}
if (profile.status === "waiting") {
@ -75,10 +86,6 @@ export function getSubmitPath(
return "/finding-match";
}
if (!isFirstEntryCompleted()) {
return "/questions-list/personal_info";
}
return "/questions-list";
}

14
src/lib/utils.ts

@ -0,0 +1,14 @@
export function cn(...inputs: (string | undefined | null | false | Record<string, boolean>)[]): string {
const classes: string[] = [];
for (const input of inputs) {
if (!input) continue;
if (typeof input === "string") {
classes.push(input);
} else if (typeof input === "object") {
for (const [key, val] of Object.entries(input)) {
if (val) classes.push(key);
}
}
}
return classes.join(" ");
}
Loading…
Cancel
Save