diff --git a/modstagram-next.zip b/modstagram-next.zip new file mode 100644 index 0000000..79689cd Binary files /dev/null and b/modstagram-next.zip differ diff --git a/next-sitemap.config.js b/next-sitemap.config.js index d2e75fd..08fbdea 100644 --- a/next-sitemap.config.js +++ b/next-sitemap.config.js @@ -1,218 +1,13 @@ -// next-sitemap.config.js -/** @type {import('next-sitemap').IConfig} */ +/** + * DISABLED — sitemap داینامیک از App Router سرو می‌شود: + * /sitemap.xml + * /sitemaps/static.xml | filters.xml | posts/N.xml | ... + * + * اسکریپت postbuild دیگر next-sitemap را اجرا نمی‌کند. + * این فایل فقط برای مرجع نگه داشته شده است. + */ module.exports = { - siteUrl: 'https://modstagram.com', // دامنه اصلی سایت شما - generateRobotsTxt: true, // تولید فایل robots.txt - sitemapSize: 7000, // حداکثر تعداد URL در هر فایل سایت‌مپ - changefreq: 'daily', // فرکانس پیش‌فرض تغییر صفحات - priority: 0.7, // اولویت پیش‌فرض صفحات - - // تابع transform برای فیلتر کردن و تنظیمات خاص هر مسیر - transform: async (config, path) => { - // مسیرهایی که نباید در سایت‌مپ باشند و نیازی به ایندکس شدن ندارند - const excludedPaths = [ - '/settings', - '/auth', - '/new-post', - '/offer', - '/private/', // مسیرهای خصوصی - // صفحات مربوط به پرداخت و ثبت‌نام/ورود - '/projects/payment/success', - '/projects/payment/failed', - '/billboards/payment/success', - '/billboards/payment/failed', - '/forget-password', - '/change-passowrd', - '/login-with-username', - '/login', - '/register-otp', - '/verify-otp', - '/register', - '/forget-password-otp', - '/register/password', - '/register/fullname', - '/register/usertype', - '/register/username', - '/verify/avatar', - '/verify/auth', - '/verify/colors', - '/verify/confirm', - '/verify/expertise', - '/verify/gender', - '/verify/national-cart', - '/verify/location', - '/verify/public-relations', - '/verify/cooperation-type', - '/verify/services', - '/verify/sizes', - '/new-project', // اگر این یک صفحه فرم داخلی است که نیازی به ایندکس شدن ندارد - '/billboards/new', // اگر این یک صفحه فرم داخلی است که نیازی به ایندکس شدن ندارد - '/search', // اگر صفحه جستجو محتوای قابل ایندکس ندارد - ]; - - // اگر مسیر در لیست excludedPaths باشد، آن را از سایت‌مپ حذف کن - if (excludedPaths.some(p => path.startsWith(p))) { - return null; - } - - let pageChangefreq = config.changefreq; - let pagePriority = config.priority; - - // تنظیمات خاص برای مسیرهای مختلف - if (path.startsWith('/users/')) { // مسیر برای پروفایل‌های کاربران - pageChangefreq = 'weekly'; - pagePriority = 0.6; - } - if (path.startsWith('/projects/') || path.startsWith('/billboards/')) { - pageChangefreq = 'daily'; - pagePriority = 0.8; - } - if (path === '/') { // صفحه اصلی - pageChangefreq = 'daily'; - pagePriority = 1.0; - } - if (path === '/about-us') { // صفحه درباره ما - pageChangefreq = 'monthly'; - pagePriority = 0.8; - } - - return { - loc: path, // آدرس کامل صفحه - changefreq: pageChangefreq, // فرکانس تغییر صفحه - priority: pagePriority, // اولویت صفحه - lastmod: config.autoLastmod ? new Date().toISOString() : undefined, // تاریخ آخرین تغییر - alternateRefs: config.alternateRefs ?? [], - }; - }, - - // تابع additionalPaths برای اضافه کردن مسیرهای داینامیک از API - additionalPaths: async (config) => { - const paths = []; - const BASE_URL_API = "https://api.modstagram.ir/api/v1"; - paths.push({ - loc: '/', - changefreq: 'daily', - priority: 1.0 - }); - paths.push({ - loc: '/projects', - changefreq: 'daily', - priority: 0.9 - }); - paths.push({ - loc: '/billboards', - changefreq: 'daily', // یا weekly بسته به میزان تغییر محتوا - priority: 0.9 - }); - try { - // واکشی داده‌های پروژه‌ها - const projectsRes = await fetch(`${BASE_URL_API}/projects?limit=500`); - const projectsData = await projectsRes.json(); - if (projectsData && Array.isArray(projectsData.projects)) { - projectsData.projects.forEach((project) => { - if (project.id && project.title) { - paths.push({ - // مسیر پروژه‌ها (با استفاده از تمپلیت لیتِرال صحیح) - loc: `/projects/${project.id}/${encodeURIComponent(project.title)}`, - changefreq: 'daily', - priority: 0.8 - }); - } - }); - } - - // واکشی داده‌های بیلبوردها - const billboardsRes = await fetch(`${BASE_URL_API}/advertising/web?limit=500`); - const billboardsData = await billboardsRes.json(); - if (billboardsData && Array.isArray(billboardsData.advertisings)) { - billboardsData.advertisings.forEach((billboard) => { - if (billboard._id && billboard.title) { - paths.push({ - // مسیر بیلبوردها (با استفاده از تمپلیت لیتِرال صحیح) - loc: `/billboards/${billboard._id}/${encodeURIComponent(billboard.title)}`, - changefreq: 'daily', - priority: 0.8 - }); - } - }); - } - - // واکشی داده‌های کاربران (مدل‌ها) - const usersRes = await fetch(`${BASE_URL_API}/users/web?limit=500`); - const usersData = await usersRes.json(); - if (usersData && Array.isArray(usersData.users)) { - usersData.users.forEach((user) => { - if (user.user_name) { - paths.push({ - // مسیر پروفایل کاربران (مسیر در Next.js شما /users/[username] است) - loc: `/users/${encodeURIComponent(user.user_name)}`, - changefreq: 'weekly', - priority: 0.6 - }); - } - }); - } - - } catch (error) { - // لاگ کردن خطا در صورت مشکل در واکشی داده‌ها از API - console.error('Error fetching additional paths for sitemap:', error); - } - - return paths; // بازگرداندن آرایه‌ای از مسیرهای داینامیک - }, - - // تنظیمات مربوط به فایل robots.txt - robotsTxtOptions: { - policies: [ - { - userAgent: '*', // اعمال سیاست‌ها برای همه ربات‌ها - allow: '/', // اجازه دسترسی به کل سایت - disallow: [ - '/api/', // مسیرهای API - '/_next/', // فایل‌های داخلی Next.js - '/settings', // صفحات تنظیمات - '/auth', // صفحات احراز هویت (ورود، ثبت‌نام) - '/private/', // مسیرهای خصوصی موجود - '/new-post', // صفحات ایجاد پست جدید - '/offer', // صفحات پیشنهاد - // اضافه کردن مسیرهای پرداخت و ثبت‌نام/ورود به disallow - '/projects/payment/success', - '/projects/payment/failed', - '/billboards/payment/success', - '/billboards/payment/failed', - '/forget-password', - '/change-passowrd', - '/login-with-username', - '/login', - '/register-otp', - '/verify-otp', - '/register', - '/forget-password-otp', - '/register/password', - '/register/fullname', - '/register/usertype', - '/register/username', - '/verify/avatar', - '/verify/auth', - '/verify/colors', - '/verify/confirm', - '/verify/expertise', - '/verify/gender', - '/verify/national-cart', - '/verify/location', - '/verify/public-relations', - '/verify/cooperation-type', - '/verify/services', - '/verify/sizes', - '/new-project', - '/billboards/new', - '/search', // اگر این صفحه برای ایندکس شدن نامناسب است - ], - }, - ], - // اضافه کردن آدرس سایت‌مپ به robots.txt - additionalSitemaps: [ - 'https://modstagram.com/sitemap.xml', - ], - }, -}; \ No newline at end of file + siteUrl: "https://modstagram.com", + generateRobotsTxt: false, + generateIndexSitemap: false, +}; diff --git a/next.config.ts b/next.config.ts index 90f7a8a..498178a 100644 --- a/next.config.ts +++ b/next.config.ts @@ -35,6 +35,12 @@ const nextConfig = { port: '', pathname: '/**', }, + { + protocol: 'https', + hostname: 'api.qrserver.com', + port: '', + pathname: '/**', + }, { protocol: 'http', hostname: 'localhost', diff --git a/package.json b/package.json index a2eed67..dc67ee2 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,6 @@ "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", "generate:pwa-icons": "node scripts/generate-pwa-icons.mjs" }, "dependencies": { diff --git a/public/images/icons/radar.png b/public/images/icons/radar.png new file mode 100644 index 0000000..1ae4b88 Binary files /dev/null and b/public/images/icons/radar.png differ diff --git a/public/images/icons/slider-horizontal.png b/public/images/icons/slider-horizontal.png new file mode 100644 index 0000000..dfff375 Binary files /dev/null and b/public/images/icons/slider-horizontal.png differ diff --git a/public/robots.txt b/public/robots.txt index af12d80..b5d3f20 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -1,47 +1,32 @@ -# * +# Generated to mirror src/app/robots.ts — Next.js serves app/robots.ts preferentially. User-agent: * Allow: / Disallow: /api/ Disallow: /_next/ Disallow: /settings +Disallow: /settings/ Disallow: /auth Disallow: /private/ Disallow: /new-post -Disallow: /offer -Disallow: /projects/payment/success -Disallow: /projects/payment/failed -Disallow: /billboards/payment/success -Disallow: /billboards/payment/failed -Disallow: /forget-password -Disallow: /change-passowrd -Disallow: /login-with-username -Disallow: /login -Disallow: /register-otp -Disallow: /verify-otp -Disallow: /register -Disallow: /forget-password-otp -Disallow: /register/password -Disallow: /register/fullname -Disallow: /register/usertype -Disallow: /register/username -Disallow: /verify/avatar -Disallow: /verify/auth -Disallow: /verify/colors -Disallow: /verify/confirm -Disallow: /verify/expertise -Disallow: /verify/gender -Disallow: /verify/national-cart -Disallow: /verify/location -Disallow: /verify/public-relations -Disallow: /verify/cooperation-type -Disallow: /verify/services -Disallow: /verify/sizes Disallow: /new-project Disallow: /billboards/new Disallow: /search +Disallow: /verify/ +Disallow: /register +Disallow: /register/ +Disallow: /login +Disallow: /login-with-username +Disallow: /login-2fa +Disallow: /verify-otp +Disallow: /forget-password +Disallow: /forget-password-otp +Disallow: /change-passowrd +Disallow: /offer +Disallow: /offer/ +Disallow: /*/payment/ +Disallow: /projects/payment/ +Disallow: /billboards/payment/ +Disallow: /academy/payment/ -# Host Host: https://modstagram.com - -# Sitemaps Sitemap: https://modstagram.com/sitemap.xml diff --git a/public/sitemap-0.xml b/public/sitemap-0.xml deleted file mode 100644 index 0f23262..0000000 --- a/public/sitemap-0.xml +++ /dev/null @@ -1,50 +0,0 @@ - - -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 -https://modstagram.com/billboards/6a0d848b12cadb57c40ecd20/%D8%B9%DA%A9%D8%A7%D8%B3%DB%8C%20%D9%88%20%D9%85%D8%AF%D9%84%DB%8C%D9%86%DA%AF%20%DA%A9%D8%AA%DB%8C%20%D9%85%D8%B7%D9%87%D8%B1%DB%8Cdaily0.8 -https://modstagram.com/billboards/6a0d6dd112cadb57c40ebba3/%D9%85%D8%AC%D9%85%D9%88%D8%B9%D9%87%20%D9%84%D9%88%DA%A9%D8%B3%E2%80%8C%20%D8%B2%DB%8C%D8%A8%D8%A7%DB%8C%DB%8C%20%7C%20%D8%A7%D9%84%D9%87%D9%87%20%D8%AC%D8%B9%D9%81%D8%B1%DB%8Cdaily0.8 -https://modstagram.com/billboards/69a1cf1c1cd06873681b4271/%D9%86%D8%AF%D8%A7%20%D8%AF%D9%87%D9%82%D8%A7%D9%86%20%7C%20%D9%85%DB%8C%DA%A9%D8%A7%D9%BE%20%D8%B9%D8%B1%D9%88%D8%B3%20%D8%A7%D8%B5%D9%81%D9%87%D8%A7%D9%86daily0.8 -https://modstagram.com/billboards/69a1c3411cd06873681b39d6/%D9%81%D8%B1%D8%B2%D8%A7%D9%86%D9%87%20%D8%AC%D9%86%D8%AA%DB%8C%D8%8C%D8%B3%D8%A7%D9%84%D9%86%20%D8%B9%D8%B1%D9%88%D8%B3%D8%8C%D8%A7%D9%93%D9%85%D9%88%D8%B2%D8%B4%20%D9%85%DB%8C%DA%A9%D8%A7%D9%BE%20%D8%A7%D8%B5%D9%81%D9%87%D8%A7%D9%86daily0.8 -https://modstagram.com/billboards/69a1bfd91cd06873681b360d/%D8%A2%D9%85%D9%88%D8%B2%D8%B4%DA%AF%D8%A7%D9%87%20%D9%88%20%D8%B3%D8%A7%D9%84%D9%86%20%D8%B2%DB%8C%D8%A8%D8%A7%DB%8C%DB%8C%20%D9%81%D9%88%D9%82%20%D8%AA%D8%AE%D8%B5%D8%B5%DB%8C%20%DA%A9%D9%85%D9%86%D8%AFdaily0.8 -https://modstagram.com/billboards/69a1b6d11cd06873681b26f3/%D8%B2%DB%8C%D8%A8%D8%A7%DB%8C%DB%8C%20%D9%85%D8%AD%D8%B4%D8%B1daily0.8 -https://modstagram.com/billboards/69a1afa01cd06873681b246d/%D8%B3%D8%A7%D9%84%D9%86%20%D8%B2%DB%8C%D8%A8%D8%A7%DB%8C%DB%8C%20%D8%B1%D8%A7%D9%85%DB%8C%D9%86%20%D9%81%D8%B1daily0.8 -https://modstagram.com/billboards/69a0206a83d4cf28444356d6/%D8%A2%D9%85%D9%88%D8%B2%D8%B4%DA%AF%D8%A7%D9%87%20%DB%8C%D8%B9%D9%82%D9%88%D8%A8%20%D9%84%D9%88%20(%D8%AD%DB%8C%D8%B1%D8%A7)daily0.8 -https://modstagram.com/billboards/69a0078d83d4cf2844434af8/%D9%81%D8%A7%D8%B7%D9%85%D9%87%20%D9%85%D8%B1%D9%88%D8%AA%DB%8C%7C%D9%85%DB%8C%DA%A9%D8%A7%D9%BE%20%D8%A7%D8%B1%D8%AF%D8%A8%DB%8C%D9%84%7C%D9%85%DB%8C%DA%A9%D8%A7%D9%BE%20%D8%B9%D8%B1%D9%88%D8%B3%7C%D8%A2%D9%85%D9%88%D8%B2%D8%B4%7C%D8%B3%D8%A7%D9%84%D9%86%20%D8%B2%DB%8C%D8%A8%D8%A7%DB%8C%DB%8C%7Cdaily0.8 -https://modstagram.com/billboards/69a0053783d4cf2844434615/%D8%B3%D8%A7%D9%84%D9%86%20%D8%B2%DB%8C%D8%A8%D8%A7%DB%8C%DB%8C%20%D8%A7%D8%B1%D8%AF%D8%A8%DB%8C%D9%84%20%7C%D9%85%D8%AF%DB%8C%D8%B3%D8%A7%20%D8%A7%D8%B5%D9%88%D9%84%DB%8Cdaily0.8 -https://modstagram.com/billboards/699ffbef83d4cf2844433523/%D8%B3%D8%A7%D9%84%D9%86%20%D8%B2%DB%8C%D8%A8%D8%A7%DB%8C%DB%8C%20%D8%B2%D9%87%D8%B1%D8%A7%20%D9%86%D9%88%DB%8C%D8%AFdaily0.8 -https://modstagram.com/billboards/699ff8d383d4cf28444330f2/%D8%B3%D8%A7%D9%84%D9%86%20%D8%B2%DB%8C%D8%A8%D8%A7%DB%8C%DB%8C%20%D8%B4%D8%A7%D9%86%D9%84daily0.8 -https://modstagram.com/billboards/699ff68b83d4cf2844432964/%F0%9F%91%91%D8%B3%D8%A7%D9%84%D9%86%20%D8%B2%DB%8C%D8%A8%D8%A7%DB%8C%DB%8C%20%D8%AA%D9%88%D8%AA%20%D9%81%D8%B1%D9%86%DA%AF%DB%8C%20%D8%A7%D8%B1%D8%AF%D8%A8%DB%8C%D9%84%F0%9F%91%91daily0.8 -https://modstagram.com/billboards/699ff2eb83d4cf2844432403/%D8%B9%D8%B1%D9%88%D8%B3%20%D8%B3%D8%B1%D8%A7%DB%8C%20%D8%A7%D9%84%20%D8%A2%DB%8C%20%7C%20%D8%A7%D8%B9%D8%B8%D9%85%20%D8%AC%D9%84%DB%8C%D9%84%20%D8%B2%D8%A7%D8%AF%D9%87%20%7C%20%D8%A7%D8%B1%D8%AF%D8%A8%DB%8C%D9%84daily0.8 -https://modstagram.com/billboards/699eca0c9ed07500ab2e0e17/%D8%B3%D8%A7%D9%84%D9%86%20%D8%B2%DB%8C%D8%A8%D8%A7%DB%8C%DB%8C%20%D9%BE%D8%AF%DB%8C%D8%AF%D9%87%20%D8%B4%D9%87%D8%B1(%D9%85%DB%8C%D9%86%D8%A7%20%D9%85%D9%87%D8%AC%D9%88%D8%B1)daily0.8 -https://modstagram.com/billboards/699ec6b59ed07500ab2e0856/%F0%9F%92%87%F0%9F%8F%BC%E2%80%8D%E2%99%80%EF%B8%8F%D9%85%D8%AC%D9%85%D9%88%D8%B9%D9%87%20%D8%B2%DB%8C%D8%A8%D8%A7%DB%8C%DB%8C%20%D8%AA%D8%A7%D8%AC%F0%9F%91%91daily0.8 -https://modstagram.com/billboards/699ebafb9ed07500ab2e01ab/%D9%85%D8%AC%D9%85%D9%88%D8%B9%D9%87%20%D8%B3%DB%8C%D9%85%D8%A7%20%D8%AD%D9%85%D8%AF%D8%A7%D9%84%D9%84%D9%87%DB%8C%7C%D8%AA%D8%A8%D8%B1%DB%8C%D8%B2daily0.8 -https://modstagram.com/billboards/699eaf959ed07500ab2df952/%D8%B3%D8%A7%D9%84%D9%86%20%D8%B2%DB%8C%D8%A8%D8%A7%DB%8C%DB%8C%20%DA%A9%D8%A7%D8%B1%DB%8C%D8%B2%D9%85%D8%A7daily0.8 -https://modstagram.com/billboards/699eaa049ed07500ab2df3a4/%D8%B3%D8%A7%D9%84%D9%86%20%D8%B2%DB%8C%D8%A8%D8%A7%DB%8C%DB%8C%20%D9%85%D9%88%D8%AA%D8%A7%D8%A8daily0.8 -https://modstagram.com/billboards/69942dd60711bf017b2069e2/%F0%9F%AA%84%D8%B3%D8%A7%D9%84%D9%86%20%D8%B2%DB%8C%D8%A8%D8%A7%DB%8C%DB%8C%20%D8%B4%D9%87%D8%B1%D8%B2%D8%A7%D8%AF%F0%9F%AA%84%7C%20shahrzaad%20beautydaily0.8 -https://modstagram.com/billboards/694fad80d93828c638fdf612/Hamid%20Eskandari%20%D9%81%DB%8C%D9%84%D9%85%D8%A8%D8%B1%D8%AF%D8%A7%D8%B1%DB%8C%20%D9%88%20%D8%B9%DA%A9%D8%A7%D8%B3%DB%8Cdaily0.8 -https://modstagram.com/billboards/693447f0a468f7683cca4e55/%E2%9A%9C%EF%B8%8F%20%D8%A8%D8%A7%D9%86%D9%88%20%D8%B1%D8%B6%D8%A7%DB%8C%DB%8C%20%7C%20vip%20salon%20%E2%9A%9C%EF%B8%8Fdaily0.8 -https://modstagram.com/billboards/6934170662e821773ad7a8f3/%D8%B3%D8%A7%D9%84%D9%86%20%D8%B2%DB%8C%D8%A8%D8%A7%DB%8C%DB%8C%20%D9%85%D8%B1%D8%AC%D8%A7%D9%86%F0%9F%91%B8daily0.8 -https://modstagram.com/billboards/69340f3e62e821773ad7a555/%D8%B3%D8%A7%D9%84%D9%86%20%D8%B2%DB%8C%D8%A8%D8%A7%DB%8C%DB%8C%20%D9%88%20%D9%85%D8%B1%DA%A9%D8%B2%20%D8%A7%DA%A9%D8%B3%D8%AA%D9%86%D8%B4%D9%86%20%DA%AF%D9%84daily0.8 -https://modstagram.com/billboards/691c3ff8e24a347e31d006a6/%D8%B3%D8%A7%D9%84%D9%86%20%D8%B2%DB%8C%D8%A8%D8%A7%DB%8C%DB%8C%20vip%20%D8%B1%DB%8C%D8%AD%D8%A7%D9%86daily0.8 -https://modstagram.com/billboards/691c3a88e24a347e31d001ea/%D8%B9%D9%85%D8%A7%D8%B1%D8%AA%20%D8%B2%DB%8C%D8%A8%D8%A7%DB%8C%DB%8C%20%DA%86%D9%8E%D9%85%20%7C%20Chambeautysalondaily0.8 -https://modstagram.com/billboards/691c33eee24a347e31cffc27/%D9%81%D8%B1%D9%86%D8%A7%D8%B2%20%D8%B5%D9%81%D8%B1%DB%8C%20%7C%D9%85%DB%8C%DA%A9%D8%A7%D9%BE%20%D8%A7%D8%B1%D8%AA%DB%8C%D8%B3%D8%AA%20%D8%B9%D8%B1%D9%88%D8%B3%7C%D9%85%D8%AF%D8%B1%D8%B3%20%D8%AA%D8%AE%D8%B5%D8%B5%DB%8Cdaily0.8 -https://modstagram.com/billboards/691af3c9e24a347e31cfedef/%D8%B3%D8%A7%D9%84%D9%86%20%D9%85%D9%85%D8%AA%D8%A7%D8%B2%20%D8%B4%D8%B1%D9%82%20%D8%AA%D9%87%D8%B1%D8%A7%D9%86%20%C2%AB%DA%AF%D9%84%20%DA%AF%DB%8C%D8%B3%C2%BBdaily0.8 -https://modstagram.com/billboards/691ae483e24a347e31cfe822/%D8%BA%D8%B2%D9%84%20%D8%B2%D8%B1%DA%AF%D8%B1%DB%8C%D8%A7%D9%86%20%7C%20%D9%85%DB%8C%DA%A9%D8%B1%D9%88%D8%A8%D9%84%DB%8C%D8%AF%DB%8C%D9%86%DA%AF%20%7C%20%D9%81%DB%8C%D8%A8%D8%B1%D9%88%D8%B2daily0.8 -https://modstagram.com/billboards/6918a635e24a347e31cf8b17/%D8%B3%D8%A7%D9%84%D9%86%20%D8%B2%DB%8C%D8%A8%D8%A7%DB%8C%DB%8C%20%D8%B3%D9%BE%DB%8C%D8%AF%D9%87%20%D9%85%D9%88%D8%B3%D9%88%DB%8Cdaily0.8 -https://modstagram.com/billboards/6918a247e24a347e31cf883f/%D8%B3%D8%A7%D9%84%D9%86%20%D8%B2%DB%8C%D8%A8%D8%A7%DB%8C%DB%8C%20%DA%AF%D9%84%D8%B3%D8%A7%D9%86%20%F0%9F%AA%B7daily0.8 -https://modstagram.com/billboards/691891a8e24a347e31cf7feb/%D8%B3%D8%A7%D9%84%D9%86%20%D8%B2%DB%8C%D8%A8%D8%A7%DB%8C%DB%8C%20%DA%AF%D9%84%20%D8%B3%D8%B1%D8%AEdaily0.8 -https://modstagram.com/billboards/69188517e24a347e31cf7241/%D8%B3%D8%A7%D9%84%D9%86%20%D8%B2%DB%8C%D8%A8%D8%A7%DB%8C%DB%8C%20%D9%BE%D8%B1%D9%86%D8%B3%D8%B3daily0.8 -https://modstagram.com/billboards/691880ffe24a347e31cf6e55/%D8%B3%D8%A7%D9%84%D9%86%20%D8%B2%D9%8A%D8%A8%D8%A7%DB%8C%DB%8C%20%D9%86%D9%82%D8%B1%D9%87%20%D9%86%DA%AF%D8%A7%D8%B1daily0.8 -https://modstagram.com/billboards/69187bfbe24a347e31cf6b00/%D8%B3%D8%A7%D9%84%D9%86%20%D8%B2%DB%8C%D8%A8%D8%A7%DB%8C%DB%8C%20%D8%A2%D9%86%D8%AC%D9%84daily0.8 -https://modstagram.com/billboards/691332c9e24a347e31cef09f/%D8%AE%D8%AF%D9%85%D8%A7%D8%AA%20%D8%B3%D8%A7%D9%84%D9%86%20%D8%B2%DB%8C%D8%A8%D8%A7%DB%8C%DB%8C%20%D8%AF%DA%A9%D8%AA%D8%B1%20%D8%B2%D8%A7%D8%B1%D8%A7daily0.8 -https://modstagram.com/billboards/6a0ef05d12cadb57c40ee4b2/%D8%B9%DA%A9%D8%A7%D8%B3%DB%8C%20%D8%A8%D8%A8%D8%B1%DB%8C%20%D8%A2%D8%B1%D8%AAdaily0.8 - \ No newline at end of file diff --git a/public/sitemap.xml b/public/sitemap.xml deleted file mode 100644 index 30cade8..0000000 --- a/public/sitemap.xml +++ /dev/null @@ -1,5 +0,0 @@ - - -https://modstagram.com/sitemap-0.xml -https://modstagram.com/sitemap.xml - \ No newline at end of file diff --git a/robots.txt b/robots.txt new file mode 100644 index 0000000..d25a18f --- /dev/null +++ b/robots.txt @@ -0,0 +1,22 @@ +# Legacy file — live robots are generated by src/app/robots.ts +# Keep in sync with public/robots.txt if this file is ever served by a reverse proxy. +User-agent: * +Allow: / +Disallow: /api/ +Disallow: /_next/ +Disallow: /settings +Disallow: /settings/ +Disallow: /auth +Disallow: /private/ +Disallow: /new-post +Disallow: /new-project +Disallow: /billboards/new +Disallow: /search +Disallow: /verify/ +Disallow: /register +Disallow: /login +Disallow: /offer +Disallow: /*/payment/ + +Host: https://modstagram.com +Sitemap: https://modstagram.com/sitemap.xml diff --git a/src/app/(auth)/(register)/register/page.tsx b/src/app/(auth)/(register)/register/page.tsx index 25d3c11..f94b4f2 100644 --- a/src/app/(auth)/(register)/register/page.tsx +++ b/src/app/(auth)/(register)/register/page.tsx @@ -13,11 +13,18 @@ import { useRouter } from "next/navigation"; import { markOtpSent } from "@/lib/auth/otpTimer"; import { GoogleSignInButton } from "@/app/(auth)/AuthProviders"; import { MOBILE_NUMERIC_INPUT_PROPS } from "@/lib/auth/inputProps"; +import { getAxiosErrorMessage } from "@/lib/auth/postLogin"; import { useTranslation } from "react-i18next"; import LocalePageShell from "@/components/i18n/LocalePageShell"; +import toast from "react-hot-toast"; +import Link from "next/link"; + +function normalizeMobileInput(value: string): string { + return value.replace(/\D/g, "").slice(0, 11); +} function Register() { - const { t } = useTranslation("common"); + const { t, i18n } = useTranslation("common"); const router = useRouter(); const [isModalOpen, setModalOpen] = useState(false); const { request, loading } = useAxios(); @@ -39,13 +46,39 @@ function Register() { }, validationSchema: schema, onSubmit: async (values) => { + const mobile = normalizeMobileInput(values.mobile); + try { - await request("POST", "/register", { mobile: values.mobile }); - await localStorage.setItem("mobile", values.mobile); + await request( + "POST", + "/register", + { mobile }, + { noToast: true } + ); + localStorage.setItem("mobile", mobile); markOtpSent(); router.push("/register-otp"); - } catch (err) { - console.log(err); + } catch (err: unknown) { + const data = ( + err as { + response?: { + data?: { message?: string; code?: string; errors?: Array<{ msg?: string }> }; + }; + } + )?.response?.data; + + const message = + data?.message || + data?.errors?.[0]?.msg || + getAxiosErrorMessage(err, i18n.language === "en" ? "en" : "fa"); + + toast.error(message); + + if (data?.code === "ALREADY_REGISTERED") { + setTimeout(() => { + router.push("/login"); + }, 1200); + } } }, }); @@ -71,7 +104,12 @@ function Register() { : "border-gray-300" }`} value={formik.values.mobile} - onChange={formik.handleChange} + onChange={(e) => + formik.setFieldValue( + "mobile", + normalizeMobileInput(e.target.value) + ) + } onBlur={formik.handleBlur} /> {formik.touched.mobile && formik.errors.mobile && ( @@ -83,13 +121,19 @@ function Register() { type="submit" className="mt-5" loading={loading} - disabled={loading} + disabled={loading || formik.values.mobile.length !== 11} > {t("auth.sendCode")} - -
+
@@ -255,7 +256,7 @@ export default function AcademyProfileClient() { />
-
+
{Number(academy.rate || 0).toFixed(1)} @@ -276,12 +277,20 @@ export default function AcademyProfileClient() { />
-
+

{academyName}

{displayUserName ? ( {displayUserName}
-
+

{academy.bio || t("academy.profile.noBio")}

-
+
-
+

{t("academy.profile.trainingPackages")}

diff --git a/src/app/(projects)/new-project/page.tsx b/src/app/(projects)/new-project/page.tsx index fccbce4..94f3b57 100644 --- a/src/app/(projects)/new-project/page.tsx +++ b/src/app/(projects)/new-project/page.tsx @@ -3,13 +3,15 @@ import Container from "@/components/elements/Container"; import MultiStepForm from "@/components/projects/NewProject/MultiStepForm"; import { ProjectFormProvider } from "@/contexts/ProjectFormContext"; -import React from "react"; +import React, { Suspense } from "react"; function NewProject() { return ( - + + + ); diff --git a/src/app/(projects)/projects/[id]/[title]/ProjectDetailClient.tsx b/src/app/(projects)/projects/[id]/[title]/ProjectDetailClient.tsx index 3f278fb..869e4ab 100644 --- a/src/app/(projects)/projects/[id]/[title]/ProjectDetailClient.tsx +++ b/src/app/(projects)/projects/[id]/[title]/ProjectDetailClient.tsx @@ -6,8 +6,8 @@ import ProjectRequestForm from "@/components/projects/ProjectPage/ProjectRequest import ProjectRequests from "@/components/projects/ProjectPage/ProjectRequests"; import PageLoader from "@/components/ui/PageLoader"; import useAxios from "@/hooks/useAxios"; -import { Project, IProjectRequest } from "@/types/types"; -import { useEffect, useState } from "react"; +import { Project, IProjectRequest, ProjectRole } from "@/types/types"; +import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; export default function ProjectDetailClient({ id }: { id: string }) { @@ -17,6 +17,7 @@ export default function ProjectDetailClient({ id }: { id: string }) { const [projectRequests, setProjectRequests] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(false); + const [selectedRoleId, setSelectedRoleId] = useState(null); useEffect(() => { let cancelled = false; @@ -28,15 +29,14 @@ export default function ProjectDetailClient({ id }: { id: string }) { const response = await request<{ project: Project; projectRequests?: IProjectRequest[]; - }>( - "GET", - `/projects/get/web/${id}`, - null, - { noToast: true } - ); + }>("GET", `/projects/get/web/${id}`, null, { noToast: true }); if (!cancelled) { - setProject(response?.project ?? null); + const loaded = response?.project ?? null; + setProject(loaded); setProjectRequests(response?.projectRequests ?? []); + if (loaded?.roles?.length) { + setSelectedRoleId(loaded.roles[0]._id); + } } } catch { if (!cancelled) setError(true); @@ -51,6 +51,15 @@ export default function ProjectDetailClient({ id }: { id: string }) { }; }, [id, request]); + const selectedRole: ProjectRole | null = useMemo(() => { + if (!project?.roles?.length) return null; + return ( + project.roles.find((r) => r._id === selectedRoleId) || + project.roles[0] || + null + ); + }, [project, selectedRoleId]); + if (loading) return ; if (error || !project) { @@ -65,22 +74,61 @@ export default function ProjectDetailClient({ id }: { id: string }) { return ( - - {projectRequests.length > 0 ? ( -
-

- {t("projects.requestUsersTitle")} + + + {selectedRole ? ( +

+

{selectedRole.expertise}

+ {selectedRole.sub_expertise?.length ? ( +

+ {selectedRole.sub_expertise.join(" _ ")}

- -
- ) : null} - + ) : null} + {(selectedRole.height_min != null || + selectedRole.height_max != null) && ( +

+ {t("filters.heightCm")}: {selectedRole.height_min ?? "—"} -{" "} + {selectedRole.height_max ?? "—"} +

+ )} + {(selectedRole.weight_min != null || + selectedRole.weight_max != null) && ( +

+ {t("filters.weightKg")}: {selectedRole.weight_min ?? "—"} -{" "} + {selectedRole.weight_max ?? "—"} +

+ )} +

+ {t("projects.newProject.fields.headcount")}:{" "} + {selectedRole.number_of_person || 1} +

+
+ ) : null} + + {projectRequests.length > 0 ? ( +
+

+ {t("projects.requestUsersTitle")} +

+ +
+ ) : null} + +
); } diff --git a/src/app/(projects)/projects/new/[id]/page.tsx b/src/app/(projects)/projects/new/[id]/page.tsx new file mode 100644 index 0000000..635aa96 --- /dev/null +++ b/src/app/(projects)/projects/new/[id]/page.tsx @@ -0,0 +1,153 @@ +"use client"; + +import Container from "@/components/elements/Container"; +import { IProjectType, Project } from "@/types/types"; +import useAxios from "@/hooks/useAxios"; +import React, { useEffect, useState } from "react"; +import MainProjectCard from "@/components/projects/MainProjectCard"; +import RoundedDiv from "@/components/elements/RoundedDiv"; +import RoundedButton from "@/components/elements/RoundedButton"; +import LocalePageShell from "@/components/i18n/LocalePageShell"; +import { useTranslation } from "react-i18next"; +import toast from "react-hot-toast"; +import { AxiosError } from "axios"; + +interface ProjectPaymentProps { + params: Promise<{ id: string }>; +} + +function ProjectPaymentPage({ params }: ProjectPaymentProps) { + const { t } = useTranslation("common"); + const resolvedParams = React.use(params); + const { request, loading } = useAxios(); + const { id } = resolvedParams; + const [project, setProject] = useState(); + const [typeList, setTypeList] = useState(null); + const [price, setPrice] = useState(""); + const [paying, setPaying] = useState(false); + + const getDisplayTypeLabel = (projectType?: string) => { + if (projectType === "normal" || projectType === "free") { + return t("projects.newProject.displaySimple"); + } + if (projectType === "force") { + return t("projects.newProject.displayUrgent"); + } + return t("projects.newProject.displayHighlight"); + }; + + useEffect(() => { + const fetchProject = async () => { + try { + const response = await request<{ project: Project }>( + "GET", + `/projects/get/web/${id}` + ); + setProject(response?.project); + } catch { + toast.error(t("projects.notFound")); + } + }; + const fetchTypes = async () => { + try { + const response = await request<{ projectTypes: IProjectType[] }>( + "GET", + "/projects/types" + ); + setTypeList(response?.projectTypes || null); + } catch (err) { + console.log(err); + } + }; + void fetchProject(); + void fetchTypes(); + }, [id, request, t]); + + useEffect(() => { + if (project?.project_type && typeList) { + const foundType = typeList.find( + (item) => item.name === project.project_type + ); + if (foundType) setPrice(String(foundType.price)); + } + }, [project, typeList]); + + const payHandler = async () => { + if (paying || !project?._id) return; + setPaying(true); + try { + const itemName = project.project_type || "normal"; + const response = await request<{ + authority?: string; + type?: string; + message?: string; + }>( + "POST", + "/projects/initiate-payment-web", + { + item_name: itemName, + projectId: id, + }, + { noToast: true } + ); + + if (response?.type === "free" || itemName === "free") { + window.location.href = `/projects/payment/success?projectId=${id}`; + return; + } + + const authority = response?.authority; + if (authority) { + // لینک خارجی — router.push ممکن است در سافاری/نکست کار نکند + window.location.href = `https://www.zarinpal.com/pg/StartPay/${authority}`; + return; + } + + toast.error(response?.message || t("projects.payment.initFailed")); + } catch (error: unknown) { + const axiosErr = error as AxiosError<{ message?: string }>; + const serverMsg = axiosErr?.response?.data?.message; + console.log(error); + toast.error(serverMsg || t("projects.payment.initFailed")); + } finally { + setPaying(false); + } + }; + + return ( + + +
+ {t("projects.payment.title")} +
+
+ {project && } +
+ + {getDisplayTypeLabel(project?.project_type)}:{" "} + {Number(price || 0).toLocaleString()} {t("settings.toman")} + + + {t("projects.payment.payableAmount", { + amount: Number(price || 0).toLocaleString(), + })} + + void payHandler()} + disabled={paying || loading || !project} + className="h-9 w-32" + > + {paying + ? t("common.loading") + : t("projects.payment.title")} + +
+
+
+
+ ); +} + +export default ProjectPaymentPage; diff --git a/src/app/(projects)/projects/payment/failed/page.tsx b/src/app/(projects)/projects/payment/failed/page.tsx new file mode 100644 index 0000000..e2fae21 --- /dev/null +++ b/src/app/(projects)/projects/payment/failed/page.tsx @@ -0,0 +1,142 @@ +"use client"; + +import Container from "@/components/elements/Container"; +import { IProjectType, Project } from "@/types/types"; +import useAxios from "@/hooks/useAxios"; +import React, { useEffect, useState } from "react"; +import RoundedDiv from "@/components/elements/RoundedDiv"; +import RoundedButton from "@/components/elements/RoundedButton"; +import { useSearchParams } from "next/navigation"; +import Image from "next/image"; +import MainProjectCard from "@/components/projects/MainProjectCard"; +import { useTranslation } from "react-i18next"; +import LocalePageShell from "@/components/i18n/LocalePageShell"; +import toast from "react-hot-toast"; + +function FailedProject() { + const { t } = useTranslation("common"); + const { request } = useAxios(); + const searchParams = useSearchParams(); + const projectId = searchParams.get("projectId"); + + const [project, setProject] = useState(); + const [typeList, setTypeList] = useState(null); + const [price, setPrice] = useState(""); + + const getDisplayTypeLabel = (projectType?: string) => { + if (projectType === "normal" || projectType === "free") { + return t("projects.newProject.displaySimple"); + } + if (projectType === "force") { + return t("projects.newProject.displayUrgent"); + } + return t("projects.newProject.displayHighlight"); + }; + + useEffect(() => { + if (!projectId) return; + const fetchAd = async () => { + const response = await request<{ project: Project }>( + "GET", + `/projects/get/web/${projectId}` + ); + setProject(response?.project); + }; + const fetchStates = async () => { + try { + const response = await request<{ projectTypes: IProjectType[] }>( + "GET", + "/projects/types" + ); + setTypeList(response?.projectTypes || null); + } catch (err) { + console.log(err); + } + }; + void fetchAd(); + void fetchStates(); + }, [projectId, request]); + + useEffect(() => { + if (project?.project_type && typeList) { + const foundType = typeList.find( + (item) => item.name === project.project_type + ); + if (foundType) setPrice(String(foundType.price)); + } + }, [project, typeList]); + + const payHandler = async () => { + try { + const response = await request<{ + authority?: string; + type?: string; + message?: string; + }>( + "POST", + "/projects/initiate-payment-web", + { + item_name: project?.project_type || "normal", + projectId: projectId, + }, + { noToast: true } + ); + if (response.type === "free") { + window.location.href = `/projects/payment/success?projectId=${projectId}`; + } else if (response.authority) { + window.location.href = `https://www.zarinpal.com/pg/StartPay/${response.authority}`; + } else { + toast.error(response?.message || t("projects.payment.initFailed")); + } + } catch (error: unknown) { + const axiosErr = error as { response?: { data?: { message?: string } } }; + toast.error( + axiosErr?.response?.data?.message || t("projects.payment.initFailed") + ); + } + }; + + return ( + + +
+ {t("projects.payment.failed")} +
+
+ + {t("projects.payment.failedHint1")} + {t("projects.payment.failedHint2")} +
+
+ {project && } +
+ + {getDisplayTypeLabel(project?.project_type)}:{" "} + {Number(price).toLocaleString()} {t("settings.toman")} + + + {t("projects.payment.payableAmount", { + amount: Number(price).toLocaleString(), + })} + + + {t("projects.payment.retry")} + +
+
+
+
+ ); +} + +export default FailedProject; diff --git a/src/app/(projects)/projects/payment/success/page.tsx b/src/app/(projects)/projects/payment/success/page.tsx new file mode 100644 index 0000000..fb0a464 --- /dev/null +++ b/src/app/(projects)/projects/payment/success/page.tsx @@ -0,0 +1,104 @@ +"use client"; + +import Container from "@/components/elements/Container"; +import { IProjectType, Project } from "@/types/types"; +import useAxios from "@/hooks/useAxios"; +import React, { useEffect, useState } from "react"; +import RoundedDiv from "@/components/elements/RoundedDiv"; +import RoundedButton from "@/components/elements/RoundedButton"; +import { useSearchParams } from "next/navigation"; +import Image from "next/image"; +import Link from "next/link"; +import MainProjectCard from "@/components/projects/MainProjectCard"; +import { useTranslation } from "react-i18next"; +import LocalePageShell from "@/components/i18n/LocalePageShell"; + +function SuccessProject() { + const { t } = useTranslation("common"); + const { request } = useAxios(); + const searchParams = useSearchParams(); + const projectId = searchParams.get("projectId"); + + const [project, setProject] = useState(); + const [typeList, setTypeList] = useState(null); + const [price, setPrice] = useState(""); + + const getDisplayTypeLabel = (projectType?: string) => { + if (projectType === "normal" || projectType === "free") { + return t("projects.newProject.displaySimple"); + } + if (projectType === "force") { + return t("projects.newProject.displayUrgent"); + } + return t("projects.newProject.displayHighlight"); + }; + + useEffect(() => { + if (!projectId) return; + const fetchAd = async () => { + const response = await request<{ project: Project }>( + "GET", + `/projects/get/web/${projectId}` + ); + setProject(response?.project); + }; + const fetchStates = async () => { + try { + const response = await request<{ projectTypes: IProjectType[] }>( + "GET", + "/projects/types" + ); + setTypeList(response?.projectTypes || null); + } catch (err) { + console.log(err); + } + }; + void fetchAd(); + void fetchStates(); + }, [projectId, request]); + + useEffect(() => { + if (project?.project_type && typeList) { + const foundType = typeList.find( + (item) => item.name === project.project_type + ); + if (foundType) setPrice(String(foundType.price)); + } + }, [project, typeList]); + + return ( + + +
+ {t("projects.payment.success")} +
+
+ +
+
+ {project && } +
+ + {getDisplayTypeLabel(project?.project_type)}:{" "} + {Number(price).toLocaleString()} {t("settings.toman")} + +

{t("projects.payment.reviewNotice")}

+ + + {t("projects.payment.workroom")} + + +
+
+
+
+ ); +} + +export default SuccessProject; diff --git a/src/app/(projects)/projects/specialists/page.tsx b/src/app/(projects)/projects/specialists/page.tsx new file mode 100644 index 0000000..fb2ad43 --- /dev/null +++ b/src/app/(projects)/projects/specialists/page.tsx @@ -0,0 +1,480 @@ +"use client"; + +import React, { useCallback, useRef, useState } from "react"; +import Image from "next/image"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { AnimatePresence, motion, PanInfo } from "framer-motion"; +import FilterModal from "@/components/models/FilterModal"; +import BoldIcon from "@/components/ui/BoldIcon"; +import Container from "@/components/elements/Container"; +import LocalePageShell from "@/components/i18n/LocalePageShell"; +import useAxios from "@/hooks/useAxios"; +import { buildStorageUrl } from "@/components/main/BaseUrl"; +import { cn } from "@/lib/utils"; +import { useTranslation } from "react-i18next"; +import type { SuggestedFollowUser } from "@/components/posts/FollowingSuggestionsCarousel"; +import { Post } from "@/types/types"; +import { validateMinMaxPairs } from "@/lib/rangeValidation"; +import toast from "react-hot-toast"; + +const SWIPE_THRESHOLD = 72; + +const slideVariants = { + enter: (direction: number) => ({ + x: direction > 0 ? "105%" : "-105%", + opacity: 0.35, + scale: 0.9, + }), + center: { x: 0, opacity: 1, scale: 1 }, + exit: (direction: number) => ({ + x: direction > 0 ? "-105%" : "105%", + opacity: 0.35, + scale: 0.9, + }), +}; + +function getDisplayName(user: SuggestedFollowUser, fallback: string) { + return ( + [user.first_name, user.last_name].filter(Boolean).join(" ") || + user.user_name || + fallback + ); +} + +function SpecialistCard({ + user, + variant, + onSkip, + userFallback, + viewProfileLabel, +}: { + user: SuggestedFollowUser; + variant: "main" | "peek"; + onSkip?: () => void; + userFallback: string; + viewProfileLabel: string; +}) { + const displayName = getDisplayName(user, userFallback); + const previewPath = user.previewPost?.files?.[0]?.path; + const previewUrl = previewPath ? buildStorageUrl(previewPath) : null; + const profileUrl = user.profile_image + ? buildStorageUrl(user.profile_image) + : null; + const isMain = variant === "main"; + const profileHref = user.user_name ? `/users/${user.user_name}` : "#"; + + return ( +
+ {previewUrl ? ( + user.previewPost?.type === "video" ? ( +
); diff --git a/src/app/robots.ts b/src/app/robots.ts index cb285f3..35e31f5 100644 --- a/src/app/robots.ts +++ b/src/app/robots.ts @@ -1,36 +1,43 @@ -import { MetadataRoute } from 'next' +import type { MetadataRoute } from "next"; +import { SITE_URL } from "@/lib/sitemap/config"; export default function robots(): MetadataRoute.Robots { return { - rules: { - userAgent: '*', - allow: [ - '/', - '/videos', - '/billboards', - '/users/', - '/posts', - '/projects', - '/academy', - '/explore', - '/about-us', - ], - disallow: [ - '/api/', - '/_next/', - '/settings', - '/auth', - '/private/', - '/new-post', - '/new-project', - '/search', - '/*/payment/', - '/verify/', - '/register', - '/login', - '/*?*', - ], - }, - sitemap: 'https://modstagram.com/sitemap.xml', - } + rules: [ + { + userAgent: "*", + allow: ["/"], + disallow: [ + "/api/", + "/_next/", + "/settings", + "/settings/", + "/auth", + "/private/", + "/new-post", + "/new-project", + "/billboards/new", + "/search", + "/verify/", + "/register", + "/register/", + "/login", + "/login-with-username", + "/login-2fa", + "/verify-otp", + "/forget-password", + "/forget-password-otp", + "/change-passowrd", + "/offer", + "/offer/", + "/*/payment/", + "/projects/payment/", + "/billboards/payment/", + "/academy/payment/", + ], + }, + ], + sitemap: `${SITE_URL}/sitemap.xml`, + host: SITE_URL, + }; } diff --git a/src/app/settings/academy/my-courses/page.tsx b/src/app/settings/academy/my-courses/page.tsx index 848f863..29a403c 100644 --- a/src/app/settings/academy/my-courses/page.tsx +++ b/src/app/settings/academy/my-courses/page.tsx @@ -86,7 +86,7 @@ export default function PurchasedCoursesPage() { if (isLoading) { return ( -
+
@@ -98,7 +98,7 @@ export default function PurchasedCoursesPage() {
-
+
-
+
{courses.length === 0 ? ( +
@@ -258,7 +258,7 @@ export default function StoreSettings() { initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.5 }} - className="w-full p-6 max-w-4xl mx-auto pb-[calc(6rem+env(safe-area-inset-bottom))]" + className="w-full max-w-4xl mx-auto px-4 pt-6 lg:px-6 pb-[calc(6rem+env(safe-area-inset-bottom))]" dir={t("dir") || "rtl"} >

diff --git a/src/app/settings/academy/wallet/page.tsx b/src/app/settings/academy/wallet/page.tsx index 00d8517..b5586ae 100644 --- a/src/app/settings/academy/wallet/page.tsx +++ b/src/app/settings/academy/wallet/page.tsx @@ -218,7 +218,7 @@ const WalletDashboard = () => { if (isLoading) { return ( -
+
@@ -233,7 +233,7 @@ const WalletDashboard = () => {
-
+
{/* کارت‌های آمار - 3 کارت */}
diff --git a/src/app/settings/chats/[username]/[id]/page.tsx b/src/app/settings/chats/[username]/[id]/page.tsx index 139fec0..b3563fb 100644 --- a/src/app/settings/chats/[username]/[id]/page.tsx +++ b/src/app/settings/chats/[username]/[id]/page.tsx @@ -37,6 +37,13 @@ import { } from "@/lib/chat/dedupeMessages"; import { useTranslation } from "react-i18next"; import LocalePageShell from "@/components/i18n/LocalePageShell"; +import { + decryptDmContent, + encryptDmContent, + E2EE_LOCKED_PREVIEW, + rememberOutboundPlaintext, + ensureIdentityKeys, +} from "@/lib/e2ee"; interface ITicketChatProps { params: Promise<{ id: string; username: string }>; @@ -306,8 +313,21 @@ function TicketChat({ params }: ITicketChatProps) { const isFileUpload = fileForUpload && fileType !== "location"; try { + let sendContent = textContent; + try { + await ensureIdentityKeys(currentUserId); + const encrypted = await encryptDmContent( + currentUserId, + targetReceiverId, + textContent + ); + if (encrypted) sendContent = encrypted; + } catch { + /* fallback plaintext if peer has no key yet */ + } + let messagePayload: Record = { - content: textContent, + content: sendContent, receiverId: targetReceiverId, }; @@ -347,6 +367,24 @@ function TicketChat({ params }: ITicketChatProps) { } const serverMsg = normalizeMessage(response as ChatMessage); + // همیشه متن اصلی خودمان را نگه دار — اگر decrypt fail شود نباید «رمزنگاری‌شده» ببینیم + if (sendContent && sendContent !== textContent) { + rememberOutboundPlaintext(sendContent, textContent); + } + if (serverMsg.content && serverMsg.content !== textContent) { + rememberOutboundPlaintext(String(serverMsg.content), textContent); + } + try { + const plain = await decryptDmContent( + currentUserId, + targetReceiverId, + serverMsg.content || textContent + ); + serverMsg.content = + !plain || plain === E2EE_LOCKED_PREVIEW ? textContent : plain; + } catch { + serverMsg.content = textContent; + } confirmSentMessage(tempId, serverMsg, replyPayload); void queryClient.invalidateQueries({ queryKey: ["messages"] }); } catch (error) { diff --git a/src/app/settings/chats/nearby/page.tsx b/src/app/settings/chats/nearby/page.tsx new file mode 100644 index 0000000..c38ce37 --- /dev/null +++ b/src/app/settings/chats/nearby/page.tsx @@ -0,0 +1,411 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import Container from "@/components/elements/Container"; +import ProfileAvatar from "@/components/main/ProfileAvatar"; +import VerificationBadge from "@/components/main/VerificationBadge"; +import LocalePageShell from "@/components/i18n/LocalePageShell"; +import RoundedButton from "@/components/elements/RoundedButton"; +import IOSSpinner from "@/components/ui/IOSSpinner"; +import useAxios from "@/hooks/useAxios"; +import { useUser } from "@/hooks/useUser"; +import { usePageTitle } from "@/hooks/usePageTitle"; +import { useTranslation } from "react-i18next"; +import { formatFullName } from "@/lib/formatFullName"; + +type NearbyUser = { + _id: string; + user_name: string; + first_name?: string; + last_name?: string; + profile_image?: string; + is_verified?: string; + is_Register?: string | boolean; + distanceMeters: number; +}; + +type NearbyGate = + | "checking" + | "location_disabled" + | "permission" + | "locating" + | "ready" + | "error"; + +const NEARBY_RADIUS_METERS = 5000; +const RADAR_SIZE = 340; +const RING_METERS = [1000, 2000, 3000, 4000, 5000] as const; + +function formatDistance(meters: number, t: (key: string, opts?: object) => string) { + if (meters < 1000) { + return t("nearbyFriends.distanceMeters", { count: meters }); + } + const km = Math.round((meters / 1000) * 10) / 10; + return t("nearbyFriends.distanceKm", { count: km }); +} + +/** Place users around the radar: closer = inner ring, angle by index. */ +function radarPosition(index: number, total: number, distanceMeters: number) { + const clamped = Math.min(Math.max(distanceMeters, 120), NEARBY_RADIUS_METERS); + const ring = 0.18 + (clamped / NEARBY_RADIUS_METERS) * 0.72; + const angle = ((index / Math.max(total, 1)) * 360 - 90) * (Math.PI / 180); + const x = 50 + Math.cos(angle) * ring * 50; + const y = 50 + Math.sin(angle) * ring * 50; + return { left: `${x}%`, top: `${y}%` }; +} + +export default function NearbyFriendsPage() { + const { t } = useTranslation("common"); + usePageTitle(t("nearbyFriends.title")); + const user = useUser(); + const { request } = useAxios(); + + const [gate, setGate] = useState("checking"); + const [users, setUsers] = useState([]); + const [errorMessage, setErrorMessage] = useState(""); + const [coords, setCoords] = useState<{ lat: number; lng: number } | null>( + null + ); + + const radarUsers = useMemo(() => users.slice(0, 12), [users]); + const radarActive = + gate === "locating" || gate === "checking" || gate === "ready"; + + const loadNearby = useCallback( + async (lat: number, lng: number) => { + setGate("locating"); + setErrorMessage(""); + try { + const res = await request<{ users?: NearbyUser[] }>( + "GET", + `/users/nearby?lat=${encodeURIComponent(String(lat))}&lng=${encodeURIComponent(String(lng))}`, + null, + { noToast: true } + ); + setUsers(res?.users ?? []); + setCoords({ lat, lng }); + setGate("ready"); + } catch (err: unknown) { + const response = ( + err as { + response?: { + status?: number; + data?: { code?: string; message?: string }; + }; + } + )?.response; + const data = response?.data; + if (data?.code === "LOCATION_DISABLED") { + setGate("location_disabled"); + return; + } + if (response?.status === 404) { + setErrorMessage(t("nearbyFriends.apiUnavailable")); + } else { + setErrorMessage(data?.message || t("nearbyFriends.loadError")); + } + setGate("error"); + } + }, + [request, t] + ); + + const requestBrowserLocation = useCallback(() => { + if (!navigator.geolocation) { + setErrorMessage(t("nearbyFriends.geoUnsupported")); + setGate("error"); + return; + } + + setGate("locating"); + setErrorMessage(""); + + navigator.geolocation.getCurrentPosition( + (pos) => { + void loadNearby(pos.coords.latitude, pos.coords.longitude); + }, + (err) => { + if (err.code === err.PERMISSION_DENIED) { + setGate("permission"); + return; + } + setErrorMessage(t("nearbyFriends.geoDenied")); + setGate("error"); + }, + { enableHighAccuracy: true, timeout: 20000, maximumAge: 15000 } + ); + }, [loadNearby, t]); + + useEffect(() => { + if (!user) return; + + if (user.show_location !== true) { + setGate("location_disabled"); + return; + } + + requestBrowserLocation(); + }, [user, requestBrowserLocation]); + + return ( + + +
+ + + +

+ {t("nearbyFriends.title")} +

+
+ +
+
+ {/* scanlines / phosphor grain */} +
+ + {/* crosshair */} +
+
+ + {/* distance rings + labels */} + {RING_METERS.map((meters) => { + const pct = (meters / NEARBY_RADIUS_METERS) * 100; + const inset = `${(100 - pct) / 2}%`; + return ( +
+
+ + {meters} + +
+ ); + })} + + {/* rotating sweep beam */} + {radarActive ? ( +
+
+
+ ) : null} + + {/* center = you (exact geometric center) */} +
+
+ {user?.profile_image ? ( + + ) : ( + + )} +
+ + {t("nearbyFriends.you")} + +
+ + {gate === "ready" + ? radarUsers.map((item, index) => { + const pos = radarPosition( + index, + radarUsers.length, + item.distanceMeters + ); + return ( + +
+
+ + +
+ + {item.user_name} + +
+ + ); + }) + : null} +
+

+ {t("nearbyFriends.radiusHint")} +

+
+ + {gate === "checking" || gate === "locating" ? ( +
+ +

+ {gate === "checking" + ? t("nearbyFriends.checking") + : t("nearbyFriends.locating")} +

+
+ ) : null} + + {gate === "location_disabled" ? ( +
+

+ {t("nearbyFriends.locationOffTitle")} +

+

+ {t("nearbyFriends.locationOffDesc")} +

+ + + {t("nearbyFriends.openSettings")} + + +
+ ) : null} + + {gate === "permission" ? ( +
+

+ {t("nearbyFriends.permissionTitle")} +

+

+ {t("nearbyFriends.permissionDesc")} +

+ + {t("nearbyFriends.retryLocation")} + +
+ ) : null} + + {gate === "error" ? ( +
+

+ {errorMessage || t("nearbyFriends.loadError")} +

+ + {t("nearbyFriends.retryLocation")} + +
+ ) : null} + + {gate === "ready" ? ( +
+ {coords ? ( +

+ {t("nearbyFriends.found", { count: users.length })} +

+ ) : null} + + {users.length === 0 ? ( +

+ {t("nearbyFriends.empty")} +

+ ) : ( + users.map((item) => ( + + +
+
+ + {item.user_name} + + +
+ {(item.first_name || item.last_name) && ( +

+ {formatFullName(item.first_name, item.last_name)} +

+ )} +

+ {formatDistance(item.distanceMeters, t)} +

+
+ + )) + )} + + + {t("nearbyFriends.refresh")} + +
+ ) : null} + + + ); +} diff --git a/src/app/settings/chats/page.tsx b/src/app/settings/chats/page.tsx index 76824e2..f70fb98 100644 --- a/src/app/settings/chats/page.tsx +++ b/src/app/settings/chats/page.tsx @@ -11,6 +11,7 @@ import useInfiniteScroll from "@/hooks/useInfiniteScroll"; import { useUser } from "@/hooks/useUser"; import Link from "next/link"; import React, { useEffect, useMemo, useState } from "react"; +import Image from "next/image"; import { ChatListSkeleton } from "@/components/ui/ChatSkeletons"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { fetchStoriesFeed } from "@/api/fetchStories"; @@ -21,6 +22,7 @@ import { cn } from "@/lib/utils"; import { useTranslation } from "react-i18next"; import LocalePageShell from "@/components/i18n/LocalePageShell"; import { isUserOnline, resolveOnlineLabel } from "@/lib/chat/onlineStatus"; +import { staticIconUrl } from "@/components/main/BaseUrl"; export interface IMessage { first_name: string; @@ -52,6 +54,8 @@ function Chats() { }); useEffect(() => { + // بلافاصله بعد از ورود به لیست، بدون نیاز به رفرش دستی + void refetch(); const id = window.setInterval(() => { void refetch(); }, 30_000); @@ -145,6 +149,31 @@ function Chats() {
+ +
+
+ +
+
+ + {t("nearbyFriends.title")} + + + {t("nearbyFriends.desc")} + +
+
+ + {isEmpty ? (

{t("chats.empty")} diff --git a/src/app/settings/chats/rooms/[roomId]/page.tsx b/src/app/settings/chats/rooms/[roomId]/page.tsx index 0ca893a..46c3cbe 100644 --- a/src/app/settings/chats/rooms/[roomId]/page.tsx +++ b/src/app/settings/chats/rooms/[roomId]/page.tsx @@ -18,6 +18,9 @@ import { import MessageInput from "@/components/chat/MessageInput"; import ChatActionBar from "@/components/chat/ChatActionBar"; import AddChatRoomMembersModal from "@/components/chat/AddChatRoomMembersModal"; +import MultiImageModal from "@/components/chat/MultiImageModal"; +import MediaPreviewModal from "@/components/chat/MediaPreviewModal"; +import VoiceMessagePlayer from "@/components/chat/VoiceMessagePlayer"; import { groupReactions } from "@/lib/chat/reactions"; import { useLongPress } from "@/hooks/useLongPress"; import { useSwipeToReply } from "@/hooks/useSwipeToReply"; @@ -34,6 +37,15 @@ import { } from "@/lib/chat/getCopyableMessageText"; import { useTranslation } from "react-i18next"; import LocalePageShell from "@/components/i18n/LocalePageShell"; +import { optimizeMediaFile } from "@/lib/media"; +import { buildStorageUrl } from "@/components/main/BaseUrl"; +import { + decryptRoomContent, + decryptRoomMessageList, + encryptRoomContent, + ensureIdentityKeys, + ensureRoomAesKey, +} from "@/lib/e2ee"; type RoomMember = { _id: string; @@ -50,6 +62,8 @@ type RoomMessage = { senderId: string; content: string; createdAt: string; + file?: string; + fileType?: "image" | "video" | "file" | "voice" | "location"; sender?: RoomMember; reactions?: Array<{ userId: string; emoji: string }>; replyTo?: { @@ -218,7 +232,62 @@ function RoomMessageRow({ isMine ? "chat-bubble-out" : "chat-bubble-in" )} > -

{msg.content}

+ {msg.file && msg.fileType === "image" ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : null} + {msg.file && msg.fileType === "video" ? ( +