Initial commit

This commit is contained in:
payacom
2026-07-08 21:01:18 +03:30
parent 78decbba05
commit d2d3398471
1210 changed files with 9645 additions and 2522 deletions

View File

@@ -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();

View File

@@ -1,4 +1,5 @@
import { BASE_URL } from "@/components/main/BaseUrl";
import { normalizeUserLevel } from "@/lib/userLevel";
export async function fetchPosts(
page: number,
@@ -14,7 +15,12 @@ export async function fetchPosts(
},
token: string
) {
const validFilters = Object.entries(filters || {})
const normalizedFilters = { ...filters };
if (normalizedFilters.userLevel) {
normalizedFilters.userLevel = normalizeUserLevel(normalizedFilters.userLevel);
}
const validFilters = Object.entries(normalizedFilters || {})
.filter(([value]) => value !== undefined && value !== "")
.reduce((acc, [key, value]) => {
acc[key] = value;

View File

@@ -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";
@@ -14,7 +14,7 @@ import * as yup from "yup";
import { useRouter } from "next/navigation";
import { IVerifyOtp } from "@/types/types";
// Validation schema using Yup //
// Validation schema using Yup
const schema = yup.object().shape({
password: yup
.string()
@@ -71,11 +71,11 @@ function ChangePassword() {
onSubmit={formik.handleSubmit}
className="w-full max-w-sm flex flex-col items-center"
>
<AuthInput
<AuthPasswordInput
name="password"
type="password"
placeholder="کلمه عبور جدید"
className={`border mt-4 ${
wrapperClassName="mt-4"
className={`border ${
formik.touched.password && formik.errors.password
? "border-red-500 dark:border-red-500"
: "border-gray-300"
@@ -90,11 +90,11 @@ function ChangePassword() {
</small>
)}
<AuthInput
<AuthPasswordInput
name="confirmPassword"
type="password"
placeholder="تکرار کلمه عبور"
className={`border mt-4 ${
wrapperClassName="mt-4"
className={`border ${
formik.touched.confirmPassword && formik.errors.confirmPassword
? "border-red-500 dark:border-red-500"
: "border-gray-300"
@@ -109,8 +109,8 @@ function ChangePassword() {
</small>
)}
<AuthButton type="submit" className="mt-5" disabled={loading}>
{loading ? "در حال ارسال..." : "تغییر کلمه عبور"}
<AuthButton type="submit" className="mt-5" loading={loading} disabled={loading}>
تغییر کلمه عبور
</AuthButton>
</form>

View File

@@ -7,13 +7,15 @@ 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 { useRouter } from "next/navigation";
import { IVerifyOtp } from "@/types/types";
import Cookies from "js-cookie";
import { setAuthSession } from "@/lib/auth/session";
import { AUTH_SUCCESS_DELAY_MS, pause } from "@/lib/auth/feedback";
// Validation schema using Yup
const schema = yup.object({
@@ -26,6 +28,10 @@ const schema = yup.object({
function ForgetPasswordOtp() {
const router = useRouter();
const [isModalOpen, setModalOpen] = useState(false);
const [otpExpired, setOtpExpired] = useState(false);
const [resendLoading, setResendLoading] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [otpError, setOtpError] = useState(false);
const { request, loading } = useAxios();
const mobile =
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
@@ -47,15 +53,14 @@ function ForgetPasswordOtp() {
mobile,
otp: values.otp.trim(),
})) as IVerifyOtp;
document.cookie = `token=${response.token}; path=/; Secure`;
Cookies.set("token", response.token, {
secure: false,
path: "/",
expires: 365
});
await setAuthSession(response.token);
await localStorage.setItem("otp", values.otp);
setIsSuccess(true);
await pause(AUTH_SUCCESS_DELAY_MS);
router.push("/change-passowrd");
} catch (err: any) {
setOtpError(true);
setIsSuccess(false);
console.log("Unhandled error:", err?.message);
}
},
@@ -65,31 +70,39 @@ function ForgetPasswordOtp() {
<Container>
<div className="p-4 flex flex-col items-center h-screen justify-center">
<AuthHead title="فراموش کردن کلمه عبور" />
<small className="mb-10">کد 6 رقمی به شماره {mobile} ارسال شد</small>
<AuthOtpStatus
mobile={mobile}
resendLoading={resendLoading}
onExpiredChange={setOtpExpired}
onResend={async () => {
if (!mobile) return;
setResendLoading(true);
try {
await request("POST", "/login", { mobile });
formik.setFieldValue("otp", "");
setOtpError(false);
setIsSuccess(false);
} finally {
setResendLoading(false);
}
}}
/>
<form
onSubmit={formik.handleSubmit}
className="w-full max-w-sm flex flex-col items-center"
>
<OtpInput
<AuthOtpInput
className="mt-3"
value={formik.values.otp}
onChange={(otp) => {
if (formik.values.otp !== otp) {
formik.setFieldValue("otp", otp);
if (otp.length === 6) {
setTimeout(() => {
formik.submitForm();
}, 500);
}
}
formik.setFieldValue("otp", otp);
setOtpError(false);
setIsSuccess(false);
}}
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) => <input {...props} />}
onComplete={() => setTimeout(() => formik.submitForm(), 400)}
error={otpError || Boolean(formik.touched.otp && formik.errors.otp)}
success={isSuccess}
disabled={loading || otpExpired || isSuccess}
/>
{formik.touched.otp && formik.errors.otp && (
<small className="text-red-500 mt-2">{formik.errors.otp}</small>
@@ -97,9 +110,9 @@ function ForgetPasswordOtp() {
<AuthButton
type="submit"
className="mt-5"
disabled={loading || formik.values.otp.length !== 6}
loading={loading} disabled={loading || otpExpired || isSuccess || formik.values.otp.length !== 6}
>
{loading ? "در حال ارسال..." : "تایید کد"}
تایید کد
</AuthButton>
</form>
<Link href={"/login-with-username"}>

View File

@@ -6,10 +6,11 @@ import AuthInput from "@/components/auth/AuthInput";
import Container from "@/components/elements/Container";
import Link from "next/link";
import React from "react";
import { useRouter } from "next/navigation";
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 +33,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);
@@ -51,6 +53,7 @@ function ForgetPassword() {
name="mobile"
maxLength={11}
type="text"
dir="ltr"
placeholder="09 _ _ _ _ _ _ _ _ _"
className={`border ${
formik.touched.mobile && formik.errors.mobile
@@ -66,8 +69,8 @@ function ForgetPassword() {
{formik.errors.mobile}
</small>
)}
<AuthButton type="submit" className="mt-5" disabled={loading}>
{loading ? "در حال ارسال..." : "ارسال کد"}
<AuthButton type="submit" className="mt-5" loading={loading} disabled={loading}>
ارسال کد
</AuthButton>
</form>
<Link href={"/register"}>

View File

@@ -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,8 @@ 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";
import { AUTH_SUCCESS_DELAY_MS, pause } from "@/lib/auth/feedback";
// Validation schema using Yup
const schema = yup.object().shape({
@@ -31,6 +33,8 @@ const schema = yup.object().shape({
function LoginWithUsername() {
const router = useRouter();
const [isModalOpen, setModalOpen] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [authError, setAuthError] = useState(false);
const { request, loading } = useAxios();
const mobile =
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
@@ -50,33 +54,31 @@ 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);
setIsSuccess(true);
await pause(AUTH_SUCCESS_DELAY_MS);
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);
setIsSuccess(true);
await pause(AUTH_SUCCESS_DELAY_MS);
router.push("/auth");
break;
default:
break;
}
} catch (err: any) {
setAuthError(true);
setIsSuccess(false);
console.log("Unhandled error:", err?.message);
}
},
@@ -94,40 +96,44 @@ function LoginWithUsername() {
name="username"
type="text"
placeholder="نام کاربری"
className={`border ${
formik.touched.username && formik.errors.username
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
success={isSuccess}
error={authError || Boolean(formik.touched.username && formik.errors.username)}
value={formik.values.username}
onChange={formik.handleChange}
onChange={(e) => {
setAuthError(false);
setIsSuccess(false);
formik.handleChange(e);
}}
onBlur={formik.handleBlur}
disabled={loading || isSuccess}
/>
{formik.touched.username && formik.errors.username && (
{formik.touched.username && formik.errors.username && !authError && (
<small className="text-red-500 mt-2 block">
{formik.errors.username}
</small>
)}
<AuthInput
<AuthPasswordInput
name="password"
type="password"
placeholder="کلمه عبور"
className={`border mt-4 ${
formik.touched.password && formik.errors.password
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
wrapperClassName="mt-4"
success={isSuccess}
error={authError || Boolean(formik.touched.password && formik.errors.password)}
value={formik.values.password}
onChange={formik.handleChange}
onChange={(e) => {
setAuthError(false);
setIsSuccess(false);
formik.handleChange(e);
}}
onBlur={formik.handleBlur}
disabled={loading || isSuccess}
/>
{formik.touched.password && formik.errors.password && (
{formik.touched.password && formik.errors.password && !authError && (
<small className="text-red-500 mt-2 block">
{formik.errors.password}
</small>
)}
<AuthButton type="submit" className="mt-5" disabled={loading}>
{loading ? "در حال ارسال..." : "ورود"}
<AuthButton type="submit" className="mt-5" loading={loading} disabled={loading || isSuccess}>
ورود
</AuthButton>
</form>
<Link href={"/forget-password"}>

View File

@@ -11,6 +11,7 @@ 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({
@@ -34,6 +35,7 @@ function Login() {
try {
await request("POST", "/login", { mobile: values.mobile });
await localStorage.setItem("mobile", values.mobile);
markOtpSent();
router.push("/verify-otp");
} catch (err) {
console.log(err);
@@ -53,7 +55,8 @@ function Login() {
name="mobile"
maxLength={11}
type="tel"
pattern="[0-9]*"
pattern="[0-9]*"
dir="ltr"
placeholder="09 _ _ _ _ _ _ _ _ _"
className={`border ${
formik.touched.mobile && formik.errors.mobile
@@ -69,8 +72,8 @@ function Login() {
{formik.errors.mobile}
</small>
)}
<AuthButton type="submit" className="mt-5" disabled={loading}>
{loading ? "در حال ارسال..." : "ارسال کد"}
<AuthButton type="submit" className="mt-5" loading={loading} disabled={loading}>
ارسال کد
</AuthButton>
</form>
<Link href={"/login-with-username"}>

View File

@@ -7,13 +7,15 @@ 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 { useRouter } from "next/navigation";
import { IVerifyOtp } from "@/types/types";
import Cookies from "js-cookie";
import { setAuthSession } from "@/lib/auth/session";
import { AUTH_SUCCESS_DELAY_MS, pause } from "@/lib/auth/feedback";
// Validation schema using Yup
const schema = yup.object({
@@ -26,6 +28,10 @@ const schema = yup.object({
function VerifyOtp() {
const router = useRouter();
const [isModalOpen, setModalOpen] = useState(false);
const [otpExpired, setOtpExpired] = useState(false);
const [resendLoading, setResendLoading] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [otpError, setOtpError] = useState(false);
const { request, loading } = useAxios();
const mobile =
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
@@ -50,25 +56,23 @@ function VerifyOtp() {
switch (response?.page) {
case "home":
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);
setIsSuccess(true);
await pause(AUTH_SUCCESS_DELAY_MS);
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);
setIsSuccess(true);
await pause(AUTH_SUCCESS_DELAY_MS);
switch (response.step) {
case "user_name":
@@ -93,6 +97,8 @@ function VerifyOtp() {
}
// router.push("/dashboard");
} catch (err: any) {
setOtpError(true);
setIsSuccess(false);
console.log("Unhandled error:", err?.message);
}
},
@@ -102,34 +108,39 @@ function VerifyOtp() {
<Container>
<div className="p-4 flex flex-col items-center h-screen justify-center">
<AuthHead title="ورود" />
<small className="mb-10">کد 6 رقمی به شماره {mobile} ارسال شد</small>
<AuthOtpStatus
mobile={mobile}
resendLoading={resendLoading}
onExpiredChange={setOtpExpired}
onResend={async () => {
if (!mobile) return;
setResendLoading(true);
try {
await request("POST", "/login", { mobile });
formik.setFieldValue("otp", "");
setOtpError(false);
setIsSuccess(false);
} finally {
setResendLoading(false);
}
}}
/>
<form
onSubmit={formik.handleSubmit}
className="w-full max-w-sm flex flex-col items-center"
>
<OtpInput
<AuthOtpInput
className="mt-3"
value={formik.values.otp}
onChange={(otp) => {
formik.setFieldValue("otp", otp);
if (otp.length === 6) {
setTimeout(() => formik.submitForm(), 500);
}
setOtpError(false);
setIsSuccess(false);
}}
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) => (
<input
{...props}
type="tel"
inputMode="numeric"
pattern="[0-9]*"
/>
)}
onComplete={() => setTimeout(() => formik.submitForm(), 400)}
error={otpError || Boolean(formik.touched.otp && formik.errors.otp)}
success={isSuccess}
disabled={loading || otpExpired || isSuccess}
/>
{formik.touched.otp && formik.errors.otp && (
@@ -138,9 +149,9 @@ function VerifyOtp() {
<AuthButton
type="submit"
className="mt-5"
disabled={loading || formik.values.otp.length !== 6}
loading={loading} disabled={loading || otpExpired || isSuccess || formik.values.otp.length !== 6}
>
{loading ? "در حال ارسال..." : "تایید کد"}
تایید کد
</AuthButton>
</form>
<Link href={"/login-with-username"}>

View File

@@ -8,13 +8,15 @@ 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";
import { AUTH_SUCCESS_DELAY_MS, pause } from "@/lib/auth/feedback";
// Validation schema using Yup
const schema = yup.object({
@@ -27,6 +29,10 @@ 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 [isSuccess, setIsSuccess] = useState(false);
const [otpError, setOtpError] = useState(false);
const { request, loading } = useAxios();
const mobile =
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
@@ -49,12 +55,9 @@ 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 });
setIsSuccess(true);
await pause(AUTH_SUCCESS_DELAY_MS);
response.step === "user_name"
? router.push("/register/username")
: response.step === "password"
@@ -65,6 +68,8 @@ function RegisterOtp() {
? router.push("/register/usertype")
: router.push("/verify/avatar");
} catch (err: any) {
setOtpError(true);
setIsSuccess(false);
console.log("Unhandled error:", err.message);
}
},
@@ -74,31 +79,39 @@ function RegisterOtp() {
<Container>
<div className="p-4 flex flex-col items-center h-screen justify-center">
<AuthHead title="ثبت نام" />
<small className="mb-10">کد 6 رقمی به شماره {mobile} ارسال شد</small>
<AuthOtpStatus
mobile={mobile}
resendLoading={resendLoading}
onExpiredChange={setOtpExpired}
onResend={async () => {
if (!mobile) return;
setResendLoading(true);
try {
await request("POST", "/register", { mobile });
formik.setFieldValue("otp", "");
setOtpError(false);
setIsSuccess(false);
} finally {
setResendLoading(false);
}
}}
/>
<form
onSubmit={formik.handleSubmit}
className="w-full max-w-sm flex flex-col items-center"
>
<OtpInput
<AuthOtpInput
className="mt-3"
value={formik.values.otp}
onChange={(otp) => {
if (formik.values.otp !== otp) {
formik.setFieldValue("otp", otp);
if (otp.length === 6) {
setTimeout(() => {
formik.submitForm();
}, 500);
}
}
formik.setFieldValue("otp", otp);
setOtpError(false);
setIsSuccess(false);
}}
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) => <input {...props} />}
onComplete={() => setTimeout(() => formik.submitForm(), 400)}
error={otpError || Boolean(formik.touched.otp && formik.errors.otp)}
success={isSuccess}
disabled={loading || otpExpired || isSuccess}
/>
{formik.touched.otp && formik.errors.otp && (
<small className="text-red-500 mt-2">{formik.errors.otp}</small>
@@ -106,9 +119,9 @@ function RegisterOtp() {
<AuthButton
type="submit"
className="mt-5"
disabled={loading || formik.values.otp.length !== 6}
loading={loading} disabled={loading || otpExpired || isSuccess || formik.values.otp.length !== 6}
>
{loading ? "در حال ارسال..." : "تایید کد"}
تایید کد
</AuthButton>
</form>
<Link href={"/forget-password"}>

View File

@@ -3,7 +3,10 @@
import AuthHead from "@/components/auth/AuthHead";
import AuthInput from "@/components/auth/AuthInput";
import Container from "@/components/elements/Container";
import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import React from "react";
import useAxios from "@/hooks/useAxios";
import { useFormik } from "formik";
@@ -45,17 +48,19 @@ function FullNamePage() {
});
return (
<Container>
<div className="p-4 flex flex-col items-center h-screen justify-center">
<AuthHead title="نام و نام خانوادگی" />
<form
onSubmit={formik.handleSubmit}
className="w-full max-w-sm flex flex-col items-center"
>
<AuthPageLayout>
<form
onSubmit={formik.handleSubmit}
className="flex w-full flex-1 flex-col items-center"
>
<AuthPageContent>
<AuthHead title="نام و نام خانوادگی" />
<div className="w-full max-w-sm flex flex-col items-center">
<AuthInput
name="name"
type="text"
placeholder="نام"
dir="rtl"
className={`border mt-4 ${
formik.touched.name && formik.errors.name
? "border-red-500 dark:border-red-500"
@@ -66,7 +71,7 @@ function FullNamePage() {
onBlur={formik.handleBlur}
/>
{formik.touched.name && formik.errors.name && (
<small className="text-red-500 mt-2 block">
<small className="text-red-500 mt-2 block text-center">
{formik.errors.name}
</small>
)}
@@ -75,6 +80,7 @@ function FullNamePage() {
name="family"
type="text"
placeholder="نام خانوادگی"
dir="rtl"
className={`border mt-4 ${
formik.touched.family && formik.errors.family
? "border-red-500 dark:border-red-500"
@@ -85,17 +91,20 @@ function FullNamePage() {
onBlur={formik.handleBlur}
/>
{formik.touched.family && formik.errors.family && (
<small className="text-red-500 mt-2 block">
<small className="text-red-500 mt-2 block text-center">
{formik.errors.family}
</small>
)}
</div>
</AuthPageContent>
<AuthNextButton type="submit" className="mt-20" disabled={loading}>
{loading ? "در حال ارسال..." : "ثبت و ادامه"}
<AuthFormFooter>
<AuthNextButton type="submit" loading={loading} disabled={loading}>
ثبت و ادامه
</AuthNextButton>
</form>
</div>
</Container>
</AuthFormFooter>
</form>
</AuthPageLayout>
);
}

View File

@@ -10,6 +10,7 @@ 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 +34,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);
@@ -52,6 +54,7 @@ function Register() {
name="mobile"
maxLength={11}
type="text"
dir="ltr"
placeholder="09 _ _ _ _ _ _ _ _ _"
className={`border ${
formik.touched.mobile && formik.errors.mobile
@@ -67,8 +70,8 @@ function Register() {
{formik.errors.mobile}
</small>
)}
<AuthButton type="submit" className="mt-5" disabled={loading}>
{loading ? "در حال ارسال..." : "ارسال کد"}
<AuthButton type="submit" className="mt-5" loading={loading} disabled={loading}>
ارسال کد
</AuthButton>
</form>
<button onClick={() => setModalOpen(true)} className="mb-2">

View File

@@ -2,8 +2,11 @@
"use client";
import AuthHead from "@/components/auth/AuthHead";
import AuthInput from "@/components/auth/AuthInput";
import Container from "@/components/elements/Container";
import AuthPasswordInput from "@/components/auth/AuthPasswordInput";
import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import React from "react";
import useAxios from "@/hooks/useAxios";
import { useFormik } from "formik";
@@ -61,18 +64,19 @@ function PasswordPage() {
});
return (
<Container>
<div className="p-4 flex flex-col items-center h-screen justify-center">
<AuthHead title="کلمه عبور" />
<form
onSubmit={formik.handleSubmit}
className="w-full max-w-sm flex flex-col items-center"
>
<AuthInput
<AuthPageLayout>
<form
onSubmit={formik.handleSubmit}
className="flex w-full flex-1 flex-col items-center"
>
<AuthPageContent>
<AuthHead title="کلمه عبور" />
<div className="w-full max-w-sm flex flex-col items-center">
<AuthPasswordInput
name="password"
type="password"
placeholder="کلمه عبور"
className={`border mt-4 ${
wrapperClassName="mt-4"
className={`border ${
formik.touched.password && formik.errors.password
? "border-red-500 dark:border-red-500"
: "border-gray-300"
@@ -87,11 +91,11 @@ function PasswordPage() {
</small>
)}
<AuthInput
<AuthPasswordInput
name="confirmPassword"
type="password"
placeholder="تکرار کلمه عبور"
className={`border mt-4 ${
wrapperClassName="mt-4"
className={`border ${
formik.touched.confirmPassword && formik.errors.confirmPassword
? "border-red-500 dark:border-red-500"
: "border-gray-300"
@@ -105,13 +109,16 @@ function PasswordPage() {
{formik.errors.confirmPassword}
</small>
)}
</div>
</AuthPageContent>
<AuthNextButton type="submit" className="mt-20" disabled={loading}>
{loading ? "در حال ارسال..." : "تایید کلمه عبور"}
<AuthFormFooter>
<AuthNextButton type="submit" loading={loading} disabled={loading}>
تایید کلمه عبور
</AuthNextButton>
</form>
</div>
</Container>
</AuthFormFooter>
</form>
</AuthPageLayout>
);
}

View File

@@ -3,28 +3,25 @@
import AuthHead from "@/components/auth/AuthHead";
import AuthInput from "@/components/auth/AuthInput";
import Container from "@/components/elements/Container";
import React from "react";
import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
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 { IVerifyOtp } from "@/types/types";
import AuthNextButton from "@/components/auth/AuthNextButton";
import toast from "react-hot-toast";
// ✅ فقط چک می‌کنیم مقدار وارد شده باشه
const schema = yup.object().shape({
username: yup
.string()
.required("نام کاربری الزامی است")
.min(1, "نام کاربری باید حداقل ۱ کاراکتر باشد")
.max(100, "نام کاربری نمی‌تواند بیشتر از ۱۰۰ کاراکتر باشد"),
});
import UsernameSuggestions from "@/components/auth/UsernameSuggestions";
import { usernameFormSchema } from "@/lib/validation/usernameSchema";
import { sanitizeUsernameInput, USERNAME_MIN_LENGTH } from "@/lib/validation/username";
function UsernamePage() {
const router = useRouter();
const { request, loading } = useAxios();
const [isDuplicate, setIsDuplicate] = useState(false);
const [suggestions, setSuggestions] = useState<string[]>([]);
const mobile =
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
@@ -32,60 +29,100 @@ function UsernamePage() {
initialValues: {
username: "",
},
validationSchema: schema,
validationSchema: usernameFormSchema,
onSubmit: async (values) => {
setIsDuplicate(false);
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?.includes("تکراری")) {
setIsDuplicate(true);
setSuggestions(Array.isArray(data.suggestions) ? data.suggestions : []);
} else if (data?.message) {
formik.setFieldError("username", data.message);
}
}
},
});
const handleUsernameChange = (value: string) => {
setIsDuplicate(false);
setSuggestions([]);
formik.setFieldValue("username", sanitizeUsernameInput(value));
};
const handleSuggestionSelect = (username: string) => {
formik.setFieldValue("username", username);
formik.setFieldTouched("username", true, false);
setIsDuplicate(false);
setSuggestions([]);
};
return (
<Container>
<div className="p-4 flex flex-col items-center h-screen justify-center">
<AuthHead title="نام کاربری" />
<form
onSubmit={formik.handleSubmit}
className="w-full max-w-sm flex flex-col items-center"
>
<AuthPageLayout>
<form
onSubmit={formik.handleSubmit}
className="flex w-full flex-1 flex-col items-center"
>
<AuthPageContent>
<AuthHead title="نام کاربری" />
<div className="w-full max-w-sm flex flex-col items-center">
<AuthInput
name="username"
type="text"
placeholder="نام کاربری خود را وارد کنید"
dir="auto" // ✅ اجازه ورود همه کاراکترها (فارسی، انگلیسی، ایموجی...)
placeholder="نام کاربری"
dir="ltr"
autoComplete="username"
spellCheck={false}
className={`border ${
formik.touched.username && formik.errors.username
(formik.touched.username && formik.errors.username) || isDuplicate
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
value={formik.values.username}
onChange={formik.handleChange}
onChange={(e) => handleUsernameChange(e.target.value)}
onBlur={formik.handleBlur}
/>
{formik.touched.username && formik.errors.username && (
<small className="text-red-500 mt-2 block">
{isDuplicate && (
<small className="text-red-500 mt-2 block text-center font-medium">
نام کاربری تکراری است
</small>
)}
{formik.touched.username && formik.errors.username && !isDuplicate && (
<small className="text-red-500 mt-2 block text-center">
{formik.errors.username}
</small>
)}
<AuthNextButton type="submit" className="mt-20" disabled={loading}>
{loading ? "در حال ارسال..." : "ثبت و ادامه"}
<UsernameSuggestions
suggestions={suggestions}
onSelect={handleSuggestionSelect}
/>
</div>
</AuthPageContent>
<AuthFormFooter>
<AuthNextButton
type="submit"
loading={loading}
disabled={loading || formik.values.username.length < USERNAME_MIN_LENGTH}
>
ثبت و ادامه
</AuthNextButton>
</form>
</div>
</Container>
</AuthFormFooter>
</form>
</AuthPageLayout>
);
}

View File

@@ -2,7 +2,10 @@
"use client";
import AuthHead from "@/components/auth/AuthHead";
import Container from "@/components/elements/Container";
import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import React, { useState } from "react";
import useAxios from "@/hooks/useAxios";
import { useFormik } from "formik";
@@ -58,14 +61,14 @@ function UsertypePage() {
});
return (
<Container>
<div className="p-4 flex flex-col items-center h-screen justify-center">
{/* <AuthHead title="نوع کاربری" /> */}
<AuthHead title="" />
<form
onSubmit={formik.handleSubmit}
className="w-full max-w-sm flex flex-col items-center"
>
<AuthPageLayout>
<form
onSubmit={formik.handleSubmit}
className="flex w-full flex-1 flex-col items-center"
>
<AuthPageContent>
<AuthHead title="" />
<div className="w-full max-w-sm flex flex-col items-center">
{/* User Type Buttons */}
{/* <AuthNextButton
type="button"
@@ -90,35 +93,36 @@ function UsertypePage() {
کارفرما
</AuthNextButton> */}
{/* Error Message */}
{formik.errors.selectedButton && formik.touched.selectedButton && (
<div className="text-red-500 mt-2 text-sm">
{formik.errors.selectedButton}
</div>
)}
</div>
</AuthPageContent>
{/* Submit Buttons */}
<AuthFormFooter>
<div className="flex gap-3">
<AuthNextButton
type="submit"
onClick={() => setSubmitAction("register")}
className="mt-20 !border-[#0C8002] !text-[#0C8002]"
className="!border-[#0C8002] !text-[#0C8002]"
disabled={loading}
>
{loading ? "در حال ارسال..." : "ادامه ثبت نام"}
ادامه ثبت نام
</AuthNextButton>
<AuthNextButton
type="submit"
onClick={() => setSubmitAction("login")}
className="mt-20 !border-[#FF0000] !text-[#FF0000]"
className="!border-[#FF0000] !text-[#FF0000]"
disabled={loading}
>
{loading ? "در حال ارسال..." : "ورود به برنامه"}
ورود به برنامه
</AuthNextButton>
</div>
</form>
</div>
</Container>
</AuthFormFooter>
</form>
</AuthPageLayout>
);
}

View File

@@ -1,7 +1,10 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import Container from "@/components/elements/Container";
import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import React from "react";
import useAxios from "@/hooks/useAxios";
import { useFormik } from "formik";
@@ -69,17 +72,18 @@ function AuthPage() {
console.log(formik.values);
return (
<Container>
<div className="p-4 flex flex-col items-center h-screen justify-center auth-page">
<span className="text-xl font-bold">احراز هویت</span>
<div className="mt-5">
<AuthUserDetails />
</div>
<AuthPageLayout className="auth-page">
<form
onSubmit={formik.handleSubmit}
className="flex w-full flex-1 flex-col items-center"
>
<AuthPageContent>
<span className="text-xl font-bold">احراز هویت</span>
<div className="mt-5">
<AuthUserDetails />
</div>
<form
onSubmit={formik.handleSubmit}
className="w-full max-w-sm flex flex-col items-center"
>
<div className="w-full max-w-sm flex flex-col items-center">
<small className="font-bold mt-10 mb-4"> جهت احراز هویت</small>
<div className="flex w-full gap-2 max-w-[300px]">
<div>
@@ -165,19 +169,16 @@ function AuthPage() {
<small className="font-bold mt-10">
شماره شبا باید به نام خود شخص باشد
</small>
<AuthNextButton type="submit" className="mt-20" disabled={loading}>
{loading ? "در حال ارسال..." : "ثبت و ادامه"}
</div>
</AuthPageContent>
<AuthFormFooter onSkip={() => router.push("/verify/location")}>
<AuthNextButton type="submit" loading={loading} disabled={loading}>
ثبت و ادامه
</AuthNextButton>
<button
className="text-sm text-gray-500 mt-5"
onClick={() => router.push("/verify/location")}
type="button"
>
<span>رد کن</span>
</button>
</form>
</div>
</Container>
</AuthFormFooter>
</form>
</AuthPageLayout>
);
}

View File

@@ -1,7 +1,10 @@
"use client";
import React, { useEffect, useState, useCallback } from "react";
import Container from "@/components/elements/Container";
import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import AuthNextButton from "@/components/auth/AuthNextButton";
import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
import useAxios from "@/hooks/useAxios";
@@ -20,7 +23,6 @@ function AvatarPage() {
const { request, loading } = useAxios();
const [userDetail, setUserDetail] = useState<IProfileData | null>(null);
const [avatar, setAvatar] = useState<string | null>(null);
const [originalImage, setOriginalImage] = useState<string | null>(null); // تصویر اصلی (سایز کامل)
const [crop, setCrop] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [loadingUpload, setLoadingUpload] = useState(false);
@@ -50,8 +52,7 @@ function AvatarPage() {
const reader = new FileReader();
reader.onload = (e) => {
const imageDataUrl = e.target?.result as string;
setAvatar(imageDataUrl); // برای کراپر
setOriginalImage(imageDataUrl); // تصویر اصلی (سایز کامل)
setAvatar(imageDataUrl);
};
reader.readAsDataURL(file);
}
@@ -105,7 +106,6 @@ function AvatarPage() {
} catch (err) {
console.log("Upload error:", err);
toast.error("خطا در ارسال تصویر!");
} finally {
setLoadingUpload(false);
}
};
@@ -120,28 +120,11 @@ function AvatarPage() {
};
return (
<Container>
<div className="p-4 flex flex-col items-center h-screen justify-center">
{(loading || loadingUpload) && <p>Loading...</p>}
<AuthPageLayout>
<AuthPageContent>
<span className="text-xl font-bold mb-4">تصویر پروفایل</span>
{!loading && !loadingUpload && (
<>
<span className="text-xl font-bold mb-4">تصویر پروفایل</span>
{/* نمایش تصویر اصلی (سایز کامل) */}
{originalImage && (
<div className="relative w-[300px] h-[400px] bg-gray-200 rounded-2xl mb-4 overflow-hidden">
<Image
src={originalImage}
alt="Original image"
fill
className="object-contain"
/>
</div>
)}
{/* کراپر برای برش تصویر به مربع 1:1 */}
<div className="relative w-[250px] h-[250px] bg-gray-200 rounded-3xl overflow-hidden mb-4">
<div className="relative aspect-square w-[250px] bg-gray-200 rounded-3xl overflow-hidden mb-4">
{avatar ? (
<Cropper
image={avatar.startsWith("data:") ? avatar : IMAGE_BASE_URL + avatar}
@@ -178,31 +161,26 @@ function AvatarPage() {
onChange={selectImage}
/>
<AuthNextButton className="mt-4">
<label className="cursor-pointer w-full h-full" htmlFor="fileInput">
انتخاب/ویرایش تصویر
</label>
</AuthNextButton>
<AuthNextButton className="mt-4">
<label className="cursor-pointer w-full h-full" htmlFor="fileInput">
انتخاب/ویرایش تصویر
</label>
</AuthNextButton>
</AuthPageContent>
<AuthNextButton
onClick={uploadImage}
className="mt-10"
disabled={loadingUpload}
>
ثبت و ادامه
</AuthNextButton>
<button
className="text-sm text-gray-500 mt-5"
onClick={navigateHandler}
type="button"
>
رد کن
</button>
</>
)}
</div>
</Container>
<AuthFormFooter
onSkip={navigateHandler}
skipDisabled={loadingUpload}
>
<AuthNextButton
onClick={uploadImage}
loading={loadingUpload}
disabled={loadingUpload}
>
ثبت و ادامه
</AuthNextButton>
</AuthFormFooter>
</AuthPageLayout>
);
}

View File

@@ -1,7 +1,10 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import Container from "@/components/elements/Container";
import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import React, { useState } from "react";
import useAxios from "@/hooks/useAxios";
import * as yup from "yup";
@@ -88,8 +91,8 @@ function Colors() {
return (
<Container>
<div className="p-4 flex flex-col items-center h-screen justify-center">
<AuthPageLayout>
<AuthPageContent>
<span className="text-xl font-bold">سایز</span>
<div className="mt-5">
<AuthUserDetails />
@@ -163,24 +166,19 @@ function Colors() {
</div>
)}
</div>
</AuthPageContent>
<AuthFormFooter onSkip={() => router.push("/verify/public-relations")}>
<AuthNextButton
type="button"
onClick={handleCheck}
className="mt-10"
loading={loading}
disabled={loading}
>
{loading ? "در حال ارسال..." : "ثبت و ادامه"}
ثبت و ادامه
</AuthNextButton>
<button
className="text-sm text-gray-500 mt-5"
onClick={() => router.push("/verify/public-relations")}
type="button"
>
<span>رد کن</span>
</button>
</div>
</Container>
</AuthFormFooter>
</AuthPageLayout>
);
}

View File

@@ -2,26 +2,48 @@
"use client";
import Container from "@/components/elements/Container";
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import AuthNextButton from "@/components/auth/AuthNextButton";
import Modal from "@/components/elements/Modal";
import Link from "next/link";
import Image from "next/image";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import VerificationBadge from "@/components/main/VerificationBadge";
import { useUser } from "@/hooks/useUser";
import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
import useAxios from "@/hooks/useAxios";
function Confirm() {
const user = useUser();
const { request } = useAxios();
const router = useRouter();
const [showModal, setShowModal] = useState<boolean>(false);
const [navigating, setNavigating] = useState(false);
const userType: string | null =
typeof window !== "undefined" ? localStorage.getItem("usertype") : null;
const [verifiedStatus, setVerifiedStatus] = useState<string | null>(null);
useEffect(() => {
const markComplete = async () => {
try {
const response = await request<{ is_verified?: string }>(
"POST",
"/verify/complete",
{}
);
setVerifiedStatus(response?.is_verified ?? "pending");
} catch {
setVerifiedStatus(user?.is_verified ?? "pending");
}
};
markComplete();
}, []);
const confirmHandler = () => {
setNavigating(true);
if (userType !== "employer") {
setShowModal(true);
setNavigating(false);
} else {
router.push("/");
}
@@ -36,38 +58,23 @@ function Confirm() {
ورود شما به خانواده مدستاگرام را تبریک می گوییم{" "}
</small>
<div className="flex flex-col justify-center items-center">
<Image
className="rounded-2xl object-cover"
width={280}
height={280}
<ProfileAvatar
src={user?.profile_image}
alt={user?.user_name || "user profile"}
src={buildStorageUrl(user?.profile_image)}
unoptimized={true}
size="xl"
rounded="2xl"
/>
<div className="flex flex-col justify-center items-center gap-1 mt-1">
<span>
{user?.first_name && user?.first_name + " " + user?.last_name}
</span>
<div className="flex flex-col justify-center items-center gap-1 mt-1">
{user?.user_name}
{user?.is_verified === "verified" ? (
<Image
width={70}
height={70}
alt={"verify icon"}
src={`/images/icons/verify.svg`}
<div className="flex items-center gap-1 mt-1">
<span className="inline-flex items-center gap-1">
{user?.user_name}
<VerificationBadge
isVerified={verifiedStatus ?? user?.is_verified ?? "pending"}
/>
) : user?.is_verified === "pending" ? (
<Image
width={70}
height={70}
alt={"not-verify icon"}
src={`/images/icons/not-verify.svg`}
/>
) : (
""
)}
</span>
</div>
</div>
</div>
@@ -117,7 +124,7 @@ function Confirm() {
</div>
</div>
{/* */}
<AuthNextButton onClick={confirmHandler} className="mt-20">
<AuthNextButton onClick={confirmHandler} className="mt-20" loading={navigating} disabled={navigating}>
ثبت و ادامه
</AuthNextButton>
<small className="font-bold mt-10 mb-1 text-center">

View File

@@ -1,7 +1,10 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import Container from "@/components/elements/Container";
import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import React from "react";
import useAxios from "@/hooks/useAxios";
import { useFormik } from "formik";
@@ -44,20 +47,22 @@ function CooperationType() {
});
return (
<Container>
<div className="p-4 flex flex-col items-center h-screen justify-center">
<span className="text-xl font-bold">نوع همکاری</span>
<div className="mt-5">
<AuthUserDetails />
</div>
<AuthPageLayout>
<form
onSubmit={formik.handleSubmit}
className="flex w-full flex-1 flex-col items-center"
>
<AuthPageContent>
<span className="text-xl font-bold">نوع همکاری</span>
<div className="mt-5">
<AuthUserDetails />
</div>
<p className="my-10 text-sm font-bold">
آیا مایل به همکاری خارج از محل سکونت خود هستید؟
</p>
<form
onSubmit={formik.handleSubmit}
className="w-full max-w-sm flex flex-col items-center"
>
<p className="my-10 text-sm font-bold">
آیا مایل به همکاری خارج از محل سکونت خود هستید؟
</p>
<div className="w-full max-w-sm flex flex-col items-center">
<div className="flex w-full gap-2">
<AuthNextButton
type="button"
@@ -83,26 +88,21 @@ function CooperationType() {
</AuthNextButton>
</div>
{/* Error Message */}
{formik.errors.selectedButton && formik.touched.selectedButton && (
<div className="text-red-500 mt-2 text-sm">
{formik.errors.selectedButton}
</div>
)}
</div>
</AuthPageContent>
<AuthNextButton type="submit" className="mt-20" disabled={loading}>
{loading ? "در حال ارسال..." : "ثبت و ادامه"}
<AuthFormFooter onSkip={() => router.push("/verify/public-relations")}>
<AuthNextButton type="submit" loading={loading} disabled={loading}>
ثبت و ادامه
</AuthNextButton>
<button
className="text-sm text-gray-500 mt-5"
onClick={() => router.push("/verify/public-relations")}
type="button"
>
<span>رد کن</span>
</button>
</form>
</div>
</Container>
</AuthFormFooter>
</form>
</AuthPageLayout>
);
}

View File

@@ -1,7 +1,10 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import Container from "@/components/elements/Container";
import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import React, { useEffect, useState } from "react";
import useAxios from "@/hooks/useAxios";
import * as yup from "yup";
@@ -89,8 +92,8 @@ function Expertise() {
);
return (
<Container>
<div className="p-4 flex flex-col items-center h-screen justify-center">
<AuthPageLayout>
<AuthPageContent>
<span className="text-xl font-bold">تخصص</span>
<AuthUserDetails />
@@ -126,25 +129,19 @@ function Expertise() {
</button>
))}
</div>
</AuthPageContent>
<AuthFormFooter onSkip={() => router.push("/verify/services")}>
<AuthNextButton
type="button"
onClick={handleCheck}
className="mt-20"
loading={loading}
disabled={loading}
>
{loading ? "در حال ارسال..." : "ثبت و ادامه"}
ثبت و ادامه
</AuthNextButton>
<button
className="text-sm text-gray-500 mt-5"
onClick={() => router.push("/verify/services")}
type="button"
>
<span>رد کن</span>
</button>
</div>
</Container>
</AuthFormFooter>
</AuthPageLayout>
);
}

View File

@@ -1,7 +1,10 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import Container from "@/components/elements/Container";
import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import React from "react";
import useAxios from "@/hooks/useAxios";
import { useFormik } from "formik";
@@ -50,16 +53,18 @@ function Gender() {
});
return (
<Container>
<div className="p-4 flex flex-col items-center h-screen justify-center">
<span className="text-xl font-bold">جنسیت</span>
<div className="mt-5">
<AuthUserDetails />
</div>
<form
onSubmit={formik.handleSubmit}
className="w-full max-w-sm flex flex-col items-center"
>
<AuthPageLayout>
<form
onSubmit={formik.handleSubmit}
className="flex w-full flex-1 flex-col items-center"
>
<AuthPageContent>
<span className="text-xl font-bold">جنسیت</span>
<div className="mt-5">
<AuthUserDetails />
</div>
<div className="w-full max-w-sm flex flex-col items-center">
<div className="flex w-full gap-4">
<AuthNextButton
type="button"
@@ -97,26 +102,21 @@ function Gender() {
</AuthNextButton>
</div>
{/* Error Message */}
{formik.errors.selectedButton && formik.touched.selectedButton && (
<div className="text-red-500 mt-2 text-sm">
{formik.errors.selectedButton}
</div>
)}
</div>
</AuthPageContent>
<AuthNextButton type="submit" className="mt-20" disabled={loading}>
{loading ? "در حال ارسال..." : "ثبت و ادامه"}
<AuthFormFooter onSkip={() => router.push("/verify/sizes")}>
<AuthNextButton type="submit" loading={loading} disabled={loading}>
ثبت و ادامه
</AuthNextButton>
<button
className="text-sm text-gray-500 mt-5"
onClick={() => router.push("/verify/sizes")}
type="button"
>
<span>رد کن</span>
</button>
</form>
</div>
</Container>
</AuthFormFooter>
</form>
</AuthPageLayout>
);
}

View File

@@ -1,7 +1,10 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import Container from "@/components/elements/Container";
import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import React, { useEffect, useState } from "react";
import useAxios from "@/hooks/useAxios";
import { useFormik } from "formik";
@@ -88,8 +91,8 @@ function LocationPage() {
address: values.address,
province_id: values.stateId,
city_id: values.cityId,
lat: values.markerCoordinate[0],
lng: values.markerCoordinate[1],
lat: values.markerCoordinate[1],
lng: values.markerCoordinate[0],
show_location: isCheckedOne,
});
router.push("/verify/national-cart");
@@ -118,20 +121,22 @@ function LocationPage() {
};
return (
<Container>
<div className="p-4 flex flex-col items-center h-screen justify-center address-page ">
<span className="text-xl font-bold">لوکیشن</span>
<div className="mt-5">
<AuthUserDetails />
</div>
<AuthPageLayout className="address-page">
<form
onSubmit={formik.handleSubmit}
className="flex w-full flex-1 flex-col items-center"
>
<AuthPageContent>
<span className="text-xl font-bold">لوکیشن</span>
<div className="mt-5">
<AuthUserDetails />
</div>
<small className="font-bold mt-10 mb-4">
مشخص کنید در کدام شهر قادر به انجام فعالیت هستید
</small>
<form
onSubmit={formik.handleSubmit}
className="w-full max-w-sm flex flex-col items-center"
>
<small className="font-bold mt-10 mb-4">
مشخص کنید در کدام شهر قادر به انجام فعالیت هستید
</small>
<div className="w-full max-w-sm flex flex-col items-center">
<SelectBox
className={`mt-4 ${
formik.touched.stateId && formik.errors.stateId
@@ -225,25 +230,26 @@ function LocationPage() {
)}
</Map>
<label className="flex items-center gap-2">
<input className="scale-125" type="checkbox" onChange={toggleCheckBox} />
<input
className="scale-125"
type="checkbox"
checked={isCheckedOne}
onChange={toggleCheckBox}
/>
<span className="text-xs font-bold">
اطلاعات لوکیشن شما برای همه قابل نمایش باشد
</span>
</label>
<AuthNextButton type="submit" className="mt-20" disabled={loading}>
{loading ? "در حال ارسال..." : "ثبت و ادامه"}
</AuthNextButton>
</div>
</AuthPageContent>
<button
className="text-sm text-gray-500 mt-5"
onClick={() => router.push("/verify/national-cart")}
type="button"
>
<span>رد کن</span>
</button>
</form>
</div>
</Container>
<AuthFormFooter onSkip={() => router.push("/verify/national-cart")}>
<AuthNextButton type="submit" loading={loading} disabled={loading}>
ثبت و ادامه
</AuthNextButton>
</AuthFormFooter>
</form>
</AuthPageLayout>
);
}

View File

@@ -1,7 +1,10 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import Container from "@/components/elements/Container";
import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import React, { useState } from "react";
import useAxios from "@/hooks/useAxios";
import { useRouter } from "next/navigation";
@@ -69,8 +72,8 @@ function NationalCart() {
};
return (
<Container>
<div className="p-4 flex flex-col items-center h-screen justify-center address-page">
<AuthPageLayout className="address-page">
<AuthPageContent>
<span className="text-xl font-bold">احراز هویت</span>
<div className="mt-5">
<AuthUserDetails />
@@ -146,25 +149,19 @@ function NationalCart() {
</span>
</div>
<AuthRules isModalOpen={isModalOpen} setModalOpen={setModalOpen} />
</AuthPageContent>
<AuthFormFooter onSkip={() => router.push("/verify/confirm")}>
<AuthNextButton
onClick={uploadImage}
className="mt-20"
loading={loading}
disabled={loading}
>
{loading ? "در حال ارسال..." : "ثبت و ادامه"}
ثبت و ادامه
</AuthNextButton>
<button
className="text-sm text-gray-500 mt-5"
onClick={() => router.push("/verify/confirm")}
type="button"
>
رد کن
</button>
<AuthRules isModalOpen={isModalOpen} setModalOpen={setModalOpen} />
</div>
</Container>
</AuthFormFooter>
</AuthPageLayout>
);
}

View File

@@ -1,7 +1,10 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import Container from "@/components/elements/Container";
import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import React from "react";
import useAxios from "@/hooks/useAxios";
import { useFormik } from "formik";
@@ -46,18 +49,18 @@ function PublicRelations() {
});
return (
<Container>
<div className="p-4 flex flex-col items-center h-screen justify-center">
<span className="text-xl font-bold">روابط عمومی</span>
<div className="mt-5">
<AuthUserDetails />
</div>
<form
onSubmit={formik.handleSubmit}
className="w-full max-w-sm flex flex-col items-center"
>
<AuthPageLayout>
<form
onSubmit={formik.handleSubmit}
className="flex w-full flex-1 flex-col items-center"
>
<AuthPageContent>
<span className="text-xl font-bold">روابط عمومی</span>
<div className="mt-5">
<AuthUserDetails />
</div>
<div className="w-full max-w-sm flex flex-col items-center">
<small className="font-bold mt-10"> در مورد خودتان چیزی بگویید</small>
<textarea
name="bio"
@@ -85,20 +88,16 @@ function PublicRelations() {
{formik.errors.bio}
</small>
)}
</div>
</AuthPageContent>
<AuthNextButton type="submit" className="mt-20" disabled={loading}>
{loading ? "در حال ارسال..." : "ثبت و ادامه"}
<AuthFormFooter onSkip={() => router.push("/verify/location")}>
<AuthNextButton type="submit" loading={loading} disabled={loading}>
ثبت و ادامه
</AuthNextButton>
<button
className="text-sm text-gray-500 mt-5"
onClick={() => router.push("/verify/location")}
type="button"
>
<span>رد کن</span>
</button>
</form>
</div>
</Container>
</AuthFormFooter>
</form>
</AuthPageLayout>
);
}

View File

@@ -5,7 +5,10 @@ import { Service } from "@/types/types";
import ServiceItem from "@/components/main/Services/ServiceItem";
import AddServiceModal from "@/components/main/Services/AddServiceModal";
import AuthUserDetails from "@/components/auth/AuthUserDetails";
import Container from "@/components/elements/Container";
import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import AuthNextButton from "@/components/auth/AuthNextButton";
import useAxios from "@/hooks/useAxios";
import toast from "react-hot-toast";
@@ -80,8 +83,8 @@ const ServicesPage: React.FC = () => {
};
return (
<Container>
<div className="p-4 flex flex-col items-center h-screen justify-center w-full">
<AuthPageLayout>
<AuthPageContent className="w-full">
<span className="text-xl font-bold">خدمات</span>
<div className="mt-5 w-full max-w-md">
@@ -98,23 +101,6 @@ const ServicesPage: React.FC = () => {
))}
</div>
<div className="flex gap-4 mt-6">
<AuthNextButton
onClick={handleSubmit}
disabled={loading}
className="mb-4 w-32"
>
{loading ? "در حال ارسال..." : "ثبت و ادامه"}
</AuthNextButton>
<AuthNextButton
onClick={() => setModalOpen(true)}
className="mb-4 !text-[#0066FF] !border-[#0066FF] w-32"
>
افزودن
</AuthNextButton>
</div>
{isModalOpen && (
<AddServiceModal
onClose={() => setModalOpen(false)}
@@ -122,15 +108,28 @@ const ServicesPage: React.FC = () => {
isModalOpen={isModalOpen}
/>
)}
<button
className="text-sm text-gray-500 mt-5"
onClick={Reject}
type="button"
>
<span>رد کن</span>
</button>
</div>
</Container>
</AuthPageContent>
<AuthFormFooter onSkip={Reject}>
<div className="flex gap-4">
<AuthNextButton
onClick={handleSubmit}
loading={loading}
disabled={loading}
className="w-32"
>
ثبت و ادامه
</AuthNextButton>
<AuthNextButton
onClick={() => setModalOpen(true)}
className="!text-[#0066FF] !border-[#0066FF] w-32"
>
افزودن
</AuthNextButton>
</div>
</AuthFormFooter>
</AuthPageLayout>
);
};

View File

@@ -1,7 +1,10 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import Container from "@/components/elements/Container";
import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import React, { useState } from "react";
import useAxios from "@/hooks/useAxios";
import * as yup from "yup";
@@ -73,8 +76,8 @@ function Sizes() {
};
return (
<Container>
<div className="p-4 flex flex-col items-center h-screen justify-center">
<AuthPageLayout>
<AuthPageContent>
<span className="text-xl font-bold">سایز</span>
<div className="mt-5">
<AuthUserDetails />
@@ -175,24 +178,19 @@ function Sizes() {
</div>
)}
</div>
</AuthPageContent>
<AuthFormFooter onSkip={() => router.push("/verify/colors")}>
<AuthNextButton
type="button"
onClick={handleCheck}
className="mt-10"
loading={loading}
disabled={loading}
>
{loading ? "در حال ارسال..." : "ثبت و ادامه"}
ثبت و ادامه
</AuthNextButton>
<button
className="text-sm text-gray-500 mt-5"
onClick={() => router.push("/verify/colors")}
type="button"
>
<span>رد کن</span>
</button>
</div>
</Container>
</AuthFormFooter>
</AuthPageLayout>
);
}

View File

@@ -21,6 +21,8 @@ import { Course } from "@/types/types";
import useAxios from "@/hooks/useAxios";
import { Comments } from "@/components/academy/CommentsModal";
import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
import VerificationBadge from "@/components/main/VerificationBadge";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import { usePathname } from "next/navigation";
import Cookies from "js-cookie";
@@ -662,39 +664,24 @@ useEffect(() => {
>
<div className="flex gap-3">
{/* آواتار کاربر */}
<div className="flex-shrink-0">
<Image
className="rounded-full object-cover"
width={40}
height={40}
<div className="shrink-0">
<ProfileAvatar
src={item?.user_id?.profile_image}
alt={item?.user_id?.user_name || "کاربر"}
src={
item?.user_id?.profile_image
? buildStorageUrl(item.user_id.profile_image)
: "/images/default-avatar.png"
}
priority={true}
unoptimized={true}
size="chat"
rounded="full"
fallback="/images/default-avatar.png"
/>
</div>
<div className="flex-1">
{/* اطلاعات کاربر و امتیاز */}
<div className="flex items-center gap-2 flex-wrap mb-2">
<span className="font-semibold text-sm dark:text-white">
<span className="font-semibold text-sm dark:text-white inline-flex items-center gap-1">
{item?.user_id?.user_name || "کاربر ناشناس"}
<VerificationBadge isVerified={item?.user_id?.is_verified} />
</span>
{item?.user_id?.is_verified === "verified" && (
<Image
width={16}
height={16}
alt="verify"
src="/images/icons/verify.svg"
className="inline"
/>
)}
{item?.rate > 0 && (
<div className="mr-2">
{renderSmallStars(item.rate)}

View File

@@ -9,6 +9,7 @@ import { Metadata } from "next";
// درون‌ریزی فایل‌های دیتا برای استخراج نام شهر و استان
import provinces from "@/data/provinces.json";
import cities from "@/data/cities.json";
import { normalizeUserLevel, parseUserLevelFromSlug } from "@/lib/userLevel";
interface IModelsProps {
params: Promise<{ slug?: string[] }>;
@@ -89,7 +90,7 @@ export default async function Models({ params, searchParams }: IModelsProps) {
// ۱. مقادیر پایه (اولویت با کوئری پارامتر برای دقت در فیلتر)
let provinceId = filters.province || "";
let cityId = filters.city || "";
let userLevel = filters.userLevel || "";
let userLevel = normalizeUserLevel(filters.userLevel || "");
let expertise = filters.expertise || "";
// ۲. استخراج و همگام‌سازی اطلاعات از URL فارسی (Slug)
@@ -102,11 +103,8 @@ export default async function Models({ params, searchParams }: IModelsProps) {
else if (fullText.includes("آرایشگر")) expertise = "آرایشگر";
else if (fullText.includes("متخصصین")) expertise = ""; // حالت بدون تخصص
// استخراج سطح
if (fullText.includes("تازه وارد")) userLevel = "تازه وارد";
else if (fullText.includes("استاندارد")) userLevel = "استاندارد";
else if (fullText.includes("حرفه ای")) userLevel = "حرفه ای";
else if (fullText.includes("استاد")) userLevel = "استاد";
const parsedLevel = parseUserLevelFromSlug(fullText);
if (parsedLevel) userLevel = parsedLevel;
// استخراج مکان (IDها)
if (fullText.includes("در شهر")) {
@@ -153,7 +151,7 @@ export default async function Models({ params, searchParams }: IModelsProps) {
hashtag: filters.hashtag, // این خط را اضافه کن
province: provinceId,
city: cityId,
userLevel: userLevel || filters.userLevel,
userLevel: userLevel || normalizeUserLevel(filters.userLevel),
expertise: expertise || filters.expertise
}}
token={token}

View File

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

View File

@@ -10,7 +10,6 @@ import BillboardDetails from "@/components/billboards/BillboardPage/BillboardDet
import { Metadata } from "next";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
import SEO from "@/config/SEO";
interface IBillboardProps {
params: Promise<{ id: string; title: string }>;
@@ -97,8 +96,6 @@ async function BillboardPage({ params }: IBillboardProps) {
return (
<Container>
<SEO type="store" data={billboard} />
<a
href={`/billboards/profile/${billboard?.creatorId}/${encodeURIComponent(billboard?.title || "")}`}
>

View File

@@ -4,7 +4,7 @@ import Container from "@/components/elements/Container";
import TabNavigation from "@/components/TabNavigation";
import ExploreGrid from "@/components/explore/ExploreGrid";
import Link from "next/link";
import { FiChevronRight } from "react-icons/fi";
import BoldIcon from "@/components/ui/BoldIcon";
export default function ExplorePage() {
return (
@@ -16,7 +16,7 @@ export default function ExplorePage() {
className="gentle-transition flex h-9 w-9 items-center justify-center rounded-full text-[#0095f6] active:scale-90"
aria-label="بازگشت"
>
<FiChevronRight size={22} />
<BoldIcon name="arrow-right-2" size={22} tinted className="text-[#0095f6]" />
</Link>
<h1 className="text-lg font-bold">اکسپلور</h1>
</header>

View File

@@ -286,26 +286,117 @@ select {
background: rgba(255, 255, 255, 0.72);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.45);
border: none;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06);
}
.dark .glass-panel {
background: rgba(28, 28, 30, 0.78);
border-color: rgba(255, 255, 255, 0.08);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.35);
}
.chat-header-glass {
background: rgba(255, 255, 255, 0.62);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: none;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
}
.dark .chat-header-glass {
background: rgba(28, 28, 30, 0.72);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.35);
}
.chat-header-status-dot {
width: 10px;
height: 10px;
flex-shrink: 0;
border-radius: 9999px;
}
.chat-header-status-dot--online {
background: #22c55e;
}
.chat-header-status-dot--offline {
background: #9ca3af;
}
.dark .chat-header-status-dot--offline {
background: #6b7280;
}
.chat-timer-glass {
background: rgba(255, 255, 255, 0.58);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: none;
box-shadow: 0 2px 14px rgba(36, 140, 200, 0.12);
}
.dark .chat-timer-glass {
background: rgba(24, 37, 51, 0.72);
box-shadow: 0 2px 14px rgba(0, 0, 0, 0.3);
}
.chat-timer-glass--active {
background: rgba(42, 171, 238, 0.18);
color: #248cc8;
box-shadow: 0 2px 14px rgba(42, 171, 238, 0.2);
}
.dark .chat-timer-glass--active {
background: rgba(42, 171, 238, 0.22);
color: #2aabee;
}
.chat-timer-badge-glass {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 1px 6px;
border-radius: 9999px;
background: rgba(255, 255, 255, 0.42);
backdrop-filter: blur(10px) saturate(160%);
-webkit-backdrop-filter: blur(10px) saturate(160%);
border: none;
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.08);
font-size: 10px;
font-weight: 600;
line-height: 1.2;
}
.chat-bubble-out .chat-timer-badge-glass {
background: rgba(255, 255, 255, 0.22);
color: inherit;
}
.chat-bubble-in .chat-timer-badge-glass {
color: #248cc8;
}
.dark .chat-timer-badge-glass {
background: rgba(255, 255, 255, 0.12);
}
.dark .chat-bubble-in .chat-timer-badge-glass {
color: #2aabee;
}
.chat-message-highlight {
animation: message-highlight-flash 1.6s ease-out;
border-radius: 1rem;
}
@keyframes message-highlight-flash {
0%,
100% {
box-shadow: 0 0 0 0 rgba(42, 171, 238, 0);
}
25%,
65% {
box-shadow: 0 0 0 3px rgba(42, 171, 238, 0.55);
}
}
.glass-chat-input {
background: rgba(255, 255, 255, 0.85);
background: rgba(255, 255, 255, 0.78);
backdrop-filter: blur(24px) saturate(200%);
-webkit-backdrop-filter: blur(24px) saturate(200%);
border: 1px solid rgba(255, 255, 255, 0.5);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08), 0 1px 0 rgba(255, 255, 255, 0.6) inset;
border: none;
box-shadow: 0 4px 20px rgba(15, 45, 70, 0.08);
}
.dark .glass-chat-input {
background: rgba(44, 44, 46, 0.88);
border-color: rgba(255, 255, 255, 0.1);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
background: rgba(24, 37, 51, 0.78);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.35);
}
/* ─── Gentle transitions ─── */
@@ -330,38 +421,90 @@ select {
max-width: min(100% - 2rem, 22rem);
}
/* ─── Instagram DM chat theme ─── */
.chat-page-bg {
background: #ffffff;
}
.dark .chat-page-bg {
background: #000000;
/* ─── Chat theme — Modstagram: modeling · makeup · beauty · photography ─── */
:root {
--chat-accent: #2aabee;
/* Studio light: warm key + soft fill */
--chat-bg: #f3ece6;
--chat-bg-pattern: url("/images/chat/wallpaper-light.svg"),
radial-gradient(ellipse 90% 70% at 8% 12%, rgba(214, 164, 176, 0.32) 0%, transparent 58%),
radial-gradient(ellipse 85% 65% at 92% 88%, rgba(141, 175, 205, 0.26) 0%, transparent 55%),
radial-gradient(ellipse 60% 50% at 50% 50%, rgba(255, 248, 242, 0.5) 0%, transparent 70%),
linear-gradient(168deg, #faf4ef 0%, #f0e8f0 38%, #e8eef4 100%);
--chat-bg-pattern-size: 480px 480px, 100% 100%, 100% 100%, 100% 100%, 100% 100%;
--chat-bubble-out: #248cc8;
--chat-bubble-out-fg: #ffffff;
--chat-bubble-out-meta: rgba(255, 255, 255, 0.78);
--chat-bubble-in: #fffdfb;
--chat-bubble-in-fg: #1c1520;
--chat-bubble-in-meta: rgba(28, 21, 32, 0.46);
}
.dark {
--chat-accent: #2aabee;
/* Dark studio: tungsten key + cool rim */
--chat-bg: #0e1014;
--chat-bg-pattern: url("/images/chat/wallpaper-dark.svg"),
radial-gradient(ellipse 80% 55% at 10% 15%, rgba(196, 149, 127, 0.14) 0%, transparent 58%),
radial-gradient(ellipse 75% 50% at 88% 82%, rgba(42, 171, 238, 0.11) 0%, transparent 52%),
radial-gradient(ellipse 50% 40% at 50% 45%, rgba(180, 140, 170, 0.06) 0%, transparent 65%),
linear-gradient(168deg, #121018 0%, #0e1014 45%, #0a0c12 100%);
--chat-bg-pattern-size: 480px 480px, 100% 100%, 100% 100%, 100% 100%, 100% 100%;
--chat-bubble-out: #2aabee;
--chat-bubble-out-fg: #ffffff;
--chat-bubble-out-meta: rgba(255, 255, 255, 0.76);
--chat-bubble-in: #1a2029;
--chat-bubble-in-fg: #eceef3;
--chat-bubble-in-meta: rgba(236, 238, 243, 0.55);
}
.chat-page-bg {
position: relative;
background: transparent;
isolation: isolate;
}
.chat-page-bg::before {
content: "";
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
background-color: var(--chat-bg);
background-image: var(--chat-bg-pattern);
background-size: var(--chat-bg-pattern-size);
}
/* فقط لیست پیام‌ها — بدون position:relative تا fixed هدر/فوتر خراب نشود */
.chat-page-bg > .chat-thread-messages {
z-index: 1;
}
/* Outgoing — Instagram DM (RTL: tail bottom-left / outer edge) */
.chat-bubble-out,
.ig-dm-out {
background: linear-gradient(135deg, #5b51d8 0%, #c13584 45%, #e1306c 100%);
color: #fff;
background: var(--chat-bubble-out);
color: var(--chat-bubble-out-fg);
}
.bubble-tail-out .chat-bubble-out,
.bubble-tail-out .chat-text-bubble.chat-bubble-out {
border-radius: 18px 18px 18px 4px;
.chat-bubble-out .message-time-inline,
.chat-text-bubble.chat-bubble-out .message-time-inline,
.chat-bubble-out .message-time-below {
color: var(--chat-bubble-out-meta);
}
/* Incoming — tail bottom-right */
.chat-bubble-in,
.ig-dm-in {
background: #efefef;
color: #000;
}
.bubble-tail-in .chat-bubble-in,
.bubble-tail-in .chat-text-bubble.chat-bubble-in {
border-radius: 18px 18px 4px 18px;
background: var(--chat-bubble-in);
color: var(--chat-bubble-in-fg);
box-shadow: 0 1px 2px rgba(26, 21, 32, 0.08);
}
.dark .chat-bubble-in,
.dark .ig-dm-in {
background: #262626;
color: #f5f5f5;
box-shadow: none;
}
.chat-bubble-in .message-time-inline,
.chat-text-bubble.chat-bubble-in .message-time-inline,
.chat-bubble-in .message-time-below {
color: var(--chat-bubble-in-meta);
}
.chat-text-bubble {
@@ -384,12 +527,15 @@ select {
display: inline-flex;
align-items: center;
gap: 2px;
white-space: nowrap;
}
.bubble-tail-out .message-time-inline {
.chat-bubble-out .message-time-inline,
.chat-text-bubble.chat-bubble-out .message-time-inline {
left: auto;
right: 8px;
}
.bubble-tail-in .message-time-inline {
.chat-bubble-in .message-time-inline,
.chat-text-bubble.chat-bubble-in .message-time-inline {
left: 8px;
right: auto;
}
@@ -416,6 +562,17 @@ select {
box-shadow: 0 4px 14px rgba(0, 122, 255, 0.35);
}
.chat-forward-btn {
background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
color: #fff;
box-shadow: 0 4px 14px rgba(124, 58, 237, 0.35);
}
.dark .chat-forward-btn {
background: linear-gradient(135deg, #a78bfa 0%, #8b5cf6 100%);
box-shadow: 0 4px 14px rgba(139, 92, 246, 0.4);
}
.header-offline {
background: linear-gradient(180deg, rgba(220, 38, 38, 0.92) 0%, rgba(185, 28, 28, 0.88) 100%) !important;
backdrop-filter: blur(12px);
@@ -448,7 +605,8 @@ select {
}
.ig-dm-send-btn {
background: linear-gradient(135deg, #5b51d8 0%, #c13584 50%, #e1306c 100%);
background: var(--chat-bubble-out);
color: var(--chat-bubble-out-fg);
}
/* ─── Instagram-style typing dots ─── */
@@ -472,14 +630,7 @@ select {
animation: check-pop 0.35s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
/* ─── Message enter animation ─── */
@keyframes message-in {
from { opacity: 0; transform: translateY(12px) scale(0.96); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.message-enter {
animation: message-in 0.35s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
}
/* Message enter — handled by framer-motion in ChatMessageCard */
/* ─── Shrink header ─── */
.header-shrink {
@@ -502,7 +653,7 @@ select {
filter: invert(1) hue-rotate(180deg) brightness(1.2);
}
/* حذف پس‌زمینه سفید اضافی در برخی مرورگرها */
.custom-audio-player::-webkit-media-controls-panel {
background-color: transparent;
}
.profile-avatar {
aspect-ratio: 1 / 1;
object-fit: cover;
}

View File

@@ -10,8 +10,15 @@ const iranSansFont = localFont({
src: "./../../public/fonts/IRANSansX-Regular.woff",
});
const defaultTitle =
typeof defaultSEOConfig.title === "string"
? defaultSEOConfig.title
: "مدستاگرام";
export const metadata: Metadata = {
title: defaultSEOConfig.title,
title: {
default: defaultTitle,
},
description: defaultSEOConfig.description,
metadataBase: new URL("https://modstagram.com"),
keywords: [
@@ -26,8 +33,21 @@ export const metadata: Metadata = {
alternates: {
canonical: "/",
},
manifest: "/manifest.json",
icons: {
icon: [{ url: "/favicon.ico", type: "image/x-icon" }],
shortcut: ["/favicon.ico"],
apple: [
{ url: "/images/icons/apple-touch-icon.png" },
{ url: "/images/icons/192x192.png" },
],
},
appleWebApp: {
capable: true,
statusBarStyle: "black",
},
openGraph: {
title: defaultSEOConfig.openGraph?.title || "",
title: defaultSEOConfig.openGraph?.title || defaultTitle,
description: defaultSEOConfig.openGraph?.description || "",
url: defaultSEOConfig.openGraph?.url || "",
siteName: defaultSEOConfig.openGraph?.site_name || "",
@@ -37,7 +57,7 @@ export const metadata: Metadata = {
robots: "index, follow",
twitter: {
card: "summary_large_image",
title: defaultSEOConfig.title || "",
title: defaultTitle,
description: defaultSEOConfig.description || "",
creator: "@modstagram",
images: defaultSEOConfig.openGraph?.images?.map((img) => img.url) || [],
@@ -45,13 +65,11 @@ export const metadata: Metadata = {
verification: {
google: "yVjB5yKPchPUtxl33GWVyMvjH6wCEInqEvaH7ZsXXMo",
},
};
const getInitialTheme = (): string => {
if (typeof window !== "undefined") {
return localStorage.getItem("theme") || "light";
}
return "light";
other: {
"theme-color": "#ffffff",
"msapplication-TileColor": "#0072BC",
samandehi: "522190795",
},
};
export default function RootLayout({
@@ -59,30 +77,9 @@ export default function RootLayout({
}: Readonly<{
children: React.ReactNode;
}>) {
const initialTheme = getInitialTheme();
return (
<html lang="fa" dir="rtl" className={initialTheme}>
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#ffffff" />
<link rel="apple-touch-icon" href="/images/icons/192x192.png" />
<head>
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
<link
rel="apple-touch-icon"
href="/images/icons/apple-touch-icon.png"
/>
<meta name="msapplication-TileColor" content="#0072BC" />
<meta name="msapplication-TileImage" content="/path/to/tileImage.png" />
<meta name="samandehi" content="522190795" />
</head>
<body
className={`${iranSansFont.className} antialiased bg-white dark:bg-neutral-950 text-text-primary-light dark:text-primary-light`}
>
<html lang="fa" dir="rtl" suppressHydrationWarning>
<body className={`${iranSansFont.className} antialiased`}>
<RegisterSW />
<Layout>{children}</Layout>
</body>

View File

@@ -1,13 +1,9 @@
"use client";
import { Separator } from "@/Components/ui/separator";
import React from "react";
import { IconArrowBack } from "@tabler/icons-react";
import { GrTasks } from "react-icons/gr";
import BoldIcon from "@/components/ui/BoldIcon";
import { useTranslation } from "react-i18next";
import Image from "next/image";
import { FaFileInvoice } from "react-icons/fa";
import { IoIosArrowForward } from "react-icons/io";
import { FcRating } from "react-icons/fc";
interface OrderItem {
@@ -31,14 +27,14 @@ export default function OrdersCart({ order }: { order: OrderItem }) {
<div className="flex items-center justify-between w-full">
<div className="flex justify-center items-center gap-2.5">
<span className="">
<GrTasks className="size-7 text-cyan-600 dark:text-neutral-600" />
<BoldIcon name="task" size={28} tinted className="text-cyan-600 dark:text-neutral-600" />
</span>
<span className="font-bold text-neutral-600 dark:text-neutral-300">
{order.statusLabel}
</span>
</div>
<span className="ltr:rotate-180">
<IconArrowBack className="text-neutral-600 dark:text-neutral-300" />
<BoldIcon name="arrow-right-2" size={24} tinted className="text-neutral-600 dark:text-neutral-300" />
</span>
</div>
<div className=" text-sm max-sm:text-xs w-full flex gap-3.5">
@@ -89,15 +85,15 @@ export default function OrdersCart({ order }: { order: OrderItem }) {
{t("orderCart.Your_rating_for_this_order")}:{" "}
</span>
<span className="font-bold flex justify-center items-center gap-0.5 flex-row-reverse text-neutral-600 dark:text-neutral-300">
{order.rating} <FcRating className="size-5"/>
{order.rating} <BoldIcon name="star" size={20} tinted className="text-amber-400" />
</span>
</div>
<div className="flex cursor-pointer justify-center items-center gap-2.5">
<span className="">
<FaFileInvoice className="size-5 text-cyan-800 dark:text-neutral-200" />
<BoldIcon name="document-text" size={20} tinted className="text-cyan-800 dark:text-neutral-200" />
</span>
<span className="font-bold flex justify-center items-center text-cyan-800 dark:text-neutral-200">
{t("orderCart.View_invoice")} <IoIosArrowForward className="rtl:rotate-180" />
{t("orderCart.View_invoice")} <BoldIcon name="arrow-right-2" size={20} tinted className="text-cyan-800 dark:text-neutral-200 rtl:rotate-180" />
</span>
</div>

View File

@@ -10,6 +10,7 @@ import { SOCKET_URL } from "@/components/main/BaseUrl";
import MessageInput from "@/components/chat/MessageInput";
import MultiImageModal from "@/components/chat/MultiImageModal";
import ChatActionBar from "@/components/chat/ChatActionBar";
import ChatDeleteBar from "@/components/chat/ChatDeleteBar";
import { useUser } from "@/hooks/useUser";
import ChatMessageList from "@/components/chat/ChatMessageList";
import ChatHeader from "@/components/chat/ChatHeader";
@@ -19,7 +20,13 @@ import toast from "react-hot-toast";
import { optimizeMediaFile } from "@/lib/media";
import ForwardMessageModal from "@/components/chat/ForwardMessageModal";
import { AnimatePresence } from "framer-motion";
import { formatBubbleTime } from "@/lib/chat/formatMessageTime";
import { useQueryClient } from "@tanstack/react-query";
import { chatThreadQueryKey } from "@/lib/chat/queryKeys";
import { appendMessageToThreadCache, normalizeThreadId } from "@/lib/chat/threadCache";
import { useStableMessageKeys } from "@/hooks/useStableMessageKeys";
import { getStoredUserId } from "@/lib/auth/session";
import { buildExpiresAtIso } from "@/lib/chat/timedMessages";
import { isViewOnceMediaType } from "@/lib/chat/viewOnce";
interface ITicketChatProps {
params: Promise<{ id: string; username: string }>;
@@ -29,7 +36,6 @@ function normalizeMessage(raw: ChatMessage & { data?: ChatMessage }): ChatMessag
const m = (raw as { data?: ChatMessage }).data ?? raw;
return {
...m,
createdAt: formatBubbleTime(m.createdAt),
replyTo: m.replyTo ?? undefined,
};
}
@@ -37,7 +43,7 @@ function normalizeMessage(raw: ChatMessage & { data?: ChatMessage }): ChatMessag
function TicketChat({ params }: ITicketChatProps) {
const resolvedParams = React.use(params);
const { request } = useAxios();
const { username } = resolvedParams;
const { username, id: chatPartnerId } = resolvedParams;
const [newMessage, setNewMessage] = useState("");
const [userTwoDetail, setUserTwoDetail] = useState<User>();
const [pendingImages, setPendingImages] = useState<File[]>([]);
@@ -47,9 +53,18 @@ function TicketChat({ params }: ITicketChatProps) {
const [forwardMessage, setForwardMessage] = useState<ChatMessage | null>(null);
const [selectedMessage, setSelectedMessage] = useState<ChatMessage | null>(null);
const [actionMode, setActionMode] = useState(false);
const [deleteMode, setDeleteMode] = useState(false);
const [selectedDeleteIds, setSelectedDeleteIds] = useState<string[]>([]);
const [deleteLoading, setDeleteLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [selfDestructSeconds, setSelfDestructSeconds] = useState<number | null>(
null
);
const [viewOnceMedia, setViewOnceMedia] = useState(false);
const user = useUser();
const [receiverId, setReceiverId] = useState("");
const queryClient = useQueryClient();
const { linkIds, getStableKey } = useStableMessageKeys();
const [receiverId, setReceiverId] = useState(chatPartnerId);
const [isTyping, setIsTyping] = useState(false);
const socketRef = useRef<ReturnType<typeof io> | null>(null);
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
@@ -98,10 +113,10 @@ function TicketChat({ params }: ITicketChatProps) {
}, [username, request]);
useEffect(() => {
setReceiverId(userTwoDetail?._id || "");
}, [user, userTwoDetail]);
setReceiverId(userTwoDetail?._id || chatPartnerId);
}, [user, userTwoDetail, chatPartnerId]);
const handleImageSelection = (e: React.ChangeEvent<HTMLInputElement>) => {
const handleFileSelection = (e: React.ChangeEvent<HTMLInputElement>) => {
const picked = Array.from(e.target.files || []);
if (!picked.length) return;
const MAX_SIZE = 200 * 1024 * 1024;
@@ -113,8 +128,27 @@ function TicketChat({ params }: ITicketChatProps) {
return true;
});
if (!valid.length) return;
setPendingImages((prev) => [...prev, ...valid].slice(0, 10));
setShowMultiModal(true);
const videos = valid.filter((f) => f.type.startsWith("video/"));
const images = valid.filter((f) => f.type.startsWith("image/"));
const others = valid.filter(
(f) => !f.type.startsWith("video/") && !f.type.startsWith("image/")
);
if (videos.length === 1 && images.length === 0 && others.length === 0) {
void processSendMessage(videos[0], "video");
return;
}
if (others.length === 1 && images.length === 0 && videos.length === 0) {
void processSendMessage(others[0], "file");
return;
}
if (images.length) {
setPendingImages((prev) => [...prev, ...images].slice(0, 10));
setShowMultiModal(true);
}
};
const handleVoiceUpload = (blob: Blob) => {
@@ -183,15 +217,26 @@ function TicketChat({ params }: ITicketChatProps) {
const tempId = `temp-${Date.now()}-${Math.random()}`;
const replyPayload = buildReplyPayload(replySource);
const currentUserId = normalizeThreadId(user?._id ?? getStoredUserId());
const targetReceiverId = normalizeThreadId(
userTwoDetail?._id ?? receiverId ?? chatPartnerId
);
const applyViewOnce =
viewOnceMedia && fileType && isViewOnceMediaType(fileType);
const optimisticMsg: ChatMessage = {
_id: tempId,
content: textContent,
senderId: user?._id || "",
createdAt: formatBubbleTime(new Date().toISOString()),
senderId: currentUserId,
receiverId: targetReceiverId,
createdAt: new Date().toISOString(),
expiresAt: buildExpiresAtIso(selfDestructSeconds),
status: "pending",
file: fileToUpload ? URL.createObjectURL(fileToUpload) : "",
fileType: fileType,
replyTo: replyPayload,
viewOnce: applyViewOnce || undefined,
};
setPendingMessages((prev) => [...prev, optimisticMsg]);
@@ -210,8 +255,8 @@ function TicketChat({ params }: ITicketChatProps) {
const formData = new FormData();
formData.append("content", textContent);
formData.append("receiverId", userTwoDetail?._id || "");
formData.append("senderId", user?._id || "");
formData.append("receiverId", targetReceiverId);
formData.append("senderId", currentUserId);
if (replySource?._id && !replySource._id.startsWith("temp")) {
formData.append("replyToId", replySource._id);
}
@@ -219,6 +264,12 @@ function TicketChat({ params }: ITicketChatProps) {
formData.append("file", fileForUpload);
if (fileType) formData.append("fileType", fileType);
}
if (selfDestructSeconds) {
formData.append("selfDestructSeconds", String(selfDestructSeconds));
}
if (applyViewOnce) {
formData.append("viewOnce", "true");
}
try {
const endpoint =
@@ -232,20 +283,26 @@ function TicketChat({ params }: ITicketChatProps) {
);
const serverMsg = normalizeMessage(response as ChatMessage);
setPendingMessages((prev) =>
prev.map((m) =>
m._id === tempId
? {
...serverMsg,
status: "sent",
replyTo: serverMsg.replyTo ?? replyPayload,
}
: m
)
);
const sentMsg: ChatMessage = {
...serverMsg,
status: "sent",
replyTo: serverMsg.replyTo ?? replyPayload,
};
if (currentUserId && chatPartnerId) {
linkIds(tempId, sentMsg._id);
queryClient.setQueryData(
chatThreadQueryKey(currentUserId, chatPartnerId),
(oldData) => appendMessageToThreadCache(oldData, sentMsg)
);
}
setPendingMessages((prev) => prev.filter((m) => m._id !== tempId));
setReplyingTo(null);
activeReplyRef.current = null;
setSelfDestructSeconds(null);
if (applyViewOnce) setViewOnceMedia(false);
} catch (error) {
console.error("Error sending message:", error);
setPendingMessages((prev) => prev.filter((m) => m._id !== tempId));
@@ -253,6 +310,10 @@ function TicketChat({ params }: ITicketChatProps) {
}
};
const handleExpirePending = useCallback((ids: string[]) => {
setPendingMessages((prev) => prev.filter((m) => !ids.includes(m._id)));
}, []);
const openActionFor = (msg: ChatMessage) => {
setSelectedMessage(msg);
setActionMode(true);
@@ -263,11 +324,15 @@ function TicketChat({ params }: ITicketChatProps) {
setSelectedMessage(null);
};
const startReplyFor = useCallback((msg: ChatMessage) => {
setReplyingTo(msg);
activeReplyRef.current = msg;
closeActionMode();
}, []);
const startReply = () => {
if (!selectedMessage) return;
setReplyingTo(selectedMessage);
activeReplyRef.current = selectedMessage;
closeActionMode();
startReplyFor(selectedMessage);
};
const startForward = () => {
@@ -276,23 +341,89 @@ function TicketChat({ params }: ITicketChatProps) {
closeActionMode();
};
const handleBlockChange = () => {
const handleBlockChange = (blocked?: boolean) => {
if (typeof blocked === "boolean") {
setUserTwoDetail((prev) =>
prev ? { ...prev, is_blocked: blocked } : prev
);
}
void request<{ user: User }>(
"GET",
`/users/get/web?user_name=${username}&_t=${Date.now()}`
).then((res) => setUserTwoDetail(res?.user));
).then((res) => {
if (res?.user) setUserTwoDetail(res.user);
});
};
const startDeleteMode = () => {
closeActionMode();
setSearchQuery("");
setDeleteMode(true);
setSelectedDeleteIds([]);
};
const cancelDeleteMode = () => {
setDeleteMode(false);
setSelectedDeleteIds([]);
};
const toggleDeleteSelect = (msg: ChatMessage) => {
setSelectedDeleteIds((prev) =>
prev.includes(msg._id)
? prev.filter((id) => id !== msg._id)
: [...prev, msg._id]
);
};
const confirmDeleteMessages = async () => {
if (!selectedDeleteIds.length || !user?._id) return;
setDeleteLoading(true);
try {
const response = await request<{ deletedIds: string[]; message?: string }>(
"DELETE",
"/chat",
{ messageIds: selectedDeleteIds }
);
const deleted = new Set(response?.deletedIds || []);
setPendingMessages((prev) => prev.filter((m) => !deleted.has(m._id)));
queryClient.setQueryData(
chatThreadQueryKey(
normalizeThreadId(user._id),
normalizeThreadId(chatPartnerId)
),
(oldData: { pages: { messages: ChatMessage[] }[] } | undefined) => {
if (!oldData) return oldData;
return {
...oldData,
pages: oldData.pages.map((page) => ({
...page,
messages: page.messages.filter((m) => !deleted.has(m._id)),
})),
};
}
);
toast.success(response?.message || "پیام‌ها حذف شدند");
cancelDeleteMode();
} catch {
toast.error("حذف پیام‌ها ناموفق بود");
} finally {
setDeleteLoading(false);
}
};
return (
<Container className="chat-page-bg flex h-[100dvh] max-h-[100dvh] flex-col overflow-hidden !px-0">
<div className="flex min-h-0 flex-1 flex-col px-2 sm:px-4">
<ChatHeader
user={userTwoDetail}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
onBlockChange={handleBlockChange}
/>
<ChatMessageList
<ChatHeader
user={userTwoDetail}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
onBlockChange={handleBlockChange}
onStartDeleteMode={startDeleteMode}
deleteMode={deleteMode}
/>
<ChatMessageList
chatPartnerId={chatPartnerId}
getStableKey={getStableKey}
pendingMessages={pendingMessages}
userDetail={user as User}
userTwoDetail={userTwoDetail}
@@ -301,11 +432,30 @@ function TicketChat({ params }: ITicketChatProps) {
searchQuery={searchQuery}
selectedMessageId={selectedMessage?._id}
onBubbleClick={openActionFor}
/>
</div>
onSwipeReply={startReplyFor}
actionMode={actionMode}
onDismissAction={closeActionMode}
deleteMode={deleteMode}
selectedDeleteIds={selectedDeleteIds}
onToggleDeleteSelect={toggleDeleteSelect}
onExpirePending={handleExpirePending}
extraBottomRem={
(replyingTo ? 2.75 : 0) +
(selfDestructSeconds != null ? 2 : 0) +
(viewOnceMedia ? 2 : 0)
}
/>
<AnimatePresence mode="wait">
{actionMode ? (
{deleteMode ? (
<ChatDeleteBar
key="delete"
selectedCount={selectedDeleteIds.length}
onDelete={confirmDeleteMessages}
onCancel={cancelDeleteMode}
loading={deleteLoading}
/>
) : actionMode ? (
<ChatActionBar
key="actions"
onReply={startReply}
@@ -318,7 +468,7 @@ function TicketChat({ params }: ITicketChatProps) {
newMessage={newMessage}
setNewMessage={setNewMessage}
sendMessage={sendMessage}
handleFileSelection={handleImageSelection}
handleFileSelection={handleFileSelection}
handleVoiceUpload={handleVoiceUpload}
onLocationShare={handleLocationShare}
onTyping={handleTyping}
@@ -334,6 +484,10 @@ function TicketChat({ params }: ITicketChatProps) {
? replyingTo.content?.slice(0, 80) || "پیام"
: undefined
}
selfDestructSeconds={selfDestructSeconds}
onSelfDestructChange={setSelfDestructSeconds}
viewOnceMedia={viewOnceMedia}
onViewOnceChange={setViewOnceMedia}
/>
)}
</AnimatePresence>
@@ -353,6 +507,8 @@ function TicketChat({ params }: ITicketChatProps) {
setShowMultiModal(false);
}}
onConfirm={sendAllImages}
viewOnceMedia={viewOnceMedia}
onViewOnceChange={setViewOnceMedia}
/>
{forwardMessage && user?._id && (
<ForwardMessageModal

View File

@@ -3,6 +3,7 @@
import Container from "@/components/elements/Container";
import RoundedInput from "@/components/elements/RoundedInput";
import { IMAGE_BASE_URL, SOCKET_URL, buildStorageUrl } from "@/components/main/BaseUrl";
import VerificationBadge from "@/components/main/VerificationBadge";
import PageTitle from "@/components/settings/PageTitle";
import UserDetails from "@/components/settings/UserDetails";
import useInfiniteScroll from "@/hooks/useInfiniteScroll";
@@ -126,15 +127,10 @@ function Chats() {
{item.first_name} {item.last_name}
</span>
<div className="mt-1 flex items-center gap-1 text-neutral-500">
@{item?.user_name}
{item?.is_verified === "verified" && (
<Image
width={18}
height={18}
alt="تایید"
src="/images/icons/verify.svg"
/>
)}
<span className="inline-flex items-center gap-1">
@{item?.user_name}
<VerificationBadge isVerified={item?.is_verified} />
</span>
</div>
</div>
</div>

View File

@@ -71,7 +71,7 @@ function NationalCart() {
return (
<Container>
<div className="p-4 flex flex-col items-center h-screen justify-center address-page">
<span className="text-xl font-bold">احراز هویت</span>
<span className="text-xl font-bold text-foreground">احراز هویت</span>
<div className="mt-5">
<AuthUserDetails />
</div>
@@ -149,9 +149,9 @@ function NationalCart() {
<AuthNextButton
onClick={uploadImage}
className="mt-20"
disabled={loading}
loading={loading} disabled={loading}
>
{loading ? "در حال ارسال..." : "ثبت و ادامه"}
ثبت و ادامه
</AuthNextButton>
<button

View File

@@ -6,6 +6,7 @@ import React, { useState, useEffect } from "react";
import AuthNextButton from "@/components/auth/AuthNextButton";
import Image from "next/image";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import { useUser } from "@/hooks/useUser";
import { BASE_URL, IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
import useAxios from "@/hooks/useAxios";
@@ -178,17 +179,13 @@ function LicensePage() {
<span className="text-xl mb-9 font-bold">بارگذاری مجوز</span>
<div className="flex flex-col justify-center items-center">
<Image
className="rounded-2xl object-cover"
width={130}
height={130}
<ProfileAvatar
src={user?.profile_image}
alt={user?.user_name || "user profile"}
src={
user?.profile_image
? buildStorageUrl(user.profile_image)
: "/images/placeholder.png"
}
unoptimized={true}
size="md"
rounded="2xl"
className="h-[130px] w-[130px]"
fallback="/images/placeholder.png"
/>
<div className="flex flex-col justify-center items-center gap-1 mt-1">
<span>
@@ -286,7 +283,7 @@ function LicensePage() {
</svg>
</div>
</div>
<AuthNextButton onClick={upload} className="mt-20">
<AuthNextButton onClick={upload} className="mt-20" loading={loadingUpload} disabled={loadingUpload}>
ثبت و ادامه
</AuthNextButton>
<small className="font-bold mt-10 mb-1 text-center">

View File

@@ -58,7 +58,6 @@ function AvatarPage() {
"Content-Type": "multipart/form-data",
},
});
setLoadingUpload(false);
router.push("/settings/edit");
} else {
setLoadingUpload(false);
@@ -80,13 +79,9 @@ function AvatarPage() {
return (
<Container>
<div className="p-4 flex flex-col items-center h-screen justify-center">
{loading || loadingUpload ? (
<p>Loading...</p>
) : (
<>
<div className="flex flex-col items-center mb-4">
<span className="text-xl font-bold">تصویر پروفایل</span>
<div className="relative w-[250px] h-[250px] flex items-center justify-center text-white rounded-3xl border border-[#676767] overflow-hidden mt-10">
<div className="flex flex-col items-center mb-4">
<span className="text-xl font-bold text-foreground">تصویر پروفایل</span>
<div className="relative aspect-square w-[250px] flex items-center justify-center text-white rounded-3xl border border-[#676767] overflow-hidden mt-10">
{avatar ? (
<>
<img
@@ -96,7 +91,7 @@ function AvatarPage() {
: IMAGE_BASE_URL + avatar
}
alt="Avatar"
className="w-full h-full object-cover"
className="w-full h-full aspect-square object-cover"
/>
<button
onClick={removeImage}
@@ -141,15 +136,14 @@ function AvatarPage() {
</label>
</AuthNextButton>
<AuthNextButton
onClick={uploadImage}
className="mt-20"
disabled={loading}
>
ویرایش
</AuthNextButton>
</>
)}
<AuthNextButton
onClick={uploadImage}
className="mt-20"
loading={loadingUpload}
disabled={loadingUpload || loading}
>
ویرایش
</AuthNextButton>
</div>
</Container>
);

View File

@@ -50,7 +50,7 @@ function PublicRelations() {
return (
<Container>
<div className="p-4 flex flex-col items-center h-screen justify-center">
<span className="text-xl font-bold">بیو</span>
<span className="text-xl font-bold text-foreground">بیو</span>
<div className="mt-5"></div>
<form
onSubmit={formik.handleSubmit}
@@ -75,8 +75,8 @@ function PublicRelations() {
</small>
)}
<AuthNextButton type="submit" className="mt-20" disabled={loading}>
{loading ? "در حال ارسال..." : "ویرایش"}
<AuthNextButton type="submit" className="mt-20" loading={loading} disabled={loading}>
ویرایش
</AuthNextButton>
</form>
</div>

View File

@@ -84,7 +84,7 @@ function Colors() {
return (
<Container>
<div className="p-4 flex flex-col items-center h-screen justify-center">
<span className="text-xl font-bold">سایز</span>
<span className="text-xl font-bold text-foreground">سایز</span>
<p className="mt-8 text-center text-sm font-bold">مشخصات ظاهری</p>
@@ -159,9 +159,9 @@ function Colors() {
type="button"
onClick={handleCheck}
className="mt-10"
disabled={loading}
loading={loading} disabled={loading}
>
{loading ? "در حال ارسال..." : "ویرایش"}
ویرایش
</AuthNextButton>
</div>
</Container>

View File

@@ -48,7 +48,7 @@ function CooperationType() {
return (
<Container>
<div className="p-4 flex flex-col items-center h-screen justify-center">
<span className="text-xl font-bold">نوع همکاری</span>
<span className="text-xl font-bold text-foreground">نوع همکاری</span>
<p className="my-10 text-sm font-bold">
آیا مایل به همکاری خارج از محل سکونت خود هستید؟
@@ -89,8 +89,8 @@ function CooperationType() {
</div>
)}
<AuthNextButton type="submit" className="mt-20" disabled={loading}>
{loading ? "در حال ارسال..." : "ویرایش"}
<AuthNextButton type="submit" className="mt-20" loading={loading} disabled={loading}>
ویرایش
</AuthNextButton>
</form>
</div>

View File

@@ -96,7 +96,7 @@ function Expertise() {
return (
<Container>
<div className="p-4 flex flex-col items-center h-screen justify-center">
<span className="text-xl font-bold">تخصص</span>
<span className="text-xl font-bold text-foreground">تخصص</span>
<p className="mt-8 text-center">در چه زمینه ای تخصص دارید؟</p>
@@ -134,9 +134,9 @@ function Expertise() {
type="button"
onClick={handleCheck}
className="mt-20"
disabled={loading}
loading={loading} disabled={loading}
>
{loading ? "در حال ارسال..." : "ویرایش"}
ویرایش
</AuthNextButton>
</div>
</Container>

View File

@@ -89,8 +89,8 @@ function LocationPage() {
address: values.address,
province_id: values.stateId,
city_id: values.cityId,
lat: values.markerCoordinate[0],
lng: values.markerCoordinate[1],
lat: values.markerCoordinate[1],
lng: values.markerCoordinate[0],
show_location: isCheckedOne,
});
router.push("/settings/edit");
@@ -121,7 +121,9 @@ function LocationPage() {
if (user) {
formik.setValues({
markerCoordinate:
user.lat && user.lng ? [Number(user.lat), Number(user.lng)] : [],
user.lat && user.lng
? [Number(user.lng), Number(user.lat)]
: [],
address: user.address || "",
cityId: user?.city?.id ? String(user.city.id) : "",
stateId: user?.province?.id ? String(user.province.id) : "",
@@ -129,8 +131,8 @@ function LocationPage() {
if (user.lat && user.lng) {
setSelectedLocation({
lat: Number(user?.lng),
lng: Number(user?.lat),
lat: Number(user.lat),
lng: Number(user.lng),
});
}
@@ -146,7 +148,7 @@ function LocationPage() {
return (
<Container>
<div className="p-4 flex flex-col items-center justify-center address-page ">
<span className="text-xl font-bold">لوکیشن</span>
<span className="text-xl font-bold text-foreground">لوکیشن</span>
<div className="mt-5"></div>
<small className="font-bold mt-10 mb-4">
@@ -224,8 +226,8 @@ function LocationPage() {
<Map
style={{ height: "calc(100vh - 260px)" }}
initialViewState={{
longitude: Number(user?.lat) || 51.375433528216654,
latitude: Number(user?.lng) || 35.73356434056531,
longitude: Number(user?.lng) || 51.375433528216654,
latitude: Number(user?.lat) || 35.73356434056531,
zoom: 11,
}}
mapboxAccessToken="pk.eyJ1IjoiYWxkZjkyIiwiYSI6ImNscHh5ZG56dTByMXAycnJ2cDNmdDQ5M3YifQ.a_sUt1rLFMfA0jHrETcM7A"
@@ -259,8 +261,8 @@ function LocationPage() {
اطلاعات لوکیشن شما برای همه قابل نمایش باشد
</span>
</label>
<AuthNextButton type="submit" className="mt-20" disabled={loading}>
{loading ? "در حال ارسال..." : "ویرایش"}
<AuthNextButton type="submit" className="mt-20" loading={loading} disabled={loading}>
ویرایش
</AuthNextButton>
</form>
</div>

View File

@@ -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,11 +64,11 @@ function PasswordPage() {
onSubmit={formik.handleSubmit}
className="w-full max-w-sm flex flex-col items-center"
>
<AuthInput
<AuthPasswordInput
name="password"
type="password"
placeholder="کلمه عبور"
className={`border mt-4 ${
wrapperClassName="mt-4"
className={`border ${
formik.touched.password && formik.errors.password
? "border-red-500 dark:border-red-500"
: "border-gray-300"
@@ -83,11 +83,11 @@ function PasswordPage() {
</small>
)}
<AuthInput
<AuthPasswordInput
name="confirmPassword"
type="password"
placeholder="تکرار کلمه عبور"
className={`border mt-4 ${
wrapperClassName="mt-4"
className={`border ${
formik.touched.confirmPassword && formik.errors.confirmPassword
? "border-red-500 dark:border-red-500"
: "border-gray-300"
@@ -102,8 +102,8 @@ function PasswordPage() {
</small>
)}
<AuthNextButton type="submit" className="mt-20" disabled={loading}>
{loading ? "در حال ارسال..." : "تایید کلمه عبور"}
<AuthNextButton type="submit" className="mt-20" loading={loading} disabled={loading}>
تایید کلمه عبور
</AuthNextButton>
</form>
</div>

View File

@@ -53,7 +53,7 @@ function PublicRelations() {
return (
<Container>
<div className="p-4 flex flex-col items-center h-screen justify-center">
<span className="text-xl font-bold">روابط عمومی</span>
<span className="text-xl font-bold text-foreground">روابط عمومی</span>
<div className="mt-5"></div>
{/* <p className="my-10 text-sm font-bold">
@@ -123,8 +123,8 @@ function PublicRelations() {
</small>
)}
<AuthNextButton type="submit" className="mt-20" disabled={loading}>
{loading ? "در حال ارسال..." : "ویرایش"}
<AuthNextButton type="submit" className="mt-20" loading={loading} disabled={loading}>
ویرایش
</AuthNextButton>
</form>
</div>

View File

@@ -4,6 +4,7 @@ import React, { useEffect, useState } from "react";
import { Service, User } from "@/types/types";
import ServiceItem from "@/components/main/Services/ServiceItem";
import AddServiceModal from "@/components/main/Services/AddServiceModal";
import AuthUserDetails from "@/components/auth/AuthUserDetails";
import Container from "@/components/elements/Container";
import AuthNextButton from "@/components/auth/AuthNextButton";
import useAxios from "@/hooks/useAxios";
@@ -91,7 +92,11 @@ const ServicesPage: React.FC = () => {
return (
<Container>
<div className="p-4 flex flex-col items-center h-screen justify-center">
<span className="text-xl font-bold">خدمات</span>
<span className="text-xl font-bold text-foreground">خدمات</span>
<div className="mt-5 w-full max-w-md">
<AuthUserDetails />
</div>
<div className="gap-4 min-h-96">
{services.map((service) => (
@@ -107,10 +112,10 @@ const ServicesPage: React.FC = () => {
<div className="flex gap-4">
<AuthNextButton
onClick={handleSubmit}
disabled={loading}
loading={loading} disabled={loading}
className="mb-4 w-32"
>
{loading ? "در حال ارسال..." : "ویرایش"}
ویرایش
</AuthNextButton>
<AuthNextButton
onClick={() => setModalOpen(true)}

View File

@@ -53,7 +53,7 @@ function AuthPage() {
return (
<Container>
<div className="p-4 flex flex-col items-center h-screen justify-center auth-page">
<span className="text-xl font-bold">شماره شبا</span>
<span className="text-xl font-bold text-foreground">شماره شبا</span>
<div className="mt-5"></div>
<form
@@ -87,8 +87,8 @@ function AuthPage() {
<small className="font-bold mt-10">
شماره شبا باید به نام خود شخص باشد
</small>
<AuthNextButton type="submit" className="mt-20" disabled={loading}>
{loading ? "در حال ارسال..." : "ویرایش"}
<AuthNextButton type="submit" className="mt-20" loading={loading} disabled={loading}>
ویرایش
</AuthNextButton>
</form>
</div>

View File

@@ -75,7 +75,7 @@ function Sizes() {
return (
<Container>
<div className="p-4 flex flex-col items-center h-screen justify-center">
<span className="text-xl font-bold">سایز</span>
<span className="text-xl font-bold text-foreground">سایز</span>
<p className="mt-8 text-center text-sm font-bold mb-4">مشخصات ظاهری</p>
{/* Buttons for categories */}
<div className="flex gap-2 mt-4 w-full max-w-[320px]">
@@ -175,9 +175,9 @@ function Sizes() {
type="button"
onClick={handleCheck}
className="mt-10"
disabled={loading}
loading={loading} disabled={loading}
>
{loading ? "در حال ارسال..." : "ویرایش"}
ویرایش
</AuthNextButton>
</div>
</Container>

View File

@@ -1,8 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import React, { useEffect } from "react";
import React, { useState } from "react";
import { useFormik } from "formik";
import * as yup from "yup";
import { useRouter } from "next/navigation";
import useAxios from "@/hooks/useAxios";
import { useUser } from "@/hooks/useUser";
@@ -10,41 +9,57 @@ import AuthHead from "@/components/auth/AuthHead";
import AuthInput from "@/components/auth/AuthInput";
import AuthNextButton from "@/components/auth/AuthNextButton";
import Container from "@/components/elements/Container";
// Validation schema using Yup
const schema = yup.object().shape({
username: yup.string().required("نام کاربری الزامی است"),
});
import UsernameSuggestions from "@/components/auth/UsernameSuggestions";
import { usernameFormSchema } from "@/lib/validation/usernameSchema";
import { sanitizeUsernameInput, USERNAME_MIN_LENGTH } from "@/lib/validation/username";
function UsernamePage() {
const router = useRouter();
const user = useUser();
const { request, loading } = useAxios();
const [isDuplicate, setIsDuplicate] = useState(false);
const [suggestions, setSuggestions] = useState<string[]>([]);
const formik = useFormik({
initialValues: {
username: user?.user_name || "",
},
validationSchema: schema,
validationSchema: usernameFormSchema,
enableReinitialize: true,
onSubmit: async (values) => {
setIsDuplicate(false);
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?.includes("تکراری")) {
setIsDuplicate(true);
setSuggestions(Array.isArray(data.suggestions) ? data.suggestions : []);
} else if (data?.message) {
formik.setFieldError("username", data.message);
}
}
},
});
// آپدیت مقدار username در صورتی که user.user_name مقدار داشته باشد
useEffect(() => {
if (user?.user_name) {
formik.setValues({ username: user.user_name });
}
}, [user?.user_name]);
const handleUsernameChange = (value: string) => {
setIsDuplicate(false);
setSuggestions([]);
formik.setFieldValue("username", sanitizeUsernameInput(value));
};
const handleSuggestionSelect = (username: string) => {
formik.setFieldValue("username", username);
formik.setFieldTouched("username", true, false);
setIsDuplicate(false);
setSuggestions([]);
};
return (
<Container>
@@ -57,23 +72,43 @@ function UsernamePage() {
<AuthInput
name="username"
type="text"
placeholder="نام کاربری خود را وارد کنید"
placeholder="نام کاربری"
dir="ltr"
autoComplete="username"
spellCheck={false}
className={`border ${
formik.touched.username && formik.errors.username
(formik.touched.username && formik.errors.username) || isDuplicate
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
value={formik.values.username}
onChange={formik.handleChange}
onChange={(e) => handleUsernameChange(e.target.value)}
onBlur={formik.handleBlur}
/>
{formik.touched.username && formik.errors.username && (
<small className="text-red-500 mt-2 block">
{isDuplicate && (
<small className="text-red-500 mt-2 block text-center font-medium">
نام کاربری تکراری است
</small>
)}
{formik.touched.username && formik.errors.username && !isDuplicate && (
<small className="text-red-500 mt-2 block text-center">
{formik.errors.username}
</small>
)}
<AuthNextButton type="submit" className="mt-20" disabled={loading}>
{loading ? "در حال ارسال..." : "ویرایش"}
<UsernameSuggestions
suggestions={suggestions}
onSelect={handleSuggestionSelect}
/>
<AuthNextButton
type="submit"
className="mt-20"
loading={loading} disabled={loading || formik.values.username.length < USERNAME_MIN_LENGTH}
>
ویرایش
</AuthNextButton>
</form>
</div>

View File

@@ -1,4 +1,12 @@
import SettingsShell from "@/components/settings/SettingsShell";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: {
default: "تنظیمات",
template: "%s | مدستاگرام",
},
};
export default function Layout({ children }: { children: React.ReactNode }) {
return <SettingsShell>{children}</SettingsShell>;

View File

@@ -57,6 +57,15 @@ function Notifications() {
item?.title?.split("/").pop() || ""
)}/${item?.project_post_id}`,
new_offer: "/settings/offers",
post_like: `/posts/${item?.project_post_id}`,
academy_like: `/academy/${item?.project_post_id}/course`,
billboard_like: `/settings/my-billboards/${item?.project_post_id}/b`,
post_comment: `/posts/${item?.project_post_id}`,
profile_comment: "/settings/profile",
user_comment: "/settings/profile",
academy_comment: `/academy/${item?.project_post_id}/course`,
billboard_comment: `/settings/my-billboards/${item?.project_post_id}/b`,
billboard_rating: `/settings/my-billboards/${item?.project_post_id}/b`,
"reject-user": `/tickets/new/${
item?.project_post_id
}/${encodeURIComponent(item?.title?.split("/").pop() || "")}`,

View File

@@ -15,19 +15,9 @@ function Profile() {
const [user, setUser] = useState<User | null>(null);
const { request, loading } = useAxios();
// گرفتن userId از localStorage فقط در مرورگر
const getUserId = () =>
typeof window !== "undefined" ? localStorage.getItem("id") : null;
const fetchUser = async () => {
const userId = getUserId();
if (!userId) return;
try {
const response = await request<{ user: User }>(
"GET",
`/users/get/web?user_id=${userId}`
);
const response = await request<{ user: User }>("GET", "/profile");
setUser(response?.user ?? null);
} catch (err) {
console.error("خطا در دریافت اطلاعات کاربر:", err);

View File

@@ -13,9 +13,13 @@ interface IUserProps {
export async function generateMetadata({ params }: IUserProps): Promise<Metadata> {
const { username } = await params;
const token = (await cookies()).get("token")?.value || "";
try {
const res = await fetch(`${BASE_URL}/users/get/web?user_name=${username}`, { cache: 'no-store' });
const res = await fetch(`${BASE_URL}/users/get/web?user_name=${username}`, {
cache: "no-store",
headers: { Authorization: token ? `Bearer ${token}` : "" },
});
const data = await res.json();
const user = data?.user as User;
@@ -23,6 +27,13 @@ export async function generateMetadata({ params }: IUserProps): Promise<Metadata
return { title: `${username} | مدستاگرام`, robots: "noindex" };
}
if (user.blocked_you) {
return {
title: "پروفایل در دسترس نیست | مدستاگرام",
robots: "noindex, nofollow",
};
}
const fullName = `${user.first_name || ""} ${user.last_name || ""}`.trim();
const city = user.city?.name || "ایران";
const expertise = user.expertise || "متخصص";
@@ -97,6 +108,22 @@ async function UserPage({ params }: IUserProps) {
);
}
if (user.blocked_you) {
return (
<Container>
<div className="flex min-h-[50vh] flex-col items-center justify-center px-6 py-20 text-center">
<div className="mb-4 flex h-20 w-20 items-center justify-center rounded-full bg-neutral-200 dark:bg-neutral-800">
<span className="text-3xl text-neutral-400">🚫</span>
</div>
<h2 className="text-lg font-bold text-foreground">این پروفایل در دسترس نیست</h2>
<p className="mt-2 max-w-sm text-sm text-neutral-500 dark:text-neutral-400">
شما توسط این کاربر مسدود شدهاید و امکان مشاهده پروفایل و پستهای او وجود ندارد.
</p>
</div>
</Container>
);
}
const fullName = `${user.first_name || ""} ${user.last_name || ""}`.trim();
// داده ساختاریافته (Schema) برای درک بهتر گوگل از ماهیت پروفایل

View File

@@ -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]);

View File

@@ -4,6 +4,7 @@ import { ThemeProvider } from "@/contexts/ThemeContext";
import { ReactQueryProvider } from "@/providers/ReactQueryProvider";
import { Toaster } from "react-hot-toast";
import BackgroundPrefetch from "@/components/BackgroundPrefetch";
import TitleGuardian from "@/components/TitleGuardian";
interface ILayoutProps {
children: React.ReactNode;
@@ -12,6 +13,7 @@ function Layout({ children }: ILayoutProps) {
return (
<ThemeProvider>
<ReactQueryProvider>
<TitleGuardian />
<Toaster />
<BackgroundPrefetch />
{children}

View File

@@ -3,68 +3,16 @@
import Link from "next/link";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import BoldIcon from "@/components/ui/BoldIcon";
import { cn } from "@/lib/utils";
const tabs = [
{
href: "/",
icon: (color: string) => (
<svg width="28" height="28" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M27.5 13.125C27.5 11.6125 26.4875 9.67496 25.25 8.81246L17.525 3.39996C15.775 2.17496 12.9625 2.23746 11.275 3.54996L4.5375 8.79996C3.4125 9.67496 2.5 11.5375 2.5 12.95V22.2125C2.5 25.1125 4.8625 27.4875 7.7625 27.4875H22.2375C25.1375 27.4875 27.5 25.1125 27.5 22.225V18.35"
stroke={color}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
),
label: "خانه",
},
{
href: "/academy",
icon: (color: string) => (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" xmlns="http://www.w3.org/2000/svg">
<path stroke={color} d="M5.99953 5.17031L4.02953 6.46031C2.09953 7.72031 2.09953 10.5403 4.02953 11.8003L10.0495 15.7303C11.1295 16.4403 12.9095 16.4403 13.9895 15.7303L19.9795 11.8003C21.8995 10.5403 21.8995 7.73031 19.9795 6.47031L13.9895 2.54031C12.9095 1.83031 11.1295 1.83031 10.0495 2.54031" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path stroke={color} d="M5.63012 13.0801L5.62012 17.7701C5.62012 19.0401 6.60012 20.4001 7.80012 20.8001L10.9901 21.8601C11.5401 22.0401 12.4501 22.0401 13.0101 21.8601L16.2001 20.8001C17.4001 20.4001 18.3801 19.0401 18.3801 17.7701V13.1301" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path stroke={color} d="M21.4004 15V9" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
),
label: "آموزشگاه",
},
{
href: "/new-post",
icon: (color: string) => (
<svg width="28" height="28" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18.7375 15H20" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M10 15H14.7625" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M15 20V10" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M2.5 16.3V18.75C2.5 25 5 27.5 11.25 27.5H18.75C25 27.5 27.5 25 27.5 18.75V11.25C27.5 5 25 2.5 18.75 2.5H11.25C5 2.5 2.5 5 2.5 11.25" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
),
label: "پست ها",
},
{
href: "/billboards",
icon: (color: string) => (
<svg width="28" height="28" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.85 15.9001H13.025V20.9626C13.025 21.7126 13.95 22.0626 14.45 21.5001L19.775 15.4501C20.2375 14.9251 19.8625 14.1001 19.1625 14.1001H16.9875V9.0376C16.9875 8.2876 16.0625 7.9376 15.5625 8.5001L10.2375 14.5501C9.775 15.0751 10.15 15.9001 10.85 15.9001Z" stroke={color} strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
<path d="M5 7.5C3.4375 9.5875 2.5 12.1875 2.5 15C2.5 21.9 8.1 27.5 15 27.5C21.9 27.5 27.5 21.9 27.5 15C27.5 8.1 21.9 2.5 15 2.5C13.2125 2.5 11.5 2.875 9.9625 3.5625" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
),
label: "بیلبورد",
},
{
href: "/settings",
icon: (color: string) => (
<svg width="28" height="28" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.75 16.35V18.6C3.75 21.25 3.75 21.25 6.25 22.9375L13.125 26.9125C14.1625 27.5125 15.85 27.5125 16.875 26.9125L23.75 22.9375C26.25 21.25 26.25 21.25 26.25 18.6125V11.3875C26.25 8.74995 26.25 8.74995 23.75 7.06245L16.875 3.08745C15.85 2.48745 14.1625 2.48745 13.125 3.08745L6.25 7.06245C3.75 8.74995 3.75 8.74995 3.75 11.3875" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M18.75 15C18.75 12.925 17.075 11.25 15 11.25C12.925 11.25 11.25 12.925 11.25 15C11.25 17.075 12.925 18.75 15 18.75C15.5125 18.75 16.0125 18.65 16.4625 18.45" stroke={color} strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
</svg>
),
label: "تنظیمات",
},
];
{ href: "/", icon: "home-2", label: "خانه" },
{ href: "/academy", icon: "teacher", label: "آموزشگاه" },
{ href: "/new-post", icon: "add-square", label: "پست ها" },
{ href: "/billboards", icon: "flash-circle", label: "بیلبورد" },
{ href: "/settings", icon: "setting", label: "تنظیمات" },
] as const;
type TabNavigationProps = {
currentPage: string;
@@ -108,9 +56,6 @@ export default function TabNavigation({ currentPage }: TabNavigationProps) {
if (!mounted) return null;
const defaultColor = isDark ? "#a1a1aa" : "#52525b";
const activeColor = "#ff107d";
return (
<nav className="fixed bottom-2 left-1/2 z-50 w-full max-w-xl -translate-x-1/2 px-4 pb-2">
<div className="glass-panel gentle-transition flex justify-evenly rounded-full border border-white/40 p-2.5 shadow-lg dark:border-white/10">
@@ -119,7 +64,6 @@ export default function TabNavigation({ currentPage }: TabNavigationProps) {
const isActive =
currentPage === href ||
(href === "/settings" && currentPage.startsWith("/settings"));
const color = isActive ? activeColor : defaultColor;
return (
<Link
@@ -137,7 +81,18 @@ export default function TabNavigation({ currentPage }: TabNavigationProps) {
}
}}
>
{tab.icon(color)}
<BoldIcon
name={tab.icon}
size={28}
tinted
className={cn(
isActive
? "text-[#ff107d]"
: isDark
? "text-zinc-400"
: "text-zinc-600"
)}
/>
<span
className={cn(
"text-[10px] font-semibold gentle-transition",

View File

@@ -1,29 +1,41 @@
"use client";
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import { usePathname } from "next/navigation";
import { defaultSEOConfig } from "@/config/seoConfig";
const DEFAULT_TITLE =
typeof defaultSEOConfig.title === "string"
? defaultSEOConfig.title
: "مدستاگرام";
export default function TitleGuardian() {
const pathname = usePathname();
const lastValidTitle = useRef(DEFAULT_TITLE);
useEffect(() => {
// جلوگیری از تغییرات ناگهانی توسط اسکریپت‌های چت و اینماد
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === "childList" || mutation.type === "characterData") {
const newTitle = document.title;
// اگر تایتل خالی شد یا به localhost تغییر کرد یا حاوی کلمات سیستمی بود، دخالت نکن
// اما اگر اسکریپت خارجی مثل "گفتینو" تایتل را عوض کرد، اینجا می‌توانید منطق بازگشت بنویسید
}
});
if (document.title.trim()) {
lastValidTitle.current = document.title;
}
const observer = new MutationObserver(() => {
const currentTitle = document.title.trim();
if (currentTitle) {
lastValidTitle.current = currentTitle;
return;
}
if (lastValidTitle.current.trim()) {
document.title = lastValidTitle.current;
}
});
const titleElement = document.querySelector("title");
if (titleElement) {
observer.observe(titleElement, {
childList: true,
characterData: true,
subtree: true
childList: true,
characterData: true,
subtree: true,
});
}
@@ -31,4 +43,4 @@ export default function TitleGuardian() {
}, [pathname]);
return null;
}
}

View File

@@ -3,7 +3,8 @@
import React, { useState, useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { motion } from "framer-motion";
import { Search, Filter, X } from "lucide-react";
import { Filter, X } from "lucide-react";
import Image from "next/image";
import useAxios from "@/hooks/useAxios";
interface AcademyFilterProps {
@@ -222,7 +223,13 @@ export default function AcademyFilter({ onFilterChange }: AcademyFilterProps) {
placeholder="جستجوی دوره، مدرس، ..."
className="w-full px-4 py-2 pr-10 border border-gray-300 dark:border-gray-600 rounded-xl bg-gray-50 dark:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-pink-500 focus:border-transparent"
/>
<Search className="absolute left-3 top-2.5 w-5 h-5 text-gray-400" />
<Image
src="/images/icons/search-normal.svg"
alt=""
width={20}
height={20}
className="absolute left-3 top-2.5 opacity-60 dark:invert"
/>
</div>
</form>

View File

@@ -1,7 +1,8 @@
import React, { useState, useRef, useCallback, useEffect } from "react";
import Modal from "../elements/Modal";
import Image from "next/image";
import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
import VerificationBadge from "@/components/main/VerificationBadge";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import RoundedInput from "@/components/elements/RoundedInput";
import useAxios from "@/hooks/useAxios";
import toast from "react-hot-toast";
@@ -362,39 +363,24 @@ function CommentsModal({
>
<div className="flex gap-3">
{/* آواتار کاربر */}
<div className="flex-shrink-0">
<Image
className="rounded-full object-cover"
width={40}
height={40}
<div className="shrink-0">
<ProfileAvatar
src={item?.user_id?.profile_image}
alt={item?.user_id?.user_name || "کاربر"}
src={
item?.user_id?.profile_image
? buildStorageUrl(item.user_id.profile_image)
: "/images/default-avatar.png"
}
priority={true}
unoptimized={true}
size="chat"
rounded="full"
fallback="/images/default-avatar.png"
/>
</div>
<div className="flex-1">
{/* اطلاعات کاربر و امتیاز */}
<div className="flex items-center gap-2 flex-wrap mb-2">
<span className="font-semibold text-sm dark:text-white">
<span className="font-semibold text-sm dark:text-white inline-flex items-center gap-1">
{item?.user_id?.user_name || "کاربر ناشناس"}
<VerificationBadge isVerified={item?.user_id?.is_verified} />
</span>
{item?.user_id?.is_verified === "verified" && (
<Image
width={16}
height={16}
alt="verify"
src="/images/icons/verify.svg"
className="inline"
/>
)}
{item?.rate > 0 && (
<div className="mr-2">
{renderStars(item.rate)}

View File

@@ -13,6 +13,7 @@ import Image from "next/image";
import MainModelCardActions from "./MainModelCardActions";
import Link from "next/link";
import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
import VerificationBadge from "@/components/main/VerificationBadge";
import { useUserById } from "@/hooks/getUserById";
import "slick-carousel/slick/slick.css";
import "slick-carousel/slick/slick-theme.css";
@@ -425,31 +426,10 @@ function MainModelCard({ postData }: { postData: Course }) {
</span>
</div>
<div className="flex items-center gap-1 mt-1">
<h2 className="max-sm:text-xs">{cuorse_name}</h2>
{is_verified === "verified" ? (
<Image
width={22}
height={22}
alt={"verify icon"}
src={`/images/icons/verify.svg`}
/>
) : is_verified === "pending" ? (
<Image
width={25}
height={25}
alt={"not-verify icon"}
src={`/images/icons/not-verify.svg`}
/>
) : is_verified === "true" ? (
<Image
width={22}
height={22}
alt="verify icon"
src="/images/icons/verify2.svg"
/>
) : (
""
)}
<h2 className="max-sm:text-xs inline-flex items-center gap-1">
{cuorse_name}
<VerificationBadge isVerified={is_verified} />
</h2>
<div className="flex items-center gap-0.5 justify-center">
<span className="mr-2">{newScore || 0}</span>
<Image

View File

@@ -1,7 +1,5 @@
"use client"
import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import { User } from "@/types/types";
import Image from "next/image";
import React, { useState } from "react";
import ModelHeadRowTwo from "../models/ModelPage/ModelHeadRowTwo";
import ModelHeadRowThree from "../models/ModelPage/ModelHeadRowThree";
@@ -31,7 +29,8 @@ function ModelHead({ user, item2 }: ModelHeadProps) {
user_level,
bio,
user_type,
_id
_id,
is_Register,
} = user;
@@ -108,30 +107,19 @@ function ModelHead({ user, item2 }: ModelHeadProps) {
</div>
</div>
</div>
{profile_image ? (
<Image
className="rounded-xl object-cover h-[72px] w-[72px] md:h-[120px] md:w-[120px]"
width={120}
height={120}
alt={user_name}
src={buildStorageUrl(profile_image)}
unoptimized={true}
/>
) : (
<Image
className="rounded-xl object-cover h-[72px] w-[72px] md:h-[120px] md:w-[120px]"
width={120}
height={120}
alt={user_name}
src={`/images/fake-avatar.png`}
unoptimized={true}
/>
)}
<ProfileAvatar
src={profile_image}
alt={user_name}
size="md"
rounded="xl"
className="md:h-[120px] md:w-[120px]"
/>
</div>
<ModelHeadRowTwo
first_name={first_name}
last_name={last_name}
is_verified={is_verified}
is_Register={is_Register}
user_name={user_name}
user_score={user_score}
rate={rate}

View File

@@ -1,13 +1,42 @@
import React, { ComponentProps } from "react";
type TButton = ComponentProps<"button"> & {};
function AuthButton({ children, style, className, ...rest }: TButton) {
import { cn } from "@/lib/utils";
type TButton = ComponentProps<"button"> & {
loading?: boolean;
loadingText?: string;
};
function AuthButton({
children,
style,
className,
loading = false,
loadingText = "در حال ارسال...",
disabled,
...rest
}: TButton) {
return (
<button
className={`w-full dir-ltr max-w-[290px] p-3 text-center text-white rounded-2xl bg-[#FC8EAC] hover:bg-[#f85e87] font-bold duration-300 ${className}`}
style={{ ...style }}
className={cn(
"w-full dir-ltr max-w-[290px] p-3 text-center text-white rounded-2xl bg-[#FC8EAC] hover:bg-[#f85e87] font-bold duration-300",
loading && "cursor-wait opacity-80",
className
)}
style={style}
disabled={disabled || loading}
{...rest}
>
{children}
{loading ? (
<span className="inline-flex items-center justify-center gap-2">
<span
className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"
aria-hidden
/>
<span>{loadingText}</span>
</span>
) : (
children
)}
</button>
);
}

View File

@@ -1,18 +1,25 @@
"use client";
import Image from "next/image";
import React from "react";
import { usePageTitle } from "@/hooks/usePageTitle";
interface IAuthHeadProps {
title: string;
}
function AuthHead({ title }: IAuthHeadProps) {
usePageTitle(title);
return (
<div className="flex flex-col items-center">
<span className="text-2xl font-bold">{title}</span>
{title ? (
<span className="text-2xl font-bold text-foreground">{title}</span>
) : null}
<Image
width={0}
height={0}
className="w-auto my-20 min-w-[300px]"
className="my-20 w-auto min-w-[300px]"
src={"/images/icons/logo.svg"}
alt="logo"
/>

View File

@@ -1,9 +1,26 @@
import React, { ComponentProps } from "react";
type TInput = ComponentProps<"input"> & {};
function AuthInput({ style, className, ...rest }: TInput) {
import { cn } from "@/lib/utils";
type TInput = ComponentProps<"input"> & {
error?: boolean;
success?: boolean;
};
function AuthInput({ style, className, error, success, ...rest }: TInput) {
return (
<input
className={`w-full dir-ltr max-w-[290px] p-3 text-center rounded-2xl border border-border-secondary-light dark:border-border-secondary-dark bg-secondary-light dark:bg-secondary-dark font-medium ${className}`}
className={cn(
"w-full max-w-[290px] p-3.5 text-center text-lg font-semibold rounded-2xl",
"border-2 bg-white text-gray-900 placeholder:text-gray-400",
"dark:bg-white dark:text-gray-900 dark:placeholder:text-gray-500",
"shadow-sm transition-[border-color,box-shadow] duration-200",
success
? "border-green-500 ring-2 ring-green-500/30 focus:border-green-500 focus:ring-green-500/30"
: error
? "border-red-500 ring-2 ring-red-500/20 focus:border-red-500 focus:ring-red-500/30"
: "border-gray-200 dark:border-gray-300 focus:outline-none focus:border-[#387E65] focus:ring-2 focus:ring-[#387E65]/30",
className
)}
style={{ ...style }}
{...rest}
/>

View File

@@ -1,13 +1,42 @@
import React, { ComponentProps } from "react";
type TButton = ComponentProps<"button"> & {};
function AuthNextButton({ children, style, className, ...rest }: TButton) {
import { cn } from "@/lib/utils";
type TButton = ComponentProps<"button"> & {
loading?: boolean;
loadingText?: string;
};
function AuthNextButton({
children,
style,
className,
loading = false,
loadingText = "در حال ارسال...",
disabled,
...rest
}: TButton) {
return (
<button
className={`border dir-ltr p-7 py-2 text-center text-border-secondary-light dark:text-border-secondary-dark border-border-secondary-light dark:border-border-secondary-dark rounded-3xl bg-tertiary-light dark:bg-tertiary-dark font-medium duration-300 hover:opacity-70 text-sm ${className}`}
style={{ ...style }}
className={cn(
"border dir-ltr p-7 py-2 text-center text-border-secondary-light dark:text-border-secondary-dark border-border-secondary-light dark:border-border-secondary-dark rounded-3xl bg-tertiary-light dark:bg-tertiary-dark font-medium duration-300 hover:opacity-70 text-sm",
loading && "cursor-wait opacity-70",
className
)}
style={style}
disabled={disabled || loading}
{...rest}
>
{children}
{loading ? (
<span className="inline-flex items-center justify-center gap-2">
<span
className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"
aria-hidden
/>
<span>{loadingText}</span>
</span>
) : (
children
)}
</button>
);
}

View File

@@ -0,0 +1,89 @@
"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;
success?: 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,
success = false,
disabled = false,
className,
}: AuthOtpInputProps) {
return (
<div className={cn("w-full flex justify-center", className)}>
<OtpInput
value={value}
onChange={(otp) => {
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 (
<motion.input
{...inputProps}
disabled={disabled}
animate={
filled
? { scale: [0.88, 1.1, 1], y: [6, -2, 0], opacity: 1 }
: { scale: 1, y: 0, opacity: 1 }
}
transition={{ duration: 0.28, ease: [0.22, 1, 0.36, 1] }}
className={cn(
cellBase,
success
? "border-green-500 shadow-lg ring-2 ring-green-500/35"
: error
? "border-red-500 ring-red-500/30"
: filled
? "border-[#387E65] shadow-lg ring-2 ring-[#387E65]/25"
: "border-gray-500 dark:border-gray-400 shadow-md",
!success &&
!error &&
"focus:border-[#387E65] focus:ring-2 focus:ring-[#387E65]/40 focus:shadow-lg",
disabled && "opacity-50 cursor-not-allowed"
)}
inputMode="numeric"
pattern="[0-9]*"
autoComplete={index === 0 ? "one-time-code" : "off"}
aria-label={`رقم ${index + 1} از ${numInputs}`}
/>
);
}}
/>
</div>
);
}

View File

@@ -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<void>;
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 (
<div className={cn("w-full max-w-sm text-center mb-6 space-y-2", className)}>
<p className="text-sm sm:text-base font-medium text-gray-800 dark:text-gray-100 leading-relaxed">
کد ۶ رقمی به شماره{" "}
<span dir="ltr" className="font-bold text-[#387E65] inline-block">
{mobile}
</span>{" "}
ارسال شد
</p>
{!expired ? (
<p className="text-sm text-gray-600 dark:text-gray-300">
اعتبار کد:{" "}
<span
dir="ltr"
className="font-bold tabular-nums text-[#FF5C00] text-base"
>
{formatOtpCountdown(remaining)}
</span>
</p>
) : (
<div className="space-y-2">
<p className="text-sm font-medium text-red-600 dark:text-red-400">
کد منقضی شده است. لطفاً دوباره درخواست ارسال کد دهید.
</p>
<button
type="button"
onClick={handleResend}
disabled={resendLoading}
className="text-sm font-bold text-[#667AC1] hover:text-[#0033EA] disabled:opacity-50"
>
{resendLoading ? "در حال ارسال..." : "ارسال مجدد کد"}
</button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,79 @@
import React from "react";
import Container from "@/components/elements/Container";
import { cn } from "@/lib/utils";
type AuthPageLayoutProps = {
children: React.ReactNode;
className?: string;
};
export function AuthPageContent({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div
className={cn(
"flex w-full flex-1 flex-col items-center justify-center",
className
)}
>
{children}
</div>
);
}
type AuthFormFooterProps = {
children: React.ReactNode;
onSkip?: () => void;
skipDisabled?: boolean;
className?: string;
};
export function AuthFormFooter({
children,
onSkip,
skipDisabled,
className,
}: AuthFormFooterProps) {
return (
<div
className={cn(
"mt-auto flex w-full flex-col items-center gap-3 pt-6 pb-[max(2rem,env(safe-area-inset-bottom))]",
className
)}
>
{children}
{onSkip && (
<button
type="button"
onClick={onSkip}
disabled={skipDisabled}
className="text-sm text-gray-500 disabled:opacity-50"
>
رد کن
</button>
)}
</div>
);
}
function AuthPageLayout({ children, className }: AuthPageLayoutProps) {
return (
<Container>
<div
className={cn(
"flex min-h-screen flex-col items-center p-4",
className
)}
>
{children}
</div>
</Container>
);
}
export default AuthPageLayout;

View File

@@ -0,0 +1,62 @@
"use client";
import React, { ComponentProps, useState } from "react";
import { cn } from "@/lib/utils";
type AuthPasswordInputProps = Omit<ComponentProps<"input">, "type"> & {
wrapperClassName?: string;
error?: boolean;
success?: boolean;
};
function AuthPasswordInput({
style,
className,
wrapperClassName,
error,
success,
...rest
}: AuthPasswordInputProps) {
const [visible, setVisible] = useState(false);
return (
<div className={cn("relative w-full max-w-[290px]", wrapperClassName)}>
<input
type={visible ? "text" : "password"}
className={cn(
"w-full dir-ltr p-3.5 px-12 text-center text-lg font-semibold rounded-2xl",
"border-2 bg-white text-gray-900 placeholder:text-gray-400",
"dark:bg-white dark:text-gray-900 dark:placeholder:text-gray-500",
"shadow-sm transition-[border-color,box-shadow] duration-200",
success
? "border-green-500 ring-2 ring-green-500/30 focus:border-green-500 focus:ring-green-500/30"
: error
? "border-red-500 ring-2 ring-red-500/20 focus:border-red-500 focus:ring-red-500/30"
: "border-gray-200 dark:border-gray-300 focus:outline-none focus:border-[#387E65] focus:ring-2 focus:ring-[#387E65]/30",
className
)}
style={{ ...style }}
{...rest}
/>
<button
type="button"
onClick={() => setVisible((prev) => !prev)}
className="absolute right-3 top-1/2 -translate-y-1/2 m-0 flex size-[22px] items-center justify-center rounded p-0 hover:opacity-70 transition-opacity"
aria-label={visible ? "مخفی کردن کلمه عبور" : "نمایش کلمه عبور"}
tabIndex={-1}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={visible ? "/images/icons/eye-slash.svg" : "/images/icons/eye-open.svg"}
alt=""
width={22}
height={22}
className="block size-[22px] object-contain object-center opacity-80 dark:invert"
draggable={false}
/>
</button>
</div>
);
}
export default AuthPasswordInput;

View File

@@ -1,4 +1,5 @@
"use client";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import useAxios from "@/hooks/useAxios";
import { IProfileData } from "@/types/types";
import React, { useEffect, useState } from "react";
@@ -6,7 +7,7 @@ import React, { useEffect, useState } from "react";
function AuthUserDetails() {
const [userDetail, setUserDetail] = useState<IProfileData | null>(null);
const { request } = useAxios();
// Fetch user data
const fetchData = async () => {
try {
const response = await request<{ user: IProfileData }>("GET", "/profile");
@@ -15,17 +16,25 @@ function AuthUserDetails() {
console.log(err);
}
};
useEffect(() => {
fetchData();
}, []);
return (
<>
<div className="mt-5 flex w-full max-w-md flex-col items-center text-center">
<ProfileAvatar
src={userDetail?.profile_image}
alt={userDetail?.user_name || "profile"}
size="md"
rounded="xl"
className="mb-3"
/>
<span className="text-lg font-bold">
{userDetail?.first_name} {userDetail?.last_name}
</span>
<p className="text-sm text-gray-500">{userDetail?.user_name}</p>
</>
</div>
);
}

View File

@@ -0,0 +1,30 @@
"use client";
import React from "react";
interface UsernameSuggestionsProps {
suggestions: string[];
onSelect: (username: string) => void;
}
export default function UsernameSuggestions({
suggestions,
onSelect,
}: UsernameSuggestionsProps) {
if (!suggestions.length) return null;
return (
<div className="flex flex-wrap justify-center gap-2 mt-3 w-full max-w-[290px]">
{suggestions.map((username) => (
<button
key={username}
type="button"
onClick={() => onSelect(username)}
className="px-3 py-1.5 text-sm font-medium rounded-full border border-[#387E65] text-[#387E65] bg-white hover:bg-[#387E65]/10 transition-colors dir-ltr"
>
{username}
</button>
))}
</div>
);
}

View File

@@ -2,7 +2,8 @@ import React, { useState } from "react";
import Modal from "../../elements/Modal";
import Image from "next/image";
import useInfiniteScroll from "@/hooks/useInfiniteScroll";
import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
import VerificationBadge from "@/components/main/VerificationBadge";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import RoundedInput from "@/components/elements/RoundedInput";
import useAxios from "@/hooks/useAxios";
@@ -76,35 +77,19 @@ function BillboardCommentsModal({
{page?.comments?.map((item: Comments) => (
<div key={item?._id} className="mb-4">
<div className="flex">
<Image
className="rounded-full ml-2"
width={50}
height={50}
alt={item?.userId?.user_name}
src={buildStorageUrl(item?.userId?.profile_image)}
priority={true}
unoptimized={true}
<ProfileAvatar
src={item?.userId?.profile_image}
alt={item?.userId?.user_name || "کاربر"}
size="chat"
rounded="full"
className="ml-2"
/>
<div className="text-sm">
<div className="flex items-center gap-1 mt-1 font-semibold">
<span>{item?.userId?.user_name}</span>
{item?.userId?.is_verified === "verified" ? (
<Image
width={22}
height={22}
alt={"verify icon"}
src={`/images/icons/verify.svg`}
/>
) : item?.userId?.is_verified === "pending" ? (
<Image
width={25}
height={25}
alt={"not-verify icon"}
src={`/images/icons/not-verify.svg`}
/>
) : (
""
)}
<span className="inline-flex items-center gap-1">
{item?.userId?.user_name}
<VerificationBadge isVerified={item?.userId?.is_verified} />
</span>
</div>
<p>{item.text}</p>
</div>

View File

@@ -5,7 +5,6 @@ import Image from "next/image";
import Link from "next/link";
import React from "react";
import MainBillboardCardActions from "./MainBillboardCardActions";
import SEO from "@/config/SEO";
function MainBillboardCard({
billboard,
@@ -30,8 +29,6 @@ function MainBillboardCard({
: "bg-tertiary-light dark:bg-tertiary-dark border-border-secondary-dark dark:border-border-secondary-dark hover:shadow-md transition-shadow"}
`}
>
<SEO type="billboardSingle" data={billboard} />
{/* نوع تبلیغ */}
{billboard?.type === "special" && (
<RoundedDiv className="bg-[#FFBDBD] dark:bg-[#794a4a] !border-[#FFBDBD] h-7 w-28 absolute right-4 -top-3">

View File

@@ -1,5 +1,6 @@
"use client"
import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
"use client";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import { IAdvertisingProfile } from "@/types/types";
import Image from "next/image";
import React, { useState } from "react";
@@ -40,13 +41,12 @@ function AdProfileHead({ profile, id }: AdProfileHeadProps) {
</div>
</div>
{profile_image ? (
<Image
className="rounded-xl object-cover h-[72px] w-[72px] md:h-[120px] md:w-[120px]"
width={120}
height={120}
<ProfileAvatar
src={profile_image}
alt={profile?.vitrine_name || "Name"}
src={buildStorageUrl(profile_image)}
unoptimized={true}
size="md"
rounded="xl"
className="md:h-[120px] md:w-[120px]"
/>
) : (
<div className="border dark:border-border-primary-dark rounded-3xl p-2">
@@ -92,13 +92,12 @@ function AdProfileHead({ profile, id }: AdProfileHeadProps) {
</div>
</div>
{profile_image ? (
<Image
className="rounded-xl object-cover h-[72px] w-[72px] md:h-[120px] md:w-[120px]"
width={120}
height={120}
<ProfileAvatar
src={profile_image}
alt={profile?.vitrine_name || "Name"}
src={buildStorageUrl(profile_image)}
unoptimized={true}
size="md"
rounded="xl"
className="md:h-[120px] md:w-[120px]"
/>
) : (
<div className="border dark:border-border-primary-dark rounded-3xl p-2">

View File

@@ -2,13 +2,7 @@
import { useEffect, useRef } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
FiCamera,
FiFile,
FiImage,
FiMapPin,
FiVideo,
} from "react-icons/fi";
import ChatBoldIcon, { type ChatBoldIconName } from "./ChatBoldIcon";
export type AttachmentType = "image" | "video" | "file" | "location" | "camera";
@@ -19,12 +13,17 @@ interface AttachmentMenuProps {
anchorRef?: React.RefObject<HTMLElement | null>;
}
const items: { type: AttachmentType; label: string; icon: React.ReactNode; color: string }[] = [
{ type: "camera", label: "دوربین", icon: <FiCamera size={22} />, color: "bg-red-500" },
{ type: "image", label: "گالری", icon: <FiImage size={22} />, color: "bg-purple-500" },
{ type: "video", label: "ویدیو", icon: <FiVideo size={22} />, color: "bg-blue-500" },
{ type: "file", label: "فایل", icon: <FiFile size={22} />, color: "bg-orange-500" },
{ type: "location", label: "موقعیت", icon: <FiMapPin size={22} />, color: "bg-green-500" },
const items: {
type: AttachmentType;
label: string;
icon: ChatBoldIconName;
color: string;
}[] = [
{ type: "camera", label: "دوربین", icon: "camera", color: "bg-red-500" },
{ type: "image", label: "گالری", icon: "gallery", color: "bg-purple-500" },
{ type: "video", label: "ویدیو", icon: "video", color: "bg-blue-500" },
{ type: "file", label: "فایل", icon: "file", color: "bg-orange-500" },
{ type: "location", label: "موقعیت", icon: "location", color: "bg-green-500" },
];
export default function AttachmentMenu({
@@ -78,7 +77,7 @@ export default function AttachmentMenu({
<span
className={`flex h-10 w-10 items-center justify-center rounded-full text-white ${item.color}`}
>
{item.icon}
<ChatBoldIcon name={item.icon} size={22} className="text-white" />
</span>
<span className="text-sm font-medium">{item.label}</span>
</motion.button>

View File

@@ -1,7 +1,7 @@
"use client";
import { motion } from "framer-motion";
import { FiCornerUpLeft, FiShare2 } from "react-icons/fi";
import BoldIcon from "@/components/ui/BoldIcon";
interface ChatActionBarProps {
onReply: () => void;
@@ -35,15 +35,15 @@ export default function ChatActionBar({
onClick={onReply}
className="chat-action-btn gentle-transition flex flex-1 items-center justify-center gap-2 rounded-full py-3 text-sm font-semibold text-white active:scale-[0.98]"
>
<FiCornerUpLeft size={18} />
<BoldIcon name="direct-right" size={18} tinted className="text-white" />
پاسخ
</button>
<button
type="button"
onClick={onForward}
className="ig-dm-send-btn gentle-transition flex flex-1 items-center justify-center gap-2 rounded-full py-3 text-sm font-semibold text-white active:scale-[0.98]"
className="chat-forward-btn gentle-transition flex flex-1 items-center justify-center gap-2 rounded-full py-3 text-sm font-semibold text-white active:scale-[0.98]"
>
<FiShare2 size={18} />
<BoldIcon name="share" size={18} tinted className="text-white" />
فوروارد
</button>
</div>

View File

@@ -0,0 +1,87 @@
"use client";
import { cn } from "@/lib/utils";
import BoldIcon from "@/components/ui/BoldIcon";
export type ChatBoldIconName =
| "timer"
| "eye"
| "attachment"
| "microphone"
| "camera"
| "gallery"
| "video"
| "file"
| "location"
| "send"
| "search"
| "delete"
| "back";
/** Chat-specific SVG assets (custom uploads) */
const CHAT_ICON_SRC: Partial<Record<ChatBoldIconName, string>> = {
timer: "/images/icons/chat/timer.svg",
search: "/images/icons/chat/search-normal.svg",
back: "/images/icons/chat/back.svg",
};
const ICON_NAME: Record<ChatBoldIconName, string> = {
timer: "timer",
eye: "eye",
attachment: "link",
microphone: "microphone-2",
camera: "camera",
gallery: "gallery",
video: "video",
file: "folder-2",
location: "location",
send: "send-2",
search: "search-normal",
delete: "trash",
back: "arrow-circle-right",
};
interface ChatBoldIconProps {
name: ChatBoldIconName;
size?: number;
className?: string;
}
export default function ChatBoldIcon({
name,
size = 20,
className,
}: ChatBoldIconProps) {
const customSrc = CHAT_ICON_SRC[name];
if (customSrc) {
return (
<span
role="img"
aria-hidden
className={cn("inline-block shrink-0 bg-current", className)}
style={{
width: size,
height: size,
WebkitMaskImage: `url(${customSrc})`,
maskImage: `url(${customSrc})`,
WebkitMaskRepeat: "no-repeat",
maskRepeat: "no-repeat",
WebkitMaskPosition: "center",
maskPosition: "center",
WebkitMaskSize: "contain",
maskSize: "contain",
}}
/>
);
}
return (
<BoldIcon
name={ICON_NAME[name]}
size={size}
className={className}
tinted
/>
);
}

View File

@@ -3,8 +3,8 @@
/** Telegram-style centered date capsule */
export default function ChatDateSeparator({ label }: { label: string }) {
return (
<div className="my-3 flex w-full justify-center">
<span className="glass-panel rounded-full px-3 py-1 text-[11px] font-medium text-neutral-600 dark:text-neutral-300">
<div className="pointer-events-none my-2 flex w-full justify-center">
<span className="rounded-full bg-black/35 px-3 py-1 text-[12px] font-medium text-white/95 shadow-sm backdrop-blur-sm dark:bg-black/45 dark:text-white/90">
{label}
</span>
</div>

View File

@@ -0,0 +1,48 @@
"use client";
import { motion } from "framer-motion";
import BoldIcon from "@/components/ui/BoldIcon";
interface ChatDeleteBarProps {
selectedCount: number;
onDelete: () => void;
onCancel: () => void;
loading?: boolean;
}
export default function ChatDeleteBar({
selectedCount,
onDelete,
onCancel,
loading = false,
}: ChatDeleteBarProps) {
return (
<motion.div
initial={{ y: 24, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 24, opacity: 0 }}
transition={{ duration: 0.28, ease: [0.25, 0.46, 0.45, 0.94] }}
className="pointer-events-none fixed bottom-0 left-0 right-0 z-[60] flex justify-center px-3 chat-input-area--thread"
>
<div className="pointer-events-auto mb-2 flex w-full max-w-lg gap-2">
<button
type="button"
onClick={onCancel}
disabled={loading}
className="glass-panel gentle-transition flex-1 rounded-full py-3 text-sm font-medium active:scale-[0.98] disabled:opacity-50"
>
انصراف
</button>
<button
type="button"
onClick={onDelete}
disabled={loading || selectedCount === 0}
className="gentle-transition flex flex-[2] items-center justify-center gap-2 rounded-full bg-red-500 py-3 text-sm font-semibold text-white active:scale-[0.98] disabled:opacity-50"
>
<BoldIcon name="trash" size={18} tinted className="text-white" />
{selectedCount > 0 ? `حذف (${selectedCount})` : "حذف"}
</button>
</div>
</motion.div>
);
}

View File

@@ -1,69 +1,103 @@
"use client";
import { User } from "@/types/types";
import Image from "next/image";
import Link from "next/link";
import { useState } from "react";
import { FiChevronRight } from "react-icons/fi";
import { IMAGE_BASE_URL, buildStorageUrl } from "../main/BaseUrl";
import ChatBoldIcon from "./ChatBoldIcon";
import ProfileAvatar from "../main/ProfileAvatar";
import { useRouter } from "next/navigation";
import ChatHeaderMenu from "./ChatHeaderMenu";
import { AnimatePresence, motion } from "framer-motion";
import { cn } from "@/lib/utils";
export const chatHeaderCircleBtn = cn(
"chat-header-glass gentle-transition flex h-11 w-11 shrink-0 items-center justify-center rounded-full",
"text-neutral-800 active:scale-90",
"dark:text-neutral-100"
);
export const chatHeaderInfoPill = cn(
"chat-header-glass gentle-transition flex h-11 min-w-0 flex-1 items-center gap-2 rounded-full py-[5px] pr-[5px] pl-3",
"active:scale-[0.98]"
);
function ChatHeader({
user,
searchQuery = "",
onSearchChange,
onBlockChange,
onStartDeleteMode,
deleteMode = false,
}: {
user: User | undefined;
searchQuery?: string;
onSearchChange?: (q: string) => void;
onBlockChange?: () => void;
onBlockChange?: (blocked: boolean) => void;
onStartDeleteMode?: () => void;
deleteMode?: boolean;
}) {
const router = useRouter();
const [searchOpen, setSearchOpen] = useState(false);
const isOnline = user?.last_online === "آنلاین";
const displayName =
[user?.first_name, user?.last_name].filter(Boolean).join(" ").trim() ||
user?.user_name ||
"";
return (
<header className="glass-panel gentle-transition sticky top-0 z-40 shrink-0 border-b border-white/20 px-2 py-2 sm:px-3 dark:border-white/5">
<div className="flex items-center gap-2">
<header className="pointer-events-none fixed left-0 right-0 top-0 z-[100] bg-transparent px-2 pb-2 pt-[max(0.5rem,env(safe-area-inset-top))] sm:px-3">
<div
dir="rtl"
className="pointer-events-auto mx-auto flex max-w-lg items-center gap-2"
>
<button
type="button"
onClick={() => router.back()}
className="gentle-transition flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-[#0095f6] active:scale-90"
className={chatHeaderCircleBtn}
aria-label="بازگشت"
>
<FiChevronRight size={24} />
<ChatBoldIcon name="back" size={22} className="text-current" />
</button>
<Link
href={`/users/${user?.user_name}`}
className="gentle-transition flex min-w-0 flex-1 items-center gap-2 active:opacity-80"
dir="ltr"
className={chatHeaderInfoPill}
>
{user?.profile_image && !user?.blocked_you ? (
<Image
width={40}
height={40}
alt=""
src={buildStorageUrl(user.profile_image)}
className="h-10 w-10 shrink-0 rounded-full object-cover"
/>
) : (
<div className="h-10 w-10 shrink-0 rounded-full bg-neutral-200 dark:bg-neutral-700" />
)}
<div className="min-w-0 flex-1 text-right">
<span className="block truncate text-[15px] font-semibold">
{user?.first_name} {user?.last_name}
<span
className={cn(
"chat-header-status-dot shrink-0",
isOnline
? "chat-header-status-dot--online"
: "chat-header-status-dot--offline"
)}
title={isOnline ? "آنلاین" : "آفلاین"}
aria-hidden
/>
<div dir="rtl" className="min-w-0 flex-1 text-right">
<span className="block truncate text-[15px] font-semibold leading-tight text-neutral-900 dark:text-neutral-50">
{displayName}
</span>
<span className="truncate text-xs text-neutral-500">
<span className="block truncate text-xs leading-tight text-neutral-500 dark:text-neutral-400">
{isOnline ? (
<span className="text-[#0095f6]">آنلاین</span>
<span className="text-[#22c55e]">آنلاین</span>
) : (
user?.last_online || user?.user_name
user?.last_online || user?.user_name || "آفلاین"
)}
</span>
</div>
{user?.profile_image && !user?.blocked_you ? (
<ProfileAvatar
src={user.profile_image}
alt={user.user_name || ""}
size="xs"
rounded="full"
className="h-[34px] w-[34px] shrink-0"
/>
) : (
<div className="h-[34px] w-[34px] shrink-0 rounded-full bg-neutral-200 dark:bg-neutral-600" />
)}
</Link>
<ChatHeaderMenu
@@ -71,16 +105,24 @@ function ChatHeader({
searchOpen={searchOpen}
onSearchToggle={setSearchOpen}
onBlockChange={onBlockChange}
onStartDeleteMode={onStartDeleteMode}
buttonClassName={chatHeaderCircleBtn}
/>
</div>
{deleteMode && (
<p className="pointer-events-auto mx-auto mt-2 max-w-lg text-center text-xs font-medium text-neutral-500 dark:text-neutral-400">
پیامهای خود را برای حذف انتخاب کنید
</p>
)}
<AnimatePresence>
{searchOpen && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden"
className="pointer-events-auto mx-auto max-w-lg overflow-hidden"
>
<input
type="search"
@@ -96,7 +138,4 @@ function ChatHeader({
);
}
// framer-motion for search expand
import { AnimatePresence, motion } from "framer-motion";
export default ChatHeader;

View File

@@ -1,16 +1,20 @@
"use client";
import { useState } from "react";
import { FiMoreVertical, FiSearch } from "react-icons/fi";
import { useState, useEffect } from "react";
import { FiMoreVertical } from "react-icons/fi";
import ChatBoldIcon from "./ChatBoldIcon";
import { motion, AnimatePresence } from "framer-motion";
import { User } from "@/types/types";
import useAxios from "@/hooks/useAxios";
import { cn } from "@/lib/utils";
interface ChatHeaderMenuProps {
user: User | undefined;
onSearchToggle: (open: boolean) => void;
searchOpen: boolean;
onBlockChange?: (blocked: boolean) => void;
buttonClassName?: string;
onStartDeleteMode?: () => void;
}
export default function ChatHeaderMenu({
@@ -18,11 +22,17 @@ export default function ChatHeaderMenu({
onSearchToggle,
searchOpen,
onBlockChange,
buttonClassName,
onStartDeleteMode,
}: ChatHeaderMenuProps) {
const [open, setOpen] = useState(false);
const [isBlocked, setIsBlocked] = useState(user?.is_blocked);
const [isBlocked, setIsBlocked] = useState(Boolean(user?.is_blocked));
const { request } = useAxios();
useEffect(() => {
setIsBlocked(Boolean(user?.is_blocked));
}, [user?.is_blocked, user?._id]);
const block = async () => {
await request("POST", "/users/block", { user_to_block: user?._id });
setIsBlocked(true);
@@ -38,24 +48,28 @@ export default function ChatHeaderMenu({
};
return (
<div className="relative shrink-0">
<div className={cn("relative shrink-0", open && "z-[110]")}>
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="gentle-transition flex h-9 w-9 items-center justify-center rounded-full text-neutral-600 active:scale-90 dark:text-neutral-300"
className={cn(buttonClassName, "relative")}
aria-label="منو"
>
<FiMoreVertical size={20} />
<FiMoreVertical size={20} strokeWidth={2.5} />
</button>
<AnimatePresence>
{open && (
<>
<div className="fixed inset-0 z-30" onClick={() => setOpen(false)} />
<div
className="fixed inset-0 z-[105]"
onClick={() => setOpen(false)}
aria-hidden
/>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: -4 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95 }}
className="glass-panel absolute left-0 top-full z-40 mt-1 min-w-[160px] overflow-hidden rounded-xl py-1 shadow-xl"
className="glass-panel absolute left-0 top-full z-[110] mt-1 min-w-[160px] overflow-hidden rounded-2xl py-1 shadow-xl"
>
<button
type="button"
@@ -65,9 +79,20 @@ export default function ChatHeaderMenu({
setOpen(false);
}}
>
<FiSearch size={16} />
<ChatBoldIcon name="search" size={16} />
جستجو در چت
</button>
<button
type="button"
className="flex w-full items-center gap-2 px-4 py-2.5 text-right text-sm text-red-500 hover:bg-black/5 dark:hover:bg-white/10"
onClick={() => {
onStartDeleteMode?.();
setOpen(false);
}}
>
<ChatBoldIcon name="delete" size={16} className="text-red-500" />
حذف
</button>
{isBlocked ? (
<button
type="button"

View File

@@ -1,7 +1,7 @@
"use client";
import Image from "next/image";
import { useState } from "react";
import { useState, useEffect, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
import Link from "next/link";
import { User } from "@/types/types";
@@ -15,6 +15,57 @@ import LocationMessageBubble, {
} from "./LocationMessageBubble";
import { detectChatFileType } from "@/lib/chat/detectFileType";
import { formatBubbleTime } from "@/lib/chat/formatMessageTime";
import { useLongPress } from "@/hooks/useLongPress";
import { useSwipeToReply } from "@/hooks/useSwipeToReply";
import BoldIcon from "@/components/ui/BoldIcon";
import ChatBoldIcon from "./ChatBoldIcon";
import { cn } from "@/lib/utils";
import {
formatCountdown,
getRemainingSeconds,
} from "@/lib/chat/timedMessages";
import { useViewOnceMedia } from "@/hooks/useViewOnceMedia";
import ViewOnceMediaBubble from "./ViewOnceMediaBubble";
import { isViewOnceMediaType } from "@/lib/chat/viewOnce";
import toast from "react-hot-toast";
import {
type BubbleGroupPosition,
getBubbleRadius,
getMessageRowSpacing,
} from "@/lib/chat/messageGrouping";
function ViewOnceBadge() {
return (
<span className="absolute left-2 top-2 z-[2] flex h-5 min-w-[20px] items-center justify-center rounded-full bg-[#ff9500] px-1.5 text-[10px] font-bold text-white shadow">
۱
</span>
);
}
function TimedBadge({ expiresAt }: { expiresAt: string }) {
const [remaining, setRemaining] = useState(() =>
getRemainingSeconds(expiresAt)
);
useEffect(() => {
const tick = () => setRemaining(getRemainingSeconds(expiresAt));
tick();
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, [expiresAt]);
if (remaining == null || remaining <= 0) return null;
return (
<span
className="chat-timer-badge-glass mr-1"
title="پیام زماندار"
>
<ChatBoldIcon name="timer" size={10} className="inline-block align-middle" />
{formatCountdown(remaining)}
</span>
);
}
export interface ChatMessage {
_id: string;
@@ -22,6 +73,11 @@ export interface ChatMessage {
senderId: string;
receiverId?: string;
createdAt: string;
createdAtIso?: string;
expiresAt?: string;
viewOnce?: boolean;
viewOnceLocked?: boolean;
viewOnceExpired?: boolean;
file?: string;
fileType?: "image" | "video" | "file" | "voice" | "location";
status?: "pending" | "sent" | "seen";
@@ -40,10 +96,24 @@ export interface ChatMessage {
interface ChatMessageCardProps {
message: ChatMessage;
userDetail: User | undefined;
groupPosition?: BubbleGroupPosition;
isGroupedWithPrev?: boolean;
showAvatar?: boolean;
partnerAvatar?: string;
isNew?: boolean;
selected?: boolean;
onBubbleClick?: (message: ChatMessage) => void;
onSwipeReply?: (message: ChatMessage) => void;
deleteMode?: boolean;
selectedForDelete?: boolean;
onToggleDeleteSelect?: (message: ChatMessage) => void;
onScrollToReply?: (messageId: string) => void;
highlighted?: boolean;
registerRef?: (id: string, el: HTMLDivElement | null) => void;
}
const LONG_PRESS_MS = 2000;
function parseForwarded(content: string): ChatMessage["forwardedFrom"] | null {
try {
const line = content.split("\n")[0];
@@ -54,21 +124,103 @@ function parseForwarded(content: string): ChatMessage["forwardedFrom"] | null {
}
}
function isEmojiOnly(text: string): boolean {
const stripped = text.trim();
if (!stripped) return false;
return !/[\p{L}\p{N}]/u.test(stripped);
}
const ChatMessageCard = ({
message,
userDetail,
groupPosition = "single",
isGroupedWithPrev = false,
showAvatar = false,
partnerAvatar,
isNew = false,
selected = false,
onBubbleClick,
onSwipeReply,
deleteMode = false,
selectedForDelete = false,
onToggleDeleteSelect,
onScrollToReply,
highlighted = false,
registerRef,
}: ChatMessageCardProps) => {
const isSender = !!(userDetail && message.senderId === userDetail._id);
const currentUserId = String(userDetail?._id ?? "");
const isSender =
!!currentUserId && String(message.senderId) === currentUserId;
const [preview, setPreview] = useState<string | null>(null);
const [viewOncePreview, setViewOncePreview] = useState<{
url: string;
type: "image" | "video";
} | null>(null);
const [viewOnceVoiceUrl, setViewOnceVoiceUrl] = useState<string | null>(null);
const { openViewOnce, completeViewOnce } = useViewOnceMedia();
const canSelectForDelete = deleteMode && isSender && !message._id.startsWith("temp-");
const swipeEnabled = !deleteMode && !!onSwipeReply;
const handleOpenActions = useCallback(() => {
onBubbleClick?.(message);
}, [message, onBubbleClick]);
const handleSwipeReply = useCallback(() => {
onSwipeReply?.(message);
}, [message, onSwipeReply]);
const { pressing, shouldBlockClick, cancelPress, handlers: longPressHandlers } =
useLongPress(handleOpenActions, { delay: LONG_PRESS_MS });
const { dragProps, replyOpacity, replyScale } = useSwipeToReply(
handleSwipeReply,
swipeEnabled
);
const swipeDragStart =
swipeEnabled && "onDragStart" in dragProps ? dragProps.onDragStart : undefined;
const restDragProps = swipeEnabled
? (() => {
const { onDragStart: _, ...rest } = dragProps;
return rest;
})()
: {};
const toggleDeleteSelection = () => {
if (canSelectForDelete) onToggleDeleteSelect?.(message);
};
const openPreview = (url: string) => {
if (deleteMode) {
toggleDeleteSelection();
return;
}
if (shouldBlockClick()) return;
setPreview(url);
};
const bubbleHandlers = deleteMode
? { onClick: toggleDeleteSelection }
: longPressHandlers;
const handleDragStart = () => {
cancelPress();
swipeDragStart?.();
};
const forwarded =
message.forwardedFrom || parseForwarded(message.content || "");
const locationData = parseLocationContent(message.content);
const hasLocation = message.fileType === "location" || !!locationData;
if (!message.content && !message.file && !hasLocation) return null;
if (
!message.content &&
!message.file &&
!hasLocation &&
!(message.viewOnce && isViewOnceMediaType(message.fileType))
) {
return null;
}
const fileUrl = message.file?.startsWith("blob:")
? message.file
@@ -93,14 +245,51 @@ const ChatMessageCard = ({
fileType !== "location" &&
(!message.file || (fileType === "image" && textContent));
const tailClass = isSender ? "bubble-tail-out" : "bubble-tail-in";
const emojiOnly = showTextBubble && !message.file && isEmojiOnly(textContent);
const borderRadius = getBubbleRadius(isSender, groupPosition);
const rowSpacing = getMessageRowSpacing(groupPosition, isGroupedWithPrev);
const timeLabel = formatBubbleTime(message.createdAt);
const isPending = message.status === "pending";
const isViewOnce =
Boolean(message.viewOnce) && isViewOnceMediaType(fileType);
const showViewOnceLocked =
isViewOnce &&
!isSender &&
(message.viewOnceLocked || message.viewOnceExpired) &&
!viewOnceVoiceUrl;
const finishViewOnce = useCallback(async () => {
setViewOncePreview(null);
setViewOnceVoiceUrl(null);
if (message._id.startsWith("temp")) return;
try {
await completeViewOnce(message._id);
} catch {
toast.error("حذف پیام یک‌بارمصرف ناموفق بود");
}
}, [completeViewOnce, message._id]);
const handleViewOnceOpen = useCallback(async () => {
if (message._id.startsWith("temp")) return;
try {
const { url, fileType: openedType } = await openViewOnce(message._id);
if (openedType === "voice") {
setViewOnceVoiceUrl(url);
} else if (openedType === "video") {
setViewOncePreview({ url, type: "video" });
} else {
setViewOncePreview({ url, type: "image" });
}
} catch {
toast.error("باز کردن پیام یک‌بارمصرف ممکن نیست");
}
}, [message._id, openViewOnce]);
const TimeInline = () => (
<span
className={`message-time-inline ${isSender ? "text-white/70" : "text-neutral-400"}`}
>
<span className="message-time-inline">
{timeLabel}
{message.expiresAt && <TimedBadge expiresAt={message.expiresAt} />}
{isSender && (
<span className="mr-1 inline-flex align-middle">
<ReadReceipt status={message.status} />
@@ -111,9 +300,13 @@ const ChatMessageCard = ({
const TimeBelow = () => (
<span
className={`message-time-below mt-0.5 block text-[10px] tabular-nums text-neutral-400 ${isSender ? "text-left" : "text-right"}`}
className={cn(
"message-time-below mt-0.5 block text-[10px] tabular-nums",
isSender ? "text-left" : "text-right"
)}
>
{timeLabel}
{message.expiresAt && <TimedBadge expiresAt={message.expiresAt} />}
{isSender && (
<span className="mr-1 inline-flex">
<ReadReceipt status={message.status} />
@@ -122,22 +315,63 @@ const ChatMessageCard = ({
</span>
);
const handleBubbleClick = (e: React.MouseEvent) => {
e.stopPropagation();
onBubbleClick?.(message);
const bubbleStyle = {
borderRadius,
...(isSender
? {}
: {}),
};
return (
<>
<motion.div
layout
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className={`message-enter relative my-0.5 flex w-full ${isSender ? "justify-end" : "justify-start"}`}
ref={(el) => registerRef?.(message._id, el)}
data-message-id={message._id}
initial={
isNew
? { opacity: 0, y: 8, scale: 0.98 }
: false
}
animate={{
opacity: isPending ? 0.82 : 1,
y: 0,
scale: 1,
}}
transition={{
duration: 0.24,
ease: [0.25, 0.46, 0.45, 0.94],
}}
className={cn(
"relative flex w-full gap-2",
isSender ? "justify-end pl-10" : "justify-start pr-10",
rowSpacing,
deleteMode && !isSender && "opacity-60",
selected && "relative z-[2]",
highlighted && "chat-message-highlight relative z-[2]"
)}
dir="rtl"
>
{canSelectForDelete && (
<button
type="button"
onClick={toggleDeleteSelection}
className={cn(
"gentle-transition order-first flex h-6 w-6 shrink-0 items-center justify-center self-end rounded-full border-2",
selectedForDelete
? "border-[#0095f6] bg-[#0095f6] text-white"
: "border-neutral-400 bg-transparent"
)}
aria-label={selectedForDelete ? "لغو انتخاب" : "انتخاب پیام"}
>
{selectedForDelete && <BoldIcon name="tick-circle" size={14} tinted className="text-white" />}
</button>
)}
<div
className={`relative flex w-full max-w-[min(88%,340px)] flex-col sm:max-w-[85%] ${isSender ? "items-end" : "items-start"}`}
className={cn(
"relative flex max-w-[min(78%,280px)] flex-col sm:max-w-[min(75%,320px)]",
isSender ? "items-end" : "items-start"
)}
>
{forwarded && (
<Link
@@ -150,56 +384,157 @@ const ChatMessageCard = ({
)}
{message.replyTo && (
<div className="mb-1 max-w-full rounded-lg border-r-2 border-[#0095f6] bg-black/5 px-2 py-1 text-[11px] opacity-90 dark:bg-white/10">
<button
type="button"
onClick={() => {
if (message.replyTo?._id) onScrollToReply?.(message.replyTo._id);
}}
className="gentle-transition mb-1 max-w-full cursor-pointer rounded-lg border-r-2 border-[#0095f6] bg-black/5 px-2 py-1 text-right text-[11px] opacity-90 hover:bg-[#0095f6]/10 active:scale-[0.98] dark:bg-white/10"
>
<span className="font-semibold text-[#0095f6]">
{message.replyTo.senderName}
</span>
<p className="truncate">{message.replyTo.content}</p>
</div>
</button>
)}
<button
type="button"
onClick={handleBubbleClick}
className={`gentle-transition relative text-right outline-none ${tailClass} ${
selected ? "ring-2 ring-[#0095f6]/50 rounded-2xl" : ""
}`}
>
{fileType === "image" && fileUrl && (
<div
className="block overflow-hidden rounded-2xl"
onClick={(e) => {
e.stopPropagation();
setPreview(fileUrl);
}}
<div className="relative w-full overflow-hidden">
{swipeEnabled && (
<motion.div
aria-hidden
className={cn(
"pointer-events-none absolute inset-y-0 flex w-11 items-center justify-center text-[#0095f6]",
isSender ? "right-0" : "right-0"
)}
style={{ opacity: replyOpacity, scale: replyScale }}
>
<div className="relative aspect-[3/4] w-44 sm:w-52">
<Image fill src={fileUrl} alt="" className="object-cover" sizes="208px" />
<BoldIcon name="direct-right" size={20} tinted className="text-current" />
</motion.div>
)}
<motion.div
{...(swipeEnabled ? restDragProps : {})}
onDragStart={swipeEnabled ? handleDragStart : undefined}
className="relative z-[1] w-full"
>
<div
data-chat-bubble
{...bubbleHandlers}
className={cn(
"relative select-none text-right outline-none touch-manipulation",
canSelectForDelete && "cursor-pointer",
(selected || selectedForDelete) &&
"rounded-2xl ring-2 ring-[#0095f6]/50",
!deleteMode && pressing && "scale-[0.98] opacity-90"
)}
>
{fileType === "image" &&
(fileUrl || (isViewOnce && !isSender)) &&
(showViewOnceLocked ? (
<div style={{ borderRadius }}>
<ViewOnceMediaBubble
fileType="image"
isOutgoing={isSender}
expired={Boolean(message.viewOnceExpired)}
borderRadius={borderRadius}
onOpen={handleViewOnceOpen}
/>
<TimeBelow />
</div>
<TimeBelow />
</div>
)}
) : fileUrl ? (
<div
className="relative block overflow-hidden"
style={{ borderRadius }}
onClick={(e) => {
e.stopPropagation();
openPreview(fileUrl);
}}
>
{isViewOnce && isSender && <ViewOnceBadge />}
<div className="relative aspect-[3/4] w-44 sm:w-52">
<Image
fill
src={fileUrl}
alt=""
className="object-cover"
sizes="208px"
/>
</div>
<TimeBelow />
</div>
) : null)}
{fileType === "video" && fileUrl && (
<div>
<VideoMessageBubble
src={fileUrl}
isOutgoing={isSender}
onOpen={() => setPreview(fileUrl)}
/>
<TimeBelow />
</div>
)}
{fileType === "video" &&
(fileUrl || (isViewOnce && !isSender)) &&
(showViewOnceLocked ? (
<div style={{ borderRadius }}>
<ViewOnceMediaBubble
fileType="video"
isOutgoing={isSender}
expired={Boolean(message.viewOnceExpired)}
borderRadius={borderRadius}
onOpen={handleViewOnceOpen}
/>
<TimeBelow />
</div>
) : fileUrl ? (
<div
className="relative cursor-pointer overflow-hidden"
style={{ borderRadius }}
onClick={(e) => {
e.stopPropagation();
openPreview(fileUrl);
}}
>
{isViewOnce && isSender && <ViewOnceBadge />}
<VideoMessageBubble src={fileUrl} isOutgoing={isSender} />
<TimeBelow />
</div>
) : null)}
{fileType === "voice" && fileUrl && (
<div className={isSender ? "chat-bubble-out rounded-2xl p-1" : "chat-bubble-in rounded-2xl p-1"}>
<VoiceMessagePlayer src={fileUrl} isOutgoing={isSender} />
<TimeBelow />
</div>
)}
{fileType === "voice" &&
(fileUrl || viewOnceVoiceUrl || (isViewOnce && !isSender)) &&
(showViewOnceLocked ? (
<div style={bubbleStyle}>
<ViewOnceMediaBubble
fileType="voice"
isOutgoing={isSender}
expired={Boolean(message.viewOnceExpired)}
borderRadius={borderRadius}
onOpen={handleViewOnceOpen}
/>
<TimeBelow />
</div>
) : viewOnceVoiceUrl ? (
<div
className={isSender ? "chat-bubble-out p-1" : "chat-bubble-in p-1"}
style={bubbleStyle}
>
<VoiceMessagePlayer
src={viewOnceVoiceUrl}
isOutgoing={isSender}
autoPlay
singlePlay
onPlaybackComplete={finishViewOnce}
/>
<TimeBelow />
</div>
) : fileUrl ? (
<div
className={cn(
isSender ? "chat-bubble-out p-1" : "chat-bubble-in p-1",
isViewOnce && isSender && "relative"
)}
style={bubbleStyle}
>
{isViewOnce && isSender && <ViewOnceBadge />}
<VoiceMessagePlayer src={fileUrl} isOutgoing={isSender} />
<TimeBelow />
</div>
) : null)}
{fileType === "file" && fileUrl && (
<div>
<div style={bubbleStyle} className="overflow-hidden">
<FileMessageBubble
url={fileUrl}
fileName={textContent || "فایل"}
@@ -210,25 +545,85 @@ const ChatMessageCard = ({
)}
{fileType === "location" && locationData && (
<div>
<div style={bubbleStyle} className="overflow-hidden">
<LocationMessageBubble location={locationData} isOutgoing={isSender} />
<TimeBelow />
</div>
)}
{showTextBubble && !message.file && (
{showTextBubble && !message.file && emojiOnly && (
<p className="px-1 py-0.5 text-[2.5rem] leading-none">{textContent}</p>
)}
{showTextBubble && !message.file && !emojiOnly && (
<div
className={`chat-text-bubble ${isSender ? "chat-bubble-out" : "chat-bubble-in"} px-3 py-2 text-[15px]`}
className={cn(
"chat-text-bubble px-3 py-2 text-[15px] leading-snug",
isSender ? "chat-bubble-out" : "chat-bubble-in"
)}
style={bubbleStyle}
>
<p className="chat-message-text">{textContent}</p>
<TimeInline />
</div>
)}
</button>
</div>
</motion.div>
</div>
</div>
{!isSender && (
<div className="flex w-7 shrink-0 items-end self-end">
{showAvatar && partnerAvatar ? (
<div className="relative h-7 w-7 overflow-hidden rounded-full bg-neutral-700">
<Image
src={buildStorageUrl(partnerAvatar)}
alt=""
fill
className="object-cover"
sizes="28px"
/>
</div>
) : (
<div className="h-7 w-7" aria-hidden />
)}
</div>
)}
</motion.div>
<AnimatePresence>
{viewOncePreview && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/95"
onClick={() => void finishViewOnce()}
>
<motion.div
initial={{ scale: 0.9 }}
animate={{ scale: 1 }}
className="max-h-[92vh] max-w-[95vw] p-4"
onClick={(e) => e.stopPropagation()}
>
{viewOncePreview.type === "video" ? (
<video
src={viewOncePreview.url}
controls
autoPlay
className="max-h-[88vh] rounded-lg"
/>
) : (
// eslint-disable-next-line @next/next/no-img-element
<img
src={viewOncePreview.url}
alt=""
className="max-h-[88vh] object-contain"
/>
)}
</motion.div>
</motion.div>
)}
{preview && (
<motion.div
initial={{ opacity: 0 }}

View File

@@ -1,20 +1,32 @@
"use client";
import React, { useEffect, useState, useRef, useMemo } from "react";
import { BASE_URL, SOCKET_URL } from "../main/BaseUrl";
import axios from "axios";
import React, { useEffect, useState, useRef, useMemo, useCallback } from "react";
import { SOCKET_URL } from "../main/BaseUrl";
import { apiClient } from "@/hooks/useAxios";
import { chatThreadQueryKey } from "@/lib/chat/queryKeys";
import {
appendMessageToThreadCache,
normalizeThreadId,
} from "@/lib/chat/threadCache";
import ChatMessageCard, { ChatMessage } from "./ChatMessageCard";
import ChatDateSeparator from "./ChatDateSeparator";
import { groupMessagesByDate } from "@/lib/chat/groupMessagesByDate";
import { dedupeChatMessages } from "@/lib/chat/dedupeMessages";
import { formatBubbleTime } from "@/lib/chat/formatMessageTime";
import { io } from "socket.io-client";
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
import { User } from "@/types/types";
import { ChatMessagesSkeleton } from "@/components/ui/ChatSkeletons";
import TypingIndicator from "./TypingIndicator";
import IOSSpinner from "@/components/ui/IOSSpinner";
import { AnimatePresence } from "framer-motion";
import { AnimatePresence, motion } from "framer-motion";
import { getStoredUserId } from "@/lib/auth/session";
import { useTimedMessageExpiry } from "@/hooks/useTimedMessageExpiry";
import {
getBubbleGroupPosition,
sameVisualGroup,
} from "@/lib/chat/messageGrouping";
import { cn } from "@/lib/utils";
import toast from "react-hot-toast";
function belongsToThread(
msg: ChatMessage,
@@ -31,77 +43,118 @@ function belongsToThread(
interface ChatMessageListProps {
userDetail: User | undefined;
userTwoDetail: User | undefined;
chatPartnerId: string;
pendingMessages: ChatMessage[];
isTyping?: boolean;
onTypingChange?: (typing: boolean) => void;
searchQuery?: string;
selectedMessageId?: string;
onBubbleClick?: (message: ChatMessage) => void;
onSwipeReply?: (message: ChatMessage) => void;
actionMode?: boolean;
onDismissAction?: () => void;
deleteMode?: boolean;
selectedDeleteIds?: string[];
onToggleDeleteSelect?: (message: ChatMessage) => void;
getStableKey?: (id: string) => string;
onExpirePending?: (ids: string[]) => void;
/** فضای اضافه وقتی بنر پاسخ/زماندار بالای input است */
extraBottomRem?: number;
}
const ChatMessageList = ({
userDetail,
userTwoDetail,
chatPartnerId,
pendingMessages,
isTyping = false,
onTypingChange,
searchQuery = "",
selectedMessageId,
onBubbleClick,
onSwipeReply,
actionMode = false,
onDismissAction,
deleteMode = false,
selectedDeleteIds = [],
onToggleDeleteSelect,
getStableKey = (id) => id,
onExpirePending,
extraBottomRem = 0,
}: ChatMessageListProps) => {
const [receiverId, setReceiverId] = useState("");
const scrollRef = useRef<HTMLDivElement | null>(null);
const prevScrollHeight = useRef(0);
const prevPendingCountRef = useRef(0);
const [toEnd, setToEnd] = useState(true);
const queryClient = useQueryClient();
const hasInitializedRef = useRef(false);
const seenMessageKeysRef = useRef(new Set<string>());
const prevMessageCountRef = useRef(0);
const messageRefs = useRef(new Map<string, HTMLDivElement>());
const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [highlightedMessageId, setHighlightedMessageId] = useState<string | null>(
null
);
useEffect(() => {
setReceiverId(userTwoDetail?._id || "");
}, [userDetail, userTwoDetail]);
const senderId = normalizeThreadId(userDetail?._id ?? getStoredUserId());
const receiverId = normalizeThreadId(chatPartnerId);
const threadReady = Boolean(senderId && receiverId);
const queryKey = chatThreadQueryKey(senderId, receiverId);
const fetchMessages = async ({ pageParam = 1 }) => {
if (!userDetail?._id || !receiverId)
return { messages: [], nextPage: undefined };
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
isError,
refetch,
} = useInfiniteQuery({
queryKey,
queryFn: async ({ pageParam = 1, queryKey: key }) => {
const [, sid, rid] = key as ReturnType<typeof chatThreadQueryKey>;
if (!sid || !rid) {
return { messages: [], nextPage: undefined };
}
try {
const response = await axios.get(
`${BASE_URL}/chat?senderId=${userDetail?._id}&receiverId=${receiverId}&page=${pageParam}&limit=50&_t=${Date.now()}`
);
const msgs = (response.data.messages || []).map((m: ChatMessage) => ({
...m,
createdAt: formatBubbleTime(m.createdAt),
}));
return {
messages: msgs,
nextPage:
pageParam < response.data.totalPages ? pageParam + 1 : undefined,
};
} catch (error) {
console.error("Error fetching messages:", error);
return { messages: [], nextPage: undefined };
}
};
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } =
useInfiniteQuery({
queryKey: ["messages", userDetail?._id, receiverId],
queryFn: fetchMessages,
try {
const response = await apiClient.get(
`/chat?senderId=${sid}&receiverId=${rid}&page=${pageParam}&limit=50`
);
const msgs = (response.data.messages || []) as ChatMessage[];
return {
messages: msgs,
nextPage:
pageParam < response.data.totalPages ? pageParam + 1 : undefined,
};
} catch (error) {
console.error("Error fetching messages:", error);
throw error;
}
},
enabled: threadReady,
initialPageParam: 1,
getNextPageParam: (lastPage) => lastPage.nextPage,
staleTime: 0,
gcTime: 0,
staleTime: 60_000,
gcTime: 10 * 60 * 1000,
refetchOnMount: true,
});
useEffect(() => {
if (toEnd && scrollRef.current) {
setTimeout(() => {
scrollRef.current?.scrollTo({
top: scrollRef.current.scrollHeight,
behavior: "smooth",
});
}, 100);
}
}, [data, toEnd, pendingMessages, isTyping]);
const hasCachedMessages = Boolean(
data?.pages?.some((page) => page.messages.length > 0)
);
const showSkeleton =
!threadReady ||
(isLoading && !hasCachedMessages && pendingMessages.length === 0);
const scrollToBottom = useCallback((behavior: ScrollBehavior = "auto") => {
const el = scrollRef.current;
if (!el) return;
const run = () => {
el.scrollTo({ top: el.scrollHeight, behavior });
};
requestAnimationFrame(() => requestAnimationFrame(run));
}, []);
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
if (!scrollRef.current) return;
@@ -127,39 +180,22 @@ const ChatMessageList = ({
};
useEffect(() => {
if (!userDetail?._id || !receiverId) return;
if (!threadReady) return;
const socket = io(SOCKET_URL);
socket.emit("joinChat", { userId: userDetail._id, receiverId });
socket.emit("joinChat", { userId: senderId, receiverId });
const belongs = (msg: ChatMessage) =>
belongsToThread(msg, userDetail._id, receiverId) ||
(msg.senderId === userDetail._id && msg.receiverId === receiverId) ||
(msg.senderId === receiverId && msg.receiverId === userDetail._id);
belongsToThread(msg, senderId, receiverId) ||
(msg.senderId === senderId && msg.receiverId === receiverId) ||
(msg.senderId === receiverId && msg.receiverId === senderId);
socket.on("newMessage", (message: ChatMessage) => {
if (!belongs(message)) return;
const formatted = {
...message,
createdAt: formatBubbleTime(message.createdAt),
};
queryClient.setQueryData(
["messages", userDetail._id, receiverId],
(oldData: { pages: { messages: ChatMessage[] }[] } | undefined) => {
if (!oldData?.pages?.length) return oldData;
const exists = oldData.pages.some((p) =>
p.messages.some((m) => m._id === formatted._id)
);
if (exists) return oldData;
const newPages = [...oldData.pages];
newPages[0] = {
...newPages[0],
messages: [formatted, ...newPages[0].messages],
};
return { ...oldData, pages: newPages };
}
chatThreadQueryKey(senderId, receiverId),
(oldData) => appendMessageToThreadCache(oldData, message)
);
setToEnd(true);
@@ -167,7 +203,7 @@ const ChatMessageList = ({
socket.emit("messageSeen", {
messageId: message._id,
senderId: message.senderId,
receiverId: userDetail._id,
receiverId: senderId,
});
}
});
@@ -176,7 +212,7 @@ const ChatMessageList = ({
"messageStatusUpdate",
({ messageId, status }: { messageId: string; status: ChatMessage["status"] }) => {
queryClient.setQueryData(
["messages", userDetail._id, receiverId],
chatThreadQueryKey(senderId, receiverId),
(oldData: { pages: { messages: ChatMessage[] }[] } | undefined) => {
if (!oldData) return oldData;
return {
@@ -200,17 +236,39 @@ const ChatMessageList = ({
}
});
socket.on(
"messagesDeleted",
({ messageIds }: { messageIds: string[] }) => {
if (!messageIds?.length) return;
const idSet = new Set(messageIds);
queryClient.setQueryData(
chatThreadQueryKey(senderId, receiverId),
(oldData: { pages: { messages: ChatMessage[] }[] } | undefined) => {
if (!oldData) return oldData;
return {
...oldData,
pages: oldData.pages.map((page) => ({
...page,
messages: page.messages.filter((m) => !idSet.has(m._id)),
})),
};
}
);
}
);
return () => {
socket.off("newMessage");
socket.off("messageStatusUpdate");
socket.off("messagesDeleted");
socket.off("userTyping");
socket.disconnect();
};
}, [userDetail, receiverId, queryClient, onTypingChange]);
}, [threadReady, senderId, receiverId, queryClient, onTypingChange]);
const allMessages = useMemo(() => {
const server =
data?.pages.flatMap((page) => page.messages).reverse() || [];
data?.pages.flatMap((page) => page.messages) || [];
const serverIds = new Set(server.map((m) => m._id));
const pendingOnly = pendingMessages.filter((p) => !serverIds.has(p._id));
const merged = dedupeChatMessages([...server, ...pendingOnly]);
@@ -226,41 +284,251 @@ const ChatMessageList = ({
[allMessages]
);
useEffect(() => {
if (!toEnd) return;
scrollToBottom("auto");
}, [
data,
toEnd,
pendingMessages,
isTyping,
allMessages.length,
extraBottomRem,
scrollToBottom,
]);
useEffect(() => {
if (pendingMessages.length > prevPendingCountRef.current) {
setToEnd(true);
scrollToBottom("smooth");
const retry = setTimeout(() => scrollToBottom("auto"), 180);
prevPendingCountRef.current = pendingMessages.length;
return () => clearTimeout(retry);
}
prevPendingCountRef.current = pendingMessages.length;
}, [pendingMessages, scrollToBottom]);
useTimedMessageExpiry(allMessages, queryKey, onExpirePending);
useEffect(() => {
if (allMessages.length === 0) return;
if (!hasInitializedRef.current) {
allMessages.forEach((m) =>
seenMessageKeysRef.current.add(getStableKey(m._id))
);
hasInitializedRef.current = true;
prevMessageCountRef.current = allMessages.length;
}
}, [allMessages, getStableKey]);
const messageIndexMap = useMemo(() => {
const map = new Map<string, number>();
allMessages.forEach((m, i) => map.set(m._id, i));
return map;
}, [allMessages]);
const registerMessageRef = useCallback(
(id: string, el: HTMLDivElement | null) => {
if (el) messageRefs.current.set(id, el);
else messageRefs.current.delete(id);
},
[]
);
const scrollToMessage = useCallback(
async (targetId: string) => {
if (!targetId) return;
setToEnd(false);
const scrollToTarget = (): boolean => {
const container = scrollRef.current;
const el = messageRefs.current.get(targetId);
if (!container || !el) return false;
const containerRect = container.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
const offset =
elRect.top -
containerRect.top -
container.clientHeight / 2 +
elRect.height / 2;
container.scrollTo({
top: container.scrollTop + offset,
behavior: "smooth",
});
return true;
};
const flashHighlight = () => {
setHighlightedMessageId(targetId);
if (highlightTimerRef.current) clearTimeout(highlightTimerRef.current);
highlightTimerRef.current = setTimeout(
() => setHighlightedMessageId(null),
1800
);
};
const waitForPaint = () =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
});
if (scrollToTarget()) {
flashHighlight();
return;
}
const isLoaded = (id: string) => allMessages.some((m) => m._id === id);
if (isLoaded(targetId)) {
await waitForPaint();
if (scrollToTarget()) {
flashHighlight();
return;
}
}
let pagesLoaded = 0;
let hasMore = hasNextPage;
while (hasMore && pagesLoaded < 15 && !isLoaded(targetId)) {
const result = await fetchNextPage();
pagesLoaded += 1;
hasMore = result.hasNextPage ?? false;
await waitForPaint();
}
await waitForPaint();
if (scrollToTarget()) {
flashHighlight();
return;
}
toast.error("پیام اصلی یافت نشد");
},
[allMessages, fetchNextPage, hasNextPage]
);
useEffect(() => {
return () => {
if (highlightTimerRef.current) clearTimeout(highlightTimerRef.current);
};
}, []);
return (
<div
dir="rtl"
className="custom-scrollbar chat-page-bg flex min-h-0 flex-1 flex-col overflow-y-auto px-2 pb-[calc(5.5rem+env(safe-area-inset-bottom))] pt-2 sm:px-3"
className={cn(
"chat-thread-messages custom-scrollbar relative flex min-h-0 flex-1 flex-col overflow-y-auto bg-transparent px-2 pt-[calc(3.75rem+env(safe-area-inset-top))] sm:px-3"
)}
style={{
paddingBottom: `calc(${5.5 + extraBottomRem}rem + env(safe-area-inset-bottom, 0px))`,
}}
onScroll={handleScroll}
onClick={(e) => {
if (!actionMode) return;
if (!(e.target as Element).closest("[data-chat-bubble]")) {
onDismissAction?.();
}
}}
ref={scrollRef}
>
<AnimatePresence>
{actionMode && (
<motion.div
key="action-dim"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18 }}
className="pointer-events-none absolute inset-0 z-[5] bg-black/15 dark:bg-black/35"
aria-hidden
/>
)}
</AnimatePresence>
{isFetchingNextPage && (
<div className="flex justify-center py-2">
<IOSSpinner size={18} color="#8e8e93" />
</div>
)}
{isLoading ? (
{showSkeleton ? (
<ChatMessagesSkeleton />
) : isError ? (
<div className="flex flex-1 flex-col items-center justify-center gap-3 py-12 text-center">
<p className="text-sm text-neutral-500">بارگذاری پیامها ناموفق بود.</p>
<button
type="button"
onClick={() => void refetch()}
className="rounded-full bg-[#2aabee] px-4 py-2 text-sm text-white"
>
تلاش مجدد
</button>
</div>
) : grouped.length === 0 ? (
<p className="py-12 text-center text-sm text-neutral-500">
هنوز پیامی رد و بدل نشده است.
</p>
) : (
<div className="flex flex-col">
{grouped.map((item, index) =>
item.type === "date" ? (
<ChatDateSeparator key={`date-${item.label}-${index}`} label={item.label} />
) : (
<div className="flex flex-col pb-1">
{grouped.map((item, index) => {
if (item.type === "date") {
return (
<ChatDateSeparator
key={`date-${item.label}-${index}`}
label={item.label}
/>
);
}
const msg = item.message;
const msgIndex = messageIndexMap.get(msg._id) ?? 0;
const groupPosition = getBubbleGroupPosition(allMessages, msgIndex);
const prevMsg = allMessages[msgIndex - 1];
const isGroupedWithPrev =
!!prevMsg && sameVisualGroup(prevMsg, msg);
const isIncoming =
String(msg.senderId) !==
String(userDetail?._id ?? getStoredUserId());
const stableKey = getStableKey(msg._id);
const isNew =
hasInitializedRef.current &&
!seenMessageKeysRef.current.has(stableKey);
if (isNew) {
seenMessageKeysRef.current.add(stableKey);
}
return (
<ChatMessageCard
key={item.message._id || `msg-${index}`}
message={item.message}
key={stableKey}
message={msg}
userDetail={userDetail}
selected={selectedMessageId === item.message._id}
onBubbleClick={onBubbleClick}
groupPosition={groupPosition}
isGroupedWithPrev={isGroupedWithPrev}
showAvatar={
isIncoming &&
(groupPosition === "last" || groupPosition === "single")
}
partnerAvatar={userTwoDetail?.profile_image ?? undefined}
isNew={isNew}
selected={selectedMessageId === msg._id}
onBubbleClick={deleteMode ? undefined : onBubbleClick}
onSwipeReply={deleteMode ? undefined : onSwipeReply}
deleteMode={deleteMode}
selectedForDelete={selectedDeleteIds.includes(msg._id)}
onToggleDeleteSelect={onToggleDeleteSelect}
onScrollToReply={scrollToMessage}
highlighted={highlightedMessageId === msg._id}
registerRef={registerMessageRef}
/>
)
)}
);
})}
</div>
)}
<AnimatePresence>
{isTyping && !isLoading && (
{isTyping && !showSkeleton && (
<TypingIndicator userName={userTwoDetail?.first_name} />
)}
</AnimatePresence>

View File

@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { FiDownload, FiFile } from "react-icons/fi";
import BoldIcon from "@/components/ui/BoldIcon";
import IOSSpinner from "@/components/ui/IOSSpinner";
import { cn } from "@/lib/utils";
@@ -73,7 +73,7 @@ export default function FileMessageBubble({
{isPending || downloading ? (
<IOSSpinner size={22} color={isOutgoing ? "#2aabee" : "#007aff"} />
) : (
<FiFile size={22} />
<BoldIcon name="document" size={22} tinted className="text-current" />
)}
</div>
<div className="min-w-0 flex-1">
@@ -97,7 +97,7 @@ export default function FileMessageBubble({
className="gentle-transition rounded-full p-2 text-neutral-500 hover:bg-neutral-200/80 active:scale-90 dark:hover:bg-neutral-700"
aria-label="دانلود"
>
<FiDownload size={20} />
<BoldIcon name="document-download" size={20} tinted className="text-current" />
</button>
)}
</div>

View File

@@ -1,6 +1,6 @@
"use client";
import { FiMapPin, FiNavigation } from "react-icons/fi";
import BoldIcon from "@/components/ui/BoldIcon";
export interface LocationData {
lat: number;
@@ -46,7 +46,7 @@ export default function LocationMessageBubble({
loading="lazy"
/>
<div className="absolute inset-0 flex items-center justify-center">
<FiMapPin className="text-3xl text-red-500 drop-shadow-md" />
<BoldIcon name="location" size={30} tinted className="text-red-500 drop-shadow-md" />
</div>
</div>
<div className="flex items-center justify-between gap-2 bg-white/90 px-3 py-2 dark:bg-neutral-900/90">
@@ -59,7 +59,7 @@ export default function LocationMessageBubble({
rel="noopener noreferrer"
className="gentle-transition flex items-center gap-1 rounded-full bg-[#2aabee] px-2.5 py-1 text-[10px] text-white active:scale-95"
>
<FiNavigation size={12} />
<BoldIcon name="routing" size={12} tinted className="text-white" />
مسیریابی
</a>
</div>

View File

@@ -1,7 +1,7 @@
import Image from "next/image";
import { User } from "@/types/types";
import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
import { FiCheck, FiCheckCircle } from "react-icons/fi"; // اضافه کردن آیکون‌ها
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import BoldIcon from "@/components/ui/BoldIcon";
interface Message {
_id: string;
@@ -9,7 +9,7 @@ interface Message {
senderId: string;
createdAt: string;
file: string;
status: 'sent' | 'delivered' | 'seen'; // اضافه شده برای مدیریت وضعیت
status: 'sent' | 'delivered' | 'seen';
}
interface MessageCardProps {
@@ -20,25 +20,24 @@ interface MessageCardProps {
const MessageCard = ({ message, userDetail }: MessageCardProps) => {
const isSender = userDetail && message.senderId === userDetail._id;
// تابع تعیین آیکون بر اساس وضعیت
const renderStatus = () => {
if (!isSender) return null; // وضعیت فقط برای پیام‌های ارسالی نمایش داده شود
if (!isSender) return null;
switch (message.status) {
case 'sent':
return <FiCheck className="text-gray-400" size={14} />;
return <BoldIcon name="tick-circle" size={14} tinted className="text-gray-400" />;
case 'delivered':
return (
<div className="flex -space-x-2">
<FiCheck className="text-gray-400" size={14} />
<FiCheck className="text-gray-400" size={14} />
<BoldIcon name="tick-circle" size={14} tinted className="text-gray-400" />
<BoldIcon name="tick-circle" size={14} tinted className="text-gray-400" />
</div>
);
case 'seen':
return (
<div className="flex -space-x-2 text-[#2196EB]">
<FiCheck size={14} />
<FiCheck size={14} />
<BoldIcon name="tick-circle" size={14} tinted className="text-[#2196EB]" />
<BoldIcon name="tick-circle" size={14} tinted className="text-[#2196EB]" />
</div>
);
default:
@@ -61,7 +60,6 @@ const MessageCard = ({ message, userDetail }: MessageCardProps) => {
</div>
)}
{/* نمایش وضعیت تیک‌ها */}
<div className="px-1">{renderStatus()}</div>
</div>
</div>

View File

@@ -2,8 +2,13 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { FiMic, FiPaperclip, FiSend, FiSquare } from "react-icons/fi";
import BoldIcon from "@/components/ui/BoldIcon";
import AttachmentMenu, { AttachmentType } from "./AttachmentMenu";
import TimedMessagePicker from "./TimedMessagePicker";
import ViewOnceToggle from "./ViewOnceToggle";
import ChatBoldIcon from "./ChatBoldIcon";
import { formatTimedLabel } from "@/lib/chat/timedMessages";
import { viewOnceLabel } from "@/lib/chat/viewOnce";
interface MessageInputProps {
newMessage: string;
@@ -19,6 +24,10 @@ interface MessageInputProps {
replyingTo?: unknown;
onCancelReply?: () => void;
replyLabel?: string;
selfDestructSeconds?: number | null;
onSelfDestructChange?: (seconds: number | null) => void;
viewOnceMedia?: boolean;
onViewOnceChange?: (value: boolean) => void;
}
const formatTime = (seconds: number) => {
@@ -39,6 +48,10 @@ const MessageInput = ({
blocked_you,
onCancelReply,
replyLabel,
selfDestructSeconds = null,
onSelfDestructChange,
viewOnceMedia = false,
onViewOnceChange,
}: MessageInputProps) => {
const [isRecording, setIsRecording] = useState(false);
const [recordingTime, setRecordingTime] = useState(0);
@@ -155,38 +168,75 @@ const MessageInput = ({
return (
<div
className={`pointer-events-none fixed left-0 right-0 z-50 flex flex-col items-center px-3 sm:px-4 ${
className={`pointer-events-none fixed left-0 right-0 z-50 flex flex-col items-center bg-transparent px-3 sm:px-4 ${
isChatThread ? "bottom-0 chat-input-area--thread" : "bottom-[5.5rem] chat-input-area"
}`}
>
{replyLabel && (
<div className="pointer-events-auto mb-2 flex w-full max-w-lg items-center gap-2 rounded-2xl border-r-2 border-[#0095f6] bg-white/80 px-3 py-2 text-xs backdrop-blur-md dark:bg-neutral-900/80">
<span className="flex-1 truncate text-neutral-600 dark:text-neutral-300">
{replyLabel}
</span>
<button
type="button"
onClick={onCancelReply}
className="shrink-0 text-[#0095f6]"
>
×
</button>
{(replyLabel ||
(selfDestructSeconds != null && isChatThread) ||
(viewOnceMedia && isChatThread)) && (
<div className="pointer-events-auto relative z-[2] mb-2 flex w-full max-w-lg flex-col gap-2">
{replyLabel && (
<div className="glass-panel flex items-center gap-2 rounded-2xl px-3 py-2.5 text-xs shadow-lg">
<span className="flex-1 truncate text-neutral-700 dark:text-neutral-200">
{replyLabel}
</span>
<button
type="button"
onClick={onCancelReply}
className="shrink-0 text-[#0095f6]"
>
×
</button>
</div>
)}
{selfDestructSeconds != null && isChatThread && (
<div className="chat-timer-glass chat-timer-glass--active flex items-center gap-2 rounded-2xl px-3 py-1.5 text-[11px]">
<ChatBoldIcon name="timer" size={14} className="shrink-0" />
<span>
پیام زماندار حذف خودکار بعد از{" "}
{formatTimedLabel(selfDestructSeconds)}
</span>
</div>
)}
{viewOnceMedia && isChatThread && (
<div className="chat-timer-glass chat-timer-glass--active flex items-center gap-2 rounded-2xl px-3 py-1.5 text-[11px] text-[#ff9500]">
<span className="font-semibold">۱</span>
<span>
{viewOnceLabel("image")} گیرنده فقط یکبار میتواند ببیند
</span>
</div>
)}
</div>
)}
<div
dir="ltr"
className="pointer-events-auto relative flex w-full max-w-lg items-end gap-2 sm:gap-3"
className="pointer-events-auto relative z-[1] flex w-full max-w-lg items-end gap-2 sm:gap-2.5"
>
<div className="relative mb-1">
<div className="relative flex shrink-0 items-center gap-1.5">
{isChatThread && onSelfDestructChange && (
<TimedMessagePicker
value={selfDestructSeconds}
onChange={onSelfDestructChange}
disabled={blocked_you || isRecording}
/>
)}
{isChatThread && onViewOnceChange && (
<ViewOnceToggle
value={viewOnceMedia}
onChange={onViewOnceChange}
disabled={blocked_you}
/>
)}
<button
ref={attachBtnRef}
type="button"
disabled={blocked_you || isRecording}
onClick={() => setMenuOpen((o) => !o)}
className="chat-action-btn gentle-transition flex h-10 w-10 shrink-0 items-center justify-center rounded-full active:scale-90 disabled:opacity-40 sm:h-11 sm:w-11"
className="chat-action-btn gentle-transition flex h-11 w-11 shrink-0 items-center justify-center rounded-full active:scale-90 disabled:opacity-40"
aria-label="پیوست"
>
<FiPaperclip size={22} className="rotate-[-45deg]" />
<ChatBoldIcon name="attachment" size={22} />
</button>
<AttachmentMenu
open={menuOpen}
@@ -197,7 +247,7 @@ const MessageInput = ({
</div>
{/* Glass input box */}
<div className="glass-chat-input gentle-transition flex min-h-[44px] flex-1 items-center rounded-[22px] px-4 py-2">
<div className="glass-chat-input gentle-transition flex min-h-11 flex-1 items-center rounded-[22px] px-4 py-2">
<input
ref={imageInputRef}
type="file"
@@ -239,7 +289,7 @@ const MessageInput = ({
onClick={() => stopRecording(false)}
className="rounded-full p-1"
>
<FiSquare size={18} />
<BoldIcon name="stop" size={18} tinted className="text-current" />
</button>
</div>
) : (
@@ -277,10 +327,10 @@ const MessageInput = ({
type="button"
disabled={blocked_you}
onClick={sendMessage}
className="ig-dm-send-btn gentle-transition mb-1 flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-white shadow-lg active:scale-90 sm:h-11 sm:w-11"
className="ig-dm-send-btn gentle-transition flex h-11 w-11 shrink-0 items-center justify-center rounded-full text-white shadow-lg active:scale-90"
aria-label="ارسال"
>
<FiSend size={20} className="-mr-0.5" />
<ChatBoldIcon name="send" size={20} className="text-white -mr-0.5" />
</motion.button>
) : (
<motion.button
@@ -294,14 +344,14 @@ const MessageInput = ({
onPointerDown={handleMicPointerDown}
onPointerUp={handleMicPointerUp}
onPointerLeave={() => isRecording && stopRecording(true)}
className={`gentle-transition mb-1 flex h-10 w-10 shrink-0 items-center justify-center rounded-full sm:h-11 sm:w-11 ${
className={`gentle-transition flex h-11 w-11 shrink-0 items-center justify-center rounded-full ${
isRecording
? "scale-110 bg-red-500 text-white shadow-inner"
: "ig-dm-send-btn text-white shadow-lg"
} active:scale-90`}
aria-label="ضبط ویس"
>
{isRecording ? <FiSquare size={20} /> : <FiMic size={22} />}
{isRecording ? <BoldIcon name="stop" size={20} tinted className="text-white" /> : <ChatBoldIcon name="microphone" size={22} className="text-white" />}
</motion.button>
)}
</AnimatePresence>

View File

@@ -4,12 +4,16 @@ import Image from "next/image";
import { motion } from "framer-motion";
import Modal from "../elements/Modal";
import ViewOnceToggle from "./ViewOnceToggle";
interface MultiImageModalProps {
isOpen: boolean;
files: File[];
onRemove: (index: number) => void;
onCancel: () => void;
onConfirm: () => void;
viewOnceMedia?: boolean;
onViewOnceChange?: (value: boolean) => void;
}
export default function MultiImageModal({
@@ -18,6 +22,8 @@ export default function MultiImageModal({
onRemove,
onCancel,
onConfirm,
viewOnceMedia = false,
onViewOnceChange,
}: MultiImageModalProps) {
if (!isOpen || files.length === 0) return null;
@@ -51,7 +57,17 @@ export default function MultiImageModal({
</motion.div>
))}
</div>
<div className="mt-4 flex justify-center gap-4">
<div className="mt-4 flex flex-col items-center gap-3">
{onViewOnceChange && (
<div className="flex items-center gap-2 text-xs text-neutral-600 dark:text-neutral-300">
<ViewOnceToggle
value={viewOnceMedia}
onChange={onViewOnceChange}
/>
<span>ارسال بهصورت یکبار مصرف</span>
</div>
)}
<div className="flex justify-center gap-4">
<button
type="button"
onClick={onConfirm}
@@ -66,6 +82,7 @@ export default function MultiImageModal({
>
لغو
</button>
</div>
</div>
</div>
</Modal>

View File

@@ -0,0 +1,95 @@
"use client";
import { useEffect, useRef, useState } from "react";
import ChatBoldIcon from "./ChatBoldIcon";
import { AnimatePresence, motion } from "framer-motion";
import { cn } from "@/lib/utils";
import {
TIMED_MESSAGE_OPTIONS,
formatTimedLabel,
} from "@/lib/chat/timedMessages";
interface TimedMessagePickerProps {
value: number | null;
onChange: (seconds: number | null) => void;
disabled?: boolean;
}
export default function TimedMessagePicker({
value,
onChange,
disabled,
}: TimedMessagePickerProps) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const close = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close);
}, [open]);
const active = value != null;
return (
<div ref={ref} className="relative shrink-0">
<button
type="button"
disabled={disabled}
onClick={() => setOpen((o) => !o)}
className={cn(
"chat-timer-glass gentle-transition flex h-11 w-11 items-center justify-center rounded-full",
active && "chat-timer-glass--active",
!active && "text-neutral-500 dark:text-neutral-400",
disabled && "opacity-40"
)}
aria-label="پیام زماندار"
title={active ? formatTimedLabel(value) : "پیام زماندار"}
>
<ChatBoldIcon name="timer" size={20} />
</button>
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0, y: 8, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 8, scale: 0.96 }}
transition={{ duration: 0.18 }}
className="glass-panel chat-timer-glass absolute bottom-full left-0 z-50 mb-2 min-w-[148px] overflow-hidden rounded-2xl p-1.5 shadow-xl"
>
<p className="px-2.5 py-1.5 text-[11px] font-medium text-neutral-500 dark:text-neutral-400">
حذف خودکار بعد از
</p>
{TIMED_MESSAGE_OPTIONS.map((opt) => (
<button
key={opt.label}
type="button"
onClick={() => {
onChange(opt.value);
setOpen(false);
}}
className={cn(
"flex w-full items-center justify-between rounded-xl px-2.5 py-2 text-right text-sm transition-colors",
value === opt.value
? "bg-[#2aabee]/12 font-semibold text-[#248cc8] dark:text-[#2aabee]"
: "text-foreground hover:bg-black/5 dark:hover:bg-white/8"
)}
>
{opt.label}
{value === opt.value && (
<span className="text-[#2aabee]"></span>
)}
</button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -1,39 +1,35 @@
"use client";
import { FiPlay } from "react-icons/fi";
interface VideoMessageBubbleProps {
src: string;
isOutgoing?: boolean;
onOpen: () => void;
}
/** Full video preview — not compressed file card */
export default function VideoMessageBubble({
src,
isOutgoing,
onOpen,
}: VideoMessageBubbleProps) {
return (
<button
type="button"
onClick={onOpen}
className={`gentle-transition relative max-w-[min(280px,85vw)] overflow-hidden rounded-2xl active:scale-[0.98] ${
isOutgoing ? "ring-1 ring-white/20" : "shadow-md"
}`}
>
<video
src={src}
className="max-h-72 w-full min-w-[200px] object-contain bg-black/90"
muted
playsInline
preload="metadata"
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-white/25 backdrop-blur-sm">
<FiPlay className="ml-0.5 text-2xl text-white" />
</span>
</div>
</button>
);
}
"use client";
import BoldIcon from "@/components/ui/BoldIcon";
interface VideoMessageBubbleProps {
src: string;
isOutgoing?: boolean;
}
/** Full video preview — not compressed file card */
export default function VideoMessageBubble({
src,
isOutgoing,
}: VideoMessageBubbleProps) {
return (
<div
className={`relative max-w-[min(280px,85vw)] overflow-hidden rounded-2xl ${
isOutgoing ? "ring-1 ring-white/20" : "shadow-md"

View File

@@ -0,0 +1,68 @@
"use client";
import { cn } from "@/lib/utils";
import BoldIcon from "@/components/ui/BoldIcon";
import { viewOnceLabel } from "@/lib/chat/viewOnce";
import ChatBoldIcon from "./ChatBoldIcon";
interface ViewOnceMediaBubbleProps {
fileType: "image" | "video" | "voice";
isOutgoing?: boolean;
expired?: boolean;
onOpen?: () => void;
borderRadius?: string;
}
export default function ViewOnceMediaBubble({
fileType,
isOutgoing = false,
expired = false,
onOpen,
borderRadius = "18px",
}: ViewOnceMediaBubbleProps) {
const mediaIcon =
fileType === "voice" ? (
<ChatBoldIcon name="microphone" size={28} className="text-[#ff9500]" />
) : fileType === "video" ? (
<BoldIcon name="video" size={28} tinted className="text-current" />
) : (
<BoldIcon name="gallery" size={28} tinted className="text-current" />
);
return (
<button
type="button"
disabled={expired}
onClick={(e) => {
e.stopPropagation();
if (!expired) onOpen?.();
}}
className={cn(
"gentle-transition flex w-44 flex-col items-center justify-center gap-2 px-4 py-6 sm:w-52",
isOutgoing ? "chat-bubble-out" : "chat-bubble-in",
expired ? "cursor-default opacity-60" : "cursor-pointer active:scale-[0.98]"
)}
style={{ borderRadius }}
>
<div
className={cn(
"flex h-14 w-14 items-center justify-center rounded-full",
expired
? "bg-neutral-300/40 dark:bg-neutral-600/40"
: "bg-[#ff9500]/15 text-[#ff9500]"
)}
>
{mediaIcon}
</div>
<span className="text-center text-xs font-medium leading-snug">
{expired ? "پیام منقضی شد" : viewOnceLabel(fileType)}
</span>
{!expired && (
<span className="flex items-center gap-1 text-[10px] text-[#ff9500]">
<ChatBoldIcon name="eye" size={12} className="text-[#ff9500]" />
برای مشاهده بزنید
</span>
)}
</button>
);
}

View File

@@ -0,0 +1,45 @@
"use client";
import { cn } from "@/lib/utils";
import ChatBoldIcon from "./ChatBoldIcon";
interface ViewOnceToggleProps {
value: boolean;
onChange: (value: boolean) => void;
disabled?: boolean;
}
export default function ViewOnceToggle({
value,
onChange,
disabled,
}: ViewOnceToggleProps) {
return (
<button
type="button"
disabled={disabled}
onClick={() => onChange(!value)}
className={cn(
"chat-timer-glass gentle-transition flex h-11 w-11 shrink-0 items-center justify-center rounded-full",
value && "chat-timer-glass--active bg-[#ff9500]/20 text-[#ff9500]",
!value && "text-neutral-500 dark:text-neutral-400",
disabled && "opacity-40"
)}
aria-label="یک‌بار مصرف"
title={value ? "یک‌بار مصرف فعال" : "یک‌بار مصرف"}
>
<span className="relative flex items-center justify-center">
<ChatBoldIcon
name="eye"
size={20}
className={value ? "text-[#ff9500]" : undefined}
/>
{value && (
<span className="absolute -right-1 -top-1 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-[#ff9500] text-[8px] font-bold text-white">
۱
</span>
)}
</span>
</button>
);
}

View File

@@ -1,13 +1,16 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { FiDownload, FiPause, FiPlay } from "react-icons/fi";
import BoldIcon from "@/components/ui/BoldIcon";
import IOSSpinner from "@/components/ui/IOSSpinner";
import { cn } from "@/lib/utils";
interface VoiceMessagePlayerProps {
src: string;
isOutgoing?: boolean;
autoPlay?: boolean;
singlePlay?: boolean;
onPlaybackComplete?: () => void;
}
function formatDuration(sec: number) {
@@ -20,6 +23,9 @@ function formatDuration(sec: number) {
export default function VoiceMessagePlayer({
src,
isOutgoing = false,
autoPlay = false,
singlePlay = false,
onPlaybackComplete,
}: VoiceMessagePlayerProps) {
const audioRef = useRef<HTMLAudioElement | null>(null);
const [downloaded, setDownloaded] = useState(false);
@@ -28,8 +34,13 @@ export default function VoiceMessagePlayer({
const [duration, setDuration] = useState(0);
const [currentTime, setCurrentTime] = useState(0);
const [waveform, setWaveform] = useState<number[]>([]);
const [playedOnce, setPlayedOnce] = useState(false);
const isBlob = src.startsWith("blob:");
useEffect(() => {
setPlayedOnce(false);
}, [src]);
useEffect(() => {
if (isBlob) {
setDownloaded(true);
@@ -87,6 +98,8 @@ export default function VoiceMessagePlayer({
const onEnd = () => {
setIsPlaying(false);
setCurrentTime(0);
setPlayedOnce(true);
onPlaybackComplete?.();
};
audio.addEventListener("loadedmetadata", onMeta);
@@ -97,11 +110,18 @@ export default function VoiceMessagePlayer({
audio.removeEventListener("timeupdate", onTime);
audio.removeEventListener("ended", onEnd);
};
}, [downloaded]);
}, [downloaded, onPlaybackComplete]);
useEffect(() => {
const audio = audioRef.current;
if (!audio || !downloaded || !autoPlay || playedOnce) return;
audio.play().then(() => setIsPlaying(true)).catch(() => {});
}, [autoPlay, downloaded, playedOnce, src]);
const togglePlay = () => {
const audio = audioRef.current;
if (!audio) return;
if (singlePlay && playedOnce) return;
if (isPlaying) {
audio.pause();
setIsPlaying(false);
@@ -143,7 +163,7 @@ export default function VoiceMessagePlayer({
{downloadProgress > 0 && downloadProgress < 100 ? (
<IOSSpinner size={18} color="#fff" />
) : (
<FiDownload size={18} />
<BoldIcon name="document-download" size={18} tinted className="text-current" />
)}
</button>
<div className="flex-1">
@@ -172,10 +192,14 @@ export default function VoiceMessagePlayer({
<button
type="button"
onClick={togglePlay}
className="gentle-transition flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-[#2aabee] text-white active:scale-95"
disabled={singlePlay && playedOnce}
className={cn(
"gentle-transition flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-[#2aabee] text-white active:scale-95",
singlePlay && playedOnce && "cursor-not-allowed opacity-50"
)}
aria-label={isPlaying ? "توقف" : "پخش"}
>
{isPlaying ? <FiPause size={18} /> : <FiPlay size={18} className="mr-[-2px]" />}
{isPlaying ? <BoldIcon name="pause" size={18} tinted className="text-current mr-[-2px]" /> : <BoldIcon name="play" size={18} tinted className="text-current mr-[-2px]" />}
</button>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div

View File

@@ -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 {

View File

@@ -74,10 +74,10 @@ function Header() {
) : null;
const headerBarClass = cn(
"gentle-transition mx-auto flex max-w-lg items-center justify-center px-4",
"gentle-transition mx-auto flex items-center",
compact
? "glass-panel safari-header-pill gap-3 rounded-full px-4 py-2 shadow-lg"
: "glass-panel w-full max-w-none rounded-none border-b border-white/30 py-1 dark:border-white/5",
? "glass-panel safari-header-pill w-full max-w-lg justify-between rounded-full px-2 py-2 shadow-lg"
: "glass-panel w-full max-w-none justify-center rounded-none border-b border-white/30 px-4 py-1 dark:border-white/5",
isOffline && "header-offline",
isSyncing && "header-syncing"
);
@@ -94,11 +94,11 @@ function Header() {
>
<Image
alt="مادستاگرام"
width={compact ? 72 : 130}
height={compact ? 24 : 36}
width={compact ? 124 : 130}
height={compact ? 34 : 36}
className={cn(
"h-auto w-auto dark:invert",
compact ? "h-6 max-w-[72px]" : "h-8 max-w-[130px]"
"h-auto w-auto dark:invert gentle-transition",
compact ? "h-[30px] max-w-[124px]" : "h-8 max-w-[130px]"
)}
src="/images/icons/logo.svg"
priority
@@ -127,7 +127,11 @@ function Header() {
<div className={headerBarClass}>
{compact ? (
<>
<Link href="/settings/chats" className={iconBtn} aria-label="پیام‌ها">
<Link
href="/settings/chats"
className={cn(iconBtn, "-mr-0.5")}
aria-label="پیام‌ها"
>
{badge(unreadMessages)}
<Image
alt=""
@@ -140,7 +144,7 @@ function Header() {
<LogoBlock />
<Link
href="/settings/notifications"
className={iconBtn}
className={cn(iconBtn, "-ml-0.5")}
aria-label="اعلان‌ها"
>
{badge(unreadNotification)}

View File

@@ -0,0 +1,57 @@
import Image from "next/image";
import { cn } from "@/lib/utils";
import { buildStorageUrl } from "./BaseUrl";
const sizeMap = {
xs: "h-8 w-8",
sm: "h-10 w-10",
chat: "h-14 w-14",
md: "h-[72px] w-[72px]",
lg: "h-[120px] w-[120px]",
xl: "h-[280px] w-[280px]",
};
type ProfileAvatarProps = {
src?: string | null;
alt?: string;
size?: keyof typeof sizeMap;
rounded?: "2xl" | "xl" | "full";
className?: string;
fallback?: string;
};
function ProfileAvatar({
src,
alt = "",
size = "md",
rounded = "2xl",
className,
fallback = "/images/fake-avatar.png",
}: ProfileAvatarProps) {
const roundedClass =
rounded === "full"
? "rounded-full"
: rounded === "xl"
? "rounded-xl"
: "rounded-2xl";
const imageSrc = src ? buildStorageUrl(src) : fallback;
return (
<Image
src={imageSrc}
alt={alt}
width={280}
height={280}
unoptimized
className={cn(
"profile-avatar aspect-square object-cover shrink-0",
sizeMap[size],
roundedClass,
className
)}
/>
);
}
export default ProfileAvatar;

View File

@@ -1,7 +1,7 @@
"use client";
import { motion, AnimatePresence } from "framer-motion";
import { FiCopy, FiShare2, FiX } from "react-icons/fi";
import BoldIcon from "@/components/ui/BoldIcon";
import toast from "react-hot-toast";
interface SharePostModalProps {
@@ -59,7 +59,7 @@ export default function SharePostModal({
<div className="mb-4 flex items-center justify-between">
<h3 className="font-bold">اشتراکگذاری</h3>
<button type="button" onClick={onClose} className="rounded-full p-2">
<FiX size={20} />
<BoldIcon name="close-circle" size={20} tinted className="text-current" />
</button>
</div>
<div className="glass-chat-input mb-4 flex items-center gap-2 rounded-2xl px-3 py-2.5">
@@ -72,7 +72,7 @@ export default function SharePostModal({
className="chat-action-btn gentle-transition flex h-9 w-9 shrink-0 items-center justify-center rounded-full active:scale-90"
aria-label="کپی"
>
<FiCopy size={16} />
<BoldIcon name="copy" size={16} tinted className="text-white" />
</button>
</div>
<button
@@ -80,7 +80,7 @@ export default function SharePostModal({
onClick={nativeShare}
className="ig-dm-send-btn gentle-transition flex w-full items-center justify-center gap-2 rounded-full py-3 text-sm font-semibold text-white active:scale-[0.98]"
>
<FiShare2 size={18} />
<BoldIcon name="share" size={18} tinted className="text-white" />
اشتراکگذاری سیستمی
</button>
</motion.div>

View File

@@ -17,8 +17,8 @@ function ShowMap({
<Map
style={{ height: height }}
initialViewState={{
longitude: Number(lat),
latitude: Number(lng),
longitude: Number(lng),
latitude: Number(lat),
zoom: 12,
}}
mapboxAccessToken="pk.eyJ1IjoiYWxkZjkyIiwiYSI6ImNscHh5ZG56dTByMXAycnJ2cDNmdDQ5M3YifQ.a_sUt1rLFMfA0jHrETcM7A"
@@ -26,7 +26,7 @@ function ShowMap({
>
<GeolocateControl />
{lat && lng && (
<Marker latitude={Number(lng)} longitude={Number(lat)}>
<Marker latitude={Number(lat)} longitude={Number(lng)}>
<Image
alt="location icon"
className="-mt-5"

View File

@@ -1,7 +1,8 @@
import Image from "next/image";
import React from "react";
import { IMAGE_BASE_URL, buildStorageUrl } from "./BaseUrl";
import Link from "next/link";
import ProfileAvatar from "./ProfileAvatar";
import VerificationBadge from "./VerificationBadge";
interface IUserInfoProps {
profile_image: string | null | undefined;
@@ -22,50 +23,22 @@ function UserInfo({
is_verified,
noLink,
}: IUserInfoProps) {
const avatar = (
<ProfileAvatar
src={profile_image}
alt={user_name || ""}
size="md"
rounded="2xl"
className="ml-3"
/>
);
return (
<div className="flex items-center text-xs max-sm:text-[10px] md:text-sm font-semibold">
{noLink ? (
profile_image ? (
<Image
className="rounded-2xl object-cover h-[72px] w-[72px] ml-3"
width={75}
height={75}
alt={user_name || ""}
src={buildStorageUrl(profile_image)}
unoptimized={true}
/>
) : (
<Image
className="rounded-2xl object-cover h-[72px] w-[72px] ml-3"
width={75}
height={75}
alt={user_name || ""}
src={`/images/fake-avatar.png`}
unoptimized={true}
/>
)
avatar
) : (
<Link href={`/users/${user_name}`}>
{profile_image ? (
<Image
className="rounded-2xl object-cover h-[72px] w-[72px] ml-3"
width={75}
height={75}
alt={user_name || ""}
src={buildStorageUrl(profile_image)}
unoptimized={true}
/>
) : (
<Image
className="rounded-2xl object-cover h-[72px] w-[72px] ml-3"
width={75}
height={75}
alt={user_name || ""}
src={`/images/fake-avatar.png`}
unoptimized={true}
/>
)}
</Link>
<Link href={`/users/${user_name}`}>{avatar}</Link>
)}
<div className="flex flex-col gap-1">
<span className="text-[#387E65]">
@@ -73,29 +46,10 @@ function UserInfo({
</span>
<h1>{first_name && first_name + " " + last_name}</h1>
<h2 className="flex items-center gap-1 mt-1">
{user_name}
{is_verified === "verified" ? (
<Image
width={22}
height={22}
alt={"verify icon"}
src={`/images/icons/verify.svg`}
/>
) : is_verified === "pending" ? (
<Image
width={25}
height={25}
alt={"not-verify icon"}
src={`/images/icons/not-verify.svg`}
/>
) :is_verified === "true" ? (
<Image
width={22}
height={22}
alt="verify icon"
src="/images/icons/verify2.svg"
/>
): ("")}
<span className="inline-flex items-center gap-1">
{user_name}
<VerificationBadge isVerified={is_verified} />
</span>
</h2>
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More