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. 5
      src/translations/locales/en.json
  9. 5
      src/translations/locales/fa.json

2
Dockerfile

@ -11,7 +11,9 @@ WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
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_SECURITY_KEY=$NEXT_PUBLIC_SECURITY_KEY
RUN npm run build
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: [
{
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"],
});
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",
@ -117,7 +125,7 @@ export default function RootLayout({
}
},
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,
} from "@/data/question-data";
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 { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { useMarriageSectionsQuery } from "@/hooks/marriage/use-sections";
@ -36,16 +40,13 @@ export default function QuestionsListPage() {
});
const startMatchMutation = useStartMarriageMatchMutation({
onSuccess: () => {
if (typeof window !== "undefined") {
localStorage.setItem("match_submitted", "true");
}
markMatchStarted();
router.push(localizePath("/finding-match", locale));
},
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);
@ -114,20 +115,11 @@ export default function QuestionsListPage() {
}, [questionListItems, sectionProgressBySlug]);
const handleStartMatch = () => {
if (typeof window !== "undefined") {
localStorage.setItem("match_submitted", "true");
}
if (!canStartMatch) {
router.push(localizePath("/finding-match", locale));
if (isStartMatchDisabled) {
return;
}
startMatchMutation.mutate(undefined, {
onSettled: () => {
router.push(localizePath("/finding-match", locale));
},
});
startMatchMutation.mutate();
};
return (
@ -244,6 +236,14 @@ export default function QuestionsListPage() {
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"
>
{startMatchMutation.isError ? (
<p
role="alert"
className="pb-2 text-center group-12 font-semibold text-[#D93025]"
>
{t.questions.startMatchFailed}
</p>
) : null}
<Button
aria-label={t.questions.findMatches}
disabled={isStartMatchDisabled}

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

@ -1,18 +1,19 @@
import type { MarriageProfileResponse } from "@/hooks/marriage/types";
import {
clearLegacyMatchSubmittedFlag,
isWithinMatchStartGrace,
} from "./match-start-grace";
export function getSubmitPath(profile: MarriageProfileResponse | undefined) {
clearLegacyMatchSubmittedFlag();
if (!profile) {
return "/intro";
}
const isMatchSubmitted =
typeof window !== "undefined" &&
localStorage.getItem("match_submitted") === "true";
const activeCase = profile.active_case;
const isInCase = profile.status === "in_case" || Boolean(activeCase);
if (isInCase && activeCase) {
if (activeCase) {
const caseStatus = activeCase.status;
const myAction = activeCase.my_action;
const isFemale = profile.gender === "female";
@ -45,23 +46,33 @@ export function getSubmitPath(profile: MarriageProfileResponse | undefined) {
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") {
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";
}
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";

9
src/lib/http.ts

@ -64,7 +64,7 @@ export const http = axios.create({
withCredentials: true,
});
http.interceptors.request.use((config) => {
http.interceptors.request.use(async (config) => {
if (shouldUseProxy() && config.url && !isAbsoluteUrl(config.url)) {
config.params = withProxyPathParam(config.params, 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["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 =
authBridge.getToken() ??
(await authBridge.waitForToken()) ??
getClientCookie("HABIB_TOKEN") ??
process.env.NEXT_PUBLIC_DEFAULT_TOKEN ??
"f3a7543b44ef0a713d1ee0d4f7866b3825cf1308";
getClientCookie("habib_token");
if (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.",
"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": {
"title": "New Match",
@ -145,4 +146,4 @@
"matchProfile": "Match Profile",
"profileLocked": "Profile is locked"
}
}
}

5
src/translations/locales/fa.json

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