diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs index 264d4cc..c4920f5 100644 --- a/ecosystem.config.cjs +++ b/ecosystem.config.cjs @@ -18,6 +18,11 @@ module.exports = { NEXT_PUBLIC_SOCKET_URL: "https://api.modstagram.ir", NEXT_PUBLIC_IMAGE_BASE_URL: "https://api.modstagram.ir/storage", NEXT_PUBLIC_BASE_URL: "/api/v1", + NEXT_PUBLIC_SITE_URL: "https://modstagram.com", + GOOGLE_CLIENT_ID: + "91028483292-vba2ktgbvus1p5ce18ir04h1b5c50gnn.apps.googleusercontent.com", + NEXT_PUBLIC_GOOGLE_CLIENT_ID: + "91028483292-vba2ktgbvus1p5ce18ir04h1b5c50gnn.apps.googleusercontent.com", }, }, ], diff --git a/next.config.ts b/next.config.ts index b5f4120..90f7a8a 100644 --- a/next.config.ts +++ b/next.config.ts @@ -65,7 +65,46 @@ const nextConfig = { async headers() { return [ { - source: '/(.*)', + source: "/service-worker.js", + headers: [ + { + key: "Content-Type", + value: "application/javascript; charset=utf-8", + }, + { + key: "Cache-Control", + value: "public, max-age=0, must-revalidate", + }, + { + key: "Service-Worker-Allowed", + value: "/", + }, + ], + }, + { + source: "/manifest.webmanifest", + headers: [ + { + key: "Content-Type", + value: "application/manifest+json; charset=utf-8", + }, + { + key: "Cache-Control", + value: "no-cache, no-store, must-revalidate", + }, + ], + }, + { + source: "/manifest.json", + headers: [ + { + key: "Content-Type", + value: "application/manifest+json; charset=utf-8", + }, + ], + }, + { + source: "/(.*)", headers: [ { key: 'X-DNS-Prefetch-Control', value: 'on' }, { key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' }, @@ -73,6 +112,8 @@ const nextConfig = { { key: 'X-Frame-Options', value: 'SAMEORIGIN' }, { key: 'X-Content-Type-Options', value: 'nosniff' }, { key: 'Referrer-Policy', value: 'origin-when-cross-origin' }, + { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=(self), payment=()' }, + { key: 'X-Permitted-Cross-Domain-Policies', value: 'none' }, { key: 'Cache-Control', value: 'public, s-maxage=1, stale-while-revalidate=59' }, ], }, diff --git a/package.json b/package.json index 9ee987f..a2eed67 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "start:local": "next start --port 3004 --hostname 0.0.0.0", "pm2:prod": "pm2 start ecosystem.config.cjs --update-env", "lint": "next lint", - "postbuild": "next-sitemap" + "postbuild": "next-sitemap", + "generate:pwa-icons": "node scripts/generate-pwa-icons.mjs" }, "dependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/public/favicon-16.png b/public/favicon-16.png new file mode 100644 index 0000000..da45d18 Binary files /dev/null and b/public/favicon-16.png differ diff --git a/public/favicon.png b/public/favicon.png new file mode 100644 index 0000000..c70d264 Binary files /dev/null and b/public/favicon.png differ diff --git a/public/images/icons/192x192.png b/public/images/icons/192x192.png index 92ed3bd..7ff1053 100644 Binary files a/public/images/icons/192x192.png and b/public/images/icons/192x192.png differ diff --git a/public/images/icons/256x256.png b/public/images/icons/256x256.png index 92ed3bd..8122837 100644 Binary files a/public/images/icons/256x256.png and b/public/images/icons/256x256.png differ diff --git a/public/images/icons/512x512.png b/public/images/icons/512x512.png index b532831..433097f 100644 Binary files a/public/images/icons/512x512.png and b/public/images/icons/512x512.png differ diff --git a/public/images/icons/apple-touch-icon.png b/public/images/icons/apple-touch-icon.png index 92ed3bd..0ac7e72 100644 Binary files a/public/images/icons/apple-touch-icon.png and b/public/images/icons/apple-touch-icon.png differ diff --git a/public/images/icons/carousel-next.png b/public/images/icons/carousel-next.png new file mode 100644 index 0000000..f426b44 Binary files /dev/null and b/public/images/icons/carousel-next.png differ diff --git a/public/images/icons/carousel-prev.png b/public/images/icons/carousel-prev.png new file mode 100644 index 0000000..9e841f7 Binary files /dev/null and b/public/images/icons/carousel-prev.png differ diff --git a/public/images/icons/google.png b/public/images/icons/google.png new file mode 100644 index 0000000..b79bc97 Binary files /dev/null and b/public/images/icons/google.png differ diff --git a/public/images/icons/logo-mark.svg b/public/images/icons/logo-mark.svg new file mode 100644 index 0000000..3f6e4a5 --- /dev/null +++ b/public/images/icons/logo-mark.svg @@ -0,0 +1,18 @@ + + + + + diff --git a/public/images/icons/maskable-192.png b/public/images/icons/maskable-192.png new file mode 100644 index 0000000..ac01b2d Binary files /dev/null and b/public/images/icons/maskable-192.png differ diff --git a/public/images/icons/maskable-512.png b/public/images/icons/maskable-512.png new file mode 100644 index 0000000..e91e943 Binary files /dev/null and b/public/images/icons/maskable-512.png differ diff --git a/public/images/icons/medal-star.png b/public/images/icons/medal-star.png new file mode 100644 index 0000000..6805ae9 Binary files /dev/null and b/public/images/icons/medal-star.png differ diff --git a/public/images/icons/reels-follow-add.svg b/public/images/icons/reels-follow-add.svg new file mode 100644 index 0000000..02fd41e --- /dev/null +++ b/public/images/icons/reels-follow-add.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/icons/reels-more.png b/public/images/icons/reels-more.png new file mode 100644 index 0000000..dc30447 Binary files /dev/null and b/public/images/icons/reels-more.png differ diff --git a/public/images/icons/reels-share-story.png b/public/images/icons/reels-share-story.png new file mode 100644 index 0000000..7abcd5c Binary files /dev/null and b/public/images/icons/reels-share-story.png differ diff --git a/public/images/icons/reels-share-story.svg b/public/images/icons/reels-share-story.svg new file mode 100644 index 0000000..d008da6 --- /dev/null +++ b/public/images/icons/reels-share-story.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/icons/shield-tick.svg b/public/images/icons/shield-tick.svg new file mode 100644 index 0000000..b048de6 --- /dev/null +++ b/public/images/icons/shield-tick.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/icons/star1.png b/public/images/icons/star1.png new file mode 100644 index 0000000..f4fe389 Binary files /dev/null and b/public/images/icons/star1.png differ diff --git a/public/images/icons/story.png b/public/images/icons/story.png new file mode 100644 index 0000000..190d909 Binary files /dev/null and b/public/images/icons/story.png differ diff --git a/public/manifest.json b/public/manifest.json index 29fdf3f..c7bf8b8 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -5,10 +5,12 @@ "lang": "fa", "dir": "rtl", "scope": "/", - "start_url": "/", - "id": "/", + "start_url": "/?source=pwa&lang=fa", + "id": "modstagram-pwa", "display": "standalone", + "display_override": ["standalone", "minimal-ui", "browser"], "orientation": "portrait-primary", + "prefer_related_applications": false, "icons": [ { "src": "/images/icons/192x192.png", @@ -29,13 +31,19 @@ "purpose": "any" }, { - "src": "/images/icons/512x512.png", + "src": "/images/icons/maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/images/icons/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } ], - "theme_color": "#ffffff", + "theme_color": "#FF107D", "background_color": "#ffffff", "categories": ["social", "business"] } diff --git a/public/service-worker.js b/public/service-worker.js index 55528b9..e4af100 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -1,61 +1,60 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ -const CACHE_NAME = "pwa-cache-v4"; +const CACHE_NAME = "pwa-cache-v10"; + +const PRECACHE_URLS = [ + "/", + "/manifest.webmanifest", + "/manifest.json", + "/images/icons/192x192.png", + "/images/icons/256x256.png", + "/images/icons/512x512.png", + "/images/icons/apple-touch-icon.png", + "/images/icons/maskable-192.png", + "/images/icons/maskable-512.png", + "/favicon.png", +]; -// نصب Service Worker self.addEventListener("install", (event) => { - console.log("Service Worker installing..."); - event.waitUntil( - caches.open(CACHE_NAME).then((cache) => { - return cache.addAll([ - "/", - "/manifest.json", - "/images/icons/256x256.png", - "/images/icons/512x512.png", - ]); - }) + caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS)) ); self.skipWaiting(); }); -// فعال‌سازی Service Worker self.addEventListener("activate", (event) => { - console.log("Service Worker activated."); event.waitUntil( caches .keys() .then((keys) => Promise.all( - keys - .filter((key) => key !== CACHE_NAME) - .map((key) => caches.delete(key)) + keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)) ) ) .then(() => self.clients.claim()) ); }); -// هندل درخواست‌ها self.addEventListener("fetch", (event) => { const url = new URL(event.request.url); - // navigation: همیشه از شبکه — HTML کش نشود تا title/meta همیشه تازه باشد if (event.request.mode === "navigate") { event.respondWith(fetch(event.request)); return; } - // آیکون‌ها: network-first - if (url.pathname.startsWith("/images/icons/")) { + if ( + url.pathname.startsWith("/images/icons/") || + url.pathname.endsWith("manifest.webmanifest") || + url.pathname.endsWith("manifest.json") || + url.pathname.endsWith("favicon.png") + ) { event.respondWith( fetch(event.request) .then((res) => { if (res.ok) { const clone = res.clone(); - caches - .open(CACHE_NAME) - .then((cache) => cache.put(event.request, clone)); + caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone)); } return res; }) @@ -64,15 +63,13 @@ self.addEventListener("fetch", (event) => { return; } - // سایر فایل‌های استاتیک: stale-while-revalidate if ( url.pathname.startsWith("/images/") || url.pathname.endsWith(".png") || url.pathname.endsWith(".jpg") || url.pathname.endsWith(".jpeg") || url.pathname.endsWith(".gif") || - url.pathname.endsWith(".webp") || - url.pathname.endsWith("manifest.json") + url.pathname.endsWith(".webp") ) { event.respondWith( caches.match(event.request).then((cached) => { diff --git a/public/sitemap-0.xml b/public/sitemap-0.xml index c4b95eb..0f23262 100644 --- a/public/sitemap-0.xml +++ b/public/sitemap-0.xml @@ -1,12 +1,12 @@ -https://modstagram.com/icon.png2026-07-17T12:03:53.291Zdaily0.7 -https://modstagram.com/robots.txt2026-07-17T12:03:53.292Zdaily0.7 -https://modstagram.com/sitemap.xml2026-07-17T12:03:53.292Zdaily0.7 -https://modstagram.com/academy/payment/failed2026-07-17T12:03:53.292Zdaily0.7 -https://modstagram.com/academy/payment/success2026-07-17T12:03:53.292Zdaily0.7 -https://modstagram.com/about-us2026-07-17T12:03:53.292Zmonthly0.8 -https://modstagram.com/explore2026-07-17T12:03:53.292Zdaily0.7 +https://modstagram.com/icon.png2026-07-21T17:52:19.573Zdaily0.7 +https://modstagram.com/robots.txt2026-07-21T17:52:19.576Zdaily0.7 +https://modstagram.com/sitemap.xml2026-07-21T17:52:19.576Zdaily0.7 +https://modstagram.com/academy/payment/success2026-07-21T17:52:19.576Zdaily0.7 +https://modstagram.com/academy/payment/failed2026-07-21T17:52:19.576Zdaily0.7 +https://modstagram.com/about-us2026-07-21T17:52:19.576Zmonthly0.8 +https://modstagram.com/explore2026-07-21T17:52:19.576Zdaily0.7 https://modstagram.comdaily1 https://modstagram.com/projectsdaily0.9 https://modstagram.com/billboardsdaily0.9 diff --git a/scripts/generate-pwa-icons.mjs b/scripts/generate-pwa-icons.mjs new file mode 100644 index 0000000..2f4fdc4 --- /dev/null +++ b/scripts/generate-pwa-icons.mjs @@ -0,0 +1,94 @@ +import sharp from "sharp"; +import path from "path"; +import { fileURLToPath } from "url"; +import fs from "fs"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const iconsDir = path.join(__dirname, "../public/images/icons"); +const publicDir = path.join(__dirname, "../public"); +const source = path.join(iconsDir, "logo-mark.svg"); + +const BRAND = { r: 255, g: 16, b: 125, alpha: 1 }; +const WHITE = { r: 255, g: 255, b: 255, alpha: 1 }; + +async function renderSquareIcon(size, paddingRatio = 0.18) { + const inner = Math.round(size * (1 - paddingRatio * 2)); + const padding = Math.round((size - inner) / 2); + + const mark = await sharp(source, { density: 512 }) + .resize(inner, inner, { + fit: "contain", + background: { r: 0, g: 0, b: 0, alpha: 0 }, + }) + .png() + .toBuffer(); + + return sharp({ + create: { + width: size, + height: size, + channels: 4, + background: WHITE, + }, + }) + .composite([{ input: mark, left: padding, top: padding }]) + .png() + .toFile(path.join(iconsDir, `${size}x${size}.png`)); +} + +async function renderMaskableIcon(size) { + const inner = Math.round(size * 0.52); + const padding = Math.round((size - inner) / 2); + + const mark = await sharp(source, { density: 512 }) + .resize(inner, inner, { + fit: "contain", + background: { r: 0, g: 0, b: 0, alpha: 0 }, + }) + .png() + .toBuffer(); + + await sharp({ + create: { + width: size, + height: size, + channels: 4, + background: BRAND, + }, + }) + .composite([{ input: mark, left: padding, top: padding }]) + .png() + .toFile(path.join(iconsDir, `maskable-${size}.png`)); + + console.log(`Created maskable-${size}.png`); +} + +if (!fs.existsSync(source)) { + console.error("Missing logo-mark.svg"); + process.exit(1); +} + +for (const size of [192, 256, 512]) { + await renderSquareIcon(size); + console.log(`Created ${size}x${size}.png`); +} + +await sharp(path.join(iconsDir, "512x512.png")) + .resize(180, 180) + .png() + .toFile(path.join(iconsDir, "apple-touch-icon.png")); +console.log("Created apple-touch-icon.png"); + +await renderMaskableIcon(192); +await renderMaskableIcon(512); + +await sharp(path.join(iconsDir, "192x192.png")) + .resize(32, 32) + .png() + .toFile(path.join(publicDir, "favicon.png")); + +await sharp(path.join(publicDir, "favicon.png")) + .resize(16, 16) + .toFile(path.join(publicDir, "favicon-16.png")); + +console.log("Created favicon.png"); diff --git a/src/api/fetchPosts.ts b/src/api/fetchPosts.ts index 0e931d4..3cccc7e 100644 --- a/src/api/fetchPosts.ts +++ b/src/api/fetchPosts.ts @@ -19,7 +19,9 @@ export async function fetchPosts( feedMode?: "grid" | "reels"; seedPostId?: string; sort?: "latest"; - reelsTab?: "for_you" | "following" | "saved"; + reelsTab?: "for_you" | "following" | "saved" | "near_me"; + lat?: string; + lng?: string; heightMin?: string; heightMax?: string; weightMin?: string; diff --git a/src/api/fetchStories.ts b/src/api/fetchStories.ts index 24e3b6a..4b72eaa 100644 --- a/src/api/fetchStories.ts +++ b/src/api/fetchStories.ts @@ -15,6 +15,7 @@ export type StoryItem = { _id: string; media_path: string; media_type: "image" | "video"; + linked_post_id?: string | null; overlays?: StoryTextOverlay[]; createdAt?: string; expires_at?: string; @@ -33,6 +34,7 @@ export type StoryFeedUser = { }; stories: StoryItem[]; has_unviewed: boolean; + is_following?: boolean; }; export type StoriesFeedResponse = { @@ -166,3 +168,21 @@ export async function fetchStoryViewers( } return res.json(); } + +export async function sharePostToStory( + postId: string, + token: string +): Promise { + const res = await fetch(`${getApiBaseUrl()}/stories/share-post`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ postId }), + }); + if (!res.ok) { + const json = await res.json().catch(() => ({})); + throw new Error(json?.message || "خطا در انتشار استوری"); + } +} diff --git a/src/app/(auth)/(login)/change-passowrd/page.tsx b/src/app/(auth)/(login)/change-passowrd/page.tsx index 9a38a24..3659ddf 100644 --- a/src/app/(auth)/(login)/change-passowrd/page.tsx +++ b/src/app/(auth)/(login)/change-passowrd/page.tsx @@ -12,30 +12,11 @@ import { useFormik } from "formik"; import * as yup from "yup"; import { useRouter } from "next/navigation"; import toast from "react-hot-toast"; - -const schema = yup.object().shape({ - otp: yup - .string() - .matches(/^\d{6}$/, "کد وارد شده صحیح نیست") - .required("کد تایید را وارد کنید"), - password: yup - .string() - .required("کلمه عبور نمی\u200Cتواند خالی باشد") - .min(8, "کلمه عبور نباید کوتاه تر از 8 کاراکتر باشد") - .matches( - /^(?=.*[a-zA-Zآ-ی])(?=.*\d).{8,}$/, - "کلمه عبور باید شامل حروف و اعداد باشد" - ), - confirmPassword: yup - .string() - .required("تکرار کلمه عبور نمی\u200Cتواند خالی باشد") - .oneOf( - [yup.ref("password")], - "تکرار کلمه عبور باید با کلمه عبور مطابقت داشته باشد" - ), -}); +import { useTranslation } from "react-i18next"; +import LocalePageShell from "@/components/i18n/LocalePageShell"; function ChangePassword() { + const { t } = useTranslation("common"); const router = useRouter(); const { request, loading } = useAxios(); const [otpError, setOtpError] = useState(false); @@ -56,6 +37,29 @@ function ChangePassword() { ); const isEmailReset = resetChannel === "email"; + const schema = useMemo( + () => + yup.object().shape({ + otp: yup + .string() + .matches(/^\d{6}$/, t("auth.otpInvalid")) + .required(t("auth.otpRequired")), + password: yup + .string() + .required(t("auth.passwordRequired")) + .min(8, t("auth.passwordMin")) + .matches( + /^(?=.*[a-zA-Zآ-ی])(?=.*\d).{8,}$/, + t("auth.passwordLettersNumbers") + ), + confirmPassword: yup + .string() + .required(t("auth.confirmPasswordRequired")) + .oneOf([yup.ref("password")], t("auth.confirmPasswordMismatch")), + }), + [t] + ); + const formik = useFormik({ initialValues: { otp: "", @@ -65,7 +69,7 @@ function ChangePassword() { validationSchema: schema, onSubmit: async (values) => { if (!resetIdentifier) { - toast.error("ابتدا از صفحه فراموشی رمز، کد دریافت کنید"); + toast.error(t("auth.getResetCodeFirst")); router.push("/forget-password"); return; } @@ -81,107 +85,109 @@ function ChangePassword() { }); localStorage.removeItem("reset_identifier"); localStorage.removeItem("reset_channel"); - toast.success("کلمه عبور با موفقیت تغییر کرد"); + toast.success(t("auth.passwordChangedSuccess")); router.push("/login-with-username"); } catch (err: unknown) { setOtpError(true); const message = (err as { response?: { data?: { message?: string } } })?.response?.data - ?.message || "تغییر کلمه عبور ناموفق بود"; + ?.message || t("auth.changePasswordFailed"); toast.error(message); } }, }); return ( - -
- -

- {isEmailReset - ? "کد ارسال‌شده به ایمیل و کلمه عبور جدید را وارد کنید." - : "کد ارسال‌شده به موبایل و کلمه عبور جدید را وارد کنید."} -

-
- { - formik.setFieldValue("otp", otp); - setOtpError(false); - }} - error={ - otpError || Boolean(formik.touched.otp && formik.errors.otp) - } - disabled={loading} - /> - {formik.touched.otp && formik.errors.otp && ( - {formik.errors.otp} - )} - - - {formik.touched.password && formik.errors.password && ( - - {formik.errors.password} - - )} - - - {formik.touched.confirmPassword && formik.errors.confirmPassword && ( - - {formik.errors.confirmPassword} - - )} - - + +
+ +

+ {isEmailReset + ? t("auth.changePasswordEmailHint") + : t("auth.changePasswordMobileHint")} +

+ - تغییر کلمه عبور - - + { + formik.setFieldValue("otp", otp); + setOtpError(false); + }} + error={ + otpError || Boolean(formik.touched.otp && formik.errors.otp) + } + disabled={loading} + /> + {formik.touched.otp && formik.errors.otp && ( + {formik.errors.otp} + )} - - - ارسال مجدد کد - - - - - بازگشت به ورود - - -
-
+ + {formik.touched.password && formik.errors.password && ( + + {formik.errors.password} + + )} + + + {formik.touched.confirmPassword && formik.errors.confirmPassword && ( + + {formik.errors.confirmPassword} + + )} + + + {t("auth.changePassword")} + + + + + + {t("auth.resendCodeLink")} + + + + + {t("auth.backToLogin")} + + +
+
+ ); } diff --git a/src/app/(auth)/(login)/forget-password-otp/page.tsx b/src/app/(auth)/(login)/forget-password-otp/page.tsx index 0ea0242..ab36360 100644 --- a/src/app/(auth)/(login)/forget-password-otp/page.tsx +++ b/src/app/(auth)/(login)/forget-password-otp/page.tsx @@ -6,7 +6,7 @@ import AuthHead from "@/components/auth/AuthHead"; import Container from "@/components/elements/Container"; import AuthRules from "@/components/auth/AuthRules"; import Link from "next/link"; -import React, { useState } from "react"; +import React, { useMemo, useState } from "react"; import AuthOtpInput from "@/components/auth/AuthOtpInput"; import AuthOtpStatus from "@/components/auth/AuthOtpStatus"; import useAxios from "@/hooks/useAxios"; @@ -16,16 +16,11 @@ import { useRouter } from "next/navigation"; import { IVerifyOtp } from "@/types/types"; import { setAuthSession } from "@/lib/auth/session"; import { AUTH_SUCCESS_DELAY_MS, pause } from "@/lib/auth/feedback"; - -// Validation schema using Yup -const schema = yup.object({ - otp: yup - .string() - .matches(/^\d{6}$/, "کد وارد شده صحیح نیست") - .required("کد تایید را وارد کنید"), -}); +import { useTranslation } from "react-i18next"; +import LocalePageShell from "@/components/i18n/LocalePageShell"; function ForgetPasswordOtp() { + const { t } = useTranslation("common"); const router = useRouter(); const [isModalOpen, setModalOpen] = useState(false); const [otpExpired, setOtpExpired] = useState(false); @@ -35,6 +30,18 @@ function ForgetPasswordOtp() { const { request, loading } = useAxios(); const mobile = typeof window !== "undefined" ? localStorage.getItem("mobile") : null; + + const schema = useMemo( + () => + yup.object({ + otp: yup + .string() + .matches(/^\d{6}$/, t("auth.otpInvalid")) + .required(t("auth.otpRequired")), + }), + [t] + ); + const formik = useFormik({ initialValues: { otp: "", @@ -67,67 +74,75 @@ function ForgetPasswordOtp() { }); return ( - -
- - { - if (!mobile) return; - setResendLoading(true); - try { - await request("POST", "/login", { mobile }); - formik.setFieldValue("otp", ""); - setOtpError(false); - setIsSuccess(false); - } finally { - setResendLoading(false); - } - }} - /> -
- { - formik.setFieldValue("otp", otp); - setOtpError(false); - setIsSuccess(false); + + +
+ + { + if (!mobile) return; + setResendLoading(true); + try { + await request("POST", "/login", { mobile }); + formik.setFieldValue("otp", ""); + setOtpError(false); + setIsSuccess(false); + } finally { + setResendLoading(false); + } }} - 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 && ( - {formik.errors.otp} - )} - - تایید کد - - - - - با نام کاربری و کلمه عبور خود وارد شوید - - - - - اصلاح شماره موبایل - - - -
-
+ { + formik.setFieldValue("otp", otp); + setOtpError(false); + setIsSuccess(false); + }} + 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 && ( + {formik.errors.otp} + )} + + {t("auth.verifyCode")} + + + + + {t("auth.loginWithUsername")} + + + + + {t("auth.editMobile")} + + + +
+
+ ); } diff --git a/src/app/(auth)/(login)/forget-password/page.tsx b/src/app/(auth)/(login)/forget-password/page.tsx index 4a1c14b..b8a778e 100644 --- a/src/app/(auth)/(login)/forget-password/page.tsx +++ b/src/app/(auth)/(login)/forget-password/page.tsx @@ -5,32 +5,39 @@ import AuthHead from "@/components/auth/AuthHead"; import AuthInput from "@/components/auth/AuthInput"; import Container from "@/components/elements/Container"; import Link from "next/link"; -import React, { useState } from "react"; +import React, { useMemo, useState } from "react"; import useAxios from "@/hooks/useAxios"; import { useFormik } from "formik"; import * as yup from "yup"; import { useRouter } from "next/navigation"; import toast from "react-hot-toast"; - -const schema = yup.object({ - identifier: yup - .string() - .required("ایمیل یا شماره موبایل الزامی است") - .test("identifier", "ایمیل یا شماره موبایل معتبر نیست", (value) => { - if (!value) return false; - const trimmed = value.trim(); - return ( - /^(09\d{9})$/.test(trimmed) || - /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed) - ); - }), -}); +import { useTranslation } from "react-i18next"; +import LocalePageShell from "@/components/i18n/LocalePageShell"; function ForgetPasswordPage() { + const { t } = useTranslation("common"); const router = useRouter(); const { request, loading } = useAxios(); const [destination, setDestination] = useState(""); + const schema = useMemo( + () => + yup.object({ + identifier: yup + .string() + .required(t("auth.identifierRequired")) + .test("identifier", t("auth.identifierInvalid"), (value) => { + if (!value) return false; + const trimmed = value.trim(); + return ( + /^(09\d{9})$/.test(trimmed) || + /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed) + ); + }), + }), + [t] + ); + const formik = useFormik({ initialValues: { identifier: "" }, validationSchema: schema, @@ -55,69 +62,70 @@ function ForgetPasswordPage() { setDestination(response.destination); } - toast.success(response.message || "کد بازیابی ارسال شد"); + toast.success(response.message || t("auth.resetCodeSentDefault")); router.push("/change-passowrd"); } catch (err: unknown) { const message = (err as { response?: { data?: { message?: string } } })?.response?.data - ?.message || "ارسال کد ناموفق بود"; + ?.message || t("auth.sendCodeFailed"); toast.error(message); } }, }); return ( - -
- -

- ایمیل (برای حساب گوگل) یا شماره موبایل خود را وارد کنید تا کد بازیابی - ارسال شود. -

-
- - {formik.touched.identifier && formik.errors.identifier && ( - - {formik.errors.identifier} - - )} - {destination && ( - - کد به {destination} ارسال شد - - )} - + +
+ +

+ {t("auth.forgetPasswordDesc")} +

+ - ارسال کد بازیابی - - - - - بازگشت به ورود - - -
-
+ + {formik.touched.identifier && formik.errors.identifier && ( + + {formik.errors.identifier} + + )} + {destination && ( + + {t("auth.codeSentTo", { destination })} + + )} + + {t("auth.sendResetCode")} + + + + + {t("auth.backToLogin")} + + +
+
+ ); } diff --git a/src/app/(auth)/(login)/login-2fa/page.tsx b/src/app/(auth)/(login)/login-2fa/page.tsx new file mode 100644 index 0000000..da138e5 --- /dev/null +++ b/src/app/(auth)/(login)/login-2fa/page.tsx @@ -0,0 +1,85 @@ +"use client"; + +import AuthHead from "@/components/auth/AuthHead"; +import AuthOtpInput from "@/components/auth/AuthOtpInput"; +import AuthButton from "@/components/auth/AuthButton"; +import Container from "@/components/elements/Container"; +import LocalePageShell from "@/components/i18n/LocalePageShell"; +import useAxios from "@/hooks/useAxios"; +import { completeLogin, getSafeRedirectPath } from "@/lib/auth/postLogin"; +import { IVerifyOtp } from "@/types/types"; +import { useRouter } from "next/navigation"; +import React, { useEffect, useState } from "react"; +import toast from "react-hot-toast"; +import { useTranslation } from "react-i18next"; + +export default function LoginTwoFactorPage() { + const { t } = useTranslation("common"); + const router = useRouter(); + const { request, loading } = useAxios(); + const [code, setCode] = useState(""); + const [tempToken, setTempToken] = useState(""); + + useEffect(() => { + const token = + typeof window !== "undefined" + ? sessionStorage.getItem("login_2fa_temp") + : null; + if (!token) { + router.replace("/login"); + return; + } + setTempToken(token); + }, [router]); + + const handleVerify = async () => { + if (!tempToken || code.length !== 6) return; + + try { + const response = (await request("POST", "/auth/totp/verify-login", { + temp_token: tempToken, + code, + })) as IVerifyOtp; + + sessionStorage.removeItem("login_2fa_temp"); + + const loggedIn = await completeLogin(router, response, { + redirectTo: getSafeRedirectPath(), + }); + + if (!loggedIn) { + toast.error(t("auth.invalidServerResponse")); + } + } catch { + toast.error(t("settings.edit.twoFactor.invalidCode")); + } + }; + + return ( + + +
+ +

+ {t("auth.twoFactorHint")} +

+ setTimeout(handleVerify, 300)} + disabled={loading || !tempToken} + /> + + {t("auth.verifyCode")} + +
+
+
+ ); +} diff --git a/src/app/(auth)/(login)/login-with-username/page.tsx b/src/app/(auth)/(login)/login-with-username/page.tsx index cbe2722..203b787 100644 --- a/src/app/(auth)/(login)/login-with-username/page.tsx +++ b/src/app/(auth)/(login)/login-with-username/page.tsx @@ -8,7 +8,7 @@ import AuthPasswordInput from "@/components/auth/AuthPasswordInput"; import Container from "@/components/elements/Container"; import AuthRules from "@/components/auth/AuthRules"; import Link from "next/link"; -import React, { useState } from "react"; +import React, { useMemo, useState } from "react"; import useAxios from "@/hooks/useAxios"; import { useFormik } from "formik"; import * as yup from "yup"; @@ -21,16 +21,11 @@ import { } from "@/lib/auth/postLogin"; import toast from "react-hot-toast"; import { useAuthSessionRedirect } from "@/hooks/useAuthSessionRedirect"; - -const schema = yup.object().shape({ - username: yup.string().required("نام کاربری الزامی است"), - password: yup - .string() - .required("کلمه عبور نمی\u200Cتواند خالی باشد") - .min(8, "کلمه عبور نباید کوتاه تر از 8 کاراکتر باشد"), -}); +import { useTranslation } from "react-i18next"; +import LocalePageShell from "@/components/i18n/LocalePageShell"; function LoginWithUsername() { + const { t, i18n } = useTranslation("common"); const router = useRouter(); const [isModalOpen, setModalOpen] = useState(false); const [isSuccess, setIsSuccess] = useState(false); @@ -40,6 +35,18 @@ function LoginWithUsername() { const redirectPath = getSafeRedirectPath(); useAuthSessionRedirect(); + const schema = useMemo( + () => + yup.object().shape({ + username: yup.string().required(t("auth.usernameRequired")), + password: yup + .string() + .required(t("auth.passwordRequired")) + .min(8, t("auth.passwordMin")), + }), + [t] + ); + const formik = useFormik({ initialValues: { username: "", @@ -66,7 +73,7 @@ function LoginWithUsername() { }); if (!loggedIn) { - const message = "پاسخ سرور نامعتبر بود. دوباره تلاش کنید."; + const message = t("auth.invalidServerResponse"); setAuthError(true); setErrorMessage(message); toast.error(message); @@ -75,7 +82,10 @@ function LoginWithUsername() { setIsSuccess(true); } catch (err: any) { - const message = getAxiosErrorMessage(err); + const message = getAxiosErrorMessage( + err, + i18n.language === "en" ? "en" : "fa" + ); setAuthError(true); setIsSuccess(false); setErrorMessage(message); @@ -85,88 +95,91 @@ function LoginWithUsername() { }); return ( - -
- -
- { - setAuthError(false); - setErrorMessage(""); - setIsSuccess(false); - formik.handleChange(e); - }} - onBlur={formik.handleBlur} - disabled={loading || isSuccess} - /> - {formik.touched.username && formik.errors.username && !authError && ( - - {formik.errors.username} - - )} - { - setAuthError(false); - setErrorMessage(""); - setIsSuccess(false); - formik.handleChange(e); - }} - onBlur={formik.handleBlur} - disabled={loading || isSuccess} - /> - {formik.touched.password && formik.errors.password && !authError && ( - - {formik.errors.password} - - )} - {authError && errorMessage && ( - - {errorMessage} - - )} - + +
+ + - ورود - - - - - کلمه عبور خود را فراموش کرده اید؟ - - - - - حساب کاربری ندارید؟ ثبت نام - - - -
-
+ { + setAuthError(false); + setErrorMessage(""); + setIsSuccess(false); + formik.handleChange(e); + }} + onBlur={formik.handleBlur} + disabled={loading || isSuccess} + /> + {formik.touched.username && formik.errors.username && !authError && ( + + {formik.errors.username} + + )} + { + setAuthError(false); + setErrorMessage(""); + setIsSuccess(false); + formik.handleChange(e); + }} + onBlur={formik.handleBlur} + disabled={loading || isSuccess} + /> + {formik.touched.password && formik.errors.password && !authError && ( + + {formik.errors.password} + + )} + {authError && errorMessage && ( + + {errorMessage} + + )} + + {t("auth.login")} + + + + + {t("auth.forgotPassword")} + + + + + {t("auth.noAccount")}{" "} + {t("auth.signUp")} + + + +
+
+ ); } diff --git a/src/app/(auth)/(login)/login/page.tsx b/src/app/(auth)/(login)/login/page.tsx index 9fb93ba..4a493ef 100644 --- a/src/app/(auth)/(login)/login/page.tsx +++ b/src/app/(auth)/(login)/login/page.tsx @@ -6,27 +6,21 @@ import AuthInput from "@/components/auth/AuthInput"; import Container from "@/components/elements/Container"; import AuthRules from "@/components/auth/AuthRules"; import Link from "next/link"; -import React, { useState } from "react"; +import React, { useMemo, useState } from "react"; import useAxios from "@/hooks/useAxios"; import { useFormik } from "formik"; import * as yup from "yup"; import { useRouter } from "next/navigation"; import { markOtpSent } from "@/lib/auth/otpTimer"; - -// Validation schema using Yup -const schema = yup.object({ - mobile: yup - .string() - .matches(/^(09\d{9})$/, "شماره موبایل معتبر نیست") - .required("شماره موبایل الزامی است"), -}); - import { getSafeRedirectPath } from "@/lib/auth/postLogin"; -import GoogleSignInButton from "@/components/auth/GoogleSignInButton"; +import { GoogleSignInButton } from "@/app/(auth)/AuthProviders"; import { MOBILE_NUMERIC_INPUT_PROPS } from "@/lib/auth/inputProps"; import { useAuthSessionRedirect } from "@/hooks/useAuthSessionRedirect"; +import { useTranslation } from "react-i18next"; +import LocalePageShell from "@/components/i18n/LocalePageShell"; function Login() { + const { t } = useTranslation("common"); const router = useRouter(); const [isModalOpen, setModalOpen] = useState(false); const { request, loading } = useAxios(); @@ -37,6 +31,17 @@ function Login() { ? `/login-with-username?redirect=${encodeURIComponent(redirectPath)}` : "/login-with-username"; + const schema = useMemo( + () => + yup.object({ + mobile: yup + .string() + .matches(/^(09\d{9})$/, t("auth.mobileInvalid")) + .required(t("auth.mobileRequired")), + }), + [t] + ); + const formik = useFormik({ initialValues: { mobile: "", @@ -55,51 +60,53 @@ function Login() { }); return ( - -
- -
- - {formik.touched.mobile && formik.errors.mobile && ( - - {formik.errors.mobile} + + +
+ + + + {formik.touched.mobile && formik.errors.mobile && ( + + {formik.errors.mobile} + + )} + + {t("auth.sendCode")} + + + + + + {t("auth.loginWithUsername")} - )} - - ارسال کد - - - - - - با نام کاربری و کلمه عبور خود وارد شوید - - - - -
-
+ + + +
+
+ ); } diff --git a/src/app/(auth)/(login)/verify-otp/page.tsx b/src/app/(auth)/(login)/verify-otp/page.tsx index 306c3f4..c0503b1 100644 --- a/src/app/(auth)/(login)/verify-otp/page.tsx +++ b/src/app/(auth)/(login)/verify-otp/page.tsx @@ -6,7 +6,7 @@ import AuthHead from "@/components/auth/AuthHead"; import Container from "@/components/elements/Container"; import AuthRules from "@/components/auth/AuthRules"; import Link from "next/link"; -import React, { useState } from "react"; +import React, { useMemo, useState } from "react"; import AuthOtpInput from "@/components/auth/AuthOtpInput"; import AuthOtpStatus from "@/components/auth/AuthOtpStatus"; import useAxios from "@/hooks/useAxios"; @@ -18,16 +18,11 @@ import { completeLogin, getSafeRedirectPath, } from "@/lib/auth/postLogin"; - -// Validation schema using Yup -const schema = yup.object({ - otp: yup - .string() - .matches(/^\d{6}$/, "کد وارد شده صحیح نیست") - .required("کد تایید را وارد کنید"), -}); +import { useTranslation } from "react-i18next"; +import LocalePageShell from "@/components/i18n/LocalePageShell"; function VerifyOtp() { + const { t } = useTranslation("common"); const router = useRouter(); const [isModalOpen, setModalOpen] = useState(false); const [otpExpired, setOtpExpired] = useState(false); @@ -37,6 +32,18 @@ function VerifyOtp() { const { request, loading } = useAxios(); const mobile = typeof window !== "undefined" ? localStorage.getItem("mobile") : null; + + const schema = useMemo( + () => + yup.object({ + otp: yup + .string() + .matches(/^\d{6}$/, t("auth.otpInvalid")) + .required(t("auth.otpRequired")), + }), + [t] + ); + const formik = useFormik({ initialValues: { otp: "", @@ -56,21 +63,13 @@ function VerifyOtp() { otp: values.otp.trim(), })) as IVerifyOtp; - switch (response?.page) { - case "home": - setIsSuccess(true); - await completeLogin(router, response, { - redirectTo: getSafeRedirectPath(), - }); - break; - case "auth-page": - setIsSuccess(true); - await completeLogin(router, response); - break; - default: - break; + const loggedIn = await completeLogin(router, response, { + redirectTo: getSafeRedirectPath(), + }); + + if (loggedIn) { + setIsSuccess(true); } - // router.push("/dashboard"); } catch (err: any) { setOtpError(true); setIsSuccess(false); @@ -80,68 +79,76 @@ function VerifyOtp() { }); return ( - -
- - { - if (!mobile) return; - setResendLoading(true); - try { - await request("POST", "/login", { mobile }); - formik.setFieldValue("otp", ""); - setOtpError(false); - setIsSuccess(false); - } finally { - setResendLoading(false); - } - }} - /> -
- { - formik.setFieldValue("otp", otp); - setOtpError(false); - setIsSuccess(false); + + +
+ + { + if (!mobile) return; + setResendLoading(true); + try { + await request("POST", "/login", { mobile }); + formik.setFieldValue("otp", ""); + setOtpError(false); + setIsSuccess(false); + } finally { + setResendLoading(false); + } }} - 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 && ( - {formik.errors.otp} - )} - - تایید کد - - - - - با نام کاربری و کلمه عبور خود وارد شوید - - - - - اصلاح شماره موبایل - - - -
-
+ { + formik.setFieldValue("otp", otp); + setOtpError(false); + setIsSuccess(false); + }} + 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 && ( + {formik.errors.otp} + )} + + {t("auth.verifyCode")} + + + + + {t("auth.loginWithUsername")} + + + + + {t("auth.editMobile")} + + + +
+
+ ); } diff --git a/src/app/(auth)/(register)/register-otp/page.tsx b/src/app/(auth)/(register)/register-otp/page.tsx index cdc1546..c953ec2 100644 --- a/src/app/(auth)/(register)/register-otp/page.tsx +++ b/src/app/(auth)/(register)/register-otp/page.tsx @@ -7,7 +7,7 @@ import AuthHead from "@/components/auth/AuthHead"; import Container from "@/components/elements/Container"; import AuthRules from "@/components/auth/AuthRules"; import Link from "next/link"; -import React, { useState } from "react"; +import React, { useMemo, useState } from "react"; import AuthOtpInput from "@/components/auth/AuthOtpInput"; import AuthOtpStatus from "@/components/auth/AuthOtpStatus"; import useAxios from "@/hooks/useAxios"; @@ -16,16 +16,11 @@ import * as yup from "yup"; import { IVerifyOtp } from "@/types/types"; import { useRouter } from "next/navigation"; import { completeLogin } from "@/lib/auth/postLogin"; - -// Validation schema using Yup -const schema = yup.object({ - otp: yup - .string() - .matches(/^\d{6}$/, "کد وارد شده صحیح نیست") - .required("کد تایید را وارد کنید"), -}); +import { useTranslation } from "react-i18next"; +import LocalePageShell from "@/components/i18n/LocalePageShell"; function RegisterOtp() { + const { t } = useTranslation("common"); const router = useRouter(); const [isModalOpen, setModalOpen] = useState(false); const [otpExpired, setOtpExpired] = useState(false); @@ -35,6 +30,18 @@ function RegisterOtp() { const { request, loading } = useAxios(); const mobile = typeof window !== "undefined" ? localStorage.getItem("mobile") : null; + + const schema = useMemo( + () => + yup.object({ + otp: yup + .string() + .matches(/^\d{6}$/, t("auth.otpInvalid")) + .required(t("auth.otpRequired")), + }), + [t] + ); + const formik = useFormik({ initialValues: { otp: "", @@ -65,62 +72,70 @@ function RegisterOtp() { }); return ( - -
- - { - if (!mobile) return; - setResendLoading(true); - try { - await request("POST", "/register", { mobile }); - formik.setFieldValue("otp", ""); - setOtpError(false); - setIsSuccess(false); - } finally { - setResendLoading(false); - } - }} - /> -
- { - formik.setFieldValue("otp", otp); - setOtpError(false); - setIsSuccess(false); + + +
+ + { + if (!mobile) return; + setResendLoading(true); + try { + await request("POST", "/register", { mobile }); + formik.setFieldValue("otp", ""); + setOtpError(false); + setIsSuccess(false); + } finally { + setResendLoading(false); + } }} - 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 && ( - {formik.errors.otp} - )} - - تایید کد - - - - - اصلاح شماره موبایل - - - -
-
+ { + formik.setFieldValue("otp", otp); + setOtpError(false); + setIsSuccess(false); + }} + 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 && ( + {formik.errors.otp} + )} + + {t("auth.verifyCode")} + + + + + {t("auth.editMobile")} + + + +
+
+ ); } diff --git a/src/app/(auth)/(register)/register/complete/page.tsx b/src/app/(auth)/(register)/register/complete/page.tsx index f91fcf5..ffc3fa2 100644 --- a/src/app/(auth)/(register)/register/complete/page.tsx +++ b/src/app/(auth)/(register)/register/complete/page.tsx @@ -12,8 +12,11 @@ import { useRouter } from "next/navigation"; import AuthNextButton from "@/components/auth/AuthNextButton"; import toast from "react-hot-toast"; import { getSafeRedirectPath } from "@/lib/auth/postLogin"; +import { useTranslation } from "react-i18next"; +import LocalePageShell from "@/components/i18n/LocalePageShell"; function RegisterCompletePage() { + const { t } = useTranslation("common"); const router = useRouter(); const { request, loading } = useAxios(); const [action, setAction] = useState<"app" | "continue" | null>(null); @@ -32,47 +35,50 @@ function RegisterCompletePage() { router.push(getSafeRedirectPath()); } } catch (err: any) { - toast.error(err?.response?.data?.message || "خطا در تکمیل ثبت‌نام"); + toast.error( + err?.response?.data?.message || t("auth.registrationFinishFailed") + ); } finally { setAction(null); } }; return ( - -
- - -

- می‌توانید وارد برنامه شوید یا ثبت‌نام کامل (تایید هویت و پروفایل) - را ادامه دهید. -

-
+ + +
+ + +

+ {t("auth.registerCompleteDesc")} +

+
- -
- finishRegistration("continue")} - className=" w-full sm:w-auto" - disabled={loading} - loading={loading && action === "continue"} - > - ادامه ثبت نام - - finishRegistration("app")} - className="!border-[#FF0000] !text-[#FF0000] w-full sm:w-auto" - disabled={loading} - loading={loading && action === "app"} - > - ورود به برنامه - -
-
-
-
+ +
+ finishRegistration("continue")} + className=" w-full sm:w-auto" + disabled={loading} + loading={loading && action === "continue"} + > + {t("auth.continueRegistration")} + + finishRegistration("app")} + className="!border-[#FF0000] !text-[#FF0000] w-full sm:w-auto" + disabled={loading} + loading={loading && action === "app"} + > + {t("auth.enterApp")} + +
+
+
+
+ ); } diff --git a/src/app/(auth)/(register)/register/fullname/page.tsx b/src/app/(auth)/(register)/register/fullname/page.tsx index 5788cab..af0e183 100644 --- a/src/app/(auth)/(register)/register/fullname/page.tsx +++ b/src/app/(auth)/(register)/register/fullname/page.tsx @@ -7,7 +7,7 @@ import AuthPageLayout, { AuthFormFooter, AuthPageContent, } from "@/components/auth/AuthPageLayout"; -import React, { useState } from "react"; +import React, { useMemo, useState } from "react"; import useAxios from "@/hooks/useAxios"; import { useFormik } from "formik"; import * as yup from "yup"; @@ -15,13 +15,11 @@ import { useRouter } from "next/navigation"; import AuthNextButton from "@/components/auth/AuthNextButton"; import toast from "react-hot-toast"; import { getSafeRedirectPath } from "@/lib/auth/postLogin"; - -const schema = yup.object().shape({ - name: yup.string().trim().required("نام الزامی است").min(2, "نام کوتاه است"), - family: yup.string().trim().optional(), -}); +import { useTranslation } from "react-i18next"; +import LocalePageShell from "@/components/i18n/LocalePageShell"; function FullNamePage() { + const { t } = useTranslation("common"); const router = useRouter(); const { request, loading } = useAxios(); const [nameSaved, setNameSaved] = useState(false); @@ -29,6 +27,19 @@ function FullNamePage() { null ); + const schema = useMemo( + () => + yup.object().shape({ + name: yup + .string() + .trim() + .required(t("auth.nameRequired")) + .min(2, t("auth.nameTooShort")), + family: yup.string().trim().optional(), + }), + [t] + ); + const formik = useFormik({ initialValues: { family: "", @@ -50,9 +61,9 @@ function FullNamePage() { } setNameSaved(true); - toast.success("نام با موفقیت ثبت شد"); + toast.success(t("auth.nameSavedSuccess")); } catch (err: any) { - toast.error(err?.response?.data?.message || "خطا در ذخیره نام"); + toast.error(err?.response?.data?.message || t("auth.saveNameFailed")); } finally { setSubmitting(false); } @@ -73,105 +84,109 @@ function FullNamePage() { router.push(getSafeRedirectPath()); } } catch (err: any) { - toast.error(err?.response?.data?.message || "خطا در تکمیل ثبت‌نام"); + toast.error( + err?.response?.data?.message || t("auth.registrationFinishFailed") + ); } finally { setChoiceLoading(null); } }; return ( - -
- - -
- - {formik.touched.name && formik.errors.name && ( - - {formik.errors.name} - - )} + + + + + +
+ + {formik.touched.name && formik.errors.name && ( + + {formik.errors.name} + + )} - - {formik.touched.family && formik.errors.family && ( - - {formik.errors.family} - - )} + + {formik.touched.family && formik.errors.family && ( + + {formik.errors.family} + + )} - {nameSaved && ( -

- نام شما ثبت شد. یکی از گزینه‌های زیر را انتخاب کنید. -

- )} -
-
- - - {!nameSaved ? ( - - ثبت و ادامه - - ) : ( -
- finishRegistration("continue")} - className=" w-full sm:w-auto" - disabled={Boolean(choiceLoading)} - loading={choiceLoading === "continue"} - > - ادامه ثبت نام - - finishRegistration("app")} - className="!border-[#FF0000] !text-[#FF0000] w-full sm:w-auto" - disabled={Boolean(choiceLoading)} - loading={choiceLoading === "app"} - > - ورود به برنامه - + {nameSaved && ( +

+ {t("auth.nameSavedChoose")} +

+ )}
- )} -
- -
+ + + + {!nameSaved ? ( + + {t("auth.saveAndContinue")} + + ) : ( +
+ finishRegistration("continue")} + className=" w-full sm:w-auto" + disabled={Boolean(choiceLoading)} + loading={choiceLoading === "continue"} + > + {t("auth.continueRegistration")} + + finishRegistration("app")} + className="!border-[#FF0000] !text-[#FF0000] w-full sm:w-auto" + disabled={Boolean(choiceLoading)} + loading={choiceLoading === "app"} + > + {t("auth.enterApp")} + +
+ )} +
+ + +
); } diff --git a/src/app/(auth)/(register)/register/loading.tsx b/src/app/(auth)/(register)/register/loading.tsx new file mode 100644 index 0000000..b2ac385 --- /dev/null +++ b/src/app/(auth)/(register)/register/loading.tsx @@ -0,0 +1,15 @@ +"use client"; + +import IOSSpinner from "@/components/ui/IOSSpinner"; +import { useTranslation } from "react-i18next"; + +export default function RegisterLoading() { + const { t } = useTranslation("common"); + + return ( +
+ + {t("auth.pageLoading")} +
+ ); +} diff --git a/src/app/(auth)/(register)/register/page.tsx b/src/app/(auth)/(register)/register/page.tsx index d0fed7f..25d3c11 100644 --- a/src/app/(auth)/(register)/register/page.tsx +++ b/src/app/(auth)/(register)/register/page.tsx @@ -5,28 +5,34 @@ import AuthHead from "@/components/auth/AuthHead"; import AuthInput from "@/components/auth/AuthInput"; import Container from "@/components/elements/Container"; import AuthRules from "@/components/auth/AuthRules"; -import React, { useState } from "react"; +import React, { useMemo, useState } from "react"; import useAxios from "@/hooks/useAxios"; import { useFormik } from "formik"; import * as yup from "yup"; import { useRouter } from "next/navigation"; import { markOtpSent } from "@/lib/auth/otpTimer"; -import GoogleSignInButton from "@/components/auth/GoogleSignInButton"; +import { GoogleSignInButton } from "@/app/(auth)/AuthProviders"; import { MOBILE_NUMERIC_INPUT_PROPS } from "@/lib/auth/inputProps"; - -// Validation schema using Yup -const schema = yup.object({ - mobile: yup - .string() - .matches(/^(09\d{9})$/, "شماره موبایل معتبر نیست") - .required("شماره موبایل الزامی است"), -}); +import { useTranslation } from "react-i18next"; +import LocalePageShell from "@/components/i18n/LocalePageShell"; function Register() { + const { t } = useTranslation("common"); const router = useRouter(); const [isModalOpen, setModalOpen] = useState(false); const { request, loading } = useAxios(); + const schema = useMemo( + () => + yup.object({ + mobile: yup + .string() + .matches(/^(09\d{9})$/, t("auth.mobileInvalid")) + .required(t("auth.mobileRequired")), + }), + [t] + ); + const formik = useFormik({ initialValues: { mobile: "", @@ -45,46 +51,53 @@ function Register() { }); return ( - -
- -
- - {formik.touched.mobile && formik.errors.mobile && ( - - {formik.errors.mobile} + + +
+ + + + {formik.touched.mobile && formik.errors.mobile && ( + + {formik.errors.mobile} + + )} + + {t("auth.sendCode")} + + + + - -
-
+ + +
+
+ ); } diff --git a/src/app/(auth)/(register)/register/password/page.tsx b/src/app/(auth)/(register)/register/password/page.tsx index 5309c1e..f2be62a 100644 --- a/src/app/(auth)/(register)/register/password/page.tsx +++ b/src/app/(auth)/(register)/register/password/page.tsx @@ -7,7 +7,7 @@ import AuthPageLayout, { AuthFormFooter, AuthPageContent, } from "@/components/auth/AuthPageLayout"; -import React from "react"; +import React, { useMemo } from "react"; import useAxios from "@/hooks/useAxios"; import { useFormik } from "formik"; import * as yup from "yup"; @@ -15,29 +15,33 @@ 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({ - password: yup - .string() - .required("کلمه عبور نمی\u200Cتواند خالی باشد") - .min(8, "کلمه عبور نباید کوتاه تر از 8 کاراکتر باشد") - .matches( - /^(?=.*[a-zA-Zآ-ی])(?=.*\d).{8,}$/, - "کلمه عبور باید شامل حروف و اعداد باشد" - ), - confirmPassword: yup - .string() - .required("تکرار کلمه عبور نمی\u200Cتواند خالی باشد") - .oneOf( - [yup.ref("password")], - "تکرار کلمه عبور باید با کلمه عبور مطابقت داشته باشد" - ), -}); +import { useTranslation } from "react-i18next"; +import LocalePageShell from "@/components/i18n/LocalePageShell"; function PasswordPage() { + const { t } = useTranslation("common"); const router = useRouter(); const { request, loading } = useAxios(); + const schema = useMemo( + () => + yup.object().shape({ + password: yup + .string() + .required(t("auth.passwordRequired")) + .min(8, t("auth.passwordMin")) + .matches( + /^(?=.*[a-zA-Zآ-ی])(?=.*\d).{8,}$/, + t("auth.passwordLettersNumbers") + ), + confirmPassword: yup + .string() + .required(t("auth.confirmPasswordRequired")) + .oneOf([yup.ref("password")], t("auth.confirmPasswordMismatch")), + }), + [t] + ); + const formik = useFormik({ initialValues: { password: "", @@ -52,68 +56,71 @@ function PasswordPage() { router.push("/register/fullname"); } catch (err: any) { const message = - err?.response?.data?.message || "ذخیره کلمه عبور ناموفق بود"; + err?.response?.data?.message || t("auth.savePasswordFailed"); toast.error(message); } }, }); return ( - -
- - -
- - {formik.touched.password && formik.errors.password && ( - - {formik.errors.password} - - )} + + + + + +
+ + {formik.touched.password && formik.errors.password && ( + + {formik.errors.password} + + )} - - {formik.touched.confirmPassword && formik.errors.confirmPassword && ( - - {formik.errors.confirmPassword} - - )} -
-
+ + {formik.touched.confirmPassword && + formik.errors.confirmPassword && ( + + {formik.errors.confirmPassword} + + )} +
+
- - - ثبت و ادامه - - -
-
+ + + {t("auth.saveAndContinue")} + + + + + ); } diff --git a/src/app/(auth)/(register)/register/username/page.tsx b/src/app/(auth)/(register)/register/username/page.tsx index b78d2d3..a478ac5 100644 --- a/src/app/(auth)/(register)/register/username/page.tsx +++ b/src/app/(auth)/(register)/register/username/page.tsx @@ -7,21 +7,25 @@ import AuthPageLayout, { AuthFormFooter, AuthPageContent, } from "@/components/auth/AuthPageLayout"; -import React, { useState } from "react"; +import React, { useMemo, 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 UsernameSuggestions from "@/components/auth/UsernameSuggestions"; -import { usernameFormSchema } from "@/lib/validation/usernameSchema"; import { sanitizeUsernameInput, USERNAME_MIN_LENGTH, - USERNAME_VALIDATION_MESSAGE, + USERNAME_MAX_LENGTH, + USERNAME_REGEX, } from "@/lib/validation/username"; +import { useTranslation } from "react-i18next"; +import LocalePageShell from "@/components/i18n/LocalePageShell"; function UsernamePage() { + const { t } = useTranslation("common"); const router = useRouter(); const { request, loading } = useAxios(); const [isDuplicate, setIsDuplicate] = useState(false); @@ -29,11 +33,31 @@ function UsernamePage() { const mobile = typeof window !== "undefined" ? localStorage.getItem("mobile") : null; + const schema = useMemo( + () => + yup.object({ + username: yup + .string() + .required(t("auth.usernameRequired")) + .min( + USERNAME_MIN_LENGTH, + t("auth.usernameMinLength", { min: USERNAME_MIN_LENGTH }) + ) + .max( + USERNAME_MAX_LENGTH, + t("auth.usernameMaxLength", { max: USERNAME_MAX_LENGTH }) + ) + .matches(/^[a-zA-Z0-9]/, t("auth.usernameStartRule")) + .matches(USERNAME_REGEX, t("auth.usernameCharsRule")), + }), + [t] + ); + const formik = useFormik({ initialValues: { username: "", }, - validationSchema: usernameFormSchema, + validationSchema: schema, onSubmit: async (values) => { setIsDuplicate(false); setSuggestions([]); @@ -45,7 +69,13 @@ function UsernamePage() { }) as IVerifyOtp; localStorage.setItem("username", values.username.trim().toLowerCase()); - router.push("/register/password"); + const authProvider = + typeof window !== "undefined" + ? localStorage.getItem("auth_provider") + : null; + router.push( + authProvider === "google" ? "/register/fullname" : "/register/password" + ); } catch (err: any) { const data = err?.response?.data; if (data?.message?.includes("تکراری")) { @@ -72,73 +102,81 @@ function UsernamePage() { }; return ( - -
- - -
-

- حداقل {USERNAME_MIN_LENGTH} کاراکتر؛ فقط حروف انگلیسی، اعداد و - . _ - -

- handleUsernameChange(e.target.value)} - onBlur={formik.handleBlur} - /> + + + + + +
+

+ {t("auth.usernameHint", { + max: USERNAME_MAX_LENGTH, + min: USERNAME_MIN_LENGTH, + })} +

+ handleUsernameChange(e.target.value)} + onBlur={formik.handleBlur} + /> - {isDuplicate && ( - - نام کاربری تکراری است - - )} + {isDuplicate && ( + + {t("auth.usernameDuplicate")} + + )} - {formik.touched.username && formik.errors.username && !isDuplicate && ( - - {formik.errors.username} - - )} + {formik.touched.username && + formik.errors.username && + !isDuplicate && ( + + {formik.errors.username} + + )} - {!formik.errors.username && !isDuplicate && ( - - {USERNAME_VALIDATION_MESSAGE} - - )} + {!formik.errors.username && !isDuplicate && ( + + {t("auth.usernameCharsRule")} + + )} - -
-
+ +
+
- - - ثبت و ادامه - - -
-
+ + + {t("auth.saveAndContinue")} + + + + + ); } diff --git a/src/app/(auth)/AuthProviders.tsx b/src/app/(auth)/AuthProviders.tsx index 7ff1ce2..7e82c7f 100644 --- a/src/app/(auth)/AuthProviders.tsx +++ b/src/app/(auth)/AuthProviders.tsx @@ -1,16 +1,72 @@ "use client"; -import { GoogleAuthRootProvider } from "@/components/auth/GoogleSignInButton"; +import GoogleSignInButtonBase, { + GoogleAuthRootProvider, +} from "@/components/auth/GoogleSignInButton"; import RegisterRouteGuard from "@/components/auth/RegisterRouteGuard"; +import { GOOGLE_OAUTH_CLIENT_ID } from "@/config/googleAuth"; +import { createContext, useContext, useEffect, useState } from "react"; + +const GoogleClientIdContext = createContext(""); + +export function useGoogleClientId(): string { + return useContext(GoogleClientIdContext); +} + +export function GoogleSignInButton( + props: Omit, "clientId"> +) { + const clientId = useGoogleClientId(); + return ( + + ); +} export default function AuthProviders({ children, + googleClientId = "", }: { children: React.ReactNode; + googleClientId?: string; }) { + const [resolvedClientId, setResolvedClientId] = useState( + googleClientId || GOOGLE_OAUTH_CLIENT_ID + ); + + useEffect(() => { + let cancelled = false; + + async function loadClientId() { + const fallback = googleClientId || GOOGLE_OAUTH_CLIENT_ID; + setResolvedClientId(fallback); + + try { + const res = await fetch("/api/auth/config", { cache: "no-store" }); + if (!res.ok) return; + const data = (await res.json()) as { googleClientId?: string }; + if (!cancelled) { + setResolvedClientId(data?.googleClientId || fallback); + } + } catch { + // keep fallback + } + } + + void loadClientId(); + + return () => { + cancelled = true; + }; + }, [googleClientId]); + return ( - - {children} - + + + {children} + + ); } diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx index d9a1192..7594a1b 100644 --- a/src/app/(auth)/layout.tsx +++ b/src/app/(auth)/layout.tsx @@ -1,18 +1,22 @@ import type { Metadata } from "next"; import AuthProviders from "./AuthProviders"; +import { getGoogleClientId } from "@/lib/auth/googleClientId"; +import { generateSeoPageMetadata } from "@/lib/i18n/server"; -export const metadata: Metadata = { - title: { - default: "ورود و ثبت‌نام | مدستاگرام", - }, - description: "ورود یا ثبت‌نام در مدستاگرام", - robots: { index: false, follow: false }, -}; +export const dynamic = "force-dynamic"; + +export async function generateMetadata(): Promise { + return generateSeoPageMetadata("auth", { index: false }); +} export default function AuthLayout({ children, }: { children: React.ReactNode; }) { - return {children}; + const googleClientId = getGoogleClientId(); + + return ( + {children} + ); } diff --git a/src/app/(auth)/verify/auth/page.tsx b/src/app/(auth)/verify/auth/page.tsx index cfb56a1..dcbff1a 100644 --- a/src/app/(auth)/verify/auth/page.tsx +++ b/src/app/(auth)/verify/auth/page.tsx @@ -5,7 +5,7 @@ import AuthPageLayout, { AuthFormFooter, AuthPageContent, } from "@/components/auth/AuthPageLayout"; -import React from "react"; +import React, { useMemo } from "react"; import useAxios from "@/hooks/useAxios"; import { useFormik } from "formik"; import * as yup from "yup"; @@ -19,30 +19,38 @@ import persian from "react-date-object/calendars/persian"; import persian_fa from "react-date-object/locales/persian_fa"; import Image from "next/image"; import moment from "jalali-moment"; - -// Validation schema using Yup -const schema = yup.object().shape({ - birthDate: yup.string().required(" انتخاب تاریخ تولد الزامی است"), - shaba: yup - .string() - .matches(/^(?=.{24}$)[0-9]*$/, "شماره شبا باید ۲۴ رقم باشد") - .required("شماره شبا الزامی است"), - nationalCode: yup - .string() - .matches(/^\d{10}$/, "کد ملی باید ۱۰ رقم باشد") - .required("کد ملی الزامی است"), -}); +import { useTranslation } from "react-i18next"; +import LocalePageShell from "@/components/i18n/LocalePageShell"; function AuthPage() { + const { t } = useTranslation("common"); const router = useRouter(); const { request, loading } = useAxios(); - + const p2e = (es: string): string => - es.replace(/[۰-۹]/g, (d) => String.fromCharCode("۰۱۲۳۴۵۶۷۸۹".indexOf(d) + 48)); - + es.replace(/[۰-۹]/g, (d) => + String.fromCharCode("۰۱۲۳۴۵۶۷۸۹".indexOf(d) + 48) + ); + const mobile = typeof window !== "undefined" ? localStorage.getItem("mobile") : null; + const schema = useMemo( + () => + yup.object().shape({ + birthDate: yup.string().required(t("auth.birthDateRequired")), + shaba: yup + .string() + .matches(/^(?=.{24}$)[0-9]*$/, t("auth.shabaInvalid")) + .required(t("auth.shabaRequired")), + nationalCode: yup + .string() + .matches(/^\d{10}$/, t("auth.nationalCodeInvalid")) + .required(t("auth.nationalCodeRequired")), + }), + [t] + ); + const formik = useFormik({ initialValues: { nationalCode: "", @@ -56,129 +64,128 @@ function AuthPage() { mobile: mobile, birthday: moment(p2e(values.birthDate), "jYYYY/jMM/jDD").format( "YYYY/MM/DD" - ), // تبدیل به میلادی + ), national_code: values.nationalCode, shaba: values.shaba, }); router.push("/verify/location"); } catch (err: any) { - toast.error(err?.message || "خطای نامشخصی رخ داد."); + toast.error(err?.response?.data?.message || t("auth.unknownError")); } finally { setSubmitting(false); } }, }); - console.log(formik.values); - return ( - -
- - احراز هویت -
- -
- -
- جهت احراز هویت -
-
- {/* کد ملی */} - + + + + + + {t("auth.identityVerification")} + +
+
- {/* تاریخ تولد */} -
- { - const formattedDate = value?.format("YYYY/MM/DD") || ""; // اگر مقدار `null` بود، رشته خالی - formik.setFieldValue("birthDate", formattedDate); // مقدار فرم را تنظیم کنید - }} - calendarPosition="bottom-center" - placeholder="تاریخ تولد" - inputClass={`w-full dir-ltr max-w-[290px] p-3 !pr-6 text-center rounded-2xl border border-border-primary-light dark:border-border-primary-dark bg-secondary-light dark:bg-secondary-dark font-medium border mt-2 !p-1 h-[36px] ${ - formik.touched.birthDate && formik.errors.birthDate - ? "border-red-500 dark:border-red-500" - : "border-gray-300" - }`} - /> - -
-
- {formik.touched.nationalCode && formik.errors.nationalCode && ( - - {formik.errors.nationalCode} - - )} - {formik.touched.birthDate && formik.errors.birthDate && ( - - {formik.errors.birthDate} - - )} - جهت واریز حق الزحمه - {/* شماره شبا */} -
- IR - - {formik.touched.shaba && formik.errors.shaba && ( - - {formik.errors.shaba} +
+ + {t("auth.forIdentityVerification")} - )} -
+
+
+ +
+
+ { + const formattedDate = value?.format("YYYY/MM/DD") || ""; + formik.setFieldValue("birthDate", formattedDate); + }} + calendarPosition="bottom-center" + placeholder={t("auth.birthDatePlaceholder")} + inputClass={`w-full dir-ltr max-w-[290px] p-3 !pr-6 text-center rounded-2xl border border-border-primary-light dark:border-border-primary-dark bg-secondary-light dark:bg-secondary-dark font-medium border mt-2 !p-1 h-[36px] ${ + formik.touched.birthDate && formik.errors.birthDate + ? "border-red-500 dark:border-red-500" + : "border-gray-300" + }`} + /> + +
+
- - شماره شبا باید به نام خود شخص باشد - -
- + {formik.touched.nationalCode && formik.errors.nationalCode && ( + + {formik.errors.nationalCode} + + )} + {formik.touched.birthDate && formik.errors.birthDate && ( + + {formik.errors.birthDate} + + )} + {t("auth.forPayment")} +
+ IR + + {formik.touched.shaba && formik.errors.shaba && ( + + {formik.errors.shaba} + + )} +
- router.push("/verify/location")}> - - ثبت و ادامه - - - - + {t("auth.shabaOwnerHint")} +
+ + + router.push("/verify/location")}> + + {t("auth.saveAndContinue")} + + + + + ); } diff --git a/src/app/(auth)/verify/avatar/page.tsx b/src/app/(auth)/verify/avatar/page.tsx index 040e37c..0d0f3e7 100644 --- a/src/app/(auth)/verify/avatar/page.tsx +++ b/src/app/(auth)/verify/avatar/page.tsx @@ -6,19 +6,20 @@ import AuthPageLayout, { AuthPageContent, } from "@/components/auth/AuthPageLayout"; import AuthNextButton from "@/components/auth/AuthNextButton"; -import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl"; +import { IMAGE_BASE_URL } from "@/components/main/BaseUrl"; import useAxios from "@/hooks/useAxios"; import { IProfileData } from "@/types/types"; import Image from "next/image"; import { useRouter } from "next/navigation"; import toast from "react-hot-toast"; - -// کتابخانه برای crop import Cropper from "react-easy-crop"; import getCroppedImg from "@/helpers/cropImage"; import { Area } from "react-easy-crop"; +import { useTranslation } from "react-i18next"; +import LocalePageShell from "@/components/i18n/LocalePageShell"; function AvatarPage() { + const { t } = useTranslation("common"); const router = useRouter(); const { request, loading } = useAxios(); const [userDetail, setUserDetail] = useState(null); @@ -45,7 +46,6 @@ function AvatarPage() { fetchData(); }, []); - // انتخاب تصویر const selectImage = async (event: React.ChangeEvent) => { if (event.target.files && event.target.files[0]) { const file = event.target.files[0]; @@ -58,37 +58,28 @@ function AvatarPage() { } }; - // حذف تصویر - // const removeImage = () => { - // setAvatar(null); - // setOriginalImage(null); - // }; - - // دریافت مختصات crop شده const onCropComplete = useCallback( - (croppedArea: Area, croppedPixels: Area) => { + (_croppedArea: Area, croppedPixels: Area) => { setCroppedAreaPixels(croppedPixels); }, [] ); - // تبدیل crop شده به blob const createCroppedImage = async () => { if (!avatar || !croppedAreaPixels) return null; const croppedImage = await getCroppedImg(avatar, croppedAreaPixels); return croppedImage; }; - // آپلود تصویر const uploadImage = async () => { if (!avatar) { - toast.error("لطفا تصویر پروفایل را انتخاب کنید"); + toast.error(t("auth.selectProfileImage")); return; } setLoadingUpload(true); const croppedBlob = await createCroppedImage(); if (!croppedBlob) { - toast.error("خطا در برش تصویر"); + toast.error(t("auth.cropError")); setLoadingUpload(false); return; } @@ -101,16 +92,15 @@ function AvatarPage() { await request("POST", "/verify/profile_image", formData, { headers: { "Content-Type": "multipart/form-data" }, }); - toast.success("تصویر با موفقیت ثبت شد!"); + toast.success(t("auth.imageSavedSuccess")); navigateHandler(); } catch (err) { console.log("Upload error:", err); - toast.error("خطا در ارسال تصویر!"); + toast.error(t("auth.imageUploadError")); setLoadingUpload(false); } }; - // ناوبری بعد از آپلود const navigateHandler = () => { if (userDetail?.user_type === "user") { router.push("/verify/expertise"); @@ -120,68 +110,71 @@ function AvatarPage() { }; return ( - - - تصویر پروفایل + + + + + {t("auth.profileImageTitle")} + -
- {avatar ? ( - + {avatar ? ( + + ) : ( +
+ + )} +
- + - - - -
+ + + + - - - ثبت و ادامه - - -
+ + + {t("auth.saveAndContinue")} + + + + ); } -export default AvatarPage; \ No newline at end of file +export default AvatarPage; diff --git a/src/app/(auth)/verify/colors/page.tsx b/src/app/(auth)/verify/colors/page.tsx index f71d675..4c5888d 100644 --- a/src/app/(auth)/verify/colors/page.tsx +++ b/src/app/(auth)/verify/colors/page.tsx @@ -5,7 +5,7 @@ import AuthPageLayout, { AuthFormFooter, AuthPageContent, } from "@/components/auth/AuthPageLayout"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import useAxios from "@/hooks/useAxios"; import * as yup from "yup"; import { useRouter } from "next/navigation"; @@ -13,15 +13,12 @@ import AuthNextButton from "@/components/auth/AuthNextButton"; import toast from "react-hot-toast"; import AuthUserDetails from "@/components/auth/AuthUserDetails"; import Image from "next/image"; - -// Validation schema using Yup -const schema = yup.object().shape({ - selectedHairImage: yup.string().required("رنگ مو الزامی است"), - selectedEyeImage: yup.string().required(" رنگ چشم الزامی است"), -}); +import { useTranslation } from "react-i18next"; +import LocalePageShell from "@/components/i18n/LocalePageShell"; function Colors() { - const [selectedButton, setSelectedButton] = useState(1); // Default: 1 for "قد" + const { t } = useTranslation("common"); + const [selectedButton, setSelectedButton] = useState(1); const [selectedHairImage, setSelectedHairImage] = useState(""); const [selectedEyeImage, setSelectedEyeImage] = useState(""); const router = useRouter(); @@ -32,6 +29,15 @@ function Colors() { typeof window !== "undefined" ? localStorage.getItem("expertise") || "" : "" ); + const schema = useMemo( + () => + yup.object().shape({ + selectedHairImage: yup.string().required(t("auth.hairColorRequired")), + selectedEyeImage: yup.string().required(t("auth.eyeColorRequired")), + }), + [t] + ); + useEffect(() => { if (expertise && expertise !== "مدل") { router.replace("/verify/public-relations"); @@ -49,8 +55,6 @@ function Colors() { } }; - console.log(selectedHairImage, selectedEyeImage); - const sendSizesHandler = async () => { try { const data = { @@ -69,7 +73,6 @@ function Colors() { setSelectedButton(buttonIndex); }; - // تصاویر رنگ چشم و رنگ مو const eyeImages = Array.from( { length: 26 }, (_, i) => `/images/eyes/${String(i + 1).padStart(2, "0")}.png` @@ -79,9 +82,8 @@ function Colors() { (_, i) => `/images/hairs/${String(i + 1).padStart(2, "0")}.png` ); - // تابع برای ذخیره نام فایل انتخاب شده const handleImageSelect = (image: string, type: "eye" | "hair") => { - const fileName = image.split("/").pop(); // استخراج فقط نام فایل + const fileName = image.split("/").pop(); if (type === "eye") { setSelectedEyeImage(fileName || ""); } else if (type === "hair") { @@ -89,98 +91,97 @@ function Colors() { } }; - - - return ( - - - سایز -
- -
+ + + + {t("auth.sizesTitle")} +
+ +
-

مشخصات ظاهری

+

+ {t("auth.appearanceDetails")} +

- {/* Buttons for categories */} -
- + +
+ +
+ {selectedButton === 1 && ( +
+ {eyeImages.map((image) => ( + {image} handleImageSelect(image, "eye")} + /> + ))} +
+ )} + + {selectedButton === 2 && ( +
+ {hairImages.map((image) => ( + {image} handleImageSelect(image, "hair")} + /> + ))} +
+ )} +
+
+ + router.push("/verify/public-relations")}> + - رنگ چشم - - -
- - {/* Display images based on selected button */} -
- {selectedButton === 1 && ( -
- {eyeImages.map((image) => ( - {image} handleImageSelect(image, "eye")} - /> - ))} -
- )} - - {selectedButton === 2 && ( -
- {hairImages.map((image) => ( - {image} handleImageSelect(image, "hair")} - /> - ))} -
- )} -
-
- - router.push("/verify/public-relations")}> - - ثبت و ادامه - - -
+ {t("auth.saveAndContinue")} + + + + ); } diff --git a/src/app/(auth)/verify/confirm/page.tsx b/src/app/(auth)/verify/confirm/page.tsx index 49e12d5..1804a9b 100644 --- a/src/app/(auth)/verify/confirm/page.tsx +++ b/src/app/(auth)/verify/confirm/page.tsx @@ -12,14 +12,16 @@ import VerificationBadge from "@/components/main/VerificationBadge"; import { useUser } from "@/hooks/useUser"; import { formatFullName } from "@/lib/formatFullName"; import useAxios from "@/hooks/useAxios"; +import { useTranslation } from "react-i18next"; +import LocalePageShell from "@/components/i18n/LocalePageShell"; function Confirm() { + const { t } = useTranslation("common"); const user = useUser(); const { request } = useAxios(); const router = useRouter(); const [showModal, setShowModal] = useState(false); const [navigating, setNavigating] = useState(false); - const [verifiedStatus, setVerifiedStatus] = useState(null); useEffect(() => { @@ -43,114 +45,122 @@ function Confirm() { setShowModal(true); setNavigating(false); }; + return ( - -
- - ثبت نام شما تکمیل شد - - - ورود شما به خانواده مدستاگرام را تبریک می گوییم{" "} - -
- -
- {formatFullName(user?.first_name, user?.last_name)} -
- - {user?.user_name} - - + + +
+ + {t("auth.registrationComplete")} + + + {t("auth.welcomeMessage")} + +
+ +
+ {formatFullName(user?.first_name, user?.last_name)} +
+ + {user?.user_name} + + +
-
- - پس از تایید مدارک و احراز هویت، تیک آبی یا طلایی کنار نام کاربری شما - قرار می گیرد - + + {t("auth.verificationBadgeHint")} + -
-
- - - - -
-
- - - - +
+
+ + + + +
+
+ + + + +
+ + {t("auth.saveAndContinue")} + + + {t("auth.verificationTimeOffice")} + + + {t("auth.verificationTimeOffHours")} + + setShowModal(false)} + height="200px" + > +

+ {t("auth.shareWorkHint")} +

+
+ + {t("auth.createPost")} + + + {t("auth.later")} + +
+
- {/* */} - - ثبت و ادامه - - - زمان تایید مدارک 5 دقیقه تا 1 ساعت در ساعات اداری و - - - 3 تا 8 ساعت در ساعات غیر اداری - - setShowModal(false)} - height="200px" - > -

- .برای بهتر دیده شدن نمونه کارهای خود را به اشتراگ بگذراید -

-
- - ثبت پست - - - بعدا - -
-
-
-
+ +
); } diff --git a/src/app/(auth)/verify/cooperation-type/page.tsx b/src/app/(auth)/verify/cooperation-type/page.tsx index 99663dc..a772a24 100644 --- a/src/app/(auth)/verify/cooperation-type/page.tsx +++ b/src/app/(auth)/verify/cooperation-type/page.tsx @@ -5,7 +5,7 @@ import AuthPageLayout, { AuthFormFooter, AuthPageContent, } from "@/components/auth/AuthPageLayout"; -import React from "react"; +import React, { useMemo } from "react"; import useAxios from "@/hooks/useAxios"; import { useFormik } from "formik"; import * as yup from "yup"; @@ -13,18 +13,24 @@ import { useRouter } from "next/navigation"; import AuthNextButton from "@/components/auth/AuthNextButton"; import toast from "react-hot-toast"; import AuthUserDetails from "@/components/auth/AuthUserDetails"; - -// Validation schema using Yup -const schema = yup.object().shape({ - selectedButton: yup.boolean().required("انتخاب نوع همکاری الزامی است"), -}); +import { useTranslation } from "react-i18next"; +import LocalePageShell from "@/components/i18n/LocalePageShell"; function CooperationType() { + const { t } = useTranslation("common"); const router = useRouter(); const { request, loading } = useAxios(); const mobile = typeof window !== "undefined" ? localStorage.getItem("mobile") : null; + const schema = useMemo( + () => + yup.object().shape({ + selectedButton: yup.boolean().required(t("auth.cooperationRequired")), + }), + [t] + ); + const formik = useFormik({ initialValues: { selectedButton: null, @@ -39,7 +45,7 @@ function CooperationType() { }); router.push("/verify/public-relations"); } catch (err: any) { - toast.error(err?.message || "خطای نامشخصی رخ داد."); + toast.error(err?.response?.data?.message || t("auth.unknownError")); } finally { setSubmitting(false); } @@ -47,62 +53,66 @@ function CooperationType() { }); return ( - -
- - نوع همکاری -
- -
- -

- آیا مایل به همکاری خارج از محل سکونت خود هستید؟ -

- -
-
- formik.setFieldValue("selectedButton", true)} - className={`mt-5 text-xs w-full max-w-[250px] flex items-center justify-center flex-row-reverse gap-2 px-1 ${ - formik.values.selectedButton === true - ? "btn-modern--selected text-white" - : "" - }`} - > - بله خوشحال هم می شم - - formik.setFieldValue("selectedButton", false)} - className={`mt-5 text-xs w-full max-w-[250px] flex items-center justify-center flex-row-reverse gap-2 px-1 ${ - formik.values.selectedButton === false - ? "btn-modern--selected text-white" - : "" - }`} - > - نه شهر خودم رو ترجیح میدم - -
- - {formik.errors.selectedButton && formik.touched.selectedButton && ( -
- {formik.errors.selectedButton} + + + + + + {t("auth.cooperationTypeTitle")} + +
+
- )} -
- - router.push("/verify/public-relations")}> - - ثبت و ادامه - - - - +

+ {t("auth.cooperationQuestion")} +

+ +
+
+ formik.setFieldValue("selectedButton", true)} + className={`mt-5 text-xs w-full max-w-[250px] flex items-center justify-center flex-row-reverse gap-2 px-1 ${ + formik.values.selectedButton === true + ? "btn-modern--selected text-white" + : "" + }`} + > + {t("auth.cooperationYes")} + + formik.setFieldValue("selectedButton", false)} + className={`mt-5 text-xs w-full max-w-[250px] flex items-center justify-center flex-row-reverse gap-2 px-1 ${ + formik.values.selectedButton === false + ? "btn-modern--selected text-white" + : "" + }`} + > + {t("auth.cooperationNo")} + +
+ + {formik.errors.selectedButton && formik.touched.selectedButton && ( +
+ {formik.errors.selectedButton} +
+ )} +
+ + + router.push("/verify/public-relations")}> + + {t("auth.saveAndContinue")} + + + + + ); } diff --git a/src/app/(auth)/verify/expertise/page.tsx b/src/app/(auth)/verify/expertise/page.tsx index b17a8bb..4842147 100644 --- a/src/app/(auth)/verify/expertise/page.tsx +++ b/src/app/(auth)/verify/expertise/page.tsx @@ -5,7 +5,7 @@ import AuthPageLayout, { AuthFormFooter, AuthPageContent, } from "@/components/auth/AuthPageLayout"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import useAxios from "@/hooks/useAxios"; import * as yup from "yup"; import { useRouter } from "next/navigation"; @@ -14,24 +14,42 @@ import toast from "react-hot-toast"; import AuthUserDetails from "@/components/auth/AuthUserDetails"; import ExpertisePicker from "@/components/auth/ExpertisePicker"; import { IExpertise } from "@/types/types"; - -const schema = yup.object().shape({ - subExpertise: yup - .array() - .of(yup.string().required("حداقل یک زیرمهارت را انتخاب کنید")) - .min(1, "حداقل یک زیرمهارت را انتخاب کنید"), - expertise: yup.string().required("انتخاب نوع تخصص الزامی است"), -}); +import { + resolveDisplaySubExpertise, + subExpertiseListIncludes, +} from "@/lib/subExpertiseDisplay"; +import { useTranslation } from "react-i18next"; +import LocalePageShell from "@/components/i18n/LocalePageShell"; function Expertise() { + const { t } = useTranslation("common"); const [expertise, setExpertise] = useState(""); const [expertiseList, setExpertiseList] = useState(null); const [subExpertise, setSubExpertise] = useState([]); + const [displaySubExpertise, setDisplaySubExpertise] = useState( + null + ); const router = useRouter(); const { request, loading } = useAxios(); const mobile = typeof window !== "undefined" ? localStorage.getItem("mobile") : null; + const schema = useMemo( + () => + yup.object().shape({ + subExpertise: yup + .array() + .of(yup.string().required(t("auth.subExpertiseMin"))) + .min(1, t("auth.subExpertiseMin")), + displaySubExpertise: yup + .string() + .nullable() + .required(t("auth.displaySubExpertiseRequired")), + expertise: yup.string().required(t("auth.expertiseRequired")), + }), + [t] + ); + const fetchData = async () => { try { const response = await request<{ expertises: IExpertise[] }>( @@ -48,10 +66,44 @@ function Expertise() { fetchData(); }, []); + const handleSubExpertiseToggle = (value: string) => { + setSubExpertise((prev) => { + const next = prev.includes(value) + ? prev.filter((item) => item !== value) + : [...prev, value]; + + setDisplaySubExpertise((current) => { + if (current === value && !next.includes(value)) { + return next[0] ?? null; + } + if (current && !next.includes(current)) { + return next[0] ?? null; + } + if (!current && next.length === 1) { + return next[0]; + } + return current; + }); + + return next; + }); + }; + const handleCheck = async () => { + const resolvedDisplay = subExpertiseListIncludes( + subExpertise, + displaySubExpertise + ) + ? displaySubExpertise + : resolveDisplaySubExpertise(subExpertise, displaySubExpertise); + try { - await schema.validate({ expertise, subExpertise }); - await sendExpertiseTypeHandler(); + await schema.validate({ + expertise, + subExpertise, + displaySubExpertise: resolvedDisplay, + }); + await sendExpertiseTypeHandler(resolvedDisplay); } catch (validationError: any) { toast.error(validationError.message, { duration: 4000, @@ -59,59 +111,62 @@ function Expertise() { } }; - const sendExpertiseTypeHandler = async () => { + const sendExpertiseTypeHandler = async (resolvedDisplay: string | null) => { try { const data = { mobile, expertise, sub_expertise: subExpertise, + display_sub_expertise: resolvedDisplay, }; await request("POST", "/verify/expertise", data); localStorage.setItem("expertise", expertise); + if (resolvedDisplay) { + localStorage.setItem("display_sub_expertise", resolvedDisplay); + } router.push("/verify/services"); } catch (error: any) { - toast.error(error?.message || "خطای نامشخصی رخ داد."); + toast.error(error?.response?.data?.message || t("auth.unknownError")); } }; return ( - - - تخصص + + + + {t("auth.expertiseTitle")} - + -

در چه زمینه ای تخصص دارید؟

+

{t("auth.expertiseQuestion")}

- { - setExpertise(value); - setSubExpertise([]); - }} - onSubExpertiseToggle={(value) => { - setSubExpertise((prev) => - prev.includes(value) - ? prev.filter((item) => item !== value) - : [...prev, value] - ); - }} - /> -
+ { + setExpertise(value); + setSubExpertise([]); + setDisplaySubExpertise(null); + }} + onSubExpertiseToggle={handleSubExpertiseToggle} + onDisplaySubExpertiseChange={setDisplaySubExpertise} + /> +
- router.push("/verify/services")}> - - ثبت و ادامه - - -
+ router.push("/verify/services")}> + + {t("auth.saveAndContinue")} + + + + ); } diff --git a/src/app/(auth)/verify/gender/page.tsx b/src/app/(auth)/verify/gender/page.tsx index 4c67b61..b699bd0 100644 --- a/src/app/(auth)/verify/gender/page.tsx +++ b/src/app/(auth)/verify/gender/page.tsx @@ -5,7 +5,7 @@ import AuthPageLayout, { AuthFormFooter, AuthPageContent, } from "@/components/auth/AuthPageLayout"; -import React from "react"; +import React, { useMemo } from "react"; import useAxios from "@/hooks/useAxios"; import { useFormik } from "formik"; import * as yup from "yup"; @@ -14,13 +14,11 @@ import AuthNextButton from "@/components/auth/AuthNextButton"; import toast from "react-hot-toast"; import AuthUserDetails from "@/components/auth/AuthUserDetails"; import Image from "next/image"; - -// Validation schema using Yup -const schema = yup.object().shape({ - selectedButton: yup.string().required("جنسیت الزامی است"), -}); +import { useTranslation } from "react-i18next"; +import LocalePageShell from "@/components/i18n/LocalePageShell"; function Gender() { + const { t } = useTranslation("common"); const router = useRouter(); const { request, loading } = useAxios(); const mobile = @@ -28,6 +26,14 @@ function Gender() { const expertise = typeof window !== "undefined" ? localStorage.getItem("expertise") : null; + const schema = useMemo( + () => + yup.object().shape({ + selectedButton: yup.string().required(t("auth.genderRequired")), + }), + [t] + ); + const formik = useFormik({ initialValues: { selectedButton: "", @@ -45,7 +51,7 @@ function Gender() { router.push("/verify/services"); } } catch (err: any) { - toast.error(err?.message || "خطای نامشخصی رخ داد."); + toast.error(err?.response?.data?.message || t("auth.unknownError")); } finally { setSubmitting(false); } @@ -53,70 +59,72 @@ function Gender() { }); return ( - -
- - جنسیت -
- -
- -
-
- formik.setFieldValue("selectedButton", "female")} - className={`mt-5 w-full max-w-[250px] !border-[#FC8EAC] flex items-center justify-center flex-row-reverse gap-2 ${ - formik.values.selectedButton === "female" - ? "btn-modern--selected text-white" - : "" - }`} - > - gender icon - خانم - - formik.setFieldValue("selectedButton", "male")} - className={`mt-5 w-full max-w-[250px] !border-[#3E8BFF] flex items-center justify-center flex-row-reverse gap-2 ${ - formik.values.selectedButton === "male" - ? "!bg-[#3E8BFF] !text-white" - : "" - }`} - > - gender icon - آقا - -
- - {formik.errors.selectedButton && formik.touched.selectedButton && ( -
- {formik.errors.selectedButton} + + + + + {t("auth.genderTitle")} +
+
- )} -
- - router.push("/verify/sizes")}> - - ثبت و ادامه - - - - +
+
+ formik.setFieldValue("selectedButton", "female")} + className={`mt-5 w-full max-w-[250px] !border-[#FC8EAC] flex items-center justify-center flex-row-reverse gap-2 ${ + formik.values.selectedButton === "female" + ? "btn-modern--selected text-white" + : "" + }`} + > + gender icon + {t("auth.female")} + + formik.setFieldValue("selectedButton", "male")} + className={`mt-5 w-full max-w-[250px] !border-[#3E8BFF] flex items-center justify-center flex-row-reverse gap-2 ${ + formik.values.selectedButton === "male" + ? "!bg-[#3E8BFF] !text-white" + : "" + }`} + > + gender icon + {t("auth.male")} + +
+ + {formik.errors.selectedButton && formik.touched.selectedButton && ( +
+ {formik.errors.selectedButton} +
+ )} +
+ + + router.push("/verify/sizes")}> + + {t("auth.saveAndContinue")} + + + + + ); } diff --git a/src/app/(auth)/verify/location/page.tsx b/src/app/(auth)/verify/location/page.tsx index c5bfbdd..0c2f79a 100644 --- a/src/app/(auth)/verify/location/page.tsx +++ b/src/app/(auth)/verify/location/page.tsx @@ -5,7 +5,7 @@ import AuthPageLayout, { AuthFormFooter, AuthPageContent, } from "@/components/auth/AuthPageLayout"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import useAxios from "@/hooks/useAxios"; import { useFormik } from "formik"; import * as yup from "yup"; @@ -16,19 +16,13 @@ import AuthUserDetails from "@/components/auth/AuthUserDetails"; import SelectBox from "@/components/elements/SelectBox"; import { ICity, IProvince } from "@/types/types"; import Map, { GeolocateControl, Marker } from "react-map-gl"; - import "mapbox-gl/dist/mapbox-gl.css"; import Image from "next/image"; - -// Validation schema using Yup -const schema = yup.object().shape({ - markerCoordinate: yup.mixed().required(" انتخاب لوکیشن الزامی است"), - address: yup.string().required(" آدرس الزامی است"), - cityId: yup.string().required(" انتخاب شهر الزامی است"), - stateId: yup.string().required(" انتخاب استان الزامی است"), -}); +import { useTranslation } from "react-i18next"; +import LocalePageShell from "@/components/i18n/LocalePageShell"; function LocationPage() { + const { t } = useTranslation("common"); const router = useRouter(); const { request, loading } = useAxios(); @@ -40,6 +34,17 @@ function LocationPage() { lng: number; } | null>(null); + const schema = useMemo( + () => + yup.object().shape({ + markerCoordinate: yup.mixed().required(t("auth.locationRequired")), + address: yup.string().required(t("auth.addressRequired")), + cityId: yup.string().required(t("auth.cityRequired")), + stateId: yup.string().required(t("auth.provinceRequired")), + }), + [t] + ); + const fetchStates = async () => { try { const response = await request<{ provinces: IProvince[] }>( @@ -52,7 +57,6 @@ function LocationPage() { } }; - // Fetch cities when a province is selected const fetchCities = async (provinceId: string) => { try { const response = await request<{ cities: ICity[] }>( @@ -97,18 +101,17 @@ function LocationPage() { }); router.push("/verify/national-cart"); } catch (err: any) { - toast.error(err?.message || "خطای نامشخصی رخ داد."); + toast.error(err?.response?.data?.message || t("auth.unknownError")); } finally { setSubmitting(false); } }, }); - // Handle province change const handleProvinceChange = (e: React.ChangeEvent) => { const selectedProvinceId = e.target.value; formik.setFieldValue("stateId", selectedProvinceId); - fetchCities(selectedProvinceId); // Fetch cities for the selected province + fetchCities(selectedProvinceId); }; const handleMapClick = (event: any) => { @@ -121,135 +124,137 @@ function LocationPage() { }; return ( - -
- - لوکیشن -
- -
+ + + + + {t("auth.locationTitle")} +
+ +
- - مشخص کنید در کدام شهر قادر به انجام فعالیت هستید - - -
- - - {allStates?.map((item: IProvince) => ( - - ))} - - {formik.touched.stateId && formik.errors.stateId && ( - - {formik.errors.stateId} + + {t("auth.locationActivityHint")} - )} - formik.setFieldValue("cityId", e.target.value)} - > - - {cities?.map((city: ICity) => ( - - ))} - - {formik.touched.cityId && formik.errors.cityId && ( - - {formik.errors.cityId} - - )} - - {formik.touched.address && formik.errors.address && ( diff --git a/src/components/PwaHead.tsx b/src/components/PwaHead.tsx new file mode 100644 index 0000000..36fc35f --- /dev/null +++ b/src/components/PwaHead.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { useEffect } from "react"; +import { useAppLanguage } from "@/contexts/LanguageProvider"; +import { + getPwaLabels, + PWA_VERSION, + toPwaLang, +} from "@/lib/pwaConfig"; + +function upsertLink(rel: string, href: string) { + let link = document.querySelector(`link[rel="${rel}"]`); + if (!link) { + link = document.createElement("link"); + link.rel = rel; + document.head.appendChild(link); + } + link.href = href; +} + +function upsertMeta(name: string, content: string) { + let meta = document.querySelector(`meta[name="${name}"]`); + if (!meta) { + meta = document.createElement("meta"); + meta.name = name; + document.head.appendChild(meta); + } + meta.content = content; +} + +export default function PwaHead() { + const { language, ready } = useAppLanguage(); + + useEffect(() => { + if (!ready) return; + + const pwaLang = toPwaLang(language); + const labels = getPwaLabels(pwaLang); + + upsertLink( + "manifest", + `/manifest.webmanifest?lang=${pwaLang}&v=${PWA_VERSION}` + ); + upsertLink("apple-touch-icon", "/images/icons/apple-touch-icon.png"); + upsertMeta("apple-mobile-web-app-title", labels.short_name); + upsertMeta("application-name", labels.short_name); + }, [language, ready]); + + return null; +} diff --git a/src/components/RegisterSW.tsx b/src/components/RegisterSW.tsx index 9ae627a..e20513b 100644 --- a/src/components/RegisterSW.tsx +++ b/src/components/RegisterSW.tsx @@ -1,17 +1,52 @@ "use client"; + import { useEffect } from "react"; -import { ICON_VERSION } from "@/components/main/BaseUrl"; +import { PWA_VERSION } from "@/lib/pwaConfig"; + +function isIosSafari(): boolean { + if (typeof navigator === "undefined") return false; + const ua = navigator.userAgent; + return /iphone|ipad|ipod/i.test(ua) && !/crios|fxios|edgios/i.test(ua); +} + +async function cleanupStaleServiceWorkers() { + if (!("serviceWorker" in navigator)) return; + + try { + const registrations = await navigator.serviceWorker.getRegistrations(); + await Promise.all( + registrations.map((registration) => registration.unregister()) + ); + } catch { + // ignore + } +} export default function RegisterSW() { useEffect(() => { - if ("serviceWorker" in navigator) { - navigator.serviceWorker - .register(`/service-worker.js?v=${ICON_VERSION}`) - .then(() => { - console.log("Service Worker Registered!"); - }); + if (!("serviceWorker" in navigator)) return; + + const register = async () => { + try { + const registration = await navigator.serviceWorker.register( + `/service-worker.js?v=${PWA_VERSION}`, + { scope: "/" } + ); + await registration.update(); + } catch (error) { + console.error("Service Worker registration failed:", error); + if (isIosSafari()) { + await cleanupStaleServiceWorkers(); + } + } + }; + + if (document.readyState === "complete") { + void register(); + } else { + window.addEventListener("load", () => void register(), { once: true }); } }, []); - return null; // این کامپوننت هیچ UI نداره، فقط Service Worker رو ثبت می‌کنه. + return null; } diff --git a/src/components/ScrollRestorationInit.tsx b/src/components/ScrollRestorationInit.tsx new file mode 100644 index 0000000..74c4c96 --- /dev/null +++ b/src/components/ScrollRestorationInit.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { useEffect } from "react"; +import { initManualScrollRestoration } from "@/lib/listScrollRestoration"; + +export default function ScrollRestorationInit() { + useEffect(() => { + initManualScrollRestoration(); + }, []); + + return null; +} diff --git a/src/components/TabNavigation.tsx b/src/components/TabNavigation.tsx index c63fd50..1e408e1 100644 --- a/src/components/TabNavigation.tsx +++ b/src/components/TabNavigation.tsx @@ -5,13 +5,15 @@ import { useEffect, useState } from "react"; import BoldIcon from "@/components/ui/BoldIcon"; import { cn } from "@/lib/utils"; import { PAGE_SHELL_CLASS } from "@/constants/pageLayout"; +import { readLocalStorage } from "@/lib/safeStorage"; +import { useTranslation } from "react-i18next"; -const tabs = [ - { href: "/", icon: "home-2", label: "خانه" }, - { href: "/academy", icon: "teacher", label: "آموزشگاه" }, - { href: "/projects", icon: "briefcase", label: "پروژه‌ها" }, - { href: "/billboards", icon: "flash-circle", label: "بیلبورد" }, - { href: "/settings", icon: "setting", label: "تنظیمات" }, +const TAB_KEYS = [ + { href: "/", icon: "home-2", labelKey: "tabs.home" }, + { href: "/academy", icon: "teacher", labelKey: "tabs.academy" }, + { href: "/projects", icon: "briefcase", labelKey: "tabs.projects" }, + { href: "/billboards", icon: "flash-circle", labelKey: "tabs.billboards" }, + { href: "/settings", icon: "setting", labelKey: "tabs.settings" }, ] as const; type TabNavigationProps = { @@ -19,6 +21,7 @@ type TabNavigationProps = { }; export default function TabNavigation({ currentPage }: TabNavigationProps) { + const { t } = useTranslation("common"); const [mounted, setMounted] = useState(false); const [isDark, setIsDark] = useState(false); @@ -29,7 +32,7 @@ export default function TabNavigation({ currentPage }: TabNavigationProps) { useEffect(() => { if (typeof window === "undefined") return; const checkTheme = () => { - const theme = localStorage.getItem("theme"); + const theme = readLocalStorage("theme"); const isDarkMode = theme === "dark" || (theme === "system" && @@ -51,7 +54,7 @@ export default function TabNavigation({ currentPage }: TabNavigationProps) { )} >
- {tabs.map((tab) => { + {TAB_KEYS.map((tab) => { const isActive = currentPage === tab.href || (tab.href === "/settings" && currentPage.startsWith("/settings")) || @@ -74,17 +77,17 @@ export default function TabNavigation({ currentPage }: TabNavigationProps) { isActive ? "text-[#ff107d]" : isDark - ? "text-zinc-400" + ? "text-white" : "text-zinc-600" )} /> - {tab.label} + {t(tab.labelKey)} ); diff --git a/src/components/TitleGuardian.tsx b/src/components/TitleGuardian.tsx index e144c2a..c894265 100644 --- a/src/components/TitleGuardian.tsx +++ b/src/components/TitleGuardian.tsx @@ -2,16 +2,17 @@ import { useEffect, useRef } from "react"; import { usePathname } from "next/navigation"; -import { defaultSEOConfig } from "@/config/seoConfig"; - -const DEFAULT_TITLE = - typeof defaultSEOConfig.title === "string" - ? defaultSEOConfig.title - : "مدستاگرام"; +import { useTranslation } from "react-i18next"; export default function TitleGuardian() { + const { t } = useTranslation("common"); const pathname = usePathname(); - const lastValidTitle = useRef(DEFAULT_TITLE); + const defaultTitle = t("header.logoAlt"); + const lastValidTitle = useRef(defaultTitle); + + useEffect(() => { + lastValidTitle.current = defaultTitle; + }, [defaultTitle]); useEffect(() => { if (document.title.trim()) { diff --git a/src/components/academy/AcademyFilter.tsx b/src/components/academy/AcademyFilter.tsx index 9c67498..9aec0ac 100644 --- a/src/components/academy/AcademyFilter.tsx +++ b/src/components/academy/AcademyFilter.tsx @@ -1,11 +1,12 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useMemo } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import toast from "react-hot-toast"; import BoldIcon from "@/components/ui/BoldIcon"; import useAxios from "@/hooks/useAxios"; import { getCreateAcademyPath } from "@/lib/getCreateContentPath"; +import { useTranslation } from "react-i18next"; import AcademyFilterModal, { AcademyFilterValues, } from "./AcademyFilterModal"; @@ -40,20 +41,23 @@ const TOOLBAR_ICON_SIZE = 25; const toolbarIconButtonClass = "inline-flex h-[25px] w-[25px] items-center justify-center shrink-0 p-0 leading-none"; -const sortOptions = [ - { value: "createdAt_desc", label: "جدیدترین" }, - { value: "createdAt_asc", label: "قدیمی‌ترین" }, - { value: "likes_desc", label: "محبوب‌ترین" }, - { value: "price_asc", label: "ارزان‌ترین" }, - { value: "price_desc", label: "گران‌ترین" }, -]; +const SORT_KEYS = [ + "createdAt_desc", + "createdAt_asc", + "likes_desc", + "price_asc", + "price_desc", +] as const; -const courseTypes = [ - { value: "", label: "همه دوره‌ها" }, - { value: "normal", label: "معمولی" }, - { value: "pro", label: "پرو" }, - { value: "free", label: "رایگان" }, -]; +const SORT_LABEL_KEYS: Record<(typeof SORT_KEYS)[number], string> = { + createdAt_desc: "newest", + createdAt_asc: "oldest", + likes_desc: "popular", + price_asc: "cheapest", + price_desc: "priciest", +}; + +const TYPE_KEYS = ["", "normal", "pro", "free"] as const; const emptyFilters: AcademyFilterValues = { search: "", @@ -96,18 +100,45 @@ function buildFinalFilters(filters: AcademyFilterValues) { } export default function AcademyFilter({ onFilterChange }: AcademyFilterProps) { + const { t } = useTranslation("common"); const router = useRouter(); const searchParams = useSearchParams(); + const sortOptions = useMemo( + () => + SORT_KEYS.map((value) => ({ + value, + label: t(`academy.filter.sort.${SORT_LABEL_KEYS[value]}`), + })), + [t] + ); + + const courseTypes = useMemo( + () => + TYPE_KEYS.map((value) => ({ + value, + label: t( + value === "" + ? "academy.filter.types.all" + : `academy.filter.types.${value}` + ), + })), + [t] + ); + const [showFilterModal, setShowFilterModal] = useState(false); const [filters, setFilters] = useState(emptyFilters); - const [categories, setCategories] = useState([ - { value: "", label: "همه دسته‌ها" }, - ]); + const [categories, setCategories] = useState([]); const [categoriesLoading, setCategoriesLoading] = useState(false); const [userType, setUserType] = useState(null); const { request } = useAxios(); + const allCategoriesLabel = t("academy.filter.allCategories"); + + useEffect(() => { + setCategories([{ value: "", label: allCategoriesLabel }]); + }, [allCategoriesLabel]); + useEffect(() => { if (typeof window !== "undefined") { setUserType(localStorage.getItem("usertype")); @@ -134,7 +165,7 @@ export default function AcademyFilter({ onFilterChange }: AcademyFilterProps) { } const formattedCategories: CategoryOption[] = [ - { value: "", label: "همه دسته‌ها" }, + { value: "", label: allCategoriesLabel }, ...categoriesList.map((cat) => ({ value: cat.title, label: cat.title, @@ -145,14 +176,14 @@ export default function AcademyFilter({ onFilterChange }: AcademyFilterProps) { setCategories(formattedCategories); } catch (err) { - console.log("خطا در دریافت دسته‌بندی آکادمی:", err); + console.log("Academy categories fetch error:", err); } finally { setCategoriesLoading(false); } }; fetchAcademyCategories(); - }, []); + }, [allCategoriesLabel, request]); useEffect(() => { setFilters({ @@ -199,7 +230,7 @@ export default function AcademyFilter({ onFilterChange }: AcademyFilterProps) { const handleCreateAcademy = () => { if (!userType) { - toast.error("لطفا ابتدا در سایت وارد شوید یا ثبت نام کنید."); + toast.error(t("academy.loginRequired")); return; } router.push(getCreateAcademyPath()); @@ -215,7 +246,7 @@ export default function AcademyFilter({ onFilterChange }: AcademyFilterProps) { - - )} -
+ {about_us.length <= maxLength ? ( + about_us + ) : ( + <> + {expanded ? about_us : about_us.slice(0, maxLength) + "..."} + + + )} +
); } diff --git a/src/components/billboards/Profile/AdProfileHeadRowTwo.tsx b/src/components/billboards/Profile/AdProfileHeadRowTwo.tsx index ec898ef..ba36894 100644 --- a/src/components/billboards/Profile/AdProfileHeadRowTwo.tsx +++ b/src/components/billboards/Profile/AdProfileHeadRowTwo.tsx @@ -25,7 +25,7 @@ function AdProfileHeadRowTwo({ width={22} height={22} alt={"verify icon"} - src={`/images/icons/star1.svg`} + src={`/images/icons/star1.png`} />
@@ -36,7 +36,7 @@ function AdProfileHeadRowTwo({ width={22} height={22} alt={"verify icon"} - src={`/images/icons/medal-star.svg`} + src={`/images/icons/medal-star.png`} />
diff --git a/src/components/chart-area-interactive.tsx b/src/components/chart-area-interactive.tsx index d55e2fa..0850e69 100644 --- a/src/components/chart-area-interactive.tsx +++ b/src/components/chart-area-interactive.tsx @@ -29,6 +29,7 @@ import { import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import useAxios from "@/hooks/useAxios"; import toast from "react-hot-toast"; +import { useTranslation } from "react-i18next"; interface Payment { _id: string; @@ -58,11 +59,11 @@ interface ChartDataItem { const chartConfig = { amount: { - label: "مبلغ فروش", + label: "amount", color: "#ff107d", }, count: { - label: "تعداد فروش", + label: "count", color: "#10b981", }, } satisfies ChartConfig; @@ -98,6 +99,7 @@ const generateDateRange = (startDate: Date, endDate: Date): string[] => { }; export function ChartAreaInteractive() { + const { t } = useTranslation("common"); const isMobile = useIsMobile(); const [timeRange, setTimeRange] = useState("90d"); const [isLoading, setIsLoading] = useState(false); @@ -252,7 +254,7 @@ export function ChartAreaInteractive() { } } catch (err) { console.log("get error:", err); - toast.error("خطا در دریافت اطلاعات"); + toast.error(t("settings.academy.chart.fetchError")); setChartData([]); } finally { setIsLoading(false); @@ -273,8 +275,8 @@ export function ChartAreaInteractive() { return ( - مقدار فروش - در حال بارگذاری داده‌ها... + {t("settings.academy.chart.salesAmount")} + {t("settings.academy.chart.loadingData")}
@@ -295,7 +297,7 @@ export function ChartAreaInteractive() {
- آمار فروش آکادمی + {t("settings.academy.chart.salesStats")}
@@ -306,9 +308,9 @@ export function ChartAreaInteractive() { variant="outline" className="hidden *:data-[slot=toggle-group-item]:!px-4 @[767px]/card:flex" > - ۹۰ روز - ۳۰ روز - ۷ روز + {t("settings.academy.chart.days90")} + {t("settings.academy.chart.days30")} + {t("settings.academy.chart.days7")} @@ -350,8 +352,8 @@ export function ChartAreaInteractive() { d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /> -

هیچ داده‌ای برای نمایش وجود ندارد

-

هنوز فروشی ثبت نشده است

+

{t("settings.academy.chart.noData")}

+

{t("settings.academy.chart.noSalesYet")}

) : ( @@ -410,11 +412,11 @@ export function ChartAreaInteractive() { if (name === "amount") { return [ `${Number(value).toLocaleString("fa-IR")}`, - "مبلغ فروش", + t("settings.academy.chart.salesAmountLabel"), ]; } if (name === "count") { - return [`${value}`, "تعداد فروش"]; + return [`${value}`, t("settings.academy.chart.salesCountLabel")]; } return [value, name]; }} diff --git a/src/components/chat/AddChatRoomMembersModal.tsx b/src/components/chat/AddChatRoomMembersModal.tsx new file mode 100644 index 0000000..5b566b4 --- /dev/null +++ b/src/components/chat/AddChatRoomMembersModal.tsx @@ -0,0 +1,105 @@ +"use client"; + +import React, { useState } from "react"; +import Modal from "@/components/elements/Modal"; +import RoundedButton from "@/components/elements/RoundedButton"; +import useAxios from "@/hooks/useAxios"; +import ChatRoomMemberPicker, { + ChatRoomMember, +} from "@/components/chat/ChatRoomMemberPicker"; +import toast from "react-hot-toast"; +import { useTranslation } from "react-i18next"; + +type Props = { + open: boolean; + onClose: () => void; + onAdded: () => void; + roomId: string; + existingMemberIds?: string[]; +}; + +export default function AddChatRoomMembersModal({ + open, + onClose, + onAdded, + roomId, + existingMemberIds = [], +}: Props) { + const { t } = useTranslation("common"); + const { request } = useAxios(); + const [selected, setSelected] = useState([]); + const [submitting, setSubmitting] = useState(false); + + const handleClose = () => { + setSelected([]); + onClose(); + }; + + const submit = async () => { + const memberIds = selected + .map((member) => member._id) + .filter((id) => !existingMemberIds.includes(id)); + + if (memberIds.length === 0) { + toast.error(t("chats.toast.selectNewMember")); + return; + } + + setSubmitting(true); + try { + await request("POST", `/chat-rooms/${roomId}/members`, { + member_ids: memberIds, + }); + toast.success(t("chats.toast.membersAdded")); + setSelected([]); + onAdded(); + onClose(); + } catch (err: unknown) { + const message = + (err as { response?: { data?: { error?: string; message?: string } } }) + ?.response?.data?.error || + (err as { response?: { data?: { message?: string } } })?.response?.data + ?.message; + toast.error(message || t("chats.toast.addMembersFailed")); + } finally { + setSubmitting(false); + } + }; + + return ( + +
+
+

+ {t("chats.modal.addMembersTitle")} +

+ +
+ void submit()} + className="h-10 w-full max-w-[200px] text-sm" + > + {submitting ? t("chats.actions.addingMembers") : t("chats.actions.add")} + + + {t("chats.actions.cancel")} + +
+
+
+
+ ); +} diff --git a/src/components/chat/AttachmentMenu.tsx b/src/components/chat/AttachmentMenu.tsx index 1888e1a..3081c4b 100644 --- a/src/components/chat/AttachmentMenu.tsx +++ b/src/components/chat/AttachmentMenu.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef } from "react"; import { motion, AnimatePresence } from "framer-motion"; import ChatBoldIcon, { type ChatBoldIconName } from "./ChatBoldIcon"; +import { useTranslation } from "react-i18next"; export type AttachmentType = "image" | "video" | "file" | "location" | "camera"; @@ -13,17 +14,17 @@ interface AttachmentMenuProps { anchorRef?: React.RefObject; } -const items: { +const attachmentConfig: { type: AttachmentType; - label: string; + labelKey: 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" }, + { type: "camera", labelKey: "chats.attachment.camera", icon: "camera", color: "bg-red-500" }, + { type: "image", labelKey: "chats.attachment.gallery", icon: "gallery", color: "bg-purple-500" }, + { type: "video", labelKey: "chats.attachment.video", icon: "video", color: "bg-blue-500" }, + { type: "file", labelKey: "chats.attachment.file", icon: "file", color: "bg-orange-500" }, + { type: "location", labelKey: "chats.attachment.location", icon: "location", color: "bg-green-500" }, ]; export default function AttachmentMenu({ @@ -32,6 +33,7 @@ export default function AttachmentMenu({ onSelect, anchorRef, }: AttachmentMenuProps) { + const { t } = useTranslation("common"); const menuRef = useRef(null); useEffect(() => { @@ -61,7 +63,7 @@ export default function AttachmentMenu({ transition={{ duration: 0.25, ease: [0.25, 0.46, 0.45, 0.94] }} className="glass-panel absolute bottom-full left-0 z-[60] mb-3 min-w-[200px] rounded-2xl p-2 shadow-xl" > - {items.map((item, i) => ( + {attachmentConfig.map((item, i) => ( - {item.label} + {t(item.labelKey)} ))} diff --git a/src/components/chat/ChatActionBar.tsx b/src/components/chat/ChatActionBar.tsx index 3da259a..5d742a6 100644 --- a/src/components/chat/ChatActionBar.tsx +++ b/src/components/chat/ChatActionBar.tsx @@ -3,18 +3,30 @@ import { motion } from "framer-motion"; import BoldIcon from "@/components/ui/BoldIcon"; import ChatBoldIcon from "./ChatBoldIcon"; +import { QUICK_REACTIONS } from "@/lib/chat/reactions"; +import { useTranslation } from "react-i18next"; interface ChatActionBarProps { onReply: () => void; - onForward: () => void; + onForward?: () => void; + onCopy?: () => void; onCancel: () => void; + onReact?: (emoji: string) => void; + hideForward?: boolean; + disableCopy?: boolean; } export default function ChatActionBar({ onReply, onForward, + onCopy, onCancel, + onReact, + hideForward = false, + disableCopy = false, }: ChatActionBarProps) { + const { t } = useTranslation("common"); + return ( -
- - - +
+ {onReact ? ( +
+ {QUICK_REACTIONS.map((emoji) => ( + + ))} +
+ ) : null} +
+ + + {onCopy ? ( + + ) : null} + {!hideForward && onForward ? ( + + ) : null} +
); diff --git a/src/components/chat/ChatDeleteBar.tsx b/src/components/chat/ChatDeleteBar.tsx index 90d7ed3..feac5ce 100644 --- a/src/components/chat/ChatDeleteBar.tsx +++ b/src/components/chat/ChatDeleteBar.tsx @@ -2,6 +2,7 @@ import { motion } from "framer-motion"; import BoldIcon from "@/components/ui/BoldIcon"; +import { useTranslation } from "react-i18next"; interface ChatDeleteBarProps { selectedCount: number; @@ -16,6 +17,8 @@ export default function ChatDeleteBar({ onCancel, loading = false, }: ChatDeleteBarProps) { + const { t } = useTranslation("common"); + return ( - انصراف + {t("chats.actions.cancel")}
diff --git a/src/components/chat/ChatGuideModal.tsx b/src/components/chat/ChatGuideModal.tsx index 696a2ad..1d92cd2 100644 --- a/src/components/chat/ChatGuideModal.tsx +++ b/src/components/chat/ChatGuideModal.tsx @@ -2,31 +2,34 @@ import Modal from "../elements/Modal"; import ChatBoldIcon from "./ChatBoldIcon"; +import { useTranslation } from "react-i18next"; interface ChatGuideModalProps { open: boolean; onClose: () => void; } -const ITEMS = [ - { - title: "حذف", - body: "امکان حذف پیام‌ها حتی از روی سرور فقط تا ۲۴ ساعت گذشته", - icon: "delete" as const, - }, - { - title: "پیام یک‌بارمصرف", - body: "حذف پیام بعد از نمایش برای دریافت‌کننده", - icon: "eye" as const, - }, - { - title: "پیام زماندار", - body: "حذف پیام در زمان مشخص بعد از نمایش برای دریافت‌کننده", - icon: "timer" as const, - }, -]; - export default function ChatGuideModal({ open, onClose }: ChatGuideModalProps) { + const { t } = useTranslation("common"); + + const items = [ + { + title: t("chats.guide.deleteTitle"), + body: t("chats.guide.deleteBody"), + icon: "delete" as const, + }, + { + title: t("chats.guide.viewOnceTitle"), + body: t("chats.guide.viewOnceBody"), + icon: "eye" as const, + }, + { + title: t("chats.guide.timedTitle"), + body: t("chats.guide.timedBody"), + icon: "timer" as const, + }, + ]; + if (!open) return null; return ( @@ -34,20 +37,20 @@ export default function ChatGuideModal({ open, onClose }: ChatGuideModalProps) {

- راهنمای چت + {t("chats.guide.title")}

    - {ITEMS.map((item) => ( + {items.map((item) => (
  • void; deleteMode?: boolean; }) { + const { t } = useTranslation("common"); const router = useRouter(); const [searchOpen, setSearchOpen] = useState(false); - const isOnline = user?.last_online === "آنلاین"; + const onlineLabel = t("chats.online"); + const offlineLabel = t("chats.offline"); + const isOnline = isUserOnline(user?.last_online, onlineLabel); const displayName = [user?.first_name, user?.last_name].filter(Boolean).join(" ").trim() || user?.user_name || @@ -55,7 +60,7 @@ function ChatHeader({ type="button" onClick={() => router.back()} className={chatHeaderCircleBtn} - aria-label="بازگشت" + aria-label={t("chats.actions.back")} > @@ -75,7 +80,7 @@ function ChatHeader({ ? "chat-header-status-dot--online" : "chat-header-status-dot--offline" )} - title={isOnline ? "آنلاین" : "آفلاین"} + title={isOnline ? onlineLabel : offlineLabel} aria-hidden />
    @@ -84,9 +89,9 @@ function ChatHeader({ {isOnline ? ( - آنلاین + {onlineLabel} ) : ( - user?.last_online || "آفلاین" + resolveOnlineLabel(user?.last_online, onlineLabel, offlineLabel) )}
    @@ -115,7 +120,7 @@ function ChatHeader({ {deleteMode && (

    - فقط پیام‌های خودتان در ۲۴ ساعت گذشته قابل حذف هستند + {t("chats.deleteHint")}

    )} @@ -131,7 +136,7 @@ function ChatHeader({ type="search" value={searchQuery} onChange={(e) => onSearchChange?.(e.target.value)} - placeholder="جستجو در پیام‌ها…" + placeholder={t("chats.searchInMessages")} className="glass-chat-input mt-2 w-full rounded-full px-4 py-2 text-sm outline-none" /> diff --git a/src/components/chat/ChatHeaderMenu.tsx b/src/components/chat/ChatHeaderMenu.tsx index e26aa41..99b84ab 100644 --- a/src/components/chat/ChatHeaderMenu.tsx +++ b/src/components/chat/ChatHeaderMenu.tsx @@ -8,6 +8,7 @@ import { motion, AnimatePresence } from "framer-motion"; import { User } from "@/types/types"; import useAxios from "@/hooks/useAxios"; import { cn } from "@/lib/utils"; +import { useTranslation } from "react-i18next"; interface ChatHeaderMenuProps { user: User | undefined; @@ -26,6 +27,7 @@ export default function ChatHeaderMenu({ buttonClassName, onStartDeleteMode, }: ChatHeaderMenuProps) { + const { t } = useTranslation("common"); const [open, setOpen] = useState(false); const [guideOpen, setGuideOpen] = useState(false); const [isBlocked, setIsBlocked] = useState(Boolean(user?.is_blocked)); @@ -55,7 +57,7 @@ export default function ChatHeaderMenu({ type="button" onClick={() => setOpen((o) => !o)} className={cn(buttonClassName, "relative")} - aria-label="منو" + aria-label={t("chats.aria.menu")} > @@ -82,7 +84,7 @@ export default function ChatHeaderMenu({ }} > - جستجو در چت + {t("chats.header.searchInChat")} {isBlocked ? ( ) : ( )} diff --git a/src/components/chat/ChatMessageCard.tsx b/src/components/chat/ChatMessageCard.tsx index 074fe06..e68e779 100644 --- a/src/components/chat/ChatMessageCard.tsx +++ b/src/components/chat/ChatMessageCard.tsx @@ -20,6 +20,7 @@ import { useSwipeToReply } from "@/hooks/useSwipeToReply"; import BoldIcon from "@/components/ui/BoldIcon"; import ChatBoldIcon from "./ChatBoldIcon"; import { cn } from "@/lib/utils"; +import { groupReactions } from "@/lib/chat/reactions"; import { formatCountdown, getRemainingSeconds, @@ -29,9 +30,11 @@ import { useViewOnceMedia } from "@/hooks/useViewOnceMedia"; import ViewOnceMediaBubble from "./ViewOnceMediaBubble"; import { isViewOnceMediaType } from "@/lib/chat/viewOnce"; import toast from "react-hot-toast"; +import { useTranslation } from "react-i18next"; import { type BubbleGroupPosition, getBubbleRadius, + getMediaBubbleRadius, getMessageRowSpacing, } from "@/lib/chat/messageGrouping"; import SharedPostBubble, { parseSharedPost } from "./SharedPostBubble"; @@ -41,14 +44,16 @@ import SharedStoryBubble, { } from "./SharedStoryBubble"; function ViewOnceBadge() { + const { t } = useTranslation("common"); return ( - ۱ + {t("chats.viewOnceBadge")} ); } function TimedBadge({ expiresAt }: { expiresAt: string }) { + const { t } = useTranslation("common"); const [remaining, setRemaining] = useState(() => getRemainingSeconds(expiresAt) ); @@ -65,7 +70,7 @@ function TimedBadge({ expiresAt }: { expiresAt: string }) { return ( {formatCountdown(remaining)} @@ -99,6 +104,7 @@ export interface ChatMessage { userName: string; displayName: string; }; + reactions?: Array<{ userId: string; emoji: string }>; } interface ChatMessageCardProps { @@ -122,6 +128,76 @@ interface ChatMessageCardProps { const LONG_PRESS_MS = 2000; +function PreviewOverlay({ + preview, + fileType, + onClose, +}: { + preview: string; + fileType?: string; + onClose: () => void; +}) { + const { t } = useTranslation("common"); + useEffect(() => { + window.history.pushState({ chatMediaPreview: true }, ""); + const onPopState = () => onClose(); + window.addEventListener("popstate", onPopState); + return () => window.removeEventListener("popstate", onPopState); + }, [onClose]); + + const requestClose = () => { + if (window.history.state?.chatMediaPreview) { + window.history.back(); + return; + } + onClose(); + }; + + return ( + + + e.stopPropagation()} + > + {fileType === "video" ? ( + + + ); +} + function parseForwarded(content: string): ChatMessage["forwardedFrom"] | null { try { const line = content.split("\n")[0]; @@ -156,6 +232,7 @@ const ChatMessageCard = ({ highlighted = false, registerRef, }: ChatMessageCardProps) => { + const { t } = useTranslation("common"); const currentUserId = String(userDetail?._id ?? ""); const isSender = !!currentUserId && String(message.senderId) === currentUserId; @@ -181,13 +258,13 @@ const ChatMessageCard = ({ onSwipeReply?.(message); }, [message, onSwipeReply]); - const { pressing, shouldBlockClick, cancelPress, handlers: longPressHandlers } = + const { pressing, shouldBlockClick, handlers: longPressHandlers } = useLongPress(handleOpenActions, { delay: LONG_PRESS_MS }); const { dragProps, replyOpacity, replyScale } = useSwipeToReply( handleSwipeReply, swipeEnabled, - isSender ? "left" : "right" + "left" ); const swipeDragStart = @@ -217,7 +294,6 @@ const ChatMessageCard = ({ : longPressHandlers; const handleDragStart = () => { - cancelPress(); swipeDragStart?.(); }; @@ -230,6 +306,7 @@ const ChatMessageCard = ({ : ""; const locationData = parseLocationContent(message.content); const hasLocation = message.fileType === "location" || !!locationData; + const reactionGroups = groupReactions(message.reactions); if ( !message.content && @@ -262,6 +339,7 @@ const ChatMessageCard = ({ : message.content?.replace(/^\{.*\}\n?/, "").trim() || ""; const showTextBubble = + !sharedStory && textContent && fileType !== "file" && fileType !== "location" && @@ -269,6 +347,7 @@ const ChatMessageCard = ({ const emojiOnly = showTextBubble && !message.file && isEmojiOnly(textContent); const borderRadius = getBubbleRadius(isSender, groupPosition); + const mediaBorderRadius = getMediaBubbleRadius(); const rowSpacing = getMessageRowSpacing(groupPosition, isGroupedWithPrev); const timeLabel = formatBubbleTime(message.createdAt); const isPending = message.status === "pending"; @@ -288,7 +367,7 @@ const ChatMessageCard = ({ try { await completeViewOnce(message._id); } catch { - toast.error("حذف پیام یک‌بارمصرف ناموفق بود"); + toast.error(t("chats.toast.viewOnceDeleteFailed")); } }, [completeViewOnce, message._id]); @@ -304,7 +383,7 @@ const ChatMessageCard = ({ setViewOncePreview({ url, type: "image" }); } } catch { - toast.error("باز کردن پیام یک‌بارمصرف ممکن نیست"); + toast.error(t("chats.toast.viewOnceOpenFailed")); } }, [message._id, openViewOnce]); @@ -314,7 +393,7 @@ const ChatMessageCard = ({ {message.expiresAt ? ( ) : message.selfDestructSeconds ? ( - + ) : null} @@ -337,7 +416,7 @@ const ChatMessageCard = ({ {message.expiresAt ? ( ) : message.selfDestructSeconds ? ( - + ) : null} @@ -377,7 +456,7 @@ const ChatMessageCard = ({ }} className={cn( "relative flex w-full gap-2", - isSender ? "justify-end pl-10" : "justify-start pr-10", + isSender ? "justify-end" : "justify-start", rowSpacing, deleteMode && !isSender && "opacity-60", deleteMode && isSender && !canSelectForDelete && "opacity-40", @@ -396,7 +475,11 @@ const ChatMessageCard = ({ ? "border-[#0095f6] bg-[#0095f6] text-white" : "border-neutral-400 bg-transparent" )} - aria-label={selectedForDelete ? "لغو انتخاب" : "انتخاب پیام"} + aria-label={ + selectedForDelete + ? t("chats.aria.deselectMessage") + : t("chats.aria.selectMessage") + } > {selectedForDelete && } @@ -483,8 +566,14 @@ const ChatMessageCard = ({ ) : fileUrl ? (
    { + if (shouldBlockClick()) { + e.preventDefault(); + e.stopPropagation(); + return; + } e.stopPropagation(); openPreview(fileUrl); }} @@ -497,6 +586,7 @@ const ChatMessageCard = ({ alt="" className="object-cover" sizes="208px" + style={{ borderRadius: mediaBorderRadius }} />
    @@ -519,14 +609,24 @@ const ChatMessageCard = ({ ) : fileUrl ? (
    { + if (shouldBlockClick()) { + e.preventDefault(); + e.stopPropagation(); + return; + } e.stopPropagation(); openPreview(fileUrl); }} > {isViewOnce && isSender && } - +
    ) : null)} @@ -573,10 +673,14 @@ const ChatMessageCard = ({ ) : null)} {fileType === "file" && fileUrl && ( -
    +
    @@ -584,7 +688,11 @@ const ChatMessageCard = ({ )} {fileType === "location" && locationData && ( -
    +
    @@ -614,7 +722,7 @@ const ChatMessageCard = ({ )} {showTextBubble && !message.file && emojiOnly && ( -

    {textContent}

    +

    {textContent}

    )} {showTextBubble && !message.file && !emojiOnly && ( @@ -625,10 +733,28 @@ const ChatMessageCard = ({ )} style={bubbleStyle} > -

    {textContent}

    +

    {textContent}

    )} + {reactionGroups.length > 0 ? ( +
    + {reactionGroups.map(({ emoji, count }) => ( + + {emoji} + {count > 1 ? ` ${count}` : ""} + + ))} +
    + ) : null}
    @@ -687,46 +813,11 @@ const ChatMessageCard = ({ )} {preview && ( - setPreview(null)} - > - - e.stopPropagation()} - > - {fileType === "video" ? ( - - + setPreview(null)} + /> )} diff --git a/src/components/chat/ChatMessageList.tsx b/src/components/chat/ChatMessageList.tsx index 5168a00..1b2d752 100644 --- a/src/components/chat/ChatMessageList.tsx +++ b/src/components/chat/ChatMessageList.tsx @@ -7,19 +7,21 @@ import { joinChatRoom, joinUserRoom, releaseChatSocket, + subscribeChatSocketConnection, } from "@/lib/chat/socketClient"; import { apiClient } from "@/hooks/useAxios"; import { chatThreadQueryKey } from "@/lib/chat/queryKeys"; import { - appendMessageToThreadCache, + mergeFetchedThreadPage, normalizeThreadId, upsertMessageInThreadCache, + type ChatThreadData, } from "@/lib/chat/threadCache"; import ChatMessageCard, { ChatMessage } from "./ChatMessageCard"; import ChatDateSeparator from "./ChatDateSeparator"; import { groupMessagesByDate } from "@/lib/chat/groupMessagesByDate"; -import { dedupeChatMessages, filterResolvedPendingMessages, findPendingMatchForServer } from "@/lib/chat/dedupeMessages"; -import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; +import { dedupeChatMessages, filterResolvedPendingMessages } from "@/lib/chat/dedupeMessages"; +import { useInfiniteQuery, useQueryClient, keepPreviousData } from "@tanstack/react-query"; import { User } from "@/types/types"; import { ChatMessagesSkeleton } from "@/components/ui/ChatSkeletons"; import TypingIndicator from "./TypingIndicator"; @@ -33,6 +35,7 @@ import { } from "@/lib/chat/messageGrouping"; import { cn } from "@/lib/utils"; import toast from "react-hot-toast"; +import { useTranslation } from "react-i18next"; function belongsToThread( msg: ChatMessage, @@ -65,7 +68,7 @@ interface ChatMessageListProps { getStableKey?: (id: string) => string; onExpirePending?: (ids: string[]) => void; onIncomingMessage?: (message: ChatMessage) => void; - onPruneResolvedPending?: (ids: string[]) => void; + onScrollToMessageReady?: (scrollToMessage: (targetId: string) => void) => void; /** فضای اضافه وقتی بنر پاسخ/زماندار بالای input است */ extraBottomRem?: number; } @@ -89,9 +92,10 @@ const ChatMessageList = ({ getStableKey = (id) => id, onExpirePending, onIncomingMessage, - onPruneResolvedPending, + onScrollToMessageReady, extraBottomRem = 0, }: ChatMessageListProps) => { + const { t, i18n } = useTranslation("common"); const scrollRef = useRef(null); const prevScrollHeight = useRef(0); const prevPendingCountRef = useRef(0); @@ -101,16 +105,16 @@ const ChatMessageList = ({ const seenMessageKeysRef = useRef(new Set()); const prevMessageCountRef = useRef(0); const messageRefs = useRef(new Map()); + const seenForDestructRef = useRef(new Set()); const highlightTimerRef = useRef | null>(null); const [highlightedMessageId, setHighlightedMessageId] = useState( null ); + const [socketConnected, setSocketConnected] = useState(true); const onIncomingMessageRef = useRef(onIncomingMessage); const onTypingChangeRef = useRef(onTypingChange); - const onPruneResolvedPendingRef = useRef(onPruneResolvedPending); onIncomingMessageRef.current = onIncomingMessage; onTypingChangeRef.current = onTypingChange; - onPruneResolvedPendingRef.current = onPruneResolvedPending; const senderId = normalizeThreadId(userDetail?._id ?? getStoredUserId()); const receiverId = normalizeThreadId( @@ -119,6 +123,10 @@ const ChatMessageList = ({ const threadReady = Boolean(senderId && receiverId); const queryKey = chatThreadQueryKey(senderId, receiverId); + useEffect(() => { + return subscribeChatSocketConnection(setSocketConnected); + }, []); + const { data, fetchNextPage, @@ -140,7 +148,14 @@ const ChatMessageList = ({ `/chat?senderId=${sid}&receiverId=${rid}&page=${pageParam}&limit=50`, { baseURL: getApiBaseUrl() } ); - const msgs = (response.data.messages || []) as ChatMessage[]; + const fetched = (response.data.messages || []) as ChatMessage[]; + const cachedData = queryClient.getQueryData( + chatThreadQueryKey(sid, rid) + ); + const msgs = + pageParam === 1 + ? mergeFetchedThreadPage(fetched, cachedData?.pages?.[0]?.messages) + : fetched; return { messages: msgs, nextPage: @@ -158,6 +173,10 @@ const ChatMessageList = ({ gcTime: 10 * 60 * 1000, refetchOnMount: false, refetchOnWindowFocus: false, + placeholderData: keepPreviousData, + refetchInterval: + socketConnected || pendingMessages.length > 0 ? false : 4000, + refetchIntervalInBackground: false, }); const hasCachedMessages = Boolean( @@ -241,26 +260,57 @@ const ChatMessageList = ({ socket.on("newMessage", onNewMessage); - socket.on( - "messageStatusUpdate", - ({ messageId, status }: { messageId: string; status: ChatMessage["status"] }) => { - 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.map((m) => - m._id === messageId ? { ...m, status } : m - ), - })), - }; - } - ); - } - ); + const onMessageStatusUpdate = ({ + messageId, + status, + }: { + messageId: string; + status: ChatMessage["status"]; + }) => { + 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.map((m) => + m._id === messageId ? { ...m, status } : m + ), + })), + }; + } + ); + }; + + socket.on("messageStatusUpdate", onMessageStatusUpdate); + + const onMessageReaction = ({ + messageId, + reactions, + }: { + messageId: string; + reactions: ChatMessage["reactions"]; + }) => { + 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.map((m) => + m._id === messageId ? { ...m, reactions } : m + ), + })), + }; + } + ); + }; + + socket.on("messageReaction", onMessageReaction); let typingHideTimer: ReturnType | null = null; socket.on("userTyping", ({ userId }: { userId: string }) => { @@ -278,62 +328,61 @@ 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)), - })), - }; - } - ); - } - ); + const onMessagesDeleted = ({ 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)), + })), + }; + } + ); + }; - socket.on( - "messageSelfDestructStarted", - ({ - messageId, - expiresAt, - }: { - messageId: string; - expiresAt: string; - }) => { - if (!messageId || !expiresAt) return; - 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.map((m) => - m._id === messageId ? { ...m, expiresAt } : m - ), - })), - }; - } - ); - } - ); + socket.on("messagesDeleted", onMessagesDeleted); + + const onSelfDestructStarted = ({ + messageId, + expiresAt, + }: { + messageId: string; + expiresAt: string; + }) => { + if (!messageId || !expiresAt) return; + 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.map((m) => + m._id === messageId ? { ...m, expiresAt } : m + ), + })), + }; + } + ); + }; + + socket.on("messageSelfDestructStarted", onSelfDestructStarted); return () => { if (typingHideTimer) clearTimeout(typingHideTimer); socket.off("connect", onConnect); socket.off("newMessage", onNewMessage); - socket.off("messageStatusUpdate"); - socket.off("messagesDeleted"); - socket.off("messageSelfDestructStarted"); + socket.off("messageStatusUpdate", onMessageStatusUpdate); + socket.off("messageReaction", onMessageReaction); + socket.off("messagesDeleted", onMessagesDeleted); + socket.off("messageSelfDestructStarted", onSelfDestructStarted); socket.off("userTyping"); socket.off("userStoppedTyping"); releaseChatSocket(); @@ -355,22 +404,10 @@ const ChatMessageList = ({ ); }, [data, pendingMessages, searchQuery]); - useEffect(() => { - if (!pendingMessages.length || !data?.pages?.length) return; - const server = data.pages.flatMap((page) => page.messages); - const resolvedIds = pendingMessages - .filter((p) => - server.some((s) => s._id === p._id || findPendingMatchForServer(s, [p])) - ) - .map((p) => p._id); - if (resolvedIds.length) { - onPruneResolvedPendingRef.current?.(resolvedIds); - } - }, [data, pendingMessages]); - + const chatLang = i18n.language === "en" ? "en" : "fa"; const grouped = useMemo( - () => groupMessagesByDate(allMessages), - [allMessages] + () => groupMessagesByDate(allMessages, chatLang), + [allMessages, chatLang] ); useEffect(() => { @@ -493,11 +530,46 @@ const ChatMessageList = ({ return; } - toast.error("پیام اصلی یافت نشد"); + toast.error(t("chats.toast.originalMessageNotFound")); }, - [allMessages, fetchNextPage, hasNextPage] + [allMessages, fetchNextPage, hasNextPage, t] ); + useEffect(() => { + onScrollToMessageReady?.(scrollToMessage); + }, [onScrollToMessageReady, scrollToMessage]); + + useEffect(() => { + const container = scrollRef.current; + if (!container || !threadReady) return; + + const socket = acquireChatSocket(); + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (!entry.isIntersecting) return; + const id = entry.target.getAttribute("data-message-id"); + if (!id || seenForDestructRef.current.has(id)) return; + + const msg = allMessages.find((m) => m._id === id); + if (!msg || msg._id.startsWith("temp")) return; + if (String(msg.senderId) === String(senderId)) return; + + seenForDestructRef.current.add(id); + socket.emit("messageSeen", { + messageId: msg._id, + senderId: msg.senderId, + receiverId: senderId, + }); + }); + }, + { root: container, threshold: 0.55 } + ); + + messageRefs.current.forEach((el) => observer.observe(el)); + return () => observer.disconnect(); + }, [allMessages, senderId, threadReady]); + useEffect(() => { return () => { if (highlightTimerRef.current) clearTimeout(highlightTimerRef.current); @@ -508,7 +580,7 @@ const ChatMessageList = ({
    ) : isError ? (
    -

    بارگذاری پیام‌ها ناموفق بود.

    +

    {t("chats.loadMessagesFailed")}

    ) : grouped.length === 0 ? (

    - هنوز پیامی رد و بدل نشده است. + {t("chats.messagesEmpty")}

    ) : (
    diff --git a/src/components/chat/ChatRoomMemberPicker.tsx b/src/components/chat/ChatRoomMemberPicker.tsx new file mode 100644 index 0000000..bb2faf8 --- /dev/null +++ b/src/components/chat/ChatRoomMemberPicker.tsx @@ -0,0 +1,168 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Image from "next/image"; +import useAxios from "@/hooks/useAxios"; +import { buildStorageUrl } from "@/components/main/BaseUrl"; +import BoldIcon from "@/components/ui/BoldIcon"; +import toast from "react-hot-toast"; +import { useTranslation } from "react-i18next"; + +export type ChatRoomMember = { + _id: string; + user_name: string; + first_name?: string; + last_name?: string; + profile_image?: string; +}; + +type Props = { + selected: ChatRoomMember[]; + onChange: (users: ChatRoomMember[]) => void; + max?: number; +}; + +function avatarSrc(profileImage?: string) { + if (!profileImage) return null; + return profileImage.startsWith("http") + ? profileImage + : buildStorageUrl(profileImage); +} + +function displayName(user: ChatRoomMember) { + return ( + [user.first_name, user.last_name].filter(Boolean).join(" ") || user.user_name + ); +} + +export default function ChatRoomMemberPicker({ + selected, + onChange, + max = 20, +}: Props) { + const { t } = useTranslation("common"); + const { request } = useAxios(); + const [query, setQuery] = useState(""); + const [results, setResults] = useState([]); + const [searching, setSearching] = useState(false); + + useEffect(() => { + if (query.trim().length < 2) { + setResults([]); + return; + } + + const timer = window.setTimeout(async () => { + setSearching(true); + try { + const res = await request<{ result?: ChatRoomMember[] }>( + "GET", + `/search-web?search=${encodeURIComponent(query.trim())}&type=user&limit=20`, + null, + { noToast: true } + ); + const list = res?.result || []; + setResults(list.filter((u) => !selected.some((s) => s._id === u._id))); + } catch { + setResults([]); + } finally { + setSearching(false); + } + }, 300); + + return () => window.clearTimeout(timer); + }, [query, request, selected]); + + const addUser = (user: ChatRoomMember) => { + if (selected.some((item) => item._id === user._id)) return; + if (selected.length >= max) { + toast.error(t("chats.toast.maxUsers", { max })); + return; + } + onChange([...selected, user]); + setQuery(""); + setResults([]); + }; + + const removeUser = (userId: string) => { + onChange(selected.filter((item) => item._id !== userId)); + }; + + return ( +
    +
    + + setQuery(e.target.value)} + placeholder={t("chats.placeholder.searchUsername")} + className="w-full rounded-full border border-neutral-200 bg-white py-2.5 text-sm ltr:pl-10 ltr:pr-4 rtl:pl-4 rtl:pr-10 dark:border-neutral-700 dark:bg-neutral-900" + /> +
    + + {searching ? ( +

    {t("chats.searching")}

    + ) : null} + + {results.length > 0 ? ( +
      + {results.map((user) => ( +
    • + +
    • + ))} +
    + ) : null} + + {selected.length > 0 ? ( +
    + {selected.map((user) => ( + + @{user.user_name} + + + ))} +
    + ) : null} +
    + ); +} diff --git a/src/components/chat/CreateChatRoomModal.tsx b/src/components/chat/CreateChatRoomModal.tsx new file mode 100644 index 0000000..9b1d2a5 --- /dev/null +++ b/src/components/chat/CreateChatRoomModal.tsx @@ -0,0 +1,313 @@ +"use client"; + +import React, { useEffect, useRef, useState } from "react"; +import Image from "next/image"; +import Modal from "@/components/elements/Modal"; +import RoundedButton from "@/components/elements/RoundedButton"; +import RoundedInput from "@/components/elements/RoundedInput"; +import SelectBox from "@/components/elements/SelectBox"; +import useAxios from "@/hooks/useAxios"; +import { ICity, IExpertise, IProvince } from "@/types/types"; +import toast from "react-hot-toast"; +import BoldIcon from "@/components/ui/BoldIcon"; +import ChatRoomMemberPicker, { + ChatRoomMember, +} from "@/components/chat/ChatRoomMemberPicker"; +import { cn } from "@/lib/utils"; +import { useTranslation } from "react-i18next"; + +type Props = { + open: boolean; + onClose: () => void; + onCreated: () => void; +}; + +type RoomVisibility = "public" | "private"; + +export default function CreateChatRoomModal({ open, onClose, onCreated }: Props) { + const { t } = useTranslation("common"); + const { request } = useAxios(); + const fileRef = useRef(null); + const [title, setTitle] = useState(""); + const [visibility, setVisibility] = useState("public"); + const [provinceId, setProvinceId] = useState(""); + const [cityId, setCityId] = useState(""); + const [expertise, setExpertise] = useState(""); + const [invitedMembers, setInvitedMembers] = useState([]); + const [imageFile, setImageFile] = useState(null); + const [imagePreview, setImagePreview] = useState(null); + const [provinces, setProvinces] = useState([]); + const [cities, setCities] = useState([]); + const [expertiseList, setExpertiseList] = useState([]); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + if (!open) return; + setTitle(""); + setVisibility("public"); + setProvinceId(""); + setCityId(""); + setExpertise(""); + setInvitedMembers([]); + setImageFile(null); + setImagePreview(null); + + void request<{ provinces: IProvince[] }>("GET", "/provinces", null, { + noToast: true, + }).then((res) => setProvinces(res?.provinces ?? [])); + + void request<{ expertises: IExpertise[] }>("GET", "/expertise", null, { + noToast: true, + }).then((res) => setExpertiseList(res?.expertises ?? [])); + }, [open, request]); + + useEffect(() => { + if (!provinceId) { + setCities([]); + setCityId(""); + return; + } + void request<{ cities: ICity[] }>( + "GET", + `/cities/${provinceId}`, + null, + { noToast: true } + ).then((res) => setCities(res?.cities ?? [])); + }, [provinceId, request]); + + const onPickImage = (file: File | null) => { + setImageFile(file); + if (!file) { + setImagePreview(null); + return; + } + const reader = new FileReader(); + reader.onload = () => setImagePreview(String(reader.result)); + reader.readAsDataURL(file); + }; + + const submit = async () => { + if (!title.trim()) { + toast.error(t("chats.toast.roomNameRequired")); + return; + } + if (visibility === "private" && invitedMembers.length === 0) { + toast.error(t("chats.toast.privateRoomMembersRequired")); + return; + } + + setSubmitting(true); + try { + const form = new FormData(); + form.append("title", title.trim()); + form.append("visibility", visibility); + if (visibility === "public") { + if (provinceId) form.append("province_id", provinceId); + if (cityId) form.append("city_id", cityId); + if (expertise) form.append("expertise", expertise); + } else { + form.append( + "member_ids", + JSON.stringify(invitedMembers.map((member) => member._id)) + ); + } + if (imageFile) form.append("room_image", imageFile); + + await request("POST", "/chat-rooms", form); + toast.success(t("chats.toast.roomCreated")); + onCreated(); + onClose(); + } catch (err: unknown) { + const message = + (err as { response?: { data?: { error?: string; message?: string } } }) + ?.response?.data?.error || + (err as { response?: { data?: { message?: string } } })?.response?.data + ?.message; + toast.error(message || t("chats.toast.createRoomFailed")); + } finally { + setSubmitting(false); + } + }; + + return ( + +
    +
    + + {t("chats.modal.createRoomTitle")} + + + + onPickImage(e.target.files?.[0] ?? null)} + /> + +
    +

    + {t("chats.modal.roomName")} +

    + setTitle(e.target.value)} + placeholder={t("chats.placeholder.roomName")} + className="text-center text-sm" + /> +
    + +
    +

    + {t("chats.modal.roomType")} +

    +
    + + +
    +
    + + {visibility === "private" ? ( +
    +

    + {t("chats.modal.inviteUsers")} +

    + +

    + {t("chats.modal.privateRoomHint")} +

    +
    + ) : ( + <> +
    +

    + {t("chats.modal.provinceOptional")} +

    + { + setProvinceId(e.target.value); + setCityId(""); + }} + className="text-sm" + > + + {provinces.map((item) => ( + + ))} + +
    + +
    +

    + {t("chats.modal.cityOptional")} +

    + setCityId(e.target.value)} + disabled={!provinceId} + className="text-sm" + > + + {cities.map((item) => ( + + ))} + +
    + +
    +

    + {t("chats.modal.expertiseOptional")} +

    + setExpertise(e.target.value)} + className="text-sm" + > + + {expertiseList.map((item) => ( + + ))} + +
    + +

    + {t("chats.modal.publicRoomHint")} +

    + + )} + +
    + void submit()} + className="h-10 w-full max-w-[200px] text-sm" + > + {submitting ? t("chats.actions.creatingRoom") : t("chats.actions.createRoom")} + + + {t("chats.actions.cancel")} + +
    +
    +
    +
    + ); +} diff --git a/src/components/chat/FileMessageBubble.tsx b/src/components/chat/FileMessageBubble.tsx index 6056c3a..9a55bdc 100644 --- a/src/components/chat/FileMessageBubble.tsx +++ b/src/components/chat/FileMessageBubble.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import BoldIcon from "@/components/ui/BoldIcon"; import IOSSpinner from "@/components/ui/IOSSpinner"; import { cn } from "@/lib/utils"; +import { useTranslation } from "react-i18next"; interface FileMessageBubbleProps { url: string; @@ -11,12 +12,13 @@ interface FileMessageBubbleProps { isOutgoing?: boolean; } -/** Telegram-style file message with upload/download progress */ export default function FileMessageBubble({ url, - fileName = "فایل پیوست", + fileName, isOutgoing = false, }: FileMessageBubbleProps) { + const { t } = useTranslation("common"); + const resolvedFileName = fileName ?? t("chats.fileAttachment"); const [progress, setProgress] = useState(0); const [downloading, setDownloading] = useState(false); const isPending = url.startsWith("blob:"); @@ -38,7 +40,7 @@ export default function FileMessageBubble({ if (xhr.status === 200) { const a = document.createElement("a"); a.href = URL.createObjectURL(xhr.response); - a.download = fileName; + a.download = resolvedFileName; a.click(); setProgress(100); } @@ -77,7 +79,7 @@ export default function FileMessageBubble({ )}
    -

    {fileName}

    +

    {resolvedFileName}

    {(isPending || downloading) && (
    )} - {isPending ? "در حال آپلود…" : downloading ? `${progress}%` : "فایل"} + {isPending + ? t("chats.fileStatus.uploading") + : downloading + ? `${progress}%` + : t("chats.file")}
    {!isPending && ( @@ -95,7 +101,7 @@ export default function FileMessageBubble({ type="button" onClick={handleDownload} 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="دانلود" + aria-label={t("chats.aria.download")} > diff --git a/src/components/chat/ForwardMessageModal.tsx b/src/components/chat/ForwardMessageModal.tsx index e0f22a0..d746f9d 100644 --- a/src/components/chat/ForwardMessageModal.tsx +++ b/src/components/chat/ForwardMessageModal.tsx @@ -7,6 +7,7 @@ import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl"; import useAxios from "@/hooks/useAxios"; import toast from "react-hot-toast"; import IOSSpinner from "@/components/ui/IOSSpinner"; +import { useTranslation } from "react-i18next"; export interface ChatUserItem { _id: string; @@ -30,7 +31,6 @@ interface ForwardMessageModalProps { }; }; currentUserId: string; - /** جلوگیری از دوباره‌نمایش در چت فعلی هنگام فوروارد به گیرندهٔ همین مکالمه */ currentReceiverId?: string; } @@ -39,8 +39,8 @@ export default function ForwardMessageModal({ onClose, message, currentUserId, - currentReceiverId, }: ForwardMessageModalProps) { + const { t } = useTranslation("common"); const { request } = useAxios(); const [users, setUsers] = useState([]); const [selected, setSelected] = useState>(new Set()); @@ -74,7 +74,7 @@ export default function ForwardMessageModal({ const next = new Set(prev); if (next.has(id)) next.delete(id); else if (next.size < 50) next.add(id); - else toast.error("حداکثر ۵۰ کاربر"); + else toast.error(t("chats.toast.maxForwardUsers")); return next; }); }; @@ -86,7 +86,8 @@ export default function ForwardMessageModal({ ? JSON.stringify({ forwardedFrom: message.forwardedFrom }) : ""; const content = - (fwdMeta ? fwdMeta + "\n" : "") + (message.content || "پیام فوروارد"); + (fwdMeta ? fwdMeta + "\n" : "") + + (message.content || t("chats.forwardedMessage")); try { const targets = Array.from(selected); @@ -100,11 +101,11 @@ export default function ForwardMessageModal({ }) ) ); - toast.success(`پیام به ${selected.size} نفر ارسال شد`); + toast.success(t("chats.toast.forwardSent", { count: selected.size })); onClose(); setSelected(new Set()); } catch { - toast.error("خطا در فوروارد"); + toast.error(t("chats.toast.forwardFailed")); } finally { setSending(false); } @@ -128,7 +129,7 @@ export default function ForwardMessageModal({ className="glass-modal-panel max-h-[70vh] w-full max-w-md overflow-hidden rounded-t-3xl sm:rounded-3xl" >
    - فوروارد به ({selected.size}/50) + {t("chats.modal.forwardTo", { selected: selected.size })}
    {loading ? ( @@ -169,7 +170,7 @@ export default function ForwardMessageModal({ onClick={onClose} className="flex-1 rounded-full py-2.5 text-sm text-neutral-500" > - انصراف + {t("chats.actions.cancel")}
    diff --git a/src/components/chat/ImageModal.tsx b/src/components/chat/ImageModal.tsx index 3dcc75c..7667be2 100644 --- a/src/components/chat/ImageModal.tsx +++ b/src/components/chat/ImageModal.tsx @@ -1,6 +1,9 @@ +"use client"; + import Image from "next/image"; import RoundedButton from "@/components/elements/RoundedButton"; import Modal from "../elements/Modal"; +import { useTranslation } from "react-i18next"; interface ImageModalProps { isOpen: boolean; @@ -15,6 +18,8 @@ const ImageModal = ({ cancelImage, confirmImage, }: ImageModalProps) => { + const { t } = useTranslation("common"); + if (!isOpen || !selectedImage) return null; return ( @@ -32,13 +37,13 @@ const ImageModal = ({ onClick={confirmImage} className="text-green-500 border-green-500 p-2 rounded-2xl w-20" > - ارسال + {t("chats.actions.send")} - لغو + {t("chats.actions.cancel")}
    diff --git a/src/components/chat/LocationMessageBubble.tsx b/src/components/chat/LocationMessageBubble.tsx index 247d04c..9c2aa6c 100644 --- a/src/components/chat/LocationMessageBubble.tsx +++ b/src/components/chat/LocationMessageBubble.tsx @@ -1,6 +1,7 @@ "use client"; import BoldIcon from "@/components/ui/BoldIcon"; +import { useTranslation } from "react-i18next"; export interface LocationData { lat: number; @@ -29,6 +30,7 @@ export default function LocationMessageBubble({ location, isOutgoing = false, }: LocationMessageBubbleProps) { + const { t } = useTranslation("common"); const mapsUrl = `https://www.google.com/maps?q=${location.lat},${location.lng}`; const embedUrl = `https://maps.google.com/maps?q=${location.lat},${location.lng}&z=15&output=embed`; @@ -40,7 +42,7 @@ export default function LocationMessageBubble({ >