From d234c7f859033c3c4f1e936f498a1b2a2a6666d4 Mon Sep 17 00:00:00 2001 From: payacom <59262811+payacom@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:50:20 +0330 Subject: [PATCH] Initial commit --- public/images/icons/eye-open.png | Bin 0 -> 1061 bytes public/images/icons/eye-slash.png | Bin 0 -> 1132 bytes public/sitemap-0.xml | 14 +-- src/api/fetchBillboards.ts | 6 +- .../(auth)/(login)/change-passowrd/page.tsx | 8 +- .../(login)/forget-password-otp/page.tsx | 57 ++++++------ .../(auth)/(login)/forget-password/page.tsx | 3 +- .../(login)/login-with-username/page.tsx | 30 +++--- src/app/(auth)/(login)/login/page.tsx | 3 +- src/app/(auth)/(login)/verify-otp/page.tsx | 75 +++++++-------- .../(auth)/(register)/register-otp/page.tsx | 57 ++++++------ src/app/(auth)/(register)/register/page.tsx | 3 +- .../(register)/register/password/page.tsx | 8 +- .../(register)/register/username/page.tsx | 75 +++++++++------ src/app/api/auth/session/route.ts | 40 ++++++++ src/app/settings/edit/password/page.tsx | 8 +- src/app/settings/edit/username/page.tsx | 72 ++++++++++----- src/components/BackgroundPrefetch.tsx | 11 ++- src/components/auth/AuthInput.tsx | 13 ++- src/components/auth/AuthOtpInput.tsx | 82 +++++++++++++++++ src/components/auth/AuthOtpStatus.tsx | 87 ++++++++++++++++++ src/components/auth/AuthPasswordInput.tsx | 54 +++++++++++ src/components/auth/UsernameSuggestions.tsx | 48 ++++++++++ src/components/main/BaseUrl.ts | 11 ++- src/components/settings/settings/Logout.tsx | 16 +--- src/hooks/useAxios.tsx | 12 ++- src/lib/auth/otpTimer.ts | 23 +++++ src/lib/auth/session.ts | 70 ++++++++++++++ src/lib/validation/username.ts | 21 +++++ src/lib/validation/usernameSchema.ts | 20 ++++ src/middleware.ts | 25 +++-- 31 files changed, 733 insertions(+), 219 deletions(-) create mode 100644 public/images/icons/eye-open.png create mode 100644 public/images/icons/eye-slash.png create mode 100644 src/app/api/auth/session/route.ts create mode 100644 src/components/auth/AuthOtpInput.tsx create mode 100644 src/components/auth/AuthOtpStatus.tsx create mode 100644 src/components/auth/AuthPasswordInput.tsx create mode 100644 src/components/auth/UsernameSuggestions.tsx create mode 100644 src/lib/auth/otpTimer.ts create mode 100644 src/lib/auth/session.ts create mode 100644 src/lib/validation/username.ts create mode 100644 src/lib/validation/usernameSchema.ts diff --git a/public/images/icons/eye-open.png b/public/images/icons/eye-open.png new file mode 100644 index 0000000000000000000000000000000000000000..8254054524b82fe86a21f256293cb42e40d49be7 GIT binary patch literal 1061 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 literal 0 HcmV?d00001 diff --git a/public/sitemap-0.xml b/public/sitemap-0.xml index 35f8079..5296d57 100644 --- a/public/sitemap-0.xml +++ b/public/sitemap-0.xml @@ -1,12 +1,14 @@ -https://modstagram.com/sitemap.xml2026-07-02T19:42:50.918Zdaily0.7 -https://modstagram.com/robots.txt2026-07-02T19:42:50.919Zdaily0.7 -https://modstagram.com/explore2026-07-02T19:42:50.919Zdaily0.7 -https://modstagram.com/academy/payment/failed2026-07-02T19:42:50.919Zdaily0.7 -https://modstagram.com/academy/payment/success2026-07-02T19:42:50.919Zdaily0.7 -https://modstagram.com/about-us2026-07-02T19:42:50.919Zmonthly0.8 +https://modstagram.com/robots.txt2026-07-07T08:04:14.348Zdaily0.7 +https://modstagram.com/sitemap.xml2026-07-07T08:04:14.349Zdaily0.7 +https://modstagram.com/explore2026-07-07T08:04:14.350Zdaily0.7 +https://modstagram.com/academy/payment/failed2026-07-07T08:04:14.350Zdaily0.7 +https://modstagram.com/academy/payment/success2026-07-07T08:04:14.350Zdaily0.7 +https://modstagram.com/about-us2026-07-07T08:04:14.350Zmonthly0.8 https://modstagram.comdaily1 https://modstagram.com/projectsdaily0.9 https://modstagram.com/billboardsdaily0.9 +https://modstagram.com/billboards/6a4b9700a05fe9d26fa4312f/%D8%AE%D8%AF%D9%85%D8%A7%D8%AA%20%D8%B3%D8%A7%D9%84%D9%86%20%D8%B2%DB%8C%D8%A8%D8%A7%DB%8C%DB%8C%20%D8%AF%DA%A9%D8%AA%D8%B1%20%D8%B2%D8%A7%D8%B1%D8%A7daily0.8 +https://modstagram.com/billboards/6a0ef05d12cadb57c40ee4b2/%D8%B9%DA%A9%D8%A7%D8%B3%DB%8C%20%D8%A8%D8%A8%D8%B1%DB%8C%20%D8%A2%D8%B1%D8%AAdaily0.8 \ No newline at end of file diff --git a/src/api/fetchBillboards.ts b/src/api/fetchBillboards.ts index ed390c3..77f99b3 100644 --- a/src/api/fetchBillboards.ts +++ b/src/api/fetchBillboards.ts @@ -41,12 +41,16 @@ export async function fetchBillboards( Authorization: token ? `Bearer ${token}` : "", }, }); + } catch (error) { + console.error("[fetchBillboards] fetch failed:", apiUrl, error); + return { advertisings: [], totalItems: 0, totalPages: 0 }; } finally { clearTimeout(timeoutId); } if (!response.ok) { - throw new Error("Network response was not ok"); + console.error("[fetchBillboards] bad response:", response.status, apiUrl); + return { advertisings: [], totalItems: 0, totalPages: 0 }; } return response.json(); diff --git a/src/app/(auth)/(login)/change-passowrd/page.tsx b/src/app/(auth)/(login)/change-passowrd/page.tsx index f30209d..136f5f6 100644 --- a/src/app/(auth)/(login)/change-passowrd/page.tsx +++ b/src/app/(auth)/(login)/change-passowrd/page.tsx @@ -3,7 +3,7 @@ import AuthButton from "@/components/auth/AuthButton"; import AuthHead from "@/components/auth/AuthHead"; -import AuthInput from "@/components/auth/AuthInput"; +import AuthPasswordInput from "@/components/auth/AuthPasswordInput"; import Container from "@/components/elements/Container"; import AuthRules from "@/components/auth/AuthRules"; import Link from "next/link"; @@ -71,9 +71,8 @@ function ChangePassword() { onSubmit={formik.handleSubmit} className="w-full max-w-sm flex flex-col items-center" > - )} -
- کد 6 رقمی به شماره {mobile} ارسال شد + { + if (!mobile) return; + setResendLoading(true); + try { + await request("POST", "/login", { mobile }); + formik.setFieldValue("otp", ""); + } finally { + setResendLoading(false); + } + }} + />
- { - if (formik.values.otp !== otp) { - formik.setFieldValue("otp", otp); - if (otp.length === 6) { - setTimeout(() => { - formik.submitForm(); - }, 500); - } - } - }} - numInputs={6} - containerStyle="mt-3 dir-ltr flex gap-x-4 justify-center" - inputStyle={`text-center min-w-[30px] border bg-secondary-light dark:bg-secondary-dark rounded-2xl py-3 ${ - formik.touched.otp && formik.errors.otp - ? "border-red-500 dark:border-red-500" - : "border-border-primary-light dark:border-border-primary-dark" - } focus:outline-none`} - renderInput={(props) => } + onChange={(otp) => formik.setFieldValue("otp", otp)} + onComplete={() => setTimeout(() => formik.submitForm(), 400)} + error={Boolean(formik.touched.otp && formik.errors.otp)} + disabled={loading || otpExpired} /> {formik.touched.otp && formik.errors.otp && ( {formik.errors.otp} @@ -97,7 +96,7 @@ function ForgetPasswordOtp() { {loading ? "در حال ارسال..." : "تایید کد"} diff --git a/src/app/(auth)/(login)/forget-password/page.tsx b/src/app/(auth)/(login)/forget-password/page.tsx index e1fc678..ed6aba1 100644 --- a/src/app/(auth)/(login)/forget-password/page.tsx +++ b/src/app/(auth)/(login)/forget-password/page.tsx @@ -9,7 +9,7 @@ import React from "react"; import useAxios from "@/hooks/useAxios"; import { useFormik } from "formik"; import * as yup from "yup"; -import { useRouter } from "next/navigation"; +import { markOtpSent } from "@/lib/auth/otpTimer"; // Validation schema using Yup const schema = yup.object({ @@ -32,6 +32,7 @@ function ForgetPassword() { try { await request("POST", "/login", { mobile: values.mobile }); await localStorage.setItem("mobile", values.mobile); + markOtpSent(); router.push("/forget-password-otp"); } catch (err) { console.log(err); diff --git a/src/app/(auth)/(login)/login-with-username/page.tsx b/src/app/(auth)/(login)/login-with-username/page.tsx index 98128c8..fc28709 100644 --- a/src/app/(auth)/(login)/login-with-username/page.tsx +++ b/src/app/(auth)/(login)/login-with-username/page.tsx @@ -4,6 +4,7 @@ import AuthButton from "@/components/auth/AuthButton"; import AuthHead from "@/components/auth/AuthHead"; import AuthInput from "@/components/auth/AuthInput"; +import AuthPasswordInput from "@/components/auth/AuthPasswordInput"; import Container from "@/components/elements/Container"; import AuthRules from "@/components/auth/AuthRules"; import Link from "next/link"; @@ -13,7 +14,7 @@ import { useFormik } from "formik"; import * as yup from "yup"; import { useRouter } from "next/navigation"; import { IVerifyOtp } from "@/types/types"; -import Cookies from "js-cookie"; +import { setAuthSession } from "@/lib/auth/session"; // Validation schema using Yup const schema = yup.object().shape({ @@ -50,27 +51,19 @@ function LoginWithUsername() { })) as IVerifyOtp; switch (response?.page) { case "home": - document.cookie = `token=${response.token}; path=/; Secure`; - Cookies.set("token", response.token, { - secure: false, - path: "/", - expires: 365, + await setAuthSession(response.token, { + id: response.id, + user_type: response.user_type, + step: response.step, }); - - await localStorage.setItem("usertype", response.user_type); - await localStorage.setItem("id", response.id); - await localStorage.setItem("step", response.step); + router.refresh(); router.push("/"); break; case "auth-page": - Cookies.set("token", response.token, { - secure: false, - path: "/", - expires: 365, + await setAuthSession(response.token, { + id: response.id, + step: response.step, }); - // await localStorage.setItem("usertype", response.user_type); - await localStorage.setItem("id", response.id); - await localStorage.setItem("step", response.step); router.push("/auth"); break; default: @@ -108,9 +101,8 @@ function LoginWithUsername() { {formik.errors.username} )} -
- کد 6 رقمی به شماره {mobile} ارسال شد + { + if (!mobile) return; + setResendLoading(true); + try { + await request("POST", "/login", { mobile }); + formik.setFieldValue("otp", ""); + } finally { + setResendLoading(false); + } + }} + /> - { - formik.setFieldValue("otp", otp); - if (otp.length === 6) { - setTimeout(() => formik.submitForm(), 500); - } - }} - numInputs={6} - containerStyle="mt-3 dir-ltr flex gap-x-4 justify-center" - inputStyle={`text-center min-w-[30px] border bg-secondary-light dark:bg-secondary-dark rounded-2xl py-3 ${ - formik.touched.otp && formik.errors.otp - ? "border-red-500 dark:border-red-500" - : "border-border-primary-light " - } focus:outline-none`} - renderInput={(props) => ( - - )} + onChange={(otp) => formik.setFieldValue("otp", otp)} + onComplete={() => setTimeout(() => formik.submitForm(), 400)} + error={Boolean(formik.touched.otp && formik.errors.otp)} + disabled={loading || otpExpired} /> {formik.touched.otp && formik.errors.otp && ( @@ -138,7 +133,7 @@ function VerifyOtp() { {loading ? "در حال ارسال..." : "تایید کد"} diff --git a/src/app/(auth)/(register)/register-otp/page.tsx b/src/app/(auth)/(register)/register-otp/page.tsx index d9e21a6..80b4a19 100644 --- a/src/app/(auth)/(register)/register-otp/page.tsx +++ b/src/app/(auth)/(register)/register-otp/page.tsx @@ -8,13 +8,14 @@ import Container from "@/components/elements/Container"; import AuthRules from "@/components/auth/AuthRules"; import Link from "next/link"; import React, { useState } from "react"; -import OtpInput from "react-otp-input"; +import AuthOtpInput from "@/components/auth/AuthOtpInput"; +import AuthOtpStatus from "@/components/auth/AuthOtpStatus"; import useAxios from "@/hooks/useAxios"; import { useFormik } from "formik"; import * as yup from "yup"; import { IVerifyOtp } from "@/types/types"; import { useRouter } from "next/navigation"; -import Cookies from "js-cookie"; +import { setAuthSession } from "@/lib/auth/session"; // Validation schema using Yup const schema = yup.object({ @@ -27,6 +28,8 @@ const schema = yup.object({ function RegisterOtp() { const router = useRouter(); const [isModalOpen, setModalOpen] = useState(false); + const [otpExpired, setOtpExpired] = useState(false); + const [resendLoading, setResendLoading] = useState(false); const { request, loading } = useAxios(); const mobile = typeof window !== "undefined" ? localStorage.getItem("mobile") : null; @@ -49,12 +52,7 @@ function RegisterOtp() { otp: values.otp.trim(), })) as IVerifyOtp; await localStorage.setItem("otp", values.otp); - Cookies.set("token", response.token, { - secure: false, - path: "/", - expires: 365 - }); - await localStorage.setItem("step", response.step); + await setAuthSession(response.token, { step: response.step }); response.step === "user_name" ? router.push("/register/username") : response.step === "password" @@ -74,31 +72,32 @@ function RegisterOtp() {
- کد 6 رقمی به شماره {mobile} ارسال شد + { + if (!mobile) return; + setResendLoading(true); + try { + await request("POST", "/register", { mobile }); + formik.setFieldValue("otp", ""); + } finally { + setResendLoading(false); + } + }} + /> - { - if (formik.values.otp !== otp) { - formik.setFieldValue("otp", otp); - if (otp.length === 6) { - setTimeout(() => { - formik.submitForm(); - }, 500); - } - } - }} - numInputs={6} - containerStyle="mt-3 dir-ltr flex gap-x-4 justify-center" - inputStyle={`text-center min-w-[30px] border bg-secondary-light dark:bg-secondary-dark rounded-2xl py-3 ${ - formik.touched.otp && formik.errors.otp - ? "border-red-500 dark:border-red-500" - : "border-border-primary-light dark:border-border-primary-dark" - } focus:outline-none`} - renderInput={(props) => } + onChange={(otp) => formik.setFieldValue("otp", otp)} + onComplete={() => setTimeout(() => formik.submitForm(), 400)} + error={Boolean(formik.touched.otp && formik.errors.otp)} + disabled={loading || otpExpired} /> {formik.touched.otp && formik.errors.otp && ( {formik.errors.otp} @@ -106,7 +105,7 @@ function RegisterOtp() { {loading ? "در حال ارسال..." : "تایید کد"} diff --git a/src/app/(auth)/(register)/register/page.tsx b/src/app/(auth)/(register)/register/page.tsx index c685642..1037df3 100644 --- a/src/app/(auth)/(register)/register/page.tsx +++ b/src/app/(auth)/(register)/register/page.tsx @@ -9,7 +9,7 @@ import React, { useState } from "react"; import useAxios from "@/hooks/useAxios"; import { useFormik } from "formik"; import * as yup from "yup"; -import { useRouter } from "next/navigation"; +import { markOtpSent } from "@/lib/auth/otpTimer"; // Validation schema using Yup const schema = yup.object({ @@ -33,6 +33,7 @@ function Register() { try { await request("POST", "/register", { mobile: values.mobile }); await localStorage.setItem("mobile", values.mobile); + markOtpSent(); router.push("/register-otp"); } catch (err) { console.log(err); diff --git a/src/app/(auth)/(register)/register/password/page.tsx b/src/app/(auth)/(register)/register/password/page.tsx index 72e37af..864e1f4 100644 --- a/src/app/(auth)/(register)/register/password/page.tsx +++ b/src/app/(auth)/(register)/register/password/page.tsx @@ -2,7 +2,7 @@ "use client"; import AuthHead from "@/components/auth/AuthHead"; -import AuthInput from "@/components/auth/AuthInput"; +import AuthPasswordInput from "@/components/auth/AuthPasswordInput"; import Container from "@/components/elements/Container"; import React from "react"; import useAxios from "@/hooks/useAxios"; @@ -68,9 +68,8 @@ function PasswordPage() { onSubmit={formik.handleSubmit} className="w-full max-w-sm flex flex-col items-center" > - )} - (null); + const [suggestions, setSuggestions] = useState([]); const mobile = typeof window !== "undefined" ? localStorage.getItem("mobile") : null; @@ -32,27 +26,42 @@ function UsernamePage() { initialValues: { username: "", }, - validationSchema: schema, + validationSchema: usernameFormSchema, onSubmit: async (values) => { + setDuplicateError(null); + setSuggestions([]); + try { await request("POST", "/register/username", { mobile, - user_name: values.username, + user_name: values.username.trim().toLowerCase(), }) as IVerifyOtp; - localStorage.setItem("username", values.username); + localStorage.setItem("username", values.username.trim().toLowerCase()); router.push("/register/password"); } catch (err: any) { - console.log("Unhandled error:", err?.message); - if (err?.response?.data?.message) { - toast.error(err.response.data.message); - } else { - toast.error("خطایی رخ داد، دوباره تلاش کنید"); + const data = err?.response?.data; + if (data?.message) { + setDuplicateError(data.message); + setSuggestions(Array.isArray(data.suggestions) ? data.suggestions : []); } } }, }); + const handleUsernameChange = (value: string) => { + setDuplicateError(null); + setSuggestions([]); + formik.setFieldValue("username", sanitizeUsernameInput(value)); + }; + + const handleSuggestionSelect = (username: string) => { + formik.setFieldValue("username", username); + formik.setFieldTouched("username", true, false); + setDuplicateError(null); + setSuggestions([]); + }; + return (
@@ -64,23 +73,37 @@ function UsernamePage() { handleUsernameChange(e.target.value)} onBlur={formik.handleBlur} /> - {formik.touched.username && formik.errors.username && ( + + فقط حروف انگلیسی، اعداد و کاراکترهای . _ - (حداقل ۶ کاراکتر) + + {formik.touched.username && formik.errors.username && !duplicateError && ( {formik.errors.username} )} - + + {loading ? "در حال ارسال..." : "ثبت و ادامه"} diff --git a/src/app/api/auth/session/route.ts b/src/app/api/auth/session/route.ts new file mode 100644 index 0000000..595b12b --- /dev/null +++ b/src/app/api/auth/session/route.ts @@ -0,0 +1,40 @@ +import { cookies } from "next/headers"; +import { NextResponse } from "next/server"; + +const TOKEN_KEY = "token"; +const MAX_AGE = 365 * 24 * 60 * 60; + +function cookieOptions() { + return { + path: "/", + maxAge: MAX_AGE, + sameSite: "lax" as const, + secure: process.env.NODE_ENV === "production", + httpOnly: false, + }; +} + +export async function POST(request: Request) { + try { + const body = await request.json(); + const token = typeof body?.token === "string" ? body.token.trim() : ""; + + if (!token) { + return NextResponse.json({ error: "token required" }, { status: 400 }); + } + + const cookieStore = await cookies(); + cookieStore.set(TOKEN_KEY, token, cookieOptions()); + + return NextResponse.json({ ok: true }); + } catch { + return NextResponse.json({ error: "invalid request" }, { status: 400 }); + } +} + +export async function DELETE() { + const cookieStore = await cookies(); + cookieStore.delete({ name: TOKEN_KEY, path: "/" }); + + return NextResponse.json({ ok: true }); +} diff --git a/src/app/settings/edit/password/page.tsx b/src/app/settings/edit/password/page.tsx index 28e1411..fb919ca 100644 --- a/src/app/settings/edit/password/page.tsx +++ b/src/app/settings/edit/password/page.tsx @@ -2,7 +2,7 @@ "use client"; import AuthHead from "@/components/auth/AuthHead"; -import AuthInput from "@/components/auth/AuthInput"; +import AuthPasswordInput from "@/components/auth/AuthPasswordInput"; import Container from "@/components/elements/Container"; import React from "react"; import useAxios from "@/hooks/useAxios"; @@ -64,9 +64,8 @@ function PasswordPage() { onSubmit={formik.handleSubmit} className="w-full max-w-sm flex flex-col items-center" > - )} - (null); + const [suggestions, setSuggestions] = useState([]); const formik = useFormik({ initialValues: { username: user?.user_name || "", }, - validationSchema: schema, + validationSchema: usernameFormSchema, + enableReinitialize: true, onSubmit: async (values) => { + setDuplicateError(null); + setSuggestions([]); + try { await request("PATCH", "/register/username", { - user_name: values.username, + user_name: values.username.trim().toLowerCase(), }); - localStorage.setItem("username", values.username); + localStorage.setItem("username", values.username.trim().toLowerCase()); router.push("/settings/edit"); } catch (err: any) { - console.log("Unhandled error:", err?.message); + const data = err?.response?.data; + if (data?.message) { + setDuplicateError(data.message); + setSuggestions(Array.isArray(data.suggestions) ? data.suggestions : []); + } } }, }); - // آپدیت مقدار username در صورتی که user.user_name مقدار داشته باشد - useEffect(() => { - if (user?.user_name) { - formik.setValues({ username: user.user_name }); - } - }, [user?.user_name]); + const handleUsernameChange = (value: string) => { + setDuplicateError(null); + setSuggestions([]); + formik.setFieldValue("username", sanitizeUsernameInput(value)); + }; + + const handleSuggestionSelect = (username: string) => { + formik.setFieldValue("username", username); + formik.setFieldTouched("username", true, false); + setDuplicateError(null); + setSuggestions([]); + }; return ( @@ -57,22 +70,37 @@ function UsernamePage() { handleUsernameChange(e.target.value)} onBlur={formik.handleBlur} /> - {formik.touched.username && formik.errors.username && ( + + فقط حروف انگلیسی، اعداد و کاراکترهای . _ - (حداقل ۶ کاراکتر) + + {formik.touched.username && formik.errors.username && !duplicateError && ( {formik.errors.username} )} - + + {loading ? "در حال ارسال..." : "ویرایش"} diff --git a/src/components/BackgroundPrefetch.tsx b/src/components/BackgroundPrefetch.tsx index 59e0d12..cda2ed0 100644 --- a/src/components/BackgroundPrefetch.tsx +++ b/src/components/BackgroundPrefetch.tsx @@ -2,16 +2,23 @@ import { useEffect } from "react"; import { useRouter } from "next/navigation"; +import { isAuthenticated } from "@/lib/auth/session"; -const ROUTES = ["/academy", "/billboards", "/settings", "/explore"]; +const PUBLIC_ROUTES = ["/academy", "/billboards", "/explore"]; export default function BackgroundPrefetch() { const router = useRouter(); useEffect(() => { const t = window.setTimeout(() => { - ROUTES.forEach((route) => router.prefetch(route)); + PUBLIC_ROUTES.forEach((route) => router.prefetch(route)); + + // settings فقط وقتی لاگین است prefetch شود + if (isAuthenticated()) { + router.prefetch("/settings"); + } }, 2000); + return () => window.clearTimeout(t); }, [router]); diff --git a/src/components/auth/AuthInput.tsx b/src/components/auth/AuthInput.tsx index 9fc32f3..98f6055 100644 --- a/src/components/auth/AuthInput.tsx +++ b/src/components/auth/AuthInput.tsx @@ -1,9 +1,20 @@ import React, { ComponentProps } from "react"; +import { cn } from "@/lib/utils"; + type TInput = ComponentProps<"input"> & {}; + function AuthInput({ style, className, ...rest }: TInput) { return ( diff --git a/src/components/auth/AuthOtpInput.tsx b/src/components/auth/AuthOtpInput.tsx new file mode 100644 index 0000000..31f4296 --- /dev/null +++ b/src/components/auth/AuthOtpInput.tsx @@ -0,0 +1,82 @@ +"use client"; + +import OtpInput from "react-otp-input"; +import { motion } from "framer-motion"; +import { cn } from "@/lib/utils"; + +interface AuthOtpInputProps { + value: string; + onChange: (otp: string) => void; + onComplete?: (otp: string) => void; + numInputs?: number; + error?: boolean; + disabled?: boolean; + className?: string; +} + +const cellBase = + "w-11 h-14 sm:w-12 sm:h-14 min-w-[44px] text-center text-2xl font-bold tabular-nums rounded-2xl border-[2.5px] bg-white text-gray-900 shadow-md outline-none caret-[#387E65] selection:bg-sky-200 dark:bg-white dark:text-gray-900 dark:caret-[#387E65]"; + +export default function AuthOtpInput({ + value, + onChange, + onComplete, + numInputs = 6, + error = false, + disabled = false, + className, +}: AuthOtpInputProps) { + return ( +
+ { + onChange(otp); + if (otp.length === numInputs) { + onComplete?.(otp); + } + }} + numInputs={numInputs} + inputType="tel" + shouldAutoFocus + skipDefaultStyles + containerStyle={{ + display: "flex", + direction: "ltr", + gap: "0.5rem", + justifyContent: "center", + width: "100%", + }} + renderInput={(inputProps, index) => { + const filled = Boolean(inputProps.value); + + return ( + + ); + }} + /> +
+ ); +} diff --git a/src/components/auth/AuthOtpStatus.tsx b/src/components/auth/AuthOtpStatus.tsx new file mode 100644 index 0000000..3ab4a5b --- /dev/null +++ b/src/components/auth/AuthOtpStatus.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + formatOtpCountdown, + getOtpRemainingSeconds, + markOtpSent, + OTP_VALID_SECONDS, +} from "@/lib/auth/otpTimer"; +import { cn } from "@/lib/utils"; + +interface AuthOtpStatusProps { + mobile: string | null; + onResend: () => Promise; + resendLoading?: boolean; + className?: string; + onExpiredChange?: (expired: boolean) => void; +} + +export default function AuthOtpStatus({ + mobile, + onResend, + resendLoading = false, + className, + onExpiredChange, +}: AuthOtpStatusProps) { + const [remaining, setRemaining] = useState(OTP_VALID_SECONDS); + const expired = remaining <= 0; + + useEffect(() => { + setRemaining(getOtpRemainingSeconds()); + const interval = window.setInterval(() => { + setRemaining(getOtpRemainingSeconds()); + }, 1000); + return () => window.clearInterval(interval); + }, []); + + useEffect(() => { + onExpiredChange?.(expired); + }, [expired, onExpiredChange]); + + const handleResend = async () => { + await onResend(); + markOtpSent(); + setRemaining(OTP_VALID_SECONDS); + }; + + if (!mobile) return null; + + return ( +
+

+ کد ۶ رقمی به شماره{" "} + + {mobile} + {" "} + ارسال شد +

+ + {!expired ? ( +

+ اعتبار کد:{" "} + + {formatOtpCountdown(remaining)} + +

+ ) : ( +
+

+ کد منقضی شده است. لطفاً دوباره درخواست ارسال کد دهید. +

+ +
+ )} +
+ ); +} diff --git a/src/components/auth/AuthPasswordInput.tsx b/src/components/auth/AuthPasswordInput.tsx new file mode 100644 index 0000000..ee0abc2 --- /dev/null +++ b/src/components/auth/AuthPasswordInput.tsx @@ -0,0 +1,54 @@ +"use client"; + +import React, { ComponentProps, useState } from "react"; +import Image from "next/image"; +import { cn } from "@/lib/utils"; + +type AuthPasswordInputProps = Omit, "type"> & { + wrapperClassName?: string; +}; + +function AuthPasswordInput({ + style, + className, + wrapperClassName, + ...rest +}: AuthPasswordInputProps) { + const [visible, setVisible] = useState(false); + + return ( +
+ + +
+ ); +} + +export default AuthPasswordInput; diff --git a/src/components/auth/UsernameSuggestions.tsx b/src/components/auth/UsernameSuggestions.tsx new file mode 100644 index 0000000..9203b48 --- /dev/null +++ b/src/components/auth/UsernameSuggestions.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { cn } from "@/lib/utils"; + +interface UsernameSuggestionsProps { + message?: string; + suggestions?: string[]; + onSelect: (username: string) => void; + className?: string; +} + +export default function UsernameSuggestions({ + message, + suggestions = [], + onSelect, + className, +}: UsernameSuggestionsProps) { + if (!message && suggestions.length === 0) return null; + + return ( +
+ {message && ( +

{message}

+ )} + + {suggestions.length > 0 && ( +
+

+ پیشنهادهای در دسترس: +

+
+ {suggestions.map((item) => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/src/components/main/BaseUrl.ts b/src/components/main/BaseUrl.ts index e678633..f7a7bd8 100644 --- a/src/components/main/BaseUrl.ts +++ b/src/components/main/BaseUrl.ts @@ -1,10 +1,15 @@ +// لوکال: NEXT_PUBLIC_BASE_URL=http://localhost:3002/api/v1 // export const SOCKET_URL = "http://localhost:8000"; // export const BASE_URL = "http://localhost:8000/api/v1"; // export const IMAGE_BASE_URL = "http://localhost:8000/storage"; -export const SOCKET_URL = "https://api.modstagram.com"; -export const BASE_URL = "https://api.modstagram.com/api/v1"; -export const IMAGE_BASE_URL = "https://api.modstagram.com/storage"; +/** app.modstagram.ir — SSL معتبر برای fetch سمت سرور Next.js */ +export const SOCKET_URL = + process.env.NEXT_PUBLIC_SOCKET_URL ?? "https://app.modstagram.ir"; +export const BASE_URL = + process.env.NEXT_PUBLIC_BASE_URL ?? "https://app.modstagram.ir/api/v1"; +export const IMAGE_BASE_URL = + process.env.NEXT_PUBLIC_IMAGE_BASE_URL ?? "https://app.modstagram.ir/storage"; /** ساخت URL کامل فایل storage — مسیرهای DB را یکسان می‌کند */ export function buildStorageUrl(path: string | null | undefined): string { diff --git a/src/components/settings/settings/Logout.tsx b/src/components/settings/settings/Logout.tsx index 0f3fefe..d6dfec1 100644 --- a/src/components/settings/settings/Logout.tsx +++ b/src/components/settings/settings/Logout.tsx @@ -1,26 +1,18 @@ "use client"; -import Cookies from "js-cookie"; import Image from "next/image"; import { useRouter } from "next/navigation"; import React, { useState } from "react"; import Modal from "../../elements/Modal"; import RoundedButton from "../../elements/RoundedButton"; +import { clearAuthSession } from "@/lib/auth/session"; function Logout() { const [showModal, setShowModal] = useState(false); const router = useRouter(); - const logoutHandler = () => { - localStorage.removeItem("id"); - localStorage.removeItem("mobile"); - localStorage.removeItem("otp"); - localStorage.removeItem("token"); - localStorage.removeItem("usertype"); - localStorage.removeItem("first_name"); - localStorage.removeItem("last_name"); - localStorage.removeItem("username"); - localStorage.removeItem("step"); - Cookies.remove("token"); + const logoutHandler = async () => { + await clearAuthSession(); router.push("/login"); + router.refresh(); }; return ( <> diff --git a/src/hooks/useAxios.tsx b/src/hooks/useAxios.tsx index 16c5dc0..261b7d3 100644 --- a/src/hooks/useAxios.tsx +++ b/src/hooks/useAxios.tsx @@ -11,6 +11,7 @@ import axios, { import toast from "react-hot-toast"; import { BASE_URL as base } from "@/components/main/BaseUrl"; import Cookies from "js-cookie"; +import { clearAuthSession } from "@/lib/auth/session"; const BASE_URL = base; const axiosInstance = axios.create({ @@ -88,9 +89,14 @@ axiosInstance.interceptors.response.use( ); const handleUnauthorized = (): void => { - if (typeof window !== "undefined") { - // toast.error("برای انجام عملیات لطفا وارد حساب کاربری خود شوید"); - } + if (typeof window === "undefined") return; + + const path = window.location.pathname; + if (path.startsWith("/login") || path.startsWith("/register")) return; + + clearAuthSession().finally(() => { + window.location.href = "/login"; + }); }; // تایپ اصلاح‌شده برای درخواست‌ها diff --git a/src/lib/auth/otpTimer.ts b/src/lib/auth/otpTimer.ts new file mode 100644 index 0000000..5ac7d1b --- /dev/null +++ b/src/lib/auth/otpTimer.ts @@ -0,0 +1,23 @@ +export const OTP_VALID_SECONDS = 3 * 60; +const OTP_SENT_AT_KEY = "otp_sent_at"; + +export function markOtpSent(): void { + if (typeof window === "undefined") return; + sessionStorage.setItem(OTP_SENT_AT_KEY, Date.now().toString()); +} + +export function getOtpRemainingSeconds(): number { + if (typeof window === "undefined") return OTP_VALID_SECONDS; + + const sent = sessionStorage.getItem(OTP_SENT_AT_KEY); + if (!sent) return OTP_VALID_SECONDS; + + const elapsed = Math.floor((Date.now() - Number(sent)) / 1000); + return Math.max(0, OTP_VALID_SECONDS - elapsed); +} + +export function formatOtpCountdown(totalSeconds: number): string { + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}:${seconds.toString().padStart(2, "0")}`; +} diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts new file mode 100644 index 0000000..ce86b5e --- /dev/null +++ b/src/lib/auth/session.ts @@ -0,0 +1,70 @@ +import Cookies from "js-cookie"; + +const TOKEN_KEY = "token"; + +export type AuthUserMeta = { + id?: string; + user_type?: string; + step?: string; +}; + +const cookieOptions = (): Cookies.CookieAttributes => ({ + path: "/", + expires: 365, + sameSite: "Lax", + secure: typeof window !== "undefined" && window.location.protocol === "https:", +}); + +/** ذخیره توکن در کوکی مرورگر + Route Handler سرور (برای middleware) */ +export async function setAuthSession( + token: string, + meta?: AuthUserMeta +): Promise { + if (!token) return; + + Cookies.set(TOKEN_KEY, token, cookieOptions()); + + await fetch("/api/auth/session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token }), + credentials: "same-origin", + }); + + if (meta?.id) localStorage.setItem("id", meta.id); + if (meta?.user_type) localStorage.setItem("usertype", meta.user_type); + if (meta?.step) localStorage.setItem("step", meta.step); +} + +/** پاک کردن کامل session */ +export async function clearAuthSession(): Promise { + Cookies.remove(TOKEN_KEY, { path: "/" }); + + try { + await fetch("/api/auth/session", { + method: "DELETE", + credentials: "same-origin", + }); + } catch { + // ignore + } + + localStorage.removeItem("id"); + localStorage.removeItem("mobile"); + localStorage.removeItem("otp"); + localStorage.removeItem("token"); + localStorage.removeItem("usertype"); + localStorage.removeItem("first_name"); + localStorage.removeItem("last_name"); + localStorage.removeItem("username"); + localStorage.removeItem("step"); +} + +export function getAuthToken(): string | undefined { + if (typeof window === "undefined") return undefined; + return Cookies.get(TOKEN_KEY); +} + +export function isAuthenticated(): boolean { + return Boolean(getAuthToken()); +} diff --git a/src/lib/validation/username.ts b/src/lib/validation/username.ts new file mode 100644 index 0000000..c4087c2 --- /dev/null +++ b/src/lib/validation/username.ts @@ -0,0 +1,21 @@ +/** حروف انگلیسی، اعداد و . _ - — بدون فارسی و کاراکترهای غیرمعمول */ +export const USERNAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9._-]{5,99}$/; + +export const USERNAME_MIN_LENGTH = 6; +export const USERNAME_MAX_LENGTH = 100; + +export const USERNAME_ALLOWED_CHARS = /[^a-zA-Z0-9._-]/g; + +export const USERNAME_VALIDATION_MESSAGE = + "نام کاربری فقط می‌تواند شامل حروف انگلیسی، اعداد و کاراکترهای . _ - باشد"; + +export const USERNAME_START_MESSAGE = + "نام کاربری باید با حرف یا عدد انگلیسی شروع شود"; + +export function sanitizeUsernameInput(value: string): string { + return value.replace(USERNAME_ALLOWED_CHARS, ""); +} + +export function isValidUsername(value: string): boolean { + return USERNAME_REGEX.test(value); +} diff --git a/src/lib/validation/usernameSchema.ts b/src/lib/validation/usernameSchema.ts new file mode 100644 index 0000000..b75591a --- /dev/null +++ b/src/lib/validation/usernameSchema.ts @@ -0,0 +1,20 @@ +import * as yup from "yup"; +import { + USERNAME_MAX_LENGTH, + USERNAME_MIN_LENGTH, + USERNAME_REGEX, + USERNAME_START_MESSAGE, + USERNAME_VALIDATION_MESSAGE, +} from "./username"; + +export const usernameSchema = yup + .string() + .required("نام کاربری الزامی است") + .min(USERNAME_MIN_LENGTH, `نام کاربری باید حداقل ${USERNAME_MIN_LENGTH} کاراکتر باشد`) + .max(USERNAME_MAX_LENGTH, `نام کاربری نمی‌تواند بیشتر از ${USERNAME_MAX_LENGTH} کاراکتر باشد`) + .matches(/^[a-zA-Z0-9]/, USERNAME_START_MESSAGE) + .matches(USERNAME_REGEX, USERNAME_VALIDATION_MESSAGE); + +export const usernameFormSchema = yup.object({ + username: usernameSchema, +}); diff --git a/src/middleware.ts b/src/middleware.ts index 0255750..ae3a194 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,17 +1,26 @@ -import { NextRequest, NextResponse } from 'next/server'; +import { NextRequest, NextResponse } from "next/server"; export function middleware(req: NextRequest) { - const token = req.cookies.get('token'); // دریافت توکن از کوکی + const isPrefetch = + req.headers.get("next-router-prefetch") === "1" || + req.headers.get("purpose") === "prefetch"; - // بررسی اینکه مسیر داخل settings هست - if (req.nextUrl.pathname.startsWith('/settings') && !token) { - return NextResponse.redirect(new URL('/login', req.url)); // انتقال به صفحه لاگین + // prefetch بدون کوکی نباید redirect به /login کش شود + if (isPrefetch) { + return NextResponse.next(); } - return NextResponse.next(); // ادامه پردازش عادی + const token = req.cookies.get("token")?.value?.trim(); + + if (req.nextUrl.pathname.startsWith("/settings") && !token) { + const loginUrl = new URL("/login", req.url); + loginUrl.searchParams.set("redirect", req.nextUrl.pathname); + return NextResponse.redirect(loginUrl); + } + + return NextResponse.next(); } -// تعیین مسیرهایی که این middleware روی آنها اعمال می‌شود export const config = { - matcher: ['/settings/:path*'], // فقط روی مسیرهای settings اعمال شود + matcher: ["/settings/:path*"], };