ghorbani 4 weeks ago
parent
commit
a594ac5438
  1. 2
      Dockerfile
  2. 21
      next.config.ts
  3. 22
      src/app/layout.tsx
  4. 38
      src/app/questions-list/page.tsx
  5. 41
      src/hooks/use-close-service-on-back.ts
  6. 34
      src/lib/get-submit-path.ts
  7. 3
      src/lib/http.ts
  8. 75
      src/lib/match-start-grace.ts
  9. 5
      src/translations/locales/en.json
  10. 5
      src/translations/locales/fa.json

2
Dockerfile

@ -11,7 +11,9 @@ WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
ARG NEXT_PUBLIC_API_BASE_URL ARG NEXT_PUBLIC_API_BASE_URL
ARG NEXT_PUBLIC_SECURITY_KEY
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
ENV NEXT_PUBLIC_SECURITY_KEY=$NEXT_PUBLIC_SECURITY_KEY
RUN npm run build RUN npm run build
FROM base AS runner FROM base AS runner

21
next.config.ts

@ -58,12 +58,16 @@ const nextConfig: NextConfig = {
], ],
}, },
{ {
// Static pages – moderate cache
source: "/:path((?!api/).*)",
// Application HTML pages – no-store to prevent HTML/shell caching in WebView
source: "/:path((?!api/|_next/|fonts/|assets/).*)",
headers: [ headers: [
{ {
key: "Cache-Control", key: "Cache-Control",
value: "public, max-age=3600, stale-while-revalidate=86400",
value: "no-store, no-cache, must-revalidate, max-age=0",
},
{
key: "Pragma",
value: "no-cache",
}, },
], ],
}, },
@ -76,6 +80,17 @@ const nextConfig: NextConfig = {
}, },
], ],
}, },
{
// Images and icons are not content-hashed, so revalidate instead of
// pinning them for a year.
source: "/assets/:path*",
headers: [
{
key: "Cache-Control",
value: "public, max-age=86400, stale-while-revalidate=604800",
},
],
},
]; ];
}, },
}; };

22
src/app/layout.tsx

@ -24,6 +24,14 @@ const amiri = Amiri({
fallback: ["Arial", "sans-serif"], fallback: ["Arial", "sans-serif"],
}); });
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 = { export const metadata: Metadata = {
title: "Habib Marriage", title: "Habib Marriage",
description: "Islamic Marriage Platform", description: "Islamic Marriage Platform",
@ -117,7 +125,7 @@ export default function RootLayout({
} }
}, },
get: function() { get: function() {
return this._habib_token || readCookie(HABIB_TOKEN_COOKIE) || sessionStorage.getItem(HABIB_TOKEN_COOKIE) || '${process.env.NEXT_PUBLIC_DEFAULT_TOKEN || "f3a7543b44ef0a713d1ee0d4f7866b3825cf1308"}';
return this._habib_token || readCookie(HABIB_TOKEN_COOKIE) || sessionStorage.getItem(HABIB_TOKEN_COOKIE) || undefined;
} }
}); });
} }
@ -163,14 +171,14 @@ export default function RootLayout({
</head> </head>
<body className={`${faminela.variable} ${amiri.variable}`} suppressHydrationWarning> <body className={`${faminela.variable} ${amiri.variable}`} suppressHydrationWarning>
<Providers> <Providers>
<div className="fixed top-3 left-3 z-[9999] pointer-events-auto">
<TokenSwitcher />
</div>
{isDevelopment ? (
<div className="fixed top-3 left-3 z-[9999] pointer-events-auto">
<TokenSwitcher />
</div>
) : null}
<div className="app-shell">{children}</div> <div className="app-shell">{children}</div>
</Providers> </Providers>
{process.env.NODE_ENV === "development" ? (
<DevClickToComponent />
) : null}
{isDevelopment ? <DevClickToComponent /> : null}
</body> </body>
</html> </html>
); );

38
src/app/questions-list/page.tsx

@ -18,6 +18,10 @@ import {
type QuestionListItem, type QuestionListItem,
} from "@/data/question-data"; } from "@/data/question-data";
import { hasQuestionAnswerValue } from "@/components/questions/question-answer-storage"; import { hasQuestionAnswerValue } from "@/components/questions/question-answer-storage";
import {
clearMatchStartGrace,
markMatchStarted,
} from "@/lib/match-start-grace";
import { useStartMarriageMatchMutation } from "@/hooks/marriage/use-match-start"; import { useStartMarriageMatchMutation } from "@/hooks/marriage/use-match-start";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { useMarriageSectionsQuery } from "@/hooks/marriage/use-sections"; import { useMarriageSectionsQuery } from "@/hooks/marriage/use-sections";
@ -26,8 +30,10 @@ import { useI18n } from "@/translations/provider";
import { toFrontendSlug } from "@/data/section-slug-map"; import { toFrontendSlug } from "@/data/section-slug-map";
import SectionsRequest from "./sections-request"; import SectionsRequest from "./sections-request";
import { getStoredAge, getLocalSectionProgress } from "@/components/questions/progress-helper"; import { getStoredAge, getLocalSectionProgress } from "@/components/questions/progress-helper";
import { useCloseServiceOnBack } from "@/hooks/use-close-service-on-back";
export default function QuestionsListPage() { export default function QuestionsListPage() {
useCloseServiceOnBack();
const { dictionary: t, locale } = useI18n(); const { dictionary: t, locale } = useI18n();
const router = useRouter(); const router = useRouter();
const { data: profile } = useMarriageProfileQuery(); const { data: profile } = useMarriageProfileQuery();
@ -36,16 +42,13 @@ export default function QuestionsListPage() {
}); });
const startMatchMutation = useStartMarriageMatchMutation({ const startMatchMutation = useStartMarriageMatchMutation({
onSuccess: () => { onSuccess: () => {
if (typeof window !== "undefined") {
localStorage.setItem("match_submitted", "true");
}
markMatchStarted();
router.push(localizePath("/finding-match", locale)); router.push(localizePath("/finding-match", locale));
}, },
onError: () => { onError: () => {
if (typeof window !== "undefined") {
localStorage.setItem("match_submitted", "true");
}
router.push(localizePath("/finding-match", locale));
// Never pretend the request went through – the user stays here and can
// retry instead of being parked on the waiting screen forever.
clearMatchStartGrace();
}, },
}); });
const [isOptionalInfoSheetOpen, setIsOptionalInfoSheetOpen] = useState(false); const [isOptionalInfoSheetOpen, setIsOptionalInfoSheetOpen] = useState(false);
@ -114,20 +117,11 @@ export default function QuestionsListPage() {
}, [questionListItems, sectionProgressBySlug]); }, [questionListItems, sectionProgressBySlug]);
const handleStartMatch = () => { const handleStartMatch = () => {
if (typeof window !== "undefined") {
localStorage.setItem("match_submitted", "true");
}
if (!canStartMatch) {
router.push(localizePath("/finding-match", locale));
if (isStartMatchDisabled) {
return; return;
} }
startMatchMutation.mutate(undefined, {
onSettled: () => {
router.push(localizePath("/finding-match", locale));
},
});
startMatchMutation.mutate();
}; };
return ( return (
@ -244,6 +238,14 @@ export default function QuestionsListPage() {
style={{ paddingBottom: "calc(16px + var(--safe-bottom))" }} style={{ paddingBottom: "calc(16px + var(--safe-bottom))" }}
className="fixed inset-x-0 bottom-0 z-20 mx-auto w-full max-w-[375px] bg-[#F5F5F5]/95 px-[17px] pt-3 backdrop-blur-md" className="fixed inset-x-0 bottom-0 z-20 mx-auto w-full max-w-[375px] bg-[#F5F5F5]/95 px-[17px] pt-3 backdrop-blur-md"
> >
{startMatchMutation.isError ? (
<p
role="alert"
className="pb-2 text-center group-12 font-semibold text-[#D93025]"
>
{t.questions.startMatchFailed}
</p>
) : null}
<Button <Button
aria-label={t.questions.findMatches} aria-label={t.questions.findMatches}
disabled={isStartMatchDisabled} disabled={isStartMatchDisabled}

41
src/hooks/use-close-service-on-back.ts

@ -0,0 +1,41 @@
"use client";
import { useEffect } from "react";
/**
* Hook to intercept browser/hardware back button (popstate) and trigger
* Flutter's `close_service` action when running inside Flutter WebView.
*/
export function useCloseServiceOnBack(enabled: boolean = true) {
useEffect(() => {
if (!enabled || typeof window === "undefined") {
return;
}
const app = (window as any).HabibApp;
if (!app?.postMessage) {
return;
}
// Push a dummy history state so hardware back triggers popstate instead of navigating away
window.history.pushState({ closeOnBack: true }, "", window.location.href);
const handlePopState = () => {
if ((window as any).HabibApp?.postMessage) {
// Keep the state pinned in webview and notify Flutter to close the webview
window.history.pushState({ closeOnBack: true }, "", window.location.href);
(window as any).HabibApp.postMessage(
JSON.stringify({ action: "close_service" }),
);
}
};
window.addEventListener("popstate", handlePopState);
return () => {
window.removeEventListener("popstate", handlePopState);
};
}, [enabled]);
}
export default useCloseServiceOnBack;

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

@ -1,18 +1,19 @@
import type { MarriageProfileResponse } from "@/hooks/marriage/types"; import type { MarriageProfileResponse } from "@/hooks/marriage/types";
import {
clearLegacyMatchSubmittedFlag,
isWithinMatchStartGrace,
} from "./match-start-grace";
export function getSubmitPath(profile: MarriageProfileResponse | undefined) { export function getSubmitPath(profile: MarriageProfileResponse | undefined) {
clearLegacyMatchSubmittedFlag();
if (!profile) { if (!profile) {
return "/intro"; return "/intro";
} }
const isMatchSubmitted =
typeof window !== "undefined" &&
localStorage.getItem("match_submitted") === "true";
const activeCase = profile.active_case; const activeCase = profile.active_case;
const isInCase = profile.status === "in_case" || Boolean(activeCase);
if (isInCase && activeCase) {
if (activeCase) {
const caseStatus = activeCase.status; const caseStatus = activeCase.status;
const myAction = activeCase.my_action; const myAction = activeCase.my_action;
const isFemale = profile.gender === "female"; const isFemale = profile.gender === "female";
@ -45,14 +46,16 @@ export function getSubmitPath(profile: MarriageProfileResponse | undefined) {
return "/finding-match"; return "/finding-match";
} }
return "/new-match";
// male_rejected / female_rejected / dismissed – the case is over, the user
// goes back to waiting for the next match.
return "/finding-match";
} }
if (profile.status === "pending_onboarding") { if (profile.status === "pending_onboarding") {
return "/terms"; return "/terms";
} }
if (profile.status === "pending_info") {
if (profile.status === "pending_info" && !isMatchSubmitted) {
return "/questions-list"; return "/questions-list";
} }
@ -64,5 +67,20 @@ export function getSubmitPath(profile: MarriageProfileResponse | undefined) {
return "/candidate-contact"; return "/candidate-contact";
} }
if (profile.status === "in_case") {
return "/finding-match";
}
if (profile.status === "waiting") {
return "/finding-match";
}
if (profile.status === "pending_info") {
// The backend may still report pending_info for a moment right after the
// match request is sent, so honour a short grace window to avoid bouncing
// the user back into the questions list.
return isWithinMatchStartGrace() ? "/finding-match" : "/questions-list";
}
return "/terms"; return "/terms";
} }

3
src/lib/http.ts

@ -87,8 +87,7 @@ http.interceptors.request.use((config) => {
const token = const token =
authBridge.getToken() ?? authBridge.getToken() ??
getClientCookie("HABIB_TOKEN") ?? getClientCookie("HABIB_TOKEN") ??
process.env.NEXT_PUBLIC_DEFAULT_TOKEN ??
"f3a7543b44ef0a713d1ee0d4f7866b3825cf1308";
getClientCookie("habib_token");
if (token) { if (token) {
config.headers.Authorization = `Token ${token}`; config.headers.Authorization = `Token ${token}`;

75
src/lib/match-start-grace.ts

@ -0,0 +1,75 @@
// The backend can still report `pending_info` for a short moment after a match
// request is accepted. Without a grace window the user is bounced from
// /finding-match straight back to /questions-list.
//
// This lives in sessionStorage on purpose: it must not survive a WebView
// restart the way the old `match_submitted` localStorage flag did, because a
// sticky flag shadows the real profile status forever.
const STORAGE_KEY = "marriage:match-start-at";
const LEGACY_STORAGE_KEY = "match_submitted";
const GRACE_MS = 60_000;
export function markMatchStarted() {
if (typeof window === "undefined") {
return;
}
try {
window.sessionStorage.setItem(STORAGE_KEY, String(Date.now()));
} catch {
// Storage can throw in private mode or when the quota is exhausted.
}
}
export function isWithinMatchStartGrace() {
if (typeof window === "undefined") {
return false;
}
try {
const rawValue = window.sessionStorage.getItem(STORAGE_KEY);
if (!rawValue) {
return false;
}
const startedAt = Number(rawValue);
if (!Number.isFinite(startedAt) || Date.now() - startedAt > GRACE_MS) {
window.sessionStorage.removeItem(STORAGE_KEY);
return false;
}
return true;
} catch {
return false;
}
}
export function clearMatchStartGrace() {
if (typeof window === "undefined") {
return;
}
try {
window.sessionStorage.removeItem(STORAGE_KEY);
} catch {
// Ignore storage failures – the grace window expires on its own anyway.
}
}
// Older builds wrote a `match_submitted` flag to localStorage and never removed
// it, which pinned affected users to /finding-match permanently. Drop it on boot
// so devices already carrying the flag recover without a manual data clear.
export function clearLegacyMatchSubmittedFlag() {
if (typeof window === "undefined") {
return;
}
try {
window.localStorage.removeItem(LEGACY_STORAGE_KEY);
} catch {
// Ignore storage failures.
}
}

5
src/translations/locales/en.json

@ -59,7 +59,8 @@
"Taking this test is not mandatory, but it will help you better understand your priorities and find a more compatible spouse.", "Taking this test is not mandatory, but it will help you better understand your priorities and find a more compatible spouse.",
"By identifying your strongest needs, you can better communicate your expectations and build healthier relationships." "By identifying your strongest needs, you can better communicate your expectations and build healthier relationships."
] ]
}
},
"startMatchFailed": "Sending the match request failed. Please check your connection and try again."
}, },
"match": { "match": {
"title": "New Match", "title": "New Match",
@ -145,4 +146,4 @@
"matchProfile": "Match Profile", "matchProfile": "Match Profile",
"profileLocked": "Profile is locked" "profileLocked": "Profile is locked"
} }
}
}

5
src/translations/locales/fa.json

@ -51,7 +51,8 @@
"انجام این تست اجباری نیست، اما به شما کمک می‌کند اولویت‌های خود را بهتر بشناسید و همسر سازگارتری پیدا کنید.", "انجام این تست اجباری نیست، اما به شما کمک می‌کند اولویت‌های خود را بهتر بشناسید و همسر سازگارتری پیدا کنید.",
"با شناسایی قوی‌ترین نیازهای خود، می‌توانید انتظارات خود را بهتر بیان کنید و روابط سالم‌تری بسازید." "با شناسایی قوی‌ترین نیازهای خود، می‌توانید انتظارات خود را بهتر بیان کنید و روابط سالم‌تری بسازید."
] ]
}
},
"startMatchFailed": "ارسال درخواست مچ انجام نشد. اتصال خود را بررسی کنید و دوباره تلاش کنید."
}, },
"match": { "match": {
"title": "گزینه جدید", "title": "گزینه جدید",
@ -126,4 +127,4 @@
"matchProfile": "مشاهده پروفایل گزینه", "matchProfile": "مشاهده پروفایل گزینه",
"profileLocked": "پروفایل قفل است" "profileLocked": "پروفایل قفل است"
} }
}
}
Loading…
Cancel
Save