Browse Source

feat: implement API proxy, add TokenSwitcher component for testing, and configure request caching headers

front-test-2
ghorbani 4 weeks ago
parent
commit
da77dc168d
  1. 21
      next.config.ts
  2. 12
      src/app/api/proxy/route.ts
  3. 24
      src/app/layout.tsx
  4. 27
      src/app/request-accepted/page.tsx
  5. 174
      src/components/ui/token-switcher.tsx
  6. 14
      src/lib/http.ts

21
next.config.ts

@ -40,7 +40,26 @@ const nextConfig: NextConfig = {
async headers() {
return [
{
source: "/:path*",
// Never cache API proxy responses – always fetch fresh from backend
source: "/api/:path*",
headers: [
{
key: "Cache-Control",
value: "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0",
},
{
key: "Pragma",
value: "no-cache",
},
{
key: "Expires",
value: "0",
},
],
},
{
// Static pages – moderate cache
source: "/:path((?!api/).*)",
headers: [
{
key: "Cache-Control",

12
src/app/api/proxy/route.ts

@ -305,10 +305,20 @@ async function proxyRequest(request: NextRequest) {
const responseHeaders = getResponseHeaders(upstreamResponse.headers);
// Force no caching on API proxy responses – always fetch fresh data
responseHeaders.set(
"Cache-Control",
"no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0",
);
responseHeaders.set("Pragma", "no-cache");
responseHeaders.set("Expires", "0");
return new Response(responseBody, {
status: upstreamResponse.status,
statusText: upstreamResponse.statusText,
headers: getResponseHeaders(upstreamResponse.headers),
headers: responseHeaders,
});
} catch (error) {
if (error instanceof ProxyError) {

24
src/app/layout.tsx

@ -1,9 +1,11 @@
import type { Metadata } from "next";
import type { Metadata, Viewport } from "next";
import { Amiri } from "next/font/google";
import localFont from "next/font/local";
import Script from "next/script";
import Providers from "./providers";
import "./globals.css";
import DevClickToComponent from "@/components/dev/dev-click-to-component";
import TokenSwitcher from "@/components/ui/token-switcher";
const faminela = localFont({
src: "../../public/fonts/Faminela/Faminela.otf",
@ -25,11 +27,12 @@ const amiri = Amiri({
export const metadata: Metadata = {
title: "Habib Marriage",
description: "Islamic Marriage Platform",
viewport: {
width: "device-width",
initialScale: 1,
maximumScale: 1,
},
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
themeColor: "#ffffff",
};
@ -43,8 +46,9 @@ export default function RootLayout({
<head>
<link rel="preconnect" href="https://habibapp.com" />
<link rel="dns-prefetch" href="https://habibapp.com" />
<script
<Script
id="flutter-and-cookies-setup"
strategy="beforeInteractive"
dangerouslySetInnerHTML={{
__html: `
if (typeof window !== 'undefined') {
@ -144,8 +148,9 @@ export default function RootLayout({
`,
}}
/>
<script
<Script
id="habib-app-ready"
strategy="afterInteractive"
dangerouslySetInnerHTML={{
__html: `
if (typeof window !== 'undefined' && window.HabibApp) {
@ -158,6 +163,9 @@ export default function RootLayout({
</head>
<body className={`${faminela.variable} ${amiri.variable}`}>
<Providers>
<div className="fixed top-3 left-3 z-[9999] pointer-events-auto">
<TokenSwitcher />
</div>
<div className="app-shell">{children}</div>
</Providers>
{process.env.NODE_ENV === "development" ? (

27
src/app/request-accepted/page.tsx

@ -215,7 +215,12 @@ export default function RequestAcceptedPage() {
return;
}
router.push(profileHref);
setIsSubscriptionSheetOpen(false);
if (caseId) {
await contactInfoQuery.refetch();
setIsContactInfoSheetOpen(true);
}
} catch (error) {
console.error("Habcoin payment request failed", error);
}
@ -331,25 +336,7 @@ export default function RequestAcceptedPage() {
<button
type="button"
onClick={() => {
if (isFemaleProfile) {
setIsCallResultSheetOpen(true);
return;
}
if (
caseStatus === "female_accepted" ||
caseStatus === "payment_pending"
) {
setIsCallResultSheetOpen(true);
return;
}
if (caseStatus === "payment_done") {
void handleSecondaryAction();
return;
}
setIsSubscriptionSheetOpen(true);
void handleSecondaryAction();
}}
className="max-w-[212px] appearance-none border-0 bg-transparent p-0 text-left"
>

174
src/components/ui/token-switcher.tsx

@ -0,0 +1,174 @@
"use client";
import { useEffect, useState } from "react";
import { MdOutlineSwitchAccount } from "react-icons/md";
import { setClientCookie, getClientCookie } from "@/lib/cookies";
export const FEMALE_TOKEN = "545f61bb3e061ccb9b19f84715eb1b1ed4740331";
export const MALE_TOKEN = "f3a7543b44ef0a713d1ee0d4f7866b3825cf1308";
const TOKEN_COOKIE_NAME = "HABIB_TOKEN";
type TokenSwitcherProps = {
variant?: "default" | "transparent";
className?: string;
};
export function TokenSwitcher({
variant = "default",
className,
}: TokenSwitcherProps) {
const [isOpen, setIsOpen] = useState(false);
const [currentToken, setCurrentToken] = useState<string>("");
useEffect(() => {
if (typeof window !== "undefined") {
const token =
(window as any).HABIB_TOKEN ??
getClientCookie(TOKEN_COOKIE_NAME) ??
getClientCookie("habib_token") ??
sessionStorage.getItem(TOKEN_COOKIE_NAME) ??
process.env.NEXT_PUBLIC_DEFAULT_TOKEN ??
MALE_TOKEN;
setCurrentToken(token);
}
}, []);
const isMale = currentToken === MALE_TOKEN;
const isFemale = currentToken === FEMALE_TOKEN;
const handleSelectToken = (newToken: string) => {
if (typeof window !== "undefined") {
// Clear local storage so answers and cached state from previous user are removed
try {
window.localStorage.clear();
} catch (e) {
console.error("Failed to clear localStorage", e);
}
setClientCookie(TOKEN_COOKIE_NAME, newToken);
setClientCookie("habib_token", newToken);
sessionStorage.setItem(TOKEN_COOKIE_NAME, newToken);
(window as any).HABIB_TOKEN = newToken;
setCurrentToken(newToken);
setIsOpen(false);
// Navigate to the beginning of the flow (intro page)
window.location.href = "/";
}
};
return (
<>
<button
type="button"
onClick={() => setIsOpen(true)}
aria-label="تغییر توکن کاربر (تست)"
title="تغییر توکن (آقا / خانم)"
className={[
"inline-flex items-center gap-1.5 rounded-[15px] px-3 py-2 text-xs font-bold transition-all active:scale-95 shadow-md border border-slate-200/80 dark:border-slate-700/80 backdrop-blur-md",
variant === "transparent"
? "bg-black/40 text-white hover:bg-black/50"
: "bg-white/95 text-slate-800 hover:bg-white dark:bg-slate-900/95 dark:text-slate-100",
className,
]
.filter(Boolean)
.join(" ")}
>
<MdOutlineSwitchAccount className="size-5 text-rose-500" />
<span>{isMale ? "آقا 👨" : isFemale ? "خانم 👩" : "توکن 🔑"}</span>
</button>
{isOpen && (
<div className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50 p-4 backdrop-blur-xs animate-in fade-in duration-200">
<div
className="w-full max-w-sm rounded-2xl bg-white p-5 shadow-xl dark:bg-slate-900 border border-slate-100 dark:border-slate-800"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between pb-3 border-b border-slate-100 dark:border-slate-800">
<h3 className="font-bold text-slate-900 dark:text-white text-base flex items-center gap-2">
<MdOutlineSwitchAccount className="text-rose-500 text-xl" />
انتخاب حساب کاربری (تست)
</h3>
<button
type="button"
onClick={() => setIsOpen(false)}
className="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 text-sm p-1"
>
</button>
</div>
<div className="mt-4 flex flex-col gap-3">
{/* Male option */}
<button
type="button"
onClick={() => handleSelectToken(MALE_TOKEN)}
className={[
"flex items-center justify-between p-3.5 rounded-xl border text-right transition-all dir-rtl",
isMale
? "border-rose-500 bg-rose-50/60 dark:bg-rose-950/30 text-rose-900 dark:text-rose-100 font-semibold shadow-xs"
: "border-slate-200 dark:border-slate-800 hover:border-slate-300 dark:hover:border-slate-700 bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-200",
].join(" ")}
>
<div className="flex items-center gap-3">
<span className="text-2xl">👨</span>
<div>
<div className="text-sm font-bold">حساب آقا</div>
<div className="text-[11px] font-mono text-slate-400 dark:text-slate-500 dir-ltr text-left">
f3a7543b...308
</div>
</div>
</div>
{isMale && (
<span className="text-xs px-2.5 py-1 rounded-full bg-rose-500 text-white font-medium">
فعال
</span>
)}
</button>
{/* Female option */}
<button
type="button"
onClick={() => handleSelectToken(FEMALE_TOKEN)}
className={[
"flex items-center justify-between p-3.5 rounded-xl border text-right transition-all dir-rtl",
isFemale
? "border-rose-500 bg-rose-50/60 dark:bg-rose-950/30 text-rose-900 dark:text-rose-100 font-semibold shadow-xs"
: "border-slate-200 dark:border-slate-800 hover:border-slate-300 dark:hover:border-slate-700 bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-200",
].join(" ")}
>
<div className="flex items-center gap-3">
<span className="text-2xl">👩</span>
<div>
<div className="text-sm font-bold">حساب خانم</div>
<div className="text-[11px] font-mono text-slate-400 dark:text-slate-500 dir-ltr text-left">
545f61bb...331
</div>
</div>
</div>
{isFemale && (
<span className="text-xs px-2.5 py-1 rounded-full bg-rose-500 text-white font-medium">
فعال
</span>
)}
</button>
</div>
<div className="mt-5 flex justify-end">
<button
type="button"
onClick={() => setIsOpen(false)}
className="w-full py-2.5 rounded-xl bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300 font-medium text-sm hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors"
>
انصراف
</button>
</div>
</div>
</div>
)}
</>
);
}
export default TokenSwitcher;

14
src/lib/http.ts

@ -70,6 +70,20 @@ http.interceptors.request.use((config) => {
config.url = "";
}
// Cache-busting: add timestamp to GET requests so browser never serves stale data
if (!config.method || config.method.toLowerCase() === "get") {
config.params = {
...(config.params && typeof config.params === "object"
? config.params
: {}),
_t: Date.now(),
};
}
// Tell browser not to cache API responses
config.headers["Cache-Control"] = "no-cache, no-store, must-revalidate";
config.headers["Pragma"] = "no-cache";
const token =
authBridge.getToken() ??
getClientCookie("HABIB_TOKEN") ??

Loading…
Cancel
Save