reels
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "modstagram-next",
|
||||
"version": "4.2.0",
|
||||
"version": "4.4.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack --port 3004",
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import AuthHead from "@/components/auth/AuthHead";
|
||||
import AuthPageLayout, {
|
||||
AuthFormFooter,
|
||||
AuthPageContent,
|
||||
} from "@/components/auth/AuthPageLayout";
|
||||
import React, { useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useRouter } from "next/navigation";
|
||||
import AuthNextButton from "@/components/auth/AuthNextButton";
|
||||
@@ -14,6 +14,7 @@ import toast from "react-hot-toast";
|
||||
import { getSafeRedirectPath } from "@/lib/auth/postLogin";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
|
||||
import { IProfileData } from "@/types/types";
|
||||
|
||||
function RegisterCompletePage() {
|
||||
@@ -21,6 +22,16 @@ function RegisterCompletePage() {
|
||||
const router = useRouter();
|
||||
const { request, loading } = useAxios();
|
||||
const [action, setAction] = useState<"app" | "continue" | null>(null);
|
||||
const [user, setUser] = useState<IProfileData | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
request<{ user: IProfileData }>("GET", "/profile")
|
||||
.then((res) => setUser(res?.user ?? null))
|
||||
.catch(() => {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const name = [user?.first_name, user?.last_name].filter(Boolean).join(" ");
|
||||
|
||||
// مطابق درخواست: عکس پروفایل حالا زودتر، توی خودِ صفحهی register/avatar گرفته
|
||||
// میشه (قبل از این صفحه) — پس دیگه لازم نیست ویزارد تایید هویت با verify/avatar
|
||||
@@ -49,7 +60,10 @@ function RegisterCompletePage() {
|
||||
if (next === "continue") {
|
||||
await goToVerifyWizard();
|
||||
} else {
|
||||
router.push(getSafeRedirectPath());
|
||||
// navigation سخت — همون دلیلِ Logout.tsx: router.push ممکنه نسخهی
|
||||
// کششدهی فید رو از زمانی که هنوز مهمان بودیم (قبل از تکمیلِ ثبتنام،
|
||||
// ریدایرکتشده به ورود) دوباره نشون بده، بدون اجرای دوبارهی میدلور
|
||||
window.location.href = getSafeRedirectPath();
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error(
|
||||
@@ -65,32 +79,53 @@ function RegisterCompletePage() {
|
||||
<AuthPageLayout>
|
||||
<div className="flex w-full flex-1 flex-col items-center">
|
||||
<AuthPageContent>
|
||||
<AuthHead title={t("auth.registerCompleteTitle")} />
|
||||
<p className="text-sm text-gray-500 text-center max-w-sm mt-2">
|
||||
<div className="flex flex-col items-center">
|
||||
<Image
|
||||
src={
|
||||
user?.profile_image
|
||||
? IMAGE_BASE_URL + user.profile_image
|
||||
: "/images/fake-avatar.png"
|
||||
}
|
||||
alt=""
|
||||
width={72}
|
||||
height={72}
|
||||
unoptimized
|
||||
className="mb-2 h-[72px] w-[72px] rounded-full object-cover bg-gray-100"
|
||||
/>
|
||||
{name ? (
|
||||
<span className="text-base font-bold text-foreground">
|
||||
{name}
|
||||
</span>
|
||||
) : null}
|
||||
{user?.user_name ? (
|
||||
<span className="text-sm text-gray-500">{user.user_name}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 text-center max-w-sm mt-6">
|
||||
{t("auth.registerCompleteDesc")}
|
||||
</p>
|
||||
</AuthPageContent>
|
||||
|
||||
<AuthFormFooter>
|
||||
<div className="flex flex-col sm:flex-row gap-3 w-full max-w-sm justify-center items-center">
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={() => finishRegistration("continue")}
|
||||
className="!border-[#16a34a] !text-[#16a34a] w-full sm:w-auto"
|
||||
disabled={loading}
|
||||
loading={loading && action === "continue"}
|
||||
>
|
||||
{t("auth.continueRegistration")}
|
||||
</AuthNextButton>
|
||||
<div className="flex flex-row gap-3 w-full max-w-sm justify-center items-center">
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={() => finishRegistration("app")}
|
||||
className="!border-[#FF0000] !text-[#FF0000] w-full sm:w-auto"
|
||||
className="!border-[#16a34a] !text-[#16a34a] flex-1"
|
||||
disabled={loading}
|
||||
loading={loading && action === "app"}
|
||||
>
|
||||
{t("auth.enterApp")}
|
||||
</AuthNextButton>
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={() => finishRegistration("continue")}
|
||||
className="!border-[#FF0000] !text-[#FF0000] flex-1"
|
||||
disabled={loading}
|
||||
loading={loading && action === "continue"}
|
||||
>
|
||||
{t("auth.continueRegistration")}
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
</AuthFormFooter>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Suspense, cache } from "react";
|
||||
import { cookies } from "next/headers";
|
||||
import ProjectDetailClient from "./ProjectDetailClient";
|
||||
import PageLoader from "@/components/ui/PageLoader";
|
||||
import GuestAuthPromptModal from "@/components/auth/GuestAuthPromptModal";
|
||||
import { generatePageMetadata } from "@/utils/generatePageMetadata";
|
||||
import { fetchProject } from "@/api/fetchProject";
|
||||
import {
|
||||
@@ -67,6 +68,7 @@ export default async function ProjectDetailPage({
|
||||
<Suspense fallback={<PageLoader className="min-h-[50vh]" />}>
|
||||
<ProjectDetailClient id={id} />
|
||||
</Suspense>
|
||||
<GuestAuthPromptModal />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BASE_URL, IMAGE_BASE_URL } from "@/components/main/BaseUrl";
|
||||
import BillboardDetailContent from "@/components/billboards/BillboardDetailContent";
|
||||
import GuestAuthPromptModal from "@/components/auth/GuestAuthPromptModal";
|
||||
import { IAdvertising, IRate } from "@/types/types";
|
||||
import { cookies } from "next/headers";
|
||||
import { cache } from "react";
|
||||
@@ -135,6 +136,7 @@ async function BillboardPage({ params }: IBillboardProps) {
|
||||
}}
|
||||
/>
|
||||
<BillboardDetailContent billboard={billboard} rate={rate} />
|
||||
<GuestAuthPromptModal />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
buildPostSeoTitle,
|
||||
} from "@/lib/postSlug";
|
||||
import ExploreReelClient from "./ExploreReelClient";
|
||||
import GuestAuthPromptModal from "@/components/auth/GuestAuthPromptModal";
|
||||
import { getServerLanguage } from "@/lib/i18n/server";
|
||||
import { translateCommon } from "@/lib/i18n/translate";
|
||||
|
||||
@@ -44,5 +45,10 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
|
||||
export default async function ExploreReelPage({ params }: Props) {
|
||||
const { id } = await params;
|
||||
return <ExploreReelClient id={id} />;
|
||||
return (
|
||||
<>
|
||||
<ExploreReelClient id={id} />
|
||||
<GuestAuthPromptModal />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -182,6 +182,14 @@
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
/* بدون این، Chrome روی گوشیهایی که «Force dark برای محتوای وب» روشنه (پیشفرض بعضی
|
||||
بیلدهای شیائومی/سامسونگ) فکر میکنه این سایت از تم تیره پشتیبانی نمیکنه و خودش
|
||||
رنگهای input/text رو الگوریتمی معکوس میکنه — دقیقاً همون چیزی که باعث میشد نام/
|
||||
نامخانوادگی توی تم تیره ناخوانا بشه. اعلامِ صریحِ color-scheme این رفتار رو خاموش میکنه */
|
||||
color-scheme: light;
|
||||
}
|
||||
html.dark {
|
||||
color-scheme: dark;
|
||||
}
|
||||
body {
|
||||
direction: rtl;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Metadata } from "next";
|
||||
import { cache } from "react";
|
||||
import { fetchPostById } from "@/api/fetchPostById";
|
||||
import PostFeedView, { PostFeedSearchParams } from "@/components/posts/PostFeedView";
|
||||
import GuestAuthPromptModal from "@/components/auth/GuestAuthPromptModal";
|
||||
import { buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import { generatePageMetadata } from "@/utils/generatePageMetadata";
|
||||
import {
|
||||
@@ -125,6 +126,7 @@ export default async function PostPage({ params, searchParams }: Props) {
|
||||
showClose
|
||||
searchParams={feedSearchParams}
|
||||
/>
|
||||
<GuestAuthPromptModal />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,18 +14,24 @@ type GeoCheckResult = {
|
||||
isProxy: boolean;
|
||||
};
|
||||
|
||||
const DISMISSED_KEY = "vpn_warning_dismissed";
|
||||
|
||||
// عینِ VpnWarningGate.tsx اندروید: هر بار اپِ وب لود میشه (mount ریشهی layout.tsx)
|
||||
// چک میشه که کاربر با آیپیِ غیرایران یا VPN/پراکسی وصل شده یا نه؛ اگه بله، هم یه
|
||||
// toast (معادلِ نوتیفِ اندروید — چون وب پرمیشنِ Notification API لازم داره) هم یه
|
||||
// مودالِ غیراجباری نشون داده میشه
|
||||
// مودالِ غیراجباری با دکمهی تایید + تیکِ «دیگر نشان نده» کنارش نشون داده میشه. با
|
||||
// زدنِ اون تیک، دیگه هیچوقت (حتی خودِ چکِ شبکهش) دوباره اجرا نمیشه
|
||||
export default function VpnWarningGate() {
|
||||
const { t } = useTranslation("common");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [checked, setChecked] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
if (localStorage.getItem(DISMISSED_KEY) === "1") return;
|
||||
|
||||
const res = await apiClient.get<GeoCheckResult>(
|
||||
`${getApiBaseUrl()}/geo/check`
|
||||
);
|
||||
@@ -42,20 +48,38 @@ export default function VpnWarningGate() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const close = () => {
|
||||
setOpen(false);
|
||||
if (checked) {
|
||||
localStorage.setItem(DISMISSED_KEY, "1");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={open} onClose={() => setOpen(false)} height="auto" elevated>
|
||||
<Modal isOpen={open} onClose={close} height="auto" elevated>
|
||||
<div className="flex flex-col items-center gap-4 py-2 text-center">
|
||||
<h2 className="text-lg font-bold">{t("settings.vpnWarningTitle")}</h2>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{t("settings.vpnWarningMessage")}
|
||||
</p>
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
className="mt-2 h-10 w-40"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{t("settings.vpnWarningAction")}
|
||||
</RoundedButton>
|
||||
<div className="mt-2 flex w-full max-w-xs items-center justify-center gap-4">
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
className="h-10 flex-1"
|
||||
onClick={close}
|
||||
>
|
||||
{t("settings.vpnWarningAction")}
|
||||
</RoundedButton>
|
||||
<label className="flex shrink-0 cursor-pointer items-center gap-2 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="scale-110"
|
||||
checked={checked}
|
||||
onChange={(e) => setChecked(e.target.checked)}
|
||||
/>
|
||||
{t("settings.vpnWarningDontShowAgain")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
57
src/components/auth/GuestAuthPromptModal.tsx
Normal file
57
src/components/auth/GuestAuthPromptModal.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Modal from "@/components/elements/Modal";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import { isAuthenticated } from "@/lib/auth/session";
|
||||
|
||||
// روی صفحهی تکیِ پست/ریلز/بیلبورد/پروژه (قابلاشتراکگذاری و قابلدیدن برای گوگل
|
||||
// و مهمان، مثل اینستاگرام) — اگه کاربر لاگین نکرده، همین مودالِ پایینصفحه (قابل
|
||||
// بستن) رو نشون بده با دو دکمهی ورود/ثبتنام؛ خودِ محتوا و رفتارهایی مثل لایک/کامنت
|
||||
// دستنخورده میمونه (لایک از قبل با useRequireAuth کاربرِ مهمان رو به ورود میفرسته)
|
||||
export default function GuestAuthPromptModal() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated()) setOpen(true);
|
||||
}, []);
|
||||
|
||||
const goTo = (path: "/login" | "/register") => {
|
||||
const current =
|
||||
typeof window !== "undefined"
|
||||
? window.location.pathname + window.location.search
|
||||
: "/";
|
||||
setOpen(false);
|
||||
router.push(`${path}?redirect=${encodeURIComponent(current)}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={open} onClose={() => setOpen(false)} height="fit" elevated>
|
||||
<div className="mx-auto w-full max-w-sm">
|
||||
<p className="mb-4 text-center text-sm">{t("auth.guestPromptMessage")}</p>
|
||||
<div className="flex flex-row gap-3">
|
||||
<RoundedButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
className="h-10 flex-1"
|
||||
onClick={() => goTo("/login")}
|
||||
>
|
||||
{t("auth.guestPromptLogin")}
|
||||
</RoundedButton>
|
||||
<RoundedButton
|
||||
type="button"
|
||||
variant="default"
|
||||
className="h-10 flex-1"
|
||||
onClick={() => goTo("/register")}
|
||||
>
|
||||
{t("auth.guestPromptRegister")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { useEffect, useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import toast from "react-hot-toast";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { btnPrimary, btnDefault } from "@/lib/ui/buttonStyles";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -27,7 +26,6 @@ export default function AccountActionModals({
|
||||
}: AccountActionModalsProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
const router = useRouter();
|
||||
const [step, setStep] = useState<Step>("confirm");
|
||||
const [typed, setTyped] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -59,7 +57,8 @@ export default function AccountActionModals({
|
||||
await request("POST", "/account/delete", { user_name: typed.trim() });
|
||||
await clearAuthSession();
|
||||
toast.success(t("settings.accountActions.deleted"));
|
||||
router.push("/login");
|
||||
// navigation سخت — همون دلیلِ Logout.tsx، کَشِ کلاینتیِ روتر رو دور میزنه
|
||||
window.location.href = "/login";
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"use client";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useState } from "react";
|
||||
import Modal from "../../elements/Modal";
|
||||
import RoundedButton from "../../elements/RoundedButton";
|
||||
@@ -10,11 +9,12 @@ import { useTranslation } from "react-i18next";
|
||||
function Logout() {
|
||||
const { t } = useTranslation("common");
|
||||
const [showModal, setShowModal] = useState<boolean>(false);
|
||||
const router = useRouter();
|
||||
const logoutHandler = async () => {
|
||||
await clearAuthSession();
|
||||
router.push("/login");
|
||||
router.refresh();
|
||||
// router.push (soft navigation) میتونه کَشِ کلاینتیِ روتر (RSC payload قبلیِ
|
||||
// صفحهی اصلی، از وقتی لاگین بودیم) رو دوباره نشون بده بدون اینکه میدلور
|
||||
// دوباره اجرا بشه — navigation سخت، درخواست تازه به سرور میزنه
|
||||
window.location.href = "/login";
|
||||
};
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -215,8 +215,9 @@
|
||||
},
|
||||
"settings": {
|
||||
"vpnWarningTitle": "VPN usage detected",
|
||||
"vpnWarningMessage": "For a better experience on the Modstagram platform, please turn off your VPN.",
|
||||
"vpnWarningAction": "Got it",
|
||||
"vpnWarningMessage": "Turn off your VPN for a better experience with Modstagram",
|
||||
"vpnWarningAction": "Confirm",
|
||||
"vpnWarningDontShowAgain": "Don't show again",
|
||||
"title": "Settings",
|
||||
"metaTitle": "Settings | Modstagram",
|
||||
"metaDescription": "Account settings on Modstagram",
|
||||
@@ -1183,6 +1184,9 @@
|
||||
"savePasswordFailed": "Could not save password",
|
||||
"registerCompleteTitle": "Initial registration complete",
|
||||
"registerCompleteDesc": "You can enter the app or continue full registration (identity verification and profile).",
|
||||
"guestPromptMessage": "Log in or sign up to see more and use all of Modstagram's features",
|
||||
"guestPromptLogin": "Log in",
|
||||
"guestPromptRegister": "Sign up",
|
||||
"identityVerification": "Identity verification",
|
||||
"forIdentityVerification": "For identity verification",
|
||||
"nationalCodePlaceholder": "National ID",
|
||||
|
||||
@@ -215,8 +215,9 @@
|
||||
},
|
||||
"settings": {
|
||||
"vpnWarningTitle": "استفاده از VPN شناسایی شد",
|
||||
"vpnWarningMessage": "برای تجربه بهتر از پلتفرم مدستاگرام، لطفاً VPN خود را خاموش کنید.",
|
||||
"vpnWarningAction": "متوجه شدم",
|
||||
"vpnWarningMessage": "برای تجربه بهتر از پلتفرم مدستاگرام VPN خود را خاموش کنید",
|
||||
"vpnWarningAction": "تایید",
|
||||
"vpnWarningDontShowAgain": "دیگر نشان نده",
|
||||
"title": "تنظیمات",
|
||||
"metaTitle": "تنظیمات | مدستاگرام",
|
||||
"metaDescription": "تنظیمات حساب کاربری در مدستاگرام",
|
||||
@@ -1183,6 +1184,9 @@
|
||||
"savePasswordFailed": "ذخیره کلمه عبور ناموفق بود",
|
||||
"registerCompleteTitle": "ثبتنام اولیه تکمیل شد",
|
||||
"registerCompleteDesc": "میتوانید وارد برنامه شوید یا ثبتنام کامل (تایید هویت و پروفایل) را ادامه دهید.",
|
||||
"guestPromptMessage": "برای دیدن کامل و استفاده از امکانات مدستاگرام وارد شوید یا ثبتنام کنید",
|
||||
"guestPromptLogin": "ورود",
|
||||
"guestPromptRegister": "ثبتنام",
|
||||
"identityVerification": "احراز هویت",
|
||||
"forIdentityVerification": "جهت احراز هویت",
|
||||
"nationalCodePlaceholder": "کد ملی",
|
||||
|
||||
@@ -1,82 +1,127 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
buildLoginRedirect,
|
||||
requiresRegistrationToken,
|
||||
} from "@/lib/auth/registrationRoutes";
|
||||
import { LANGUAGE_COOKIE } from "@/lib/i18n/registry";
|
||||
|
||||
// فعلاً فقط en بهصورت prefix مسیر پشتیبانی میشود؛ fa همچنان بدون prefix
|
||||
// (روش سبک: بدون تغییر ساختار src/app، فقط rewrite داخلی + هدر زبان)
|
||||
const LOCALE_PREFIXES = ["en"] as const;
|
||||
type LocalePrefix = (typeof LOCALE_PREFIXES)[number];
|
||||
|
||||
function stripLocalePrefix(pathname: string): {
|
||||
locale: LocalePrefix | null;
|
||||
path: string;
|
||||
} {
|
||||
for (const locale of LOCALE_PREFIXES) {
|
||||
if (pathname === `/${locale}`) return { locale, path: "/" };
|
||||
if (pathname.startsWith(`/${locale}/`)) {
|
||||
return { locale, path: pathname.slice(locale.length + 1) };
|
||||
}
|
||||
}
|
||||
return { locale: null, path: pathname };
|
||||
}
|
||||
|
||||
export function middleware(req: NextRequest) {
|
||||
const isPrefetch =
|
||||
req.headers.get("next-router-prefetch") === "1" ||
|
||||
req.headers.get("purpose") === "prefetch";
|
||||
|
||||
const { locale, path: pathname } = stripLocalePrefix(req.nextUrl.pathname);
|
||||
const localePrefix = locale ? `/${locale}` : "";
|
||||
|
||||
// prefetch بدون کوکی نباید redirect به /login کش شود
|
||||
if (!isPrefetch) {
|
||||
const token = req.cookies.get("token")?.value?.trim();
|
||||
|
||||
// صفحاتی که ذاتاً نیازمند ورود هستند (حساب کاربری و ساخت محتوا)
|
||||
const protectedPaths = ["/settings", "/new-post", "/new-project"];
|
||||
const needsAuth = protectedPaths.some((p) => pathname.startsWith(p));
|
||||
|
||||
if (needsAuth && !token) {
|
||||
const loginUrl = new URL(`${localePrefix}/login`, req.url);
|
||||
loginUrl.searchParams.set("redirect", pathname);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
// مراحل میانی ثبتنام بدون توکن → ورود
|
||||
if (requiresRegistrationToken(pathname) && !token) {
|
||||
return NextResponse.redirect(
|
||||
new URL(`${localePrefix}${buildLoginRedirect(pathname)}`, req.url)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (locale) {
|
||||
const rewriteUrl = req.nextUrl.clone();
|
||||
rewriteUrl.pathname = pathname;
|
||||
|
||||
const requestHeaders = new Headers(req.headers);
|
||||
requestHeaders.set("x-app-language", locale);
|
||||
|
||||
const res = NextResponse.rewrite(rewriteUrl, {
|
||||
request: { headers: requestHeaders },
|
||||
});
|
||||
// برای اینکه ناوبریهای بعدی (بدون prefix) هم انگلیسی بمونن، مثل انتخاب
|
||||
// دستی زبان از سوییچر عمل میکنیم
|
||||
res.cookies.set(LANGUAGE_COOKIE, locale, {
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 24 * 365,
|
||||
sameSite: "lax",
|
||||
});
|
||||
return res;
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!api|_next/static|_next/image|favicon.ico|.*\\..*).*)"],
|
||||
};
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
buildLoginRedirect,
|
||||
requiresRegistrationToken,
|
||||
} from "@/lib/auth/registrationRoutes";
|
||||
import { LANGUAGE_COOKIE } from "@/lib/i18n/registry";
|
||||
|
||||
// فعلاً فقط en بهصورت prefix مسیر پشتیبانی میشود؛ fa همچنان بدون prefix
|
||||
// (روش سبک: بدون تغییر ساختار src/app، فقط rewrite داخلی + هدر زبان)
|
||||
const LOCALE_PREFIXES = ["en"] as const;
|
||||
type LocalePrefix = (typeof LOCALE_PREFIXES)[number];
|
||||
|
||||
function stripLocalePrefix(pathname: string): {
|
||||
locale: LocalePrefix | null;
|
||||
path: string;
|
||||
} {
|
||||
for (const locale of LOCALE_PREFIXES) {
|
||||
if (pathname === `/${locale}`) return { locale, path: "/" };
|
||||
if (pathname.startsWith(`/${locale}/`)) {
|
||||
return { locale, path: pathname.slice(locale.length + 1) };
|
||||
}
|
||||
}
|
||||
return { locale: null, path: pathname };
|
||||
}
|
||||
|
||||
// صفحاتی که کاربر مهمان (بدون توکن) همیشه باید بتونه ببینه: خودِ صفحات
|
||||
// ورود/ثبتنام/قانونی، و callback اکانتِ گوگل/اینستاگرام
|
||||
const PUBLIC_EXACT_PATHS = new Set([
|
||||
"/login",
|
||||
"/login-with-username",
|
||||
"/login-2fa",
|
||||
"/forget-password",
|
||||
"/forget-password-otp",
|
||||
"/change-passowrd",
|
||||
"/verify-otp",
|
||||
"/register",
|
||||
"/register-otp",
|
||||
"/about-us",
|
||||
"/privacy",
|
||||
"/instagram-data-deletion",
|
||||
]);
|
||||
|
||||
const PUBLIC_PREFIXES = ["/auth/", "/sitemaps/", "/storage/"];
|
||||
|
||||
// صفحهی تکیِ یک پست/ریلز/بیلبورد/پروژه که بهاشتراک گذاشته میشه — باید عین
|
||||
// اینستاگرام برای گوگل و کاربرِ مهمان قابلدیدن بمونه (SEO + پیشنمایشِ لینکِ
|
||||
// اشتراکگذاری)؛ خودِ مودالِ ورود/ثبتنام و قفلبودنِ اقدامهایی مثل لایک سمتِ
|
||||
// کلاینت مدیریت میشه، نه اینجا
|
||||
const RESERVED_FIRST_SEGMENT: Record<string, Set<string>> = {
|
||||
billboards: new Set(["new", "payment", "profile"]),
|
||||
projects: new Set(["new", "payment", "specialists"]),
|
||||
};
|
||||
|
||||
function isSharedDetailPath(pathname: string, root: "billboards" | "projects"): boolean {
|
||||
const match = pathname.match(new RegExp(`^/${root}/([^/]+)/([^/]+)$`));
|
||||
if (!match) return false;
|
||||
return !RESERVED_FIRST_SEGMENT[root].has(match[1]);
|
||||
}
|
||||
|
||||
function isPublicGuestPath(pathname: string): boolean {
|
||||
if (PUBLIC_EXACT_PATHS.has(pathname)) return true;
|
||||
if (PUBLIC_PREFIXES.some((p) => pathname.startsWith(p))) return true;
|
||||
if (/^\/posts\/[^/]+(\/.*)?$/.test(pathname)) return true;
|
||||
if (/^\/explore\/[^/]+$/.test(pathname)) return true;
|
||||
if (isSharedDetailPath(pathname, "billboards")) return true;
|
||||
if (isSharedDetailPath(pathname, "projects")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function middleware(req: NextRequest) {
|
||||
const isPrefetch =
|
||||
req.headers.get("next-router-prefetch") === "1" ||
|
||||
req.headers.get("purpose") === "prefetch";
|
||||
|
||||
const { locale, path: pathname } = stripLocalePrefix(req.nextUrl.pathname);
|
||||
const localePrefix = locale ? `/${locale}` : "";
|
||||
|
||||
// prefetch بدون کوکی نباید redirect به /login کش شود
|
||||
if (!isPrefetch) {
|
||||
const token = req.cookies.get("token")?.value?.trim();
|
||||
|
||||
if (!token) {
|
||||
// مراحل میانی ثبتنام: نیاز به توکنِ مرحلهایِ خودِ ثبتنام (redirect با پارامترِ
|
||||
// بازگشتِ دقیق به همون مرحله)
|
||||
if (requiresRegistrationToken(pathname)) {
|
||||
return NextResponse.redirect(
|
||||
new URL(`${localePrefix}${buildLoginRedirect(pathname)}`, req.url)
|
||||
);
|
||||
}
|
||||
|
||||
// کاربر مهمان مستقیم وارد خودِ اپ نمیشه — اول باید ورود/ثبتنام کنه؛ فقط
|
||||
// صفحاتِ عمومی (ورود/ثبتنام/قانونی) و صفحهی تکیِ اشتراکگذاریشده
|
||||
// (پست/ریلز/بیلبورد/پروژه) از این قاعده مستثنیاند
|
||||
if (!isPublicGuestPath(pathname)) {
|
||||
const loginUrl = new URL(`${localePrefix}/login`, req.url);
|
||||
loginUrl.searchParams.set("redirect", pathname);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (locale) {
|
||||
const rewriteUrl = req.nextUrl.clone();
|
||||
rewriteUrl.pathname = pathname;
|
||||
|
||||
const requestHeaders = new Headers(req.headers);
|
||||
requestHeaders.set("x-app-language", locale);
|
||||
|
||||
const res = NextResponse.rewrite(rewriteUrl, {
|
||||
request: { headers: requestHeaders },
|
||||
});
|
||||
// برای اینکه ناوبریهای بعدی (بدون prefix) هم انگلیسی بمونن، مثل انتخاب
|
||||
// دستی زبان از سوییچر عمل میکنیم
|
||||
res.cookies.set(LANGUAGE_COOKIE, locale, {
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 24 * 365,
|
||||
sameSite: "lax",
|
||||
});
|
||||
return res;
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!api|_next/static|_next/image|favicon.ico|.*\\..*).*)"],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user