Browse Source

refactor: replace legacy localStorage submission flag with sessionStorage-based grace period and remove hardcoded dev authentication tokens

front-test-2
mortezaei 4 weeks ago
parent
commit
2dd3f9a3b2
  1. 2
      Dockerfile
  2. 10
      next.config.ts
  3. 10
      src/app/layout.tsx
  4. 36
      src/app/questions-list/page.tsx
  5. 35
      src/lib/get-submit-path.ts
  6. 9
      src/lib/http.ts
  7. 75
      src/lib/match-start-grace.ts
  8. 3
      src/translations/locales/en.json
  9. 3
      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

10
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",
}, },
], ],
}, },

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

36
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";
@ -36,16 +40,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 +115,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 +236,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}

35
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,23 +46,33 @@ 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" && !isMatchSubmitted) {
return "/questions-list";
// Checked before "waiting" so a matched profile is never shadowed.
if (profile.status === "matched") {
return "/candidate-contact";
}
if (profile.status === "in_case") {
return "/finding-match";
} }
if (profile.status === "waiting" || isMatchSubmitted) {
if (profile.status === "waiting") {
return "/finding-match"; return "/finding-match";
} }
if (profile.status === "matched") {
return "/candidate-contact";
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";

9
src/lib/http.ts

@ -64,7 +64,7 @@ export const http = axios.create({
withCredentials: true, withCredentials: true,
}); });
http.interceptors.request.use((config) => {
http.interceptors.request.use(async (config) => {
if (shouldUseProxy() && config.url && !isAbsoluteUrl(config.url)) { if (shouldUseProxy() && config.url && !isAbsoluteUrl(config.url)) {
config.params = withProxyPathParam(config.params, config.url); config.params = withProxyPathParam(config.params, config.url);
config.url = ""; config.url = "";
@ -84,11 +84,12 @@ http.interceptors.request.use((config) => {
config.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"; config.headers["Cache-Control"] = "no-cache, no-store, must-revalidate";
config.headers["Pragma"] = "no-cache"; config.headers["Pragma"] = "no-cache";
// Wait for a pending Flutter token injection rather than firing the request
// unauthenticated (or, as before, with a hardcoded test token).
const token = const token =
authBridge.getToken() ??
(await authBridge.waitForToken()) ??
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.
}
}

3
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",

3
src/translations/locales/fa.json

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

Loading…
Cancel
Save