This commit is contained in:
payacom
2026-07-28 10:06:42 +03:30
parent a1d7db5e7e
commit 67f59eefdf
129 changed files with 7964 additions and 1561 deletions

BIN
modstagram-next.zip Normal file

Binary file not shown.

View File

@@ -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',
],
},
};
siteUrl: "https://modstagram.com",
generateRobotsTxt: false,
generateIndexSitemap: false,
};

View File

@@ -35,6 +35,12 @@ const nextConfig = {
port: '',
pathname: '/**',
},
{
protocol: 'https',
hostname: 'api.qrserver.com',
port: '',
pathname: '/**',
},
{
protocol: 'http',
hostname: 'localhost',

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 B

View File

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

View File

@@ -1,50 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
<url><loc>https://modstagram.com/icon.png</loc><lastmod>2026-07-21T17:52:19.573Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://modstagram.com/robots.txt</loc><lastmod>2026-07-21T17:52:19.576Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://modstagram.com/sitemap.xml</loc><lastmod>2026-07-21T17:52:19.576Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://modstagram.com/academy/payment/success</loc><lastmod>2026-07-21T17:52:19.576Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://modstagram.com/academy/payment/failed</loc><lastmod>2026-07-21T17:52:19.576Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://modstagram.com/about-us</loc><lastmod>2026-07-21T17:52:19.576Z</lastmod><changefreq>monthly</changefreq><priority>0.8</priority></url>
<url><loc>https://modstagram.com/explore</loc><lastmod>2026-07-21T17:52:19.576Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://modstagram.com</loc><changefreq>daily</changefreq><priority>1</priority></url>
<url><loc>https://modstagram.com/projects</loc><changefreq>daily</changefreq><priority>0.9</priority></url>
<url><loc>https://modstagram.com/billboards</loc><changefreq>daily</changefreq><priority>0.9</priority></url>
<url><loc>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%8C</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>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%8C</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>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%86</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>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%86</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>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%AF</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>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%B1</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>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%B1</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>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)</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>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%7C</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>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%8C</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>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%AF</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>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%84</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>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%91</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>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%84</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>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)</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>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%91</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>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%B2</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>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%A7</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>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%A8</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>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%20beauty</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>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%8C</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>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%8F</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>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%B8</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>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%84</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>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%86</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>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%20Chambeautysalon</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>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%8C</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>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%BB</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>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%B2</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>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%8C</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>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%B7</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>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%AE</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>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%B3</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>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%B1</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>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%84</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>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%A7</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>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%AA</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
</urlset>

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<sitemap><loc>https://modstagram.com/sitemap-0.xml</loc></sitemap>
<sitemap><loc>https://modstagram.com/sitemap.xml</loc></sitemap>
</sitemapindex>

22
robots.txt Normal file
View File

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

View File

@@ -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")}
</AuthButton>
</form>
<GoogleSignInButton mode="register" />
<button onClick={() => setModalOpen(true)} className="mb-2">
<Link href="/login">
<small className="text-[#292D32] dark:text-neutral-300 font-bold text-xs mt-6 block text-center">
{t("auth.haveAccount")}{" "}
<span className="text-[#0033EA]">{t("auth.login")}</span>
</small>
</Link>
<button type="button" onClick={() => setModalOpen(true)} className="mb-2">
<small className="text-[#FF5C00] font-bold text-xs mt-8 block">
{t("auth.rules")}
</small>

View File

@@ -32,9 +32,9 @@ function Confirm() {
"/verify/complete",
{}
);
setVerifiedStatus(response?.is_verified ?? "pending");
setVerifiedStatus(response?.is_verified ?? "none");
} catch {
setVerifiedStatus(user?.is_verified ?? "pending");
setVerifiedStatus(user?.is_verified ?? "none");
}
};
markComplete();
@@ -69,9 +69,7 @@ function Confirm() {
<span className="inline-flex items-center gap-1">
{user?.user_name}
<VerificationBadge
isVerified={
verifiedStatus ?? user?.is_verified ?? "pending"
}
isVerified={verifiedStatus ?? user?.is_verified ?? "none"}
/>
</span>
</div>

View File

@@ -522,18 +522,8 @@ export default function CourseDetail({
if (paymentUrl) {
console.log("هدایت به درگاه:", paymentUrl);
// باز کردن درگاه پرداخت
const newWindow = window.open(paymentUrl, "_blank");
if (!newWindow) {
toast.error(t("academy.course.popupBlocked"));
return;
}
window.open(paymentUrl, "_blank", "noopener,noreferrer");
toast.success(t("academy.course.redirectedToPayment"));
} else {
console.error("ساختار پاسخ نامعتبر:", response);

View File

@@ -114,7 +114,7 @@ function isContactAvailable(
}
export default function AcademyProfileClient() {
const { t } = useTranslation("common");
const { t, i18n } = useTranslation("common");
const params = useParams();
const academyId = params?.academyId as string;
const { request } = useAxios();
@@ -203,13 +203,14 @@ export default function AcademyProfileClient() {
const isRegister = owner?.is_Register ?? ownerFromApi?.is_Register;
const stats = academy.stats;
const shareUrl = `modstagram.com/academy/profile/${academyId}`;
const isFa = (i18n.language || "fa").toLowerCase().startsWith("fa");
return (
<Container className="pb-28">
<div className="w-full">
<button
type="button"
className="flex w-full items-center justify-end gap-1.5 px-4 py-2 text-xs font-semibold md:text-sm"
className="flex w-full items-center justify-start gap-1.5 py-2 text-xs font-semibold md:text-sm"
onClick={() => {
navigator.clipboard.writeText(shareUrl);
toast.success(t("academy.profile.addressCopied"));
@@ -225,7 +226,7 @@ export default function AcademyProfileClient() {
<span>{t("academy.profile.copyAddress")}</span>
</button>
<div className="flex w-full items-center justify-between px-4 py-2">
<div className="flex w-full flex-row-reverse items-center justify-between py-2">
<div className="flex flex-col items-center text-xs md:text-sm">
<div className="flex gap-5 mt-2">
<div className="font-semibold flex flex-col items-center max-sm:text-[10px]">
@@ -255,7 +256,7 @@ export default function AcademyProfileClient() {
/>
</div>
<div className="flex justify-between items-end px-4 py-2 w-full text-xs md:text-sm font-semibold">
<div className="flex w-full flex-row-reverse items-end justify-between py-2 text-xs font-semibold md:text-sm">
<div className="flex items-center gap-3">
<div className="inline-flex items-center gap-1">
<span>{Number(academy.rate || 0).toFixed(1)}</span>
@@ -276,12 +277,20 @@ export default function AcademyProfileClient() {
/>
</div>
</div>
<div className="flex flex-col items-end text-right">
<div
className={cn(
"flex flex-col",
isFa ? "items-end text-right" : "items-start text-left"
)}
>
<h3 className="text-base md:text-lg font-bold">{academyName}</h3>
{displayUserName ? (
<Link
href={`/users/${displayUserName}`}
className="mt-1 flex items-center gap-1 flex-row-reverse font-bold text-[#3A59A9] hover:underline"
className={cn(
"mt-1 flex items-center gap-1 font-bold text-[#3A59A9] hover:underline",
isFa ? "flex-row-reverse" : "flex-row"
)}
>
{displayUserName}
<VerificationBadge
@@ -293,13 +302,13 @@ export default function AcademyProfileClient() {
</div>
</div>
<div className="px-4 py-2 w-full text-xs md:text-sm font-semibold">
<div className="py-2 w-full text-xs md:text-sm font-semibold">
<p className="leading-relaxed text-neutral-700 dark:text-neutral-300">
{academy.bio || t("academy.profile.noBio")}
</p>
</div>
<div className="px-4 py-2 w-full text-xs md:text-sm font-semibold">
<div className="py-2 w-full text-xs md:text-sm font-semibold">
<div className="mt-2 grid grid-cols-2 gap-1 md:grid-cols-4 md:gap-4">
<button
type="button"
@@ -353,7 +362,7 @@ export default function AcademyProfileClient() {
</div>
</div>
<div className="px-4 mt-6">
<div className="mt-6">
<h2 className="font-bold text-base mb-4">
{t("academy.profile.trainingPackages")}
</h2>

View File

@@ -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 (
<ProjectFormProvider>
<Container>
<MultiStepForm />
<Suspense fallback={null}>
<MultiStepForm />
</Suspense>
</Container>
</ProjectFormProvider>
);

View File

@@ -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<IProjectRequest[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [selectedRoleId, setSelectedRoleId] = useState<string | null>(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 <PageLoader className="min-h-[50vh]" />;
if (error || !project) {
@@ -65,22 +74,61 @@ export default function ProjectDetailClient({ id }: { id: string }) {
return (
<Container className="pb-28">
<MainProjectCard project={project} full />
{projectRequests.length > 0 ? (
<div className="mt-4">
<p className="mb-2 text-center text-xs font-semibold md:text-sm">
{t("projects.requestUsersTitle")}
<MainProjectCard
project={project}
full
borderless
selectedRoleId={selectedRoleId}
onRoleSelect={setSelectedRoleId}
/>
{selectedRole ? (
<div className="mt-2 rounded-2xl border border-border-secondary-light p-3 text-xs dark:border-border-secondary-dark">
<p className="font-bold">{selectedRole.expertise}</p>
{selectedRole.sub_expertise?.length ? (
<p className="mt-1 text-neutral-500">
{selectedRole.sub_expertise.join(" _ ")}
</p>
<ProjectRequests projectRequests={projectRequests} />
</div>
) : null}
<ProjectRequestForm
projectId={id}
creatorId={
(project as Project & { creator_id?: string }).creator_id ||
project.creator?._id
}
/>
) : null}
{(selectedRole.height_min != null ||
selectedRole.height_max != null) && (
<p className="mt-1">
{t("filters.heightCm")}: {selectedRole.height_min ?? "—"} -{" "}
{selectedRole.height_max ?? "—"}
</p>
)}
{(selectedRole.weight_min != null ||
selectedRole.weight_max != null) && (
<p className="mt-1">
{t("filters.weightKg")}: {selectedRole.weight_min ?? "—"} -{" "}
{selectedRole.weight_max ?? "—"}
</p>
)}
<p className="mt-1">
{t("projects.newProject.fields.headcount")}:{" "}
{selectedRole.number_of_person || 1}
</p>
</div>
) : null}
{projectRequests.length > 0 ? (
<div className="mt-4">
<p className="mb-2 text-center text-xs font-semibold md:text-sm">
{t("projects.requestUsersTitle")}
</p>
<ProjectRequests projectRequests={projectRequests} />
</div>
) : null}
<ProjectRequestForm
projectId={id}
creatorId={
(project as Project & { creator_id?: string }).creator_id ||
project.creator?._id
}
selectedRole={selectedRole}
portfolioRequired={Boolean(selectedRole?.portfolio_required)}
/>
</Container>
);
}

View File

@@ -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<Project>();
const [typeList, setTypeList] = useState<IProjectType[] | null>(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 (
<LocalePageShell>
<Container>
<h6 className="mb-4 mt-2 line-clamp-2 text-center text-lg font-bold text-text-blue-light dark:text-text-blue-dark md:text-xl">
{t("projects.payment.title")}
</h6>
<div className="mt-10 w-full px-4 text-sm font-semibold md:text-base">
{project && <MainProjectCard project={project} />}
<div className="mt-4 flex flex-col items-center gap-4">
<RoundedDiv className="w-full p-2">
{getDisplayTypeLabel(project?.project_type)}:{" "}
{Number(price || 0).toLocaleString()} {t("settings.toman")}
</RoundedDiv>
<RoundedDiv className="w-full p-2">
{t("projects.payment.payableAmount", {
amount: Number(price || 0).toLocaleString(),
})}
</RoundedDiv>
<RoundedButton
type="button"
variant="primary"
onClick={() => void payHandler()}
disabled={paying || loading || !project}
className="h-9 w-32"
>
{paying
? t("common.loading")
: t("projects.payment.title")}
</RoundedButton>
</div>
</div>
</Container>
</LocalePageShell>
);
}
export default ProjectPaymentPage;

View File

@@ -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<Project>();
const [typeList, setTypeList] = useState<IProjectType[] | null>(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 (
<LocalePageShell>
<Container>
<h6 className="mb-4 mt-2 line-clamp-2 text-center text-lg font-bold text-[#FF0000] md:text-xl">
{t("projects.payment.failed")}
</h6>
<div className="flex flex-col items-center text-sm">
<Image
width={50}
height={50}
alt=""
src={`/images/icons/failed.svg`}
className="mb-5 pb-1"
/>
<span>{t("projects.payment.failedHint1")}</span>
<span>{t("projects.payment.failedHint2")}</span>
</div>
<div className="mt-10 w-full px-4 text-sm font-semibold md:text-base">
{project && <MainProjectCard project={project} />}
<div className="mt-4 flex flex-col items-center gap-4">
<RoundedDiv className="w-full p-2">
{getDisplayTypeLabel(project?.project_type)}:{" "}
{Number(price).toLocaleString()} {t("settings.toman")}
</RoundedDiv>
<RoundedDiv className="w-full p-2">
{t("projects.payment.payableAmount", {
amount: Number(price).toLocaleString(),
})}
</RoundedDiv>
<RoundedButton
onClick={payHandler}
variant="primary"
className="h-9 w-32"
>
{t("projects.payment.retry")}
</RoundedButton>
</div>
</div>
</Container>
</LocalePageShell>
);
}
export default FailedProject;

View File

@@ -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<Project>();
const [typeList, setTypeList] = useState<IProjectType[] | null>(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 (
<LocalePageShell>
<Container>
<h6 className="mb-4 mt-2 line-clamp-2 text-center text-lg font-bold text-[#17A600] md:text-xl">
{t("projects.payment.success")}
</h6>
<div className="flex flex-col items-center text-sm">
<Image
width={50}
height={50}
alt=""
src={`/images/icons/success.svg`}
className="mb-5 pb-1"
/>
</div>
<div className="mt-10 w-full px-4 text-sm font-semibold md:text-base">
{project && <MainProjectCard project={project} />}
<div className="mt-4 flex flex-col items-center gap-4">
<RoundedDiv className="w-full p-2">
{getDisplayTypeLabel(project?.project_type)}:{" "}
{Number(price).toLocaleString()} {t("settings.toman")}
</RoundedDiv>
<p className="my-5">{t("projects.payment.reviewNotice")}</p>
<Link href={"/settings/workroom"}>
<RoundedButton variant="primary" className="h-9 w-32">
{t("projects.payment.workroom")}
</RoundedButton>
</Link>
</div>
</div>
</Container>
</LocalePageShell>
);
}
export default SuccessProject;

View File

@@ -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 (
<div className="relative h-full w-full overflow-hidden rounded-3xl bg-neutral-900 shadow-2xl">
{previewUrl ? (
user.previewPost?.type === "video" ? (
<video
src={previewUrl}
className="h-full w-full object-cover"
muted
playsInline
autoPlay={isMain}
loop
preload={isMain ? "auto" : "metadata"}
/>
) : (
<Image
src={previewUrl}
alt=""
fill
className="object-cover"
sizes={isMain ? "280px" : "120px"}
unoptimized
/>
)
) : (
<div className="h-full w-full bg-neutral-800" />
)}
<div
className={cn(
"absolute inset-0 bg-gradient-to-t from-black/85 via-black/20 to-black/30",
!isMain && "from-black/70"
)}
/>
{isMain && onSkip ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onSkip();
}}
className="absolute left-3 top-3 flex h-8 w-8 items-center justify-center rounded-full bg-black/40 text-white"
aria-label="skip"
>
<BoldIcon name="close-circle" size={18} tinted className="text-white" />
</button>
) : null}
<div
className={cn(
"absolute inset-x-0 bottom-0 flex flex-col items-center pt-6",
isMain ? "px-4 pb-5" : "px-2 pb-3"
)}
>
<div
className={cn(
"relative mb-2 overflow-hidden rounded-full ring-2 ring-white/90",
isMain ? "h-16 w-16" : "h-9 w-9"
)}
>
{profileUrl ? (
<Image
src={profileUrl}
alt=""
fill
className="object-cover"
sizes={isMain ? "64px" : "36px"}
unoptimized
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-neutral-700 text-white">
{displayName.charAt(0)}
</div>
)}
</div>
{isMain ? (
<>
<Link href={profileHref} className="text-center">
<p className="text-base font-bold text-white">{displayName}</p>
{user.user_name ? (
<p className="text-xs text-white/70">@{user.user_name}</p>
) : null}
</Link>
{user.expertise ? (
<p className="mt-1 text-xs text-white/55">{user.expertise}</p>
) : null}
<Link
href={profileHref}
className="mt-4 w-full max-w-[200px] rounded-full bg-[#fe2c55] py-2.5 text-center text-sm font-bold text-white"
>
{viewProfileLabel}
</Link>
</>
) : (
<p className="line-clamp-1 text-center text-[10px] font-semibold text-white">
{displayName}
</p>
)}
</div>
</div>
);
}
export default function SpecialistsPage() {
const { t } = useTranslation("common");
const router = useRouter();
const { request } = useAxios();
const [phase, setPhase] = useState<"filter" | "results">("filter");
const [showFilter, setShowFilter] = useState(true);
const [users, setUsers] = useState<SuggestedFollowUser[]>([]);
const [loading, setLoading] = useState(false);
const [[index, direction], setSlide] = useState<[number, number]>([0, 0]);
const applyingFilterRef = useRef(false);
const [stateId, setStateId] = useState("");
const [cityId, setCityId] = useState("");
const [rateFilter, setRateFilter] = useState("");
const [userLevel, setUserLevel] = useState("");
const [selectedExpertise, setSelectedExpertise] = useState("");
const [heightMin, setHeightMin] = useState("");
const [heightMax, setHeightMax] = useState("");
const [weightMin, setWeightMin] = useState("");
const [weightMax, setWeightMax] = useState("");
const [sizeMin, setSizeMin] = useState("");
const [sizeMax, setSizeMax] = useState("");
const [hairColor, setHairColor] = useState("");
const [eyeColor, setEyeColor] = useState("");
const clearFilters = () => {
setStateId("");
setCityId("");
setRateFilter("");
setUserLevel("");
setSelectedExpertise("");
setHeightMin("");
setHeightMax("");
setWeightMin("");
setWeightMax("");
setSizeMin("");
setSizeMax("");
setHairColor("");
setEyeColor("");
};
const fetchUsers = useCallback(async () => {
setLoading(true);
try {
const qs = new URLSearchParams({ page: "1", limit: "20" });
if (selectedExpertise) qs.set("expertise", selectedExpertise);
if (stateId) qs.set("province", stateId);
if (cityId) qs.set("city", cityId);
if (userLevel) qs.set("userLevel", userLevel);
if (rateFilter) qs.set("rateFilter", rateFilter);
if (heightMin) qs.set("heightMin", heightMin);
if (heightMax) qs.set("heightMax", heightMax);
if (weightMin) qs.set("weightMin", weightMin);
if (weightMax) qs.set("weightMax", weightMax);
if (sizeMin) qs.set("sizeMin", sizeMin);
if (sizeMax) qs.set("sizeMax", sizeMax);
if (hairColor) qs.set("hair_color", hairColor);
if (eyeColor) qs.set("eye_color", eyeColor);
const response = await request<{
posts?: Post[];
feedMeta?: { suggestedUsers?: SuggestedFollowUser[] };
}>("GET", `/users/web?${qs.toString()}`, null, { noToast: true });
const suggested = response?.feedMeta?.suggestedUsers;
if (Array.isArray(suggested) && suggested.length) {
setUsers(suggested);
} else {
const posts = response?.posts || [];
const map = new Map<string, SuggestedFollowUser>();
posts.forEach((post) => {
const id = String(post.userId || post.user_id || "");
if (!id || map.has(id)) return;
map.set(id, {
_id: id,
user_name: post.user_name,
first_name: post.first_name,
last_name: post.last_name,
expertise: post.expertise,
profile_image: post.profile_image,
previewPost: {
_id: post._id,
type: post.type,
files: post.files,
caption: post.caption,
},
is_following: post.is_following,
});
});
setUsers(Array.from(map.values()));
}
setSlide([0, 0]);
} catch {
setUsers([]);
} finally {
setLoading(false);
}
}, [
request,
selectedExpertise,
stateId,
cityId,
userLevel,
rateFilter,
heightMin,
heightMax,
weightMin,
weightMax,
sizeMin,
sizeMax,
hairColor,
eyeColor,
]);
const handleFilterChange = () => {
const invalid = validateMinMaxPairs([
{ min: heightMin, max: heightMax },
{ min: weightMin, max: weightMax },
{ min: sizeMin, max: sizeMax },
]);
if (invalid) {
toast.error(t("filters.maxMustBeGreater"));
return;
}
// FilterModal بعد از اعمال، setShowFilterModal(false) می‌زند —
// با ref جلوی برگشت اشتباه به /projects را می‌گیریم
applyingFilterRef.current = true;
setPhase("results");
setShowFilter(false);
void fetchUsers();
};
const current = users[index];
const paginate = useCallback(
(step: number) => {
if (users.length <= 1) return;
setSlide(([i]) => [
(i + step + users.length) % users.length,
step > 0 ? 1 : -1,
]);
},
[users.length]
);
const handleDragEnd = (_: unknown, info: PanInfo) => {
if (info.offset.x < -SWIPE_THRESHOLD) paginate(1);
else if (info.offset.x > SWIPE_THRESHOLD) paginate(-1);
};
const prevUser =
users.length > 1
? users[(index - 1 + users.length) % users.length]
: null;
const nextUser =
users.length > 1 ? users[(index + 1) % users.length] : null;
return (
<LocalePageShell>
<Container className="pb-28">
<div className="mb-4 flex items-center justify-between px-1 pt-2">
<button
type="button"
onClick={() => router.push("/projects")}
className="flex h-9 w-9 items-center justify-center"
aria-label={t("common.back")}
>
<BoldIcon name="arrow-right" size={22} />
</button>
<h1 className="text-base font-bold">
{t("projects.specialists.title")}
</h1>
<span className="w-9" />
</div>
{phase === "filter" || showFilter ? (
<FilterModal
setShowFilterModal={(open) => {
if (!open) {
if (applyingFilterRef.current) {
applyingFilterRef.current = false;
setShowFilter(false);
return;
}
if (phase === "results") {
setShowFilter(false);
} else {
router.push("/projects");
}
} else {
setShowFilter(true);
}
}}
showFilterModal={showFilter || phase === "filter"}
setStateId={setStateId}
setCityId={setCityId}
setRateFilter={setRateFilter}
setUserLevel={setUserLevel}
selectedExpertise={selectedExpertise}
setSelectedExpertise={setSelectedExpertise}
heightMin={heightMin}
setHeightMin={setHeightMin}
heightMax={heightMax}
setHeightMax={setHeightMax}
weightMin={weightMin}
setWeightMin={setWeightMin}
weightMax={weightMax}
setWeightMax={setWeightMax}
sizeMin={sizeMin}
setSizeMin={setSizeMin}
sizeMax={sizeMax}
setSizeMax={setSizeMax}
hairColor={hairColor}
setHairColor={setHairColor}
eyeColor={eyeColor}
setEyeColor={setEyeColor}
stateId={stateId}
cityId={cityId}
rateFilter={rateFilter}
userLevel={userLevel}
handleFilterChange={handleFilterChange}
clearFilters={clearFilters}
/>
) : null}
{phase === "results" && !showFilter ? (
<div className="flex w-full flex-col items-center rounded-3xl bg-black px-3 py-6 text-white">
<p className="mb-2 text-center text-xs text-white/60">
{t("projects.specialists.resultsHint")}
</p>
<button
type="button"
className="mb-4 text-xs text-[#fe2c55]"
onClick={() => setShowFilter(true)}
>
{t("projects.specialists.editFilter")}
</button>
{loading ? (
<p className="py-16 text-sm text-white/60">
{t("projects.specialists.loading")}
</p>
) : !users.length || !current ? (
<p className="py-16 text-sm text-white/60">
{t("projects.specialists.empty")}
</p>
) : (
<div
dir="ltr"
className="relative mx-auto h-[min(58dvh,460px)] w-full max-w-[420px] overflow-hidden"
>
{prevUser ? (
<button
type="button"
onClick={() => paginate(-1)}
className="absolute left-1 top-1/2 z-10 h-[88%] w-[22%] -translate-y-1/2 overflow-hidden rounded-2xl opacity-55"
>
<SpecialistCard
user={prevUser}
variant="peek"
userFallback={t("common.user")}
viewProfileLabel={t("projects.specialists.viewProfile")}
/>
</button>
) : null}
{nextUser ? (
<button
type="button"
onClick={() => paginate(1)}
className="absolute right-1 top-1/2 z-10 h-[88%] w-[22%] -translate-y-1/2 overflow-hidden rounded-2xl opacity-55"
>
<SpecialistCard
user={nextUser}
variant="peek"
userFallback={t("common.user")}
viewProfileLabel={t("projects.specialists.viewProfile")}
/>
</button>
) : null}
<AnimatePresence initial={false} custom={direction} mode="popLayout">
<motion.div
key={current._id}
custom={direction}
variants={slideVariants}
initial="enter"
animate="center"
exit="exit"
transition={{ type: "spring", stiffness: 320, damping: 30 }}
drag="x"
dragConstraints={{ left: 0, right: 0 }}
dragElastic={0.18}
onDragEnd={handleDragEnd}
className="absolute left-1/2 top-0 z-20 h-full w-[62%] -translate-x-1/2"
>
<SpecialistCard
user={current}
variant="main"
onSkip={() => paginate(1)}
userFallback={t("common.user")}
viewProfileLabel={t("projects.specialists.viewProfile")}
/>
</motion.div>
</AnimatePresence>
</div>
)}
</div>
) : null}
</Container>
</LocalePageShell>
);
}

View File

@@ -235,6 +235,17 @@ select {
width: 100%;
padding-right: 48px;
}
.project-date-field .rmdp-container,
.project-date-field .rmdp-input {
width: 100% !important;
}
.project-date-field .rmdp-input {
text-align: center !important;
padding-left: 2.75rem !important;
padding-right: 2.75rem !important;
}
.address-page .mapboxgl-map {
max-height: 210px;
height: 100%;
@@ -661,6 +672,36 @@ select {
animation: check-pop 0.35s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
@keyframes nearby-radar-sweep {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes nearby-blip-glow {
0%,
100% {
box-shadow: 0 0 4px 1px rgba(57, 255, 20, 0.55);
opacity: 0.85;
}
50% {
box-shadow: 0 0 10px 3px rgba(57, 255, 20, 0.95);
opacity: 1;
}
}
.nearby-radar-sweep {
animation: nearby-radar-sweep 4s linear infinite;
transform-origin: center center;
}
.nearby-blip-glow {
animation: nearby-blip-glow 1.8s ease-in-out infinite;
}
@keyframes story-ring-spin {
to {
transform: rotate(360deg);

View File

@@ -5,14 +5,16 @@ import { getSiteSeoMeta, getSeoPage } from "@/lib/i18n/seo";
import { getLanguageDefinition } from "@/lib/i18n/registry";
import { getServerLanguage } from "@/lib/i18n/server";
import SiteJsonLd from "@/components/seo/SiteJsonLd";
import Script from 'next/script';
import "./globals.css";
import Layout from "@/components/Layout";
import RegisterSW from "@/components/RegisterSW";
import ClientErrorBoundary from "@/components/ClientErrorBoundary";
const iranSansFont = localFont({
src: "./../../public/fonts/IRANSansX-Regular.woff",
src: "../../public/fonts/IRANSansX-Regular.woff",
display: "swap",
fallback: ["Tahoma", "Arial", "sans-serif"],
});
export async function generateMetadata(): Promise<Metadata> {
@@ -22,7 +24,7 @@ export async function generateMetadata(): Promise<Metadata> {
return {
title: {
default: site.name,
default: home.title || site.name,
template: `%s | ${site.titleSuffix}`,
},
description: home.description,
@@ -99,7 +101,20 @@ export default async function RootLayout({
<ClientErrorBoundary>
<Layout>{children}</Layout>
</ClientErrorBoundary>
</body>
{/* ====================== گوگل آنالیتیکس ====================== */}
<Script
src="https://www.googletagmanager.com/gtag/js?id=G-GT0QQX6F97"
strategy="afterInteractive"
/>
<Script id="google-analytics" strategy="afterInteractive">
{`
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-GT0QQX6F97');
`}
</Script>
{/* ======================================================== */} </body>
</html>
);
}

View File

@@ -19,6 +19,9 @@ import {
import PostLocationNoticeModal from "@/components/posts/PostLocationNoticeModal";
import PostExpertiseRequiredModal from "@/components/posts/PostExpertiseRequiredModal";
import VideoCoverPicker from "@/components/posts/VideoCoverPicker";
import PostImageGallery, {
PostGalleryItem,
} from "@/components/posts/PostImageGallery";
import BoldIcon from "@/components/ui/BoldIcon";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import { useUser } from "@/hooks/useUser";
@@ -29,6 +32,9 @@ import {
hasSeenPostLocationNotice,
markPostLocationNoticeSeen,
} from "@/lib/postLocationNotice";
import { canEditPostWithinWindow } from "@/lib/postEditWindow";
import { buildStorageUrl } from "@/components/main/BaseUrl";
import { fetchPostById } from "@/api/fetchPostById";
import Cookies from "js-cookie";
import { useRouter, useSearchParams } from "next/navigation";
import React, { useEffect, useMemo, useState } from "react";
@@ -55,14 +61,15 @@ export default function NewPostPage() {
const { request, loading } = useAxios();
const user = useUser();
const editPostId = searchParams.get("edit") || "";
const isEditMode = Boolean(editPostId);
const initialMode = (searchParams.get("type") as CreateMode) || "image";
const [mode, setMode] = useState<CreateMode>(
["image", "video", "story"].includes(initialMode) ? initialMode : "image"
);
const [file, setFile] = useState<File | null>(null);
const [preview, setPreview] = useState<string>("");
const [extraImages, setExtraImages] = useState<(File | null)[]>([null]);
const [extraPreviews, setExtraPreviews] = useState<string[]>([""]);
const [gallery, setGallery] = useState<PostGalleryItem[]>([]);
const [description, setDescription] = useState("");
const [taggedUsers, setTaggedUsers] = useState<TaggedUser[]>([]);
const [selectedPackages, setSelectedPackages] = useState<SelectedPostLink[]>(
@@ -76,8 +83,10 @@ export default function NewPostPage() {
>([]);
const [storyOverlays, setStoryOverlays] = useState<StoryTextOverlay[]>([]);
const [videoCover, setVideoCover] = useState<File | null>(null);
const [existingVideoPath, setExistingVideoPath] = useState<string | null>(null);
const [locationNoticeOpen, setLocationNoticeOpen] = useState(false);
const [expertiseModalOpen, setExpertiseModalOpen] = useState(false);
const [loadingEdit, setLoadingEdit] = useState(false);
const modeLabels: Record<CreateMode, string> = useMemo(
() => ({
@@ -90,21 +99,84 @@ export default function NewPostPage() {
useEffect(() => {
const t = searchParams.get("type") as CreateMode;
if (t && ["image", "video", "story"].includes(t)) setMode(t);
}, [searchParams]);
if (!isEditMode && t && ["image", "video", "story"].includes(t)) setMode(t);
}, [searchParams, isEditMode]);
useEffect(() => {
if (!editPostId) return;
let cancelled = false;
const load = async () => {
setLoadingEdit(true);
try {
const token = Cookies.get("token") || "";
const post = await fetchPostById(editPostId, token);
if (cancelled || !post) return;
if (!canEditPostWithinWindow(post.createdAt)) {
toast.error(t("posts.editPostExpired"));
router.replace("/");
return;
}
setMode(post.type === "video" ? "video" : "image");
setDescription(post.caption || "");
const imageFiles = (post.files || []).filter((f) => f.type !== "video");
const videoFile = (post.files || []).find((f) => f.type === "video");
if (post.type === "video" && videoFile?.path) {
setPreview(buildStorageUrl(videoFile.path));
setFile(null);
setExistingVideoPath(videoFile.path);
const cover = imageFiles[0];
if (cover?.path) {
setGallery([
{
id: `ex-${cover.path}`,
preview: buildStorageUrl(cover.path),
existingPath: cover.path,
},
]);
}
} else {
setExistingVideoPath(null);
setGallery(
(post.files || [])
.filter((f) => f.path)
.map((f) => ({
id: `ex-${f.path}`,
preview: buildStorageUrl(f.path),
existingPath: f.path,
}))
);
const first = post.files?.[0];
if (first?.path) {
setPreview(buildStorageUrl(first.path));
}
}
} catch {
toast.error(t("posts.uploadError"));
router.replace("/");
} finally {
if (!cancelled) setLoadingEdit(false);
}
};
void load();
return () => {
cancelled = true;
};
}, [editPostId, router, t]);
const resetMedia = () => {
if (preview) URL.revokeObjectURL(preview);
extraPreviews.forEach((p) => p && URL.revokeObjectURL(p));
if (preview?.startsWith("blob:")) URL.revokeObjectURL(preview);
gallery.forEach((item) => {
if (item.preview?.startsWith("blob:")) URL.revokeObjectURL(item.preview);
});
setFile(null);
setPreview("");
setExtraImages([null]);
setExtraPreviews([""]);
setGallery([]);
setStoryOverlays([]);
setVideoCover(null);
};
const switchMode = (next: CreateMode) => {
if (isEditMode) return;
resetMedia();
setMode(next);
};
@@ -127,27 +199,27 @@ export default function NewPostPage() {
return;
}
if (preview) URL.revokeObjectURL(preview);
setFile(picked);
setPreview(URL.createObjectURL(picked));
};
const onPickExtra = (index: number) => (e: React.ChangeEvent<HTMLInputElement>) => {
const picked = e.target.files?.[0];
if (!picked || !picked.type.startsWith("image/")) {
toast.error(t("posts.imageOnly"));
if (mode === "image") {
const item: PostGalleryItem = {
id: `new-${Date.now()}`,
file: picked,
preview: URL.createObjectURL(picked),
};
setGallery((prev) => {
if (!prev.length) {
setPreview(item.preview);
setFile(picked);
return [item];
}
return [...prev, item].slice(0, 10);
});
return;
}
const files = [...extraImages];
const previews = [...extraPreviews];
files[index] = picked;
previews[index] = URL.createObjectURL(picked);
setExtraImages(files);
setExtraPreviews(previews);
if (files.filter(Boolean).length < 10 && index === files.length - 1) {
setExtraImages((prev) => [...prev, null]);
setExtraPreviews((prev) => [...prev, ""]);
}
if (preview?.startsWith("blob:")) URL.revokeObjectURL(preview);
setFile(picked);
setPreview(URL.createObjectURL(picked));
if (mode === "video") setVideoCover(null);
};
const returnPath = useMemo(() => {
@@ -232,21 +304,120 @@ export default function NewPostPage() {
return;
}
const imageFiles =
mode === "image"
? ([...(file ? [file] : []), ...extraImages.filter(Boolean)] as File[])
: mode === "video" && file
? ([...(videoCover ? [videoCover] : []), file] as File[])
: file
? [file]
: [];
const caption = buildFullPostCaption(
description,
taggedUsers,
selectedPackages,
selectedProjects,
selectedBillboards,
captionLang
);
if (!imageFiles.length) {
toast.error(t("posts.mediaRequired"));
if (caption.length > POST_CAPTION_MAX_LENGTH) {
toast.error(
t("posts.captionTooLong", { max: POST_CAPTION_MAX_LENGTH }),
{
id: "post-upload",
}
);
return;
}
try {
toast.loading(t("posts.uploading"), { id: "post-upload" });
if (isEditMode) {
const keep_files = [
...gallery
.filter((g) => g.existingPath)
.map((g) => ({ path: g.existingPath!, type: "image" as const })),
...(mode === "video" && existingVideoPath && !file
? [{ path: existingVideoPath, type: "video" as const }]
: []),
];
const newGalleryFiles = gallery
.filter((g) => g.file)
.map((g) => g.file!) as File[];
const videoFiles =
mode === "video" && file
? ([...(videoCover ? [videoCover] : []), file] as File[])
: mode === "video" && videoCover
? [videoCover]
: [];
const uploadFiles =
mode === "video" ? videoFiles : newGalleryFiles;
if (!keep_files.length && !uploadFiles.length) {
toast.error(t("posts.mediaRequired"), { id: "post-upload" });
return;
}
const optimizedFiles = await Promise.all(
uploadFiles.map(async (f) =>
f.type.startsWith("video/")
? optimizeVideoToMp4(f)
: optimizeImageToWebP(f)
)
);
const filesBase64 = await Promise.all(
optimizedFiles.map(async (f) => ({
name: f.name,
type: f.type,
data: await fileToBase64(f),
}))
);
let media_order: string[] = [];
if (mode === "image") {
let newIdx = 0;
let keepIdx = 0;
media_order = gallery.map((g) => {
if (g.existingPath) return `existing:${keepIdx++}`;
return `new:${newIdx++}`;
});
} else {
media_order = [
...keep_files.map((_, i) => `existing:${i}`),
...filesBase64.map((_, i) => `new:${i}`),
];
}
await request(
"POST",
`/posts/${editPostId}/update-base64`,
{
postId: editPostId,
files: filesBase64,
caption,
tagged_user_ids: taggedUsers.map((u) => u._id),
keep_files,
media_order,
},
{ noToast: true }
);
toast.success(t("posts.postUpdated"), { id: "post-upload" });
router.push(`/posts/${editPostId}`);
return;
}
const imageFiles =
mode === "image"
? ((gallery.length
? gallery.map((g) => g.file).filter(Boolean)
: file
? [file]
: []) as File[])
: mode === "video" && file
? ([...(videoCover ? [videoCover] : []), file] as File[])
: file
? [file]
: [];
if (!imageFiles.length) {
toast.error(t("posts.mediaRequired"), { id: "post-upload" });
return;
}
const optimizedFiles = await Promise.all(
imageFiles.map(async (f) =>
f.type.startsWith("video/")
@@ -261,24 +432,6 @@ export default function NewPostPage() {
data: await fileToBase64(f),
}))
);
const caption = buildFullPostCaption(
description,
taggedUsers,
selectedPackages,
selectedProjects,
selectedBillboards,
captionLang
);
if (caption.length > POST_CAPTION_MAX_LENGTH) {
toast.error(
t("posts.captionTooLong", { max: POST_CAPTION_MAX_LENGTH }),
{
id: "post-upload",
}
);
return;
}
await request("POST", "/posts/create-base64", {
files: filesBase64,
@@ -356,19 +509,24 @@ export default function NewPostPage() {
<BoldIcon name="arrow-right-3" size={22} className="block dark:invert" />
</button>
<span className="text-base font-semibold">
{isStory ? t("posts.newStory") : t("posts.newPost")}
{isEditMode
? t("posts.editPostTitle")
: isStory
? t("posts.newStory")
: t("posts.newPost")}
</span>
<button
type="button"
onClick={handleShareClick}
disabled={loading}
disabled={loading || loadingEdit}
className="text-sm font-semibold text-[#0095f6] disabled:opacity-40"
>
{loading ? "…" : t("posts.shareButton")}
{loading || loadingEdit ? "…" : t("posts.shareButton")}
</button>
</header>
{/* Mode tabs */}
{!isEditMode ? (
<div className="flex border-b border-neutral-200 dark:border-neutral-800">
{(["image", "video", "story"] as CreateMode[]).map((m) => (
<button
@@ -385,6 +543,7 @@ export default function NewPostPage() {
</button>
))}
</div>
) : null}
{/* Story editor (media + draggable text) */}
{isStory && preview && file ? (
@@ -466,38 +625,28 @@ export default function NewPostPage() {
</div>
)}
{/* Extra images for photo post */}
{mode === "image" && !isStory && (
<div className="grid grid-cols-4 gap-2 border-t border-neutral-200 p-3 dark:border-neutral-800">
{[...(file ? [preview] : []), ...extraPreviews.filter(Boolean)]
.slice(0, 4)
.map((p, i) => (
<div key={i} className="relative aspect-square overflow-hidden rounded-md bg-neutral-200">
{p && <img src={p} alt="" className="h-full w-full object-cover" />}
</div>
))}
{extraPreviews.map((p, index) =>
!p ? (
<label
key={`slot-${index}`}
className="flex aspect-square cursor-pointer items-center justify-center rounded-md border border-dashed border-neutral-300 dark:border-neutral-700"
>
<BoldIcon name="add" size={20} className="block dark:invert" />
<input
type="file"
accept="image/*"
className="hidden"
onChange={onPickExtra(index)}
/>
</label>
) : null
)}
</div>
)}
{/* Extra images for photo post — reorder / delete */}
{mode === "image" && !isStory ? (
<PostImageGallery
items={gallery}
onChange={(items) => {
setGallery(items);
const first = items[0];
setPreview(first?.preview || "");
setFile(first?.file || null);
}}
/>
) : null}
{mode === "video" && preview && file?.type.startsWith("video/") && (
<VideoCoverPicker videoUrl={preview} onCoverChange={setVideoCover} />
)}
{mode === "video" && preview && (file || existingVideoPath) ? (
<VideoCoverPicker
videoUrl={preview}
onCoverChange={setVideoCover}
initialCoverUrl={
gallery.find((item) => item.existingPath && !item.file)?.preview
}
/>
) : null}
{/* Caption — not for story */}
{!isStory && (

View File

@@ -14,22 +14,42 @@ import axios from "axios";
import { useFormik } from "formik";
import Image from "next/image";
import { useRouter } from "next/navigation";
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useMemo } from "react";
import toast from "react-hot-toast";
import Cookies from "js-cookie";
import * as Yup from "yup";
import { useTranslation } from "react-i18next";
import { useUser } from "@/hooks/useUser";
type OfferTypeRow = IOfferType & {
disabled?: boolean;
unavailableReason?: string | null;
};
interface ITicketChatProps {
params: Promise<{ id: string; title: string }>;
}
const FALLBACK_TYPES: OfferTypeRow[] = [
{ _id: "normal", name: "normal", price: 50000 },
{ _id: "free", name: "free", price: 0 },
];
function sortOfferTypes(list: OfferTypeRow[]): OfferTypeRow[] {
const order = ["normal", "special", "highlight", "free"];
return [...list].sort((a, b) => {
const ai = order.indexOf(a.name);
const bi = order.indexOf(b.name);
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
});
}
function TicketChat({ params }: ITicketChatProps) {
const { t } = useTranslation("common");
const resolvedParams = React.use(params);
const { request } = useAxios();
const { id } = resolvedParams;
const router = useRouter();
const me = useUser();
const validationSchema = Yup.object({
selectedType: Yup.string().required(t("offerPage.typeRequired")),
@@ -37,7 +57,7 @@ function TicketChat({ params }: ITicketChatProps) {
const [userDetail, setUserDetail] = useState<User>();
const [selectedType, setSelectedType] = useState("normal");
const [typeList, setTypeList] = useState<IOfferType[] | null>(null);
const [typeList, setTypeList] = useState<OfferTypeRow[] | null>(null);
const [hasUsedFreeOffer, setHasUsedFreeOffer] = useState(false);
useEffect(() => {
@@ -47,24 +67,43 @@ function TicketChat({ params }: ITicketChatProps) {
`/users/get?user_id=${id}`
);
setUserDetail(response?.user);
setHasUsedFreeOffer(response?.user?.hasUsedFreeOffer || false);
};
fetchUser();
}, [id]);
void fetchUser();
}, [id, request]);
useEffect(() => {
const monthly = Number(me?.monthly_free_offer ?? 1);
setHasUsedFreeOffer(monthly <= 0);
}, [me]);
const fetchOfferTypes = async () => {
try {
const response = await request<{ offerTypes: IOfferType[] }>(
"GET",
"/offers/types"
const response = await request<{
offerTypes: OfferTypeRow[];
monthly_free_offer?: number;
}>("GET", "/offers/types");
const list = sortOfferTypes(
response?.offerTypes?.length ? response.offerTypes : FALLBACK_TYPES
);
setTypeList(response?.offerTypes || null);
setTypeList(list);
if (typeof response?.monthly_free_offer === "number") {
setHasUsedFreeOffer(response.monthly_free_offer <= 0);
}
// همیشه پیش‌فرض روی پولی (normal)
const paid =
list.find((item) => item.name === "normal" && !item.disabled) ||
list.find((item) => item.name !== "free" && !item.disabled);
setSelectedType(paid?.name || "normal");
} catch (err) {
console.log(err);
setTypeList(FALLBACK_TYPES);
setSelectedType("normal");
}
};
useEffect(() => {
fetchOfferTypes();
void fetchOfferTypes();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const formik = useFormik({
@@ -72,9 +111,16 @@ function TicketChat({ params }: ITicketChatProps) {
selectedType: "normal",
},
validationSchema,
enableReinitialize: true,
onSubmit: async (values, { setSubmitting }) => {
if (values.selectedType === "free" && hasUsedFreeOffer) {
toast.error(t("offerPage.freeAlreadyUsed"));
const chosen = typeList?.find((t) => t.name === values.selectedType);
if (
chosen?.disabled ||
(values.selectedType === "free" && hasUsedFreeOffer)
) {
toast.error(
chosen?.unavailableReason || t("offerPage.freeAlreadyUsed")
);
return;
}
@@ -87,132 +133,160 @@ function TicketChat({ params }: ITicketChatProps) {
};
try {
const response = await axios.post(`${BASE_URL}/offers/initiate-payment-web`, {
offerType: values.selectedType,
receiverId: id,
},
{
headers: {
Authorization: getToken(), // ✅ اضافه شدن توکن به هدر
"Content-Type": "application/json", // اختیاری ولی بهتره باشه
},
}
);
const data: { authority?: string; offerId?: string; type?: string } = response.data;
console.log("Authority:", data.authority);
console.log("Response:", response);
if (values.selectedType === "free") {
toast.success(t("offerPage.freeSuccess"));
setHasUsedFreeOffer(true);
router.push(`/offer/payment/success?userId=${id}`);
}
else if (data.authority) {
const paymentUrl = `https://www.zarinpal.com/pg/StartPay/${data.authority}`;
console.log("Redirecting to:", paymentUrl);
window.open(paymentUrl, "_blank");
} else {
toast.error(t("offerPage.paymentInfoError"));
}
const response = await axios.post(
`${BASE_URL}/offers/initiate-payment-web`,
{
offerType: values.selectedType,
receiverId: id,
},
{
headers: {
Authorization: getToken(),
"Content-Type": "application/json",
},
}
);
const data: {
authority?: string;
paymentUrl?: string;
type?: string;
} = response.data;
if (values.selectedType === "free" || data.type === "free") {
toast.success(t("offerPage.freeSuccess"));
setHasUsedFreeOffer(true);
router.push(`/offer/payment/success?userId=${id}`);
} else if (data.paymentUrl) {
window.open(data.paymentUrl, "_blank", "noopener,noreferrer");
} else if (data.authority) {
window.open(
`https://www.zarinpal.com/pg/StartPay/${data.authority}`,
"_blank",
"noopener,noreferrer"
);
} else {
toast.error(t("offerPage.paymentInfoError"));
}
} catch (err: any) {
console.error("Payment initiation error:", err);
toast.error(err?.response?.data?.error || t("offerPage.paymentStartError"));
toast.error(
err?.response?.data?.error ||
err?.response?.data?.message ||
t("offerPage.paymentStartError")
);
} finally {
setSubmitting(false);
}
},
}
);
});
useEffect(() => {
void formik.setFieldValue("selectedType", selectedType);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedType]);
const displayTypes = useMemo(
() => sortOfferTypes(typeList || FALLBACK_TYPES),
[typeList]
);
const typeLabel = (name: string) => {
if (name === "normal") return t("offerPage.types.normal");
if (name === "free") return t("offerPage.types.free");
if (name === "special") return t("offerPage.types.special");
return t("offerPage.types.highlight");
};
const userHeader = userDetail ? (
<div className="flex w-full items-end justify-between">
<UserInfo
first_name={userDetail?.first_name}
is_verified={userDetail?.is_verified}
profile_image={userDetail?.profile_image}
user_level={userDetail?.user_level}
last_name={userDetail?.last_name}
user_name={userDetail?.user_name}
noLink
/>
<div className="flex items-center justify-center gap-0.5">
<span>{userDetail?.user_score || "0"}</span>
<Image
width={21}
height={21}
alt=""
src={`/images/icons/medal-star.png`}
className="pb-1"
/>
</div>
<div className="flex items-center gap-0.5">
<span>{userDetail?.rate || "0"}</span>
<Image
width={21}
height={21}
alt=""
src={`/images/icons/star1.png`}
className="pb-1"
/>
</div>
</div>
) : null;
return (
<Container>
<div className="mx-3 mb-10">
<PageTitle>{t("offerPage.paymentTitle")}</PageTitle>
<PageTitle>{t("offerPage.paymentTitle")}</PageTitle>
<p className="text-center">
{t("offerPage.freeOnceHint")}
</p>
<form
className="flex flex-col gap-2 w-full items-center text-sm"
onSubmit={formik.handleSubmit}
>
{typeList?.map((item: IOfferType) => {
const isFree = item.name === "free";
const isDisabled = isFree && hasUsedFreeOffer;
<form
className="flex w-full flex-col items-center gap-2 text-sm"
onSubmit={formik.handleSubmit}
>
{displayTypes.map((item) => {
const isFree = item.name === "free";
const isDisabled =
Boolean(item.disabled) || (isFree && hasUsedFreeOffer);
return (
<div
onClick={() => {
if (!isDisabled) {
setSelectedType(item?.name);
formik.setFieldValue("selectedType", item?.name);
}
}}
className="w-full flex flex-col items-center max-w-md mt-8 gap-4 cursor-pointer"
key={item?._id}
>
<div className="flex items-end justify-between w-full">
<UserInfo
first_name={userDetail?.first_name}
is_verified={userDetail?.is_verified}
profile_image={userDetail?.profile_image}
user_level={userDetail?.user_level}
last_name={userDetail?.last_name}
user_name={userDetail?.user_name}
noLink
/>
<div className="flex items-center justify-center gap-0.5">
<span>{userDetail?.user_score || "0"}</span>
<Image
width={21}
height={21}
alt={"verify icon"}
src={`/images/icons/medal-star.png`}
className="pb-1"
/>
</div>
<div className="flex items-center gap-0.5">
<span>{userDetail?.rate || "0"}</span>
<Image
width={21}
height={21}
alt={"verify icon"}
src={`/images/icons/star1.png`}
className="pb-1"
/>
</div>
</div>
<RoundedDiv
className={`${selectionCardClass(selectedType === item?.name)} ${
isDisabled ? "cursor-not-allowed opacity-50" : ""
}`}
return (
<div
key={item?._id || item.name}
className="mt-8 flex w-full max-w-md cursor-pointer flex-col items-center gap-4"
onClick={() => {
if (isDisabled) return;
setSelectedType(item.name);
void formik.setFieldValue("selectedType", item.name);
}}
>
{item.name === "normal"
? t("offerPage.types.normal")
: item.name === "free"
? t("offerPage.types.free")
: item.name === "special"
? t("offerPage.types.special")
: t("offerPage.types.highlight")}
{item.price !== 0
? ": " + Number(item.price).toLocaleString() + t("offerPage.currencySuffix")
: ""}
</RoundedDiv>
</div>
);
})}
{userHeader}
<RoundedDiv
className={`${selectionCardClass(selectedType === item?.name)} ${
isDisabled ? "cursor-not-allowed opacity-50" : ""
}`}
>
{typeLabel(item.name)}
{Number(item.price) !== 0
? ": " +
Number(item.price).toLocaleString() +
t("offerPage.currencySuffix")
: ""}
</RoundedDiv>
{isDisabled && item.unavailableReason ? (
<p className="text-center text-[11px] text-red-500">
{item.unavailableReason}
</p>
) : null}
</div>
);
})}
<RoundedButton type="submit" variant="primary" className="mt-4 px-8 py-2">
{t("offerPage.submitRequest")}
</RoundedButton>
</form>
<RoundedButton
type="submit"
variant="primary"
className="mt-4 px-8 py-2"
disabled={formik.isSubmitting}
>
{t("offerPage.submitRequest")}
</RoundedButton>
</form>
</div>
</Container>
);

View File

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

View File

@@ -86,7 +86,7 @@ export default function PurchasedCoursesPage() {
if (isLoading) {
return (
<LocalePageShell>
<div className="min-h-screen container mx-auto px-4 py-8">
<div className="min-h-screen container mx-auto px-4 py-8 lg:px-6">
<AcademyListSkeleton count={4} />
</div>
</LocalePageShell>
@@ -98,7 +98,7 @@ export default function PurchasedCoursesPage() {
<div className="min-h-screen bg-white dark:bg-neutral-950">
<div className="relative bg-gradient-to-r from-purple-400 via-purple-600 to-pink-600 dark:from-purple-600 dark:via-purple-800 dark:to-pink-800">
<div className="absolute inset-0 bg-black/10"></div>
<div className="relative container mx-auto px-4 py-12 md:py-16">
<div className="relative container mx-auto px-4 py-12 lg:px-6 md:py-16">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
@@ -145,7 +145,7 @@ export default function PurchasedCoursesPage() {
</div>
</div>
<div className="container mx-auto px-4 py-12">
<div className="container mx-auto px-4 py-12 lg:px-6">
{courses.length === 0 ? (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}

View File

@@ -241,7 +241,7 @@ export default function StoreSettings() {
// نمایش لودینگ در حین دریافت دیتا
if (isFetching) {
return (
<div className="w-full p-6 max-w-4xl mx-auto mb-32">
<div className="w-full max-w-4xl mx-auto mb-32 px-4 py-6 lg:px-6">
<AcademyProfileHeadSkeleton />
<div className="space-y-4 mt-6">
<Skeleton className="h-10 w-full" />
@@ -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"}
>
<h2 className="text-3xl font-bold bg-gradient-to-r from-primary to-primary/60 bg-clip-text text-transparent mb-6">

View File

@@ -218,7 +218,7 @@ const WalletDashboard = () => {
if (isLoading) {
return (
<div className="container mx-auto p-6 space-y-4">
<div className="container mx-auto space-y-4 px-4 py-6 lg:px-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Skeleton className="h-28 w-full rounded-xl" />
<Skeleton className="h-28 w-full rounded-xl" />
@@ -233,7 +233,7 @@ const WalletDashboard = () => {
<LocalePageShell>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<div className="min-h-screen text-foreground">
<div className="container mx-auto p-6 space-y-8">
<div className="container mx-auto space-y-8 px-4 py-6 lg:px-6">
{/* کارت‌های آمار - 3 کارت */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">

View File

@@ -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<string, unknown> = {
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) {

View File

@@ -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<NearbyGate>("checking");
const [users, setUsers] = useState<NearbyUser[]>([]);
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 (
<LocalePageShell>
<Container className="pb-28">
<div className="mb-4 flex items-center gap-3 pt-2">
<Link
href="/settings/chats"
className="flex h-9 w-9 items-center justify-center rounded-full bg-neutral-100 dark:bg-neutral-800"
aria-label={t("nearbyFriends.back")}
>
<span className="text-lg leading-none"></span>
</Link>
<h1 className="text-base font-bold md:text-lg">
{t("nearbyFriends.title")}
</h1>
</div>
<div className="mb-6 flex flex-col items-center">
<div
className="relative overflow-hidden rounded-full border border-[#1f6b2a] shadow-[0_0_40px_rgba(57,255,20,0.18)]"
style={{
width: RADAR_SIZE,
height: RADAR_SIZE,
background:
"radial-gradient(circle at center, #0a2e12 0%, #031408 62%, #010805 100%)",
}}
>
{/* scanlines / phosphor grain */}
<div
className="pointer-events-none absolute inset-0 opacity-[0.12]"
style={{
backgroundImage:
"repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,0,0,0.35) 3px)",
}}
/>
{/* crosshair */}
<div className="pointer-events-none absolute left-1/2 top-0 h-full w-px -translate-x-1/2 bg-[#39ff14]/25" />
<div className="pointer-events-none absolute left-0 top-1/2 h-px w-full -translate-y-1/2 bg-[#39ff14]/25" />
{/* distance rings + labels */}
{RING_METERS.map((meters) => {
const pct = (meters / NEARBY_RADIUS_METERS) * 100;
const inset = `${(100 - pct) / 2}%`;
return (
<div key={meters}>
<div
className="pointer-events-none absolute rounded-full border border-[#39ff14]/30"
style={{ inset }}
/>
<span
className="pointer-events-none absolute left-1/2 z-[1] -translate-x-1/2 font-mono text-[9px] text-[#39ff14]/70"
style={{
top: `calc(${inset} - 1px)`,
}}
>
{meters}
</span>
</div>
);
})}
{/* rotating sweep beam */}
{radarActive ? (
<div
className="nearby-radar-sweep pointer-events-none absolute inset-0"
aria-hidden
>
<div
className="absolute inset-0"
style={{
background:
"conic-gradient(from 0deg, rgba(57,255,20,0.55) 0deg, rgba(57,255,20,0.18) 28deg, transparent 55deg, transparent 360deg)",
}}
/>
</div>
) : null}
{/* center = you (exact geometric center) */}
<div className="absolute left-1/2 top-1/2 z-20 h-12 w-12 -translate-x-1/2 -translate-y-1/2">
<div className="relative flex h-12 w-12 items-center justify-center rounded-full bg-black ring-2 ring-[#39ff14] shadow-[0_0_14px_rgba(57,255,20,0.65)]">
{user?.profile_image ? (
<ProfileAvatar
src={user.profile_image}
alt={user.user_name || t("nearbyFriends.you")}
size="sm"
rounded="full"
className="!h-10 !w-10"
/>
) : (
<span className="h-3 w-3 rounded-full bg-[#39ff14] shadow-[0_0_8px_#39ff14]" />
)}
</div>
<span className="pointer-events-none absolute left-1/2 top-[calc(100%+4px)] -translate-x-1/2 whitespace-nowrap text-[10px] font-semibold text-[#39ff14]">
{t("nearbyFriends.you")}
</span>
</div>
{gate === "ready"
? radarUsers.map((item, index) => {
const pos = radarPosition(
index,
radarUsers.length,
item.distanceMeters
);
return (
<Link
key={item._id}
href={`/offer/${item._id}`}
className="absolute z-10 -translate-x-1/2 -translate-y-1/2 transition-transform active:scale-95"
style={pos}
title={item.user_name}
>
<div className="flex flex-col items-center">
<div className="nearby-blip-glow relative rounded-full ring-1 ring-[#39ff14]/80">
<ProfileAvatar
src={item.profile_image}
alt={item.user_name}
size="sm"
rounded="full"
className="!h-9 !w-9"
/>
<span className="pointer-events-none absolute -inset-1 rounded-full border border-[#39ff14]/40" />
</div>
<span
className="mt-0.5 max-w-[72px] truncate text-[9px] font-medium text-[#9dff8a]"
dir="ltr"
>
{item.user_name}
</span>
</div>
</Link>
);
})
: null}
</div>
<p className="mt-3 text-center text-xs text-neutral-500">
{t("nearbyFriends.radiusHint")}
</p>
</div>
{gate === "checking" || gate === "locating" ? (
<div className="flex flex-col items-center gap-3 py-6">
<IOSSpinner />
<p className="text-sm text-neutral-500">
{gate === "checking"
? t("nearbyFriends.checking")
: t("nearbyFriends.locating")}
</p>
</div>
) : null}
{gate === "location_disabled" ? (
<div className="mx-auto max-w-sm rounded-2xl border border-neutral-200 p-5 text-center dark:border-neutral-800">
<p className="text-sm font-semibold">
{t("nearbyFriends.locationOffTitle")}
</p>
<p className="mt-2 text-xs text-neutral-500">
{t("nearbyFriends.locationOffDesc")}
</p>
<Link href="/settings/profile/user-settings" className="mt-4 block">
<RoundedButton variant="primary" className="w-full max-w-none">
{t("nearbyFriends.openSettings")}
</RoundedButton>
</Link>
</div>
) : null}
{gate === "permission" ? (
<div className="mx-auto max-w-sm rounded-2xl border border-neutral-200 p-5 text-center dark:border-neutral-800">
<p className="text-sm font-semibold">
{t("nearbyFriends.permissionTitle")}
</p>
<p className="mt-2 text-xs text-neutral-500">
{t("nearbyFriends.permissionDesc")}
</p>
<RoundedButton
variant="primary"
className="mt-4 w-full max-w-none"
onClick={requestBrowserLocation}
>
{t("nearbyFriends.retryLocation")}
</RoundedButton>
</div>
) : null}
{gate === "error" ? (
<div className="mx-auto max-w-sm rounded-2xl border border-red-200 p-5 text-center dark:border-red-900">
<p className="text-sm text-red-600 dark:text-red-400">
{errorMessage || t("nearbyFriends.loadError")}
</p>
<RoundedButton
variant="primary"
className="mt-4 w-full max-w-none"
onClick={requestBrowserLocation}
>
{t("nearbyFriends.retryLocation")}
</RoundedButton>
</div>
) : null}
{gate === "ready" ? (
<div className="flex flex-col gap-1">
{coords ? (
<p className="mb-2 text-center text-[11px] text-neutral-400">
{t("nearbyFriends.found", { count: users.length })}
</p>
) : null}
{users.length === 0 ? (
<p className="py-6 text-center text-sm text-neutral-500">
{t("nearbyFriends.empty")}
</p>
) : (
users.map((item) => (
<Link
key={`list-${item._id}`}
href={`/offer/${item._id}`}
className="flex items-center gap-3 py-3"
>
<ProfileAvatar
src={item.profile_image}
alt={item.user_name}
size="sm"
rounded="full"
className="!h-12 !w-12 shrink-0"
/>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1">
<span className="truncate font-semibold" dir="ltr">
{item.user_name}
</span>
<VerificationBadge
isVerified={item.is_verified}
isRegister={item.is_Register}
/>
</div>
{(item.first_name || item.last_name) && (
<p className="truncate text-xs text-neutral-500">
{formatFullName(item.first_name, item.last_name)}
</p>
)}
<p className="mt-0.5 text-[11px] text-[#0095f6]">
{formatDistance(item.distanceMeters, t)}
</p>
</div>
</Link>
))
)}
<RoundedButton
className="mx-auto mt-4 max-w-none"
onClick={requestBrowserLocation}
>
{t("nearbyFriends.refresh")}
</RoundedButton>
</div>
) : null}
</Container>
</LocalePageShell>
);
}

View File

@@ -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() {
</div>
</Link>
<Link
href="/settings/chats/nearby"
className="flex items-center justify-between py-3"
>
<div className="flex min-w-0 flex-1 items-center gap-3">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-[#22c55e]/15">
<Image
src={staticIconUrl("/images/icons/radar.png")}
alt=""
width={26}
height={26}
className="dark:invert"
/>
</div>
<div className="min-w-0 flex flex-col gap-0.5">
<span className="truncate font-semibold">
{t("nearbyFriends.title")}
</span>
<span className="truncate text-[11px] text-neutral-500">
{t("nearbyFriends.desc")}
</span>
</div>
</div>
</Link>
{isEmpty ? (
<p className="py-8 text-center text-neutral-500">
{t("chats.empty")}

View File

@@ -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"
)}
>
<p className="chat-message-text select-text whitespace-pre-wrap break-words">{msg.content}</p>
{msg.file && msg.fileType === "image" ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={
msg.file.startsWith("blob:")
? msg.file
: buildStorageUrl(msg.file)
}
alt=""
className="mb-1 max-h-64 w-full rounded-xl object-cover"
/>
) : null}
{msg.file && msg.fileType === "video" ? (
<video
src={
msg.file.startsWith("blob:")
? msg.file
: buildStorageUrl(msg.file)
}
controls
playsInline
className="mb-1 max-h-64 w-full rounded-xl"
/>
) : null}
{msg.file && msg.fileType === "voice" ? (
<div className="mb-1 min-w-[200px]">
<VoiceMessagePlayer
src={
msg.file.startsWith("blob:")
? msg.file
: buildStorageUrl(msg.file)
}
isOutgoing={isMine}
/>
</div>
) : null}
{msg.file && msg.fileType === "file" ? (
<a
href={
msg.file.startsWith("blob:")
? msg.file
: buildStorageUrl(msg.file)
}
target="_blank"
rel="noopener noreferrer"
className="mb-1 flex items-center gap-2 text-sm underline"
>
<BoldIcon name="document-download" size={18} />
<span>File</span>
</a>
) : null}
{msg.content ? (
<p className="chat-message-text select-text whitespace-pre-wrap break-words">
{msg.content}
</p>
) : null}
<span className="mt-1 block text-[10px] opacity-70">
{msg.createdAt}
</span>
@@ -263,6 +332,12 @@ export default function ChatRoomThreadPage({
const [selectedMessage, setSelectedMessage] = useState<RoomMessage | null>(null);
const [actionMode, setActionMode] = useState(false);
const [showAddMembers, setShowAddMembers] = useState(false);
const [pendingImages, setPendingImages] = useState<File[]>([]);
const [showMultiModal, setShowMultiModal] = useState(false);
const [pendingMediaPreview, setPendingMediaPreview] = useState<{
file: File;
kind: "video" | "file";
} | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
const headerRef = useRef<HTMLElement | null>(null);
const messageRefs = useRef(new Map<string, HTMLDivElement>());
@@ -308,8 +383,19 @@ export default function ChatRoomThreadPage({
{ noToast: true }
);
const incoming = res?.messages ?? [];
setMessages((prev) => mergeRoomMessages(prev, incoming));
}, [request, roomId]);
const uid = String(user?._id ?? getStoredUserId());
let decrypted = incoming;
if (uid) {
try {
await ensureIdentityKeys(uid);
await ensureRoomAesKey(uid, roomId);
decrypted = await decryptRoomMessageList(uid, roomId, incoming);
} catch {
decrypted = incoming;
}
}
setMessages((prev) => mergeRoomMessages(prev, decrypted));
}, [request, roomId, user?._id]);
useEffect(() => {
(async () => {
@@ -355,16 +441,39 @@ export default function ChatRoomThreadPage({
const onNew = (msg: RoomMessage) => {
if (String(msg.roomId) !== String(roomId)) return;
setMessages((prev) => {
if (prev.some((m) => m._id === msg._id)) return prev;
const cleaned = prev.filter(
(m) =>
!String(m._id).startsWith("temp-") ||
m.content !== msg.content ||
String(m.senderId) !== String(msg.senderId)
);
return [...cleaned, msg];
});
void (async () => {
let next = msg;
try {
const uid = String(user?._id ?? getStoredUserId());
if (uid && msg.content) {
const content = await decryptRoomContent(uid, roomId, msg.content);
next = { ...msg, content };
if (msg.replyTo?.content) {
const replyContent = await decryptRoomContent(
uid,
roomId,
msg.replyTo.content
);
next = {
...next,
replyTo: { ...msg.replyTo, content: replyContent },
};
}
}
} catch {
/* keep as-is */
}
setMessages((prev) => {
if (prev.some((m) => m._id === next._id)) return prev;
const cleaned = prev.filter(
(m) =>
!String(m._id).startsWith("temp-") ||
m.content !== next.content ||
String(m.senderId) !== String(next.senderId)
);
return [...cleaned, next];
});
})();
};
socket.on("newGroupMessage", onNew);
@@ -415,12 +524,15 @@ export default function ChatRoomThreadPage({
scrollToBottom();
}, [messages.length, scrollToBottom]);
const sendMessage = async () => {
const processSendMessage = async (
fileToUpload: File | null = null,
fileType?: RoomMessage["fileType"]
) => {
const text = newMessage.trim();
if (!text || sending) return;
if ((!text && !fileToUpload) || sending) return;
const senderId = String(user?._id ?? getStoredUserId());
const tempId = `temp-${Date.now()}`;
const tempId = `temp-${Date.now()}-${Math.random()}`;
const savedReply = replyingTo;
const replyPayload = savedReply
? {
@@ -441,6 +553,8 @@ export default function ChatRoomThreadPage({
senderId,
content: text,
createdAt: new Date().toLocaleTimeString("fa-IR"),
file: fileToUpload ? URL.createObjectURL(fileToUpload) : undefined,
fileType,
sender: user
? {
_id: senderId,
@@ -459,19 +573,64 @@ export default function ChatRoomThreadPage({
setReplyingTo(null);
setSending(true);
let fileForUpload = fileToUpload;
if (fileToUpload) {
try {
toast.loading(t("chats.toast.optimizingFile"), { id: "room-media-opt" });
fileForUpload = await optimizeMediaFile(fileToUpload);
toast.dismiss("room-media-opt");
} catch {
toast.dismiss("room-media-opt");
}
}
try {
const res = await request<{ message?: RoomMessage }>(
"POST",
`/chat-rooms/${roomId}/messages`,
{
content: text,
...(replyPayload ? { replyToId: replyPayload._id } : {}),
}
);
let sendContent = text;
try {
await ensureIdentityKeys(senderId);
const encrypted = await encryptRoomContent(senderId, roomId, text);
if (encrypted) sendContent = encrypted;
} catch {
/* fallback plaintext */
}
let res: { message?: RoomMessage } | null = null;
if (fileForUpload) {
const formData = new FormData();
formData.append("content", sendContent);
if (replyPayload) formData.append("replyToId", replyPayload._id);
if (fileType) formData.append("fileType", fileType);
formData.append("file", fileForUpload);
res = await request<{ message?: RoomMessage }>(
"POST",
`/chat-rooms/${roomId}/messages`,
formData
);
} else {
res = await request<{ message?: RoomMessage }>(
"POST",
`/chat-rooms/${roomId}/messages`,
{
content: sendContent,
...(replyPayload ? { replyToId: replyPayload._id } : {}),
}
);
}
if (res?.message?._id) {
let serverMsg = res.message;
try {
const plain = await decryptRoomContent(
senderId,
roomId,
serverMsg.content || text
);
serverMsg = { ...serverMsg, content: plain || text };
} catch {
serverMsg = { ...serverMsg, content: text };
}
setMessages((prev) =>
prev.map((m) => (m._id === tempId ? res.message! : m))
prev.map((m) => (m._id === tempId ? serverMsg : m))
);
} else {
await loadMessages();
@@ -493,6 +652,65 @@ export default function ChatRoomThreadPage({
}
};
const handleFileSelection = (e: React.ChangeEvent<HTMLInputElement>) => {
const picked = Array.from(e.target.files || []);
if (!picked.length) return;
const MAX_SIZE = 200 * 1024 * 1024;
const valid = picked.filter((f) => {
if (f.size > MAX_SIZE) {
toast.error(t("chats.toast.fileTooLargeNamed", { name: f.name }));
return false;
}
return true;
});
if (!valid.length) return;
const videos = valid.filter((f) => f.type.startsWith("video/"));
const images = valid.filter((f) => f.type.startsWith("image/"));
const others = valid.filter(
(f) => !f.type.startsWith("video/") && !f.type.startsWith("image/")
);
if (videos.length === 1 && images.length === 0 && others.length === 0) {
setPendingMediaPreview({ file: videos[0], kind: "video" });
return;
}
if (others.length === 1 && images.length === 0 && videos.length === 0) {
setPendingMediaPreview({ file: others[0], kind: "file" });
return;
}
if (images.length) {
setPendingImages((prev) => [...prev, ...images].slice(0, 10));
setShowMultiModal(true);
}
};
const handleVoiceUpload = (blob: Blob) => {
const voiceFile = new File([blob], "voice-message.webm", {
type: "audio/webm",
});
void processSendMessage(voiceFile, "voice");
};
const sendAllImages = async () => {
const batch = [...pendingImages];
setShowMultiModal(false);
setPendingImages([]);
for (const file of batch) {
await processSendMessage(file, "image");
}
};
const sendMessage = () => {
if (pendingImages.length > 0) {
void sendAllImages();
return;
}
void processSendMessage(null);
};
const canAddMembers =
room?.visibility === "private" &&
room?.createdBy &&
@@ -685,8 +903,8 @@ export default function ChatRoomThreadPage({
newMessage={newMessage}
setNewMessage={setNewMessage}
sendMessage={sendMessage}
handleFileSelection={() => {}}
handleVoiceUpload={() => {}}
handleFileSelection={handleFileSelection}
handleVoiceUpload={handleVoiceUpload}
isChatThread
replyingTo={replyingTo}
onCancelReply={() => setReplyingTo(null)}
@@ -701,6 +919,36 @@ export default function ChatRoomThreadPage({
/>
)}
<MultiImageModal
isOpen={showMultiModal}
files={pendingImages}
onCancel={() => {
setShowMultiModal(false);
setPendingImages([]);
}}
onConfirm={() => void sendAllImages()}
onRemove={(index) =>
setPendingImages((prev) => {
const next = prev.filter((_, i) => i !== index);
if (next.length === 0) setShowMultiModal(false);
return next;
})
}
/>
<MediaPreviewModal
isOpen={!!pendingMediaPreview}
file={pendingMediaPreview?.file ?? null}
kind={pendingMediaPreview?.kind ?? "file"}
onCancel={() => setPendingMediaPreview(null)}
onConfirm={() => {
if (!pendingMediaPreview) return;
const { file, kind } = pendingMediaPreview;
setPendingMediaPreview(null);
void processSendMessage(file, kind);
}}
/>
<AddChatRoomMembersModal
open={showAddMembers}
onClose={() => setShowAddMembers(false)}

View File

@@ -11,7 +11,7 @@ import AuthNextButton from "@/components/auth/AuthNextButton";
import Container from "@/components/elements/Container";
import UsernameSuggestions from "@/components/auth/UsernameSuggestions";
import { usernameFormSchema } from "@/lib/validation/usernameSchema";
import { sanitizeUsernameInput, USERNAME_MIN_LENGTH } from "@/lib/validation/username";
import { sanitizeUsernameInput, USERNAME_MIN_LENGTH, USERNAME_MAX_LENGTH } from "@/lib/validation/username";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import { useTranslation } from "react-i18next";
@@ -80,6 +80,7 @@ function UsernamePage() {
dir="ltr"
autoComplete="username"
spellCheck={false}
maxLength={USERNAME_MAX_LENGTH}
className={`border ${
(formik.touched.username && formik.errors.username) || isDuplicate
? "border-red-500 dark:border-red-500"

View File

@@ -105,16 +105,17 @@ function Notifications() {
};
function getNotificationLink(item: INotification): string {
const applicantWorkroom = `/settings/workroom/user/${item?.project_post_id}`;
const creatorWorkroom = `/settings/workroom/${item?.project_post_id}`;
const workroomPath =
userType === "user"
? `/settings/workroom/user/${item?.project_post_id}`
: `/settings/workroom/${item?.project_post_id}`;
userType === "user" ? applicantWorkroom : creatorWorkroom;
const routes: Record<string, string> = {
request_accepted: workroomPath,
// پیشنهاد جدید برای کارفرما → صفحه اتاق کار سازنده (تایید درخواست)
request: creatorWorkroom,
request_accepted: applicantWorkroom,
payment_progress: workroomPath,
"recive-project": workroomPath,
request: workroomPath,
"recive-project": applicantWorkroom,
"accept-project": workroomPath,
end_project: workroomPath,
"reject-project": workroomPath,
@@ -139,9 +140,8 @@ function Notifications() {
academy_purchase: `/academy/${item?.project_post_id}/course`,
billboard_comment: `/settings/my-billboards/${item?.project_post_id}/b`,
billboard_rating: `/settings/my-billboards/${item?.project_post_id}/b`,
"reject-user": `/tickets/new/${item?.project_post_id}/${encodeURIComponent(
item?.title?.split("/").pop() || ""
)}`,
"reject-user": "/settings/edit/Authentication",
verify: "/settings/edit/Authentication",
};
return routes[item?.type] || "#";
@@ -160,7 +160,8 @@ function Notifications() {
filteredItems,
(item) => item.createdAt,
(item) => item._id,
listLang
listLang,
{ includeTime: true }
),
[filteredItems, listLang]
);

View File

@@ -1,7 +1,16 @@
"use client";
import Header from "@/components/main/Header";
export default function ProfileLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
return (
<>
<Header />
{children}
</>
);
}

View File

@@ -11,7 +11,6 @@ import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { useAppLanguage } from "@/contexts/LanguageProvider";
import { isAppLanguage } from "@/lib/i18n/constants";
type PrivacyPatch = {
allow_save_posts?: boolean;
ghost_mode?: boolean;
@@ -131,7 +130,7 @@ export default function UserSettingsPage() {
<LocalePageShell>
<Container>
<PageTitle>{t("settings.userSettingsTitle")}</PageTitle>
<div className="space-y-4 px-4 pb-8 text-sm">
<div className="space-y-4 pb-8 text-sm">
{accountStatus === "deactivated" && (
<div className="rounded-xl border border-amber-300 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-950/30">
<p className="font-semibold text-amber-800 dark:text-amber-200">
@@ -249,6 +248,7 @@ export default function UserSettingsPage() {
className="h-5 w-5 accent-[#0095f6]"
/>
</label>
</div>
</Container>
</LocalePageShell>

View File

@@ -39,11 +39,40 @@ export default function EditProjectWrapper({ params }: IEditProps) {
subExpertise: res?.sub_expertise || [],
gender: res.gender || "",
age: res.age || "",
ageMin: res.age_min != null ? String(res.age_min) : "",
ageMax: res.age_max != null ? String(res.age_max) : "",
publicType: String(res.conversation_projects) || "",
projTitle: res.title || "",
projectTime: res.offer_time || "",
offerPrice: String(res?.offer_price) || "",
numberOfPerson: res.numberOfPerson || "",
neighborhood: res.neighborhood || "",
address: res.address || "",
markerCoordinate:
res.lng != null && res.lat != null
? [Number(res.lng), Number(res.lat)]
: [],
paymentMethod: res.payment_method || "",
startDate: res.start_date || "",
workHoursStart: res.work_hours_start || "",
workHoursEnd: res.work_hours_end || "",
collaborationType: res.collaboration_type || "",
experience: res.experience || "",
roles: (res.roles || []).map((role) => ({
id: role._id,
expertise: role.expertise,
subExpertise: role.sub_expertise || [],
numberOfPerson: String(role.number_of_person || 1),
heightMin: role.height_min != null ? String(role.height_min) : "",
heightMax: role.height_max != null ? String(role.height_max) : "",
weightMin: role.weight_min != null ? String(role.weight_min) : "",
weightMax: role.weight_max != null ? String(role.weight_max) : "",
sizeMin: role.size_min != null ? String(role.size_min) : "",
sizeMax: role.size_max != null ? String(role.size_max) : "",
eyeColor: role.eye_color || "",
hairColor: role.hair_color || "",
portfolioRequired: Boolean(role.portfolio_required),
})),
_id: res._id || "",
});
setFetched(true);

View File

@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Container from "@/components/elements/Container";
import RoundedButton from "@/components/elements/RoundedButton";
@@ -11,6 +11,12 @@ import useInfiniteScroll from "@/hooks/useInfiniteScroll";
import { useUser } from "@/hooks/useUser";
import { Project } from "@/types/types";
import { useTranslation } from "react-i18next";
import { statusMap } from "@/constants";
import {
listProjectDrafts,
ProjectDraft,
removeProjectDraft,
} from "@/lib/projectDrafts";
const STATUS_OPTIONS = [
{ key: "received", value: "دریافتی", color: "#B89FFF" },
@@ -28,6 +34,8 @@ const Workroom = () => {
const router = useRouter();
const user = useUser();
const [statusFilter, setStatusFilter] = useState("");
const [showDrafts, setShowDrafts] = useState(false);
const [drafts, setDrafts] = useState<ProjectDraft[]>([]);
const { data, isFetchingNextPage } = useInfiniteScroll({
endpoint: "/workroom",
@@ -35,9 +43,14 @@ const Workroom = () => {
params: { status_filter: statusFilter },
});
const getStatusLabel = (statusType: string) => {
const option = STATUS_OPTIONS.find((item) => item.value === statusType);
if (option) return t(`settings.workroom.statuses.${option.key}`);
useEffect(() => {
if (showDrafts) setDrafts(listProjectDrafts());
}, [showDrafts]);
const getStatusLabel = (statusType?: string) => {
if (!statusType) return t("settings.unknown");
const entry = statusMap[statusType as keyof typeof statusMap];
if (entry?.labelKey) return t(entry.labelKey);
return t("settings.unknown");
};
@@ -62,16 +75,38 @@ const Workroom = () => {
<PageTitle>{t("settings.nav.workroom")}</PageTitle>
<div className="px-4 text-xs md:text-sm">
<div className="grid grid-cols-3 gap-2 px-4 text-sm">
<RoundedButton
onClick={() => {
setShowDrafts(true);
setStatusFilter("");
}}
className={`p-1 text-xs sm:p-2 md:text-sm ${
showDrafts ? "text-primary-dark" : ""
}`}
style={{
backgroundColor: showDrafts ? "#FFD6E7" : "",
borderColor: showDrafts ? "#FFD6E7" : "",
}}
>
{t("settings.workroom.drafts")}
</RoundedButton>
{STATUS_OPTIONS.map(({ key, value, color }) => (
<RoundedButton
key={value}
onClick={() => setStatusFilter(value)}
onClick={() => {
setShowDrafts(false);
setStatusFilter(value);
}}
className={`p-1 text-xs sm:p-2 md:text-sm ${
statusFilter === value ? " text-primary-dark" : ""
!showDrafts && statusFilter === value
? " text-primary-dark"
: ""
}`}
style={{
backgroundColor: statusFilter === value ? color : "",
borderColor: statusFilter === value ? color : "",
backgroundColor:
!showDrafts && statusFilter === value ? color : "",
borderColor:
!showDrafts && statusFilter === value ? color : "",
}}
>
{t(`settings.workroom.statuses.${key}`)}
@@ -79,8 +114,65 @@ const Workroom = () => {
))}
</div>
<div className="mt-10 w-full">
{data?.pages.length === 0 ||
(data?.pages[0]?.projects?.length === 0 && !isFetchingNextPage) ? (
{showDrafts ? (
drafts.length === 0 ? (
<p className="text-center text-gray-500">
{t("settings.workroom.draftsEmpty")}
</p>
) : (
<div className="flex flex-col gap-3">
{drafts.map((draft) => (
<div
key={draft.id}
className="rounded-3xl border border-border-secondary-light p-4 dark:border-border-secondary-dark"
>
<button
type="button"
className="w-full text-right"
onClick={() =>
router.push(`/new-project?draft=${draft.id}`)
}
>
<p className="font-semibold text-text-blue-light dark:text-text-blue-dark">
{draft.title}
</p>
<p className="mt-1 text-xs text-neutral-500">
{t("settings.workroom.draftStep", {
step: draft.step,
})}
{" · "}
{new Date(draft.updatedAt).toLocaleDateString("fa-IR")}
</p>
</button>
<div className="mt-3 flex gap-2">
<RoundedButton
type="button"
variant="primary"
className="h-8 flex-1 text-xs"
onClick={() =>
router.push(`/new-project?draft=${draft.id}`)
}
>
{t("settings.workroom.continueDraft")}
</RoundedButton>
<RoundedButton
type="button"
className="h-8 flex-1 text-xs"
onClick={() => {
removeProjectDraft(draft.id);
setDrafts(listProjectDrafts());
}}
>
{t("settings.workroom.deleteDraft")}
</RoundedButton>
</div>
</div>
))}
</div>
)
) : data?.pages.length === 0 ||
(data?.pages[0]?.projects?.length === 0 &&
!isFetchingNextPage) ? (
<p className="text-center text-gray-500">
{t("settings.workroom.empty")}
</p>

View File

@@ -1,107 +0,0 @@
import { MetadataRoute } from 'next'
const API_BASE = 'https://api.modstagram.ir/api/v1'
async function fetchJsonSafe<T>(url: string): Promise<T | null> {
try {
const res = await fetch(url, { next: { revalidate: 3600 } })
const contentType = res.headers.get('content-type') || ''
if (!res.ok || !contentType.includes('application/json')) {
return null
}
return (await res.json()) as T
} catch {
return null
}
}
async function fetchAllPublicProfiles(): Promise<
Array<{ user_name: string; updatedAt?: string }>
> {
const allProfiles: Array<{ user_name: string; updatedAt?: string }> = []
let page = 1
const limit = 500
while (page <= 20) {
const data = await fetchJsonSafe<{
users?: Array<{ user_name: string; updatedAt?: string }>
hasMore?: boolean
}>(`${API_BASE}/users/seo/public-profiles?page=${page}&limit=${limit}`)
const batch = data?.users || []
allProfiles.push(...batch)
if (!data?.hasMore || batch.length < limit) break
page += 1
}
return allProfiles
}
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = 'https://modstagram.com'
const staticRoutes = [
'',
'/videos',
'/billboards',
'/users',
'/posts',
'/projects',
'/academy',
'/explore',
'/about-us',
].map((route) => ({
url: `${baseUrl}${route}`,
lastModified: new Date(),
changeFrequency: 'daily' as const,
priority: route === '' ? 1.0 : 0.9,
}))
const billboardData = await fetchJsonSafe<{ advertisings?: Array<Record<string, unknown>> }>(
`${API_BASE}/advertising/web?page=1&limit=200`
)
const billboards = billboardData?.advertisings || []
const billboardRoutes = billboards.map((b) => {
const titleParts = [
b.title,
b.category,
(b.province as { name?: string } | undefined)?.name,
(b.city as { name?: string } | undefined)?.name,
b.neighbourhood !== (b.city as { name?: string } | undefined)?.name ? b.neighbourhood : null,
].filter(Boolean)
const slug = String(titleParts.join(' '))
.trim()
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
return {
url: `${baseUrl}/billboards/${b._id}/${encodeURIComponent(slug)}`,
lastModified: new Date(String(b.updatedAt || new Date())),
priority: 0.8,
}
})
const postsData = await fetchJsonSafe<{ posts?: Array<{ _id: string; updatedAt?: string }> }>(
`${API_BASE}/users/web?page=1&limit=100`
)
const posts = postsData?.posts || []
const postRoutes = posts.map((p) => ({
url: `${baseUrl}/posts/${p._id}`,
lastModified: new Date(p.updatedAt || new Date()),
changeFrequency: 'weekly' as const,
priority: 0.65,
}))
const publicProfiles = await fetchAllPublicProfiles()
const profileRoutes = publicProfiles.map((user) => ({
url: `${baseUrl}/users/${encodeURIComponent(user.user_name)}`,
lastModified: new Date(user.updatedAt || new Date()),
changeFrequency: 'weekly' as const,
priority: 0.75,
}))
return [...staticRoutes, ...profileRoutes, ...billboardRoutes, ...postRoutes]
}

View File

@@ -0,0 +1,82 @@
import { NextResponse } from "next/server";
import {
SITE_URL,
SITEMAP_REVALIDATE_SECONDS,
} from "@/lib/sitemap/config";
import {
countAcademiesPages,
countBillboardsPages,
countCoursesPages,
countPostsPages,
countProjectsPages,
countUsersPages,
} from "@/lib/sitemap/builders";
export const dynamic = "force-dynamic";
// Must be a literal — Next.js cannot analyze imported revalidate values
export const revalidate = 3600;
function sitemapLoc(path: string) {
return ` <sitemap>
<loc>${SITE_URL}${path}</loc>
<lastmod>${new Date().toISOString()}</lastmod>
</sitemap>`;
}
/**
* ایندکس اصلی — هر بار از API تعداد صفحات را می‌گیرد
* تا پست/کاربر/پروژه/… جدید خودکار وارد sitemap شوند.
*/
export async function GET() {
const [
postsPages,
usersPages,
projectsPages,
billboardsPages,
coursesPages,
academiesPages,
] = await Promise.all([
countPostsPages().catch(() => 1),
countUsersPages().catch(() => 1),
countProjectsPages().catch(() => 1),
countBillboardsPages().catch(() => 1),
countCoursesPages().catch(() => 1),
countAcademiesPages().catch(() => 1),
]);
const parts: string[] = [
sitemapLoc("/sitemaps/static.xml"),
sitemapLoc("/sitemaps/filters.xml"),
];
for (let i = 1; i <= postsPages; i++) {
parts.push(sitemapLoc(`/sitemaps/posts/${i}.xml`));
}
for (let i = 1; i <= usersPages; i++) {
parts.push(sitemapLoc(`/sitemaps/users/${i}.xml`));
}
for (let i = 1; i <= projectsPages; i++) {
parts.push(sitemapLoc(`/sitemaps/projects/${i}.xml`));
}
for (let i = 1; i <= billboardsPages; i++) {
parts.push(sitemapLoc(`/sitemaps/billboards/${i}.xml`));
}
for (let i = 1; i <= coursesPages; i++) {
parts.push(sitemapLoc(`/sitemaps/courses/${i}.xml`));
}
for (let i = 1; i <= academiesPages; i++) {
parts.push(sitemapLoc(`/sitemaps/academies/${i}.xml`));
}
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${parts.join("\n")}
</sitemapindex>`;
return new NextResponse(xml, {
headers: {
"Content-Type": "application/xml; charset=utf-8",
"Cache-Control": `public, s-maxage=${SITEMAP_REVALIDATE_SECONDS}, stale-while-revalidate`,
},
});
}

View File

@@ -0,0 +1,100 @@
import { NextResponse } from "next/server";
import {
SITEMAP_REVALIDATE_SECONDS,
type SitemapEntry,
} from "@/lib/sitemap/config";
import {
buildAcademiesPage,
buildBillboardsPage,
buildCoursesPage,
buildFilterEntries,
buildPostsPage,
buildProjectsPage,
buildStaticEntries,
buildUsersPage,
} from "@/lib/sitemap/builders";
export const dynamic = "force-dynamic";
// Must be a literal — Next.js cannot analyze imported revalidate values
export const revalidate = 3600;
function escapeXml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
function toXml(entries: SitemapEntry[]): string {
const urls = entries
.map((item) => {
const lastmod = item.lastModified
? new Date(item.lastModified).toISOString()
: new Date().toISOString();
return ` <url>
<loc>${escapeXml(item.url)}</loc>
<lastmod>${lastmod}</lastmod>
<changefreq>${item.changeFrequency || "daily"}</changefreq>
<priority>${(item.priority ?? 0.5).toFixed(1)}</priority>
</url>`;
})
.join("\n");
return `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls}
</urlset>`;
}
function xmlResponse(entries: SitemapEntry[]) {
return new NextResponse(toXml(entries), {
headers: {
"Content-Type": "application/xml; charset=utf-8",
"Cache-Control": `public, s-maxage=${SITEMAP_REVALIDATE_SECONDS}, stale-while-revalidate`,
},
});
}
type RouteParams = {
params: Promise<{ type: string; page?: string[] }>;
};
async function resolveEntries(
type: string,
pageRaw?: string
): Promise<SitemapEntry[]> {
const page = Math.max(1, parseInt(String(pageRaw || "1"), 10) || 1);
switch (type) {
case "static":
return buildStaticEntries();
case "filters":
return buildFilterEntries();
case "posts":
return buildPostsPage(page);
case "users":
return buildUsersPage(page);
case "projects":
return buildProjectsPage(page);
case "billboards":
return buildBillboardsPage(page);
case "courses":
return buildCoursesPage(page);
case "academies":
return buildAcademiesPage(page);
default:
return [];
}
}
/** /sitemaps/static.xml | /sitemaps/filters.xml | /sitemaps/posts/1.xml */
export async function GET(_req: Request, { params }: RouteParams) {
const { type, page } = await params;
const normalizedType = type.replace(/\.xml$/i, "");
const normalizedPage = page?.[0]?.replace(/\.xml$/i, "");
const entries = await resolveEntries(normalizedType, normalizedPage);
return xmlResponse(entries);
}

View File

@@ -10,6 +10,7 @@ import ScrollRestorationInit from "@/components/ScrollRestorationInit";
import TitleGuardian from "@/components/TitleGuardian";
import AuthSessionSync from "@/components/auth/AuthSessionSync";
import PwaInstallPrompt from "@/components/pwa/PwaInstallPrompt";
import PwaBootSplash from "@/components/pwa/PwaBootSplash";
import PwaHead from "@/components/PwaHead";
interface ILayoutProps {
@@ -21,6 +22,7 @@ function Layout({ children }: ILayoutProps) {
<LanguageProvider>
<ReactQueryProvider>
<PwaHead />
<PwaBootSplash />
<ScrollRestorationInit />
<TitleGuardian />
<Toaster />

View File

@@ -49,7 +49,7 @@ export default function TabNavigation({ currentPage }: TabNavigationProps) {
return (
<nav
className={cn(
"fixed bottom-2 left-1/2 z-50 -translate-x-1/2 pb-2",
"fixed bottom-2 left-1/2 z-[55] -translate-x-1/2 pb-2",
PAGE_SHELL_CLASS
)}
>

View File

@@ -19,7 +19,8 @@ interface ModelHeadProps {
}
function ModelHead({ user, item2 }: ModelHeadProps) {
const { t } = useTranslation("common");
const { t, i18n } = useTranslation("common");
const isFa = (i18n.language || "fa").toLowerCase().startsWith("fa");
const {
profile_image,
user_name,
@@ -67,8 +68,8 @@ function ModelHead({ user, item2 }: ModelHeadProps) {
return (
<div className="w-full">
<div className="flex w-full items-center justify-between px-4 py-2">
<div className="w-full" dir={isFa ? "ltr" : "rtl"}>
<div className="flex w-full items-center justify-between py-2">
<div className="flex flex-col items-center text-xs md:text-sm ">
{user_type == "user" && (
<div className="flex text-[#3A59A9] font-semibold w-full">
@@ -128,8 +129,9 @@ function ModelHead({ user, item2 }: ModelHeadProps) {
user_score={user_score}
rate={rate}
user_id={_id}
isFa={isFa}
/>
<ModelHeadRowThree user_level={user_level} bio={bio} />
<ModelHeadRowThree user_level={user_level} bio={bio} isFa={isFa} />
</div>
);
}

View File

@@ -9,6 +9,8 @@ import ChatRoomMemberPicker, {
} from "@/components/chat/ChatRoomMemberPicker";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { getStoredUserId } from "@/lib/auth/session";
import { ensureRoomAesKey } from "@/lib/e2ee";
type Props = {
open: boolean;
@@ -50,6 +52,14 @@ export default function AddChatRoomMembersModal({
await request("POST", `/chat-rooms/${roomId}/members`, {
member_ids: memberIds,
});
const uid = getStoredUserId();
if (uid) {
try {
await ensureRoomAesKey(String(uid), roomId);
} catch {
/* wrap sync best-effort */
}
}
toast.success(t("chats.toast.membersAdded"));
setSelected([]);
onAdded();

View File

@@ -42,6 +42,7 @@ import SharedStoryBubble, {
parseSharedStory,
extractStoryReactionText,
} from "./SharedStoryBubble";
import { E2EE_LOCKED_PREVIEW, isE2eeEnvelope } from "@/lib/e2ee";
function ViewOnceBadge() {
const { t } = useTranslation("common");
@@ -199,15 +200,32 @@ function PreviewOverlay({
}
function parseForwarded(content: string): ChatMessage["forwardedFrom"] | null {
if (!content || isE2eeEnvelope(content)) return null;
try {
const line = content.split("\n")[0];
const j = JSON.parse(line);
if (j?.e2ee) return null;
return j.forwardedFrom ?? null;
} catch {
return null;
}
}
/** متن قابل نمایش — پاکت E2EE را با regex خراب نکن */
function getBubbleTextContent(content: string | undefined): string {
if (!content) return "";
if (isE2eeEnvelope(content)) return E2EE_LOCKED_PREVIEW;
if (parseLocationContent(content)) return "";
const forwarded = parseForwarded(content);
if (forwarded && content.includes("\n")) {
return content.slice(content.indexOf("\n") + 1).trim();
}
// فقط پیشوند forwarded یک‌خطی؛ نه کل JSON با .* greedy
return content;
}
function isEmojiOnly(text: string): boolean {
const stripped = text.trim();
if (!stripped) return false;
@@ -330,13 +348,9 @@ const ChatMessageCard = ({
: detectChatFileType(message.file || "", message.fileType);
const textContent =
message.content &&
!parseLocationContent(message.content) &&
!forwarded &&
!sharedPost &&
!sharedStory
? message.content.replace(/^\{.*\}\n?/, "").trim()
: message.content?.replace(/^\{.*\}\n?/, "").trim() || "";
sharedPost || sharedStory
? ""
: getBubbleTextContent(message.content);
const showTextBubble =
!sharedStory &&

View File

@@ -36,6 +36,13 @@ import {
import { cn } from "@/lib/utils";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import {
decryptDmContent,
decryptDmMessageList,
ensureIdentityKeys,
E2EE_LOCKED_PREVIEW,
recallOutboundPlaintext,
} from "@/lib/e2ee";
function belongsToThread(
msg: ChatMessage,
@@ -144,18 +151,28 @@ const ChatMessageList = ({
}
try {
const response = await apiClient.get(
`/chat?senderId=${sid}&receiverId=${rid}&page=${pageParam}&limit=50`,
{ baseURL: getApiBaseUrl() }
);
// کلیدها و فچ موازی تا تأخیر کوتاه قبل از نمایش پیام‌ها حذف شود
const [response] = await Promise.all([
apiClient.get(
`/chat?senderId=${sid}&receiverId=${rid}&page=${pageParam}&limit=50`,
{ baseURL: getApiBaseUrl() }
),
ensureIdentityKeys(sid).catch(() => undefined),
]);
const fetched = (response.data.messages || []) as ChatMessage[];
let decrypted = fetched;
try {
decrypted = await decryptDmMessageList(sid, rid, fetched);
} catch {
decrypted = fetched;
}
const cachedData = queryClient.getQueryData<ChatThreadData>(
chatThreadQueryKey(sid, rid)
);
const msgs =
pageParam === 1
? mergeFetchedThreadPage(fetched, cachedData?.pages?.[0]?.messages)
: fetched;
? mergeFetchedThreadPage(decrypted, cachedData?.pages?.[0]?.messages)
: decrypted;
return {
messages: msgs,
nextPage:
@@ -169,10 +186,10 @@ const ChatMessageList = ({
enabled: threadReady,
initialPageParam: 1,
getNextPageParam: (lastPage) => lastPage.nextPage,
staleTime: 60_000,
staleTime: 0,
gcTime: 10 * 60 * 1000,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnMount: "always",
refetchOnWindowFocus: true,
placeholderData: keepPreviousData,
refetchInterval:
socketConnected || pendingMessages.length > 0 ? false : 4000,
@@ -241,21 +258,56 @@ const ChatMessageList = ({
const onNewMessage = (message: ChatMessage) => {
if (!belongs(message)) return;
onIncomingMessageRef.current?.(message);
void (async () => {
let decrypted = message;
try {
let content = await decryptDmContent(
senderId,
receiverId,
message.content || ""
);
// پیام خودمان: اگر decrypt fail شد از plaintext ذخیره‌شده استفاده کن
if (
content === E2EE_LOCKED_PREVIEW &&
message.content &&
String(message.senderId) === String(senderId)
) {
content =
recallOutboundPlaintext(message.content) || content;
}
decrypted = { ...message, content };
if (message.replyTo?.content) {
const replyContent = await decryptDmContent(
senderId,
receiverId,
message.replyTo.content
);
decrypted = {
...decrypted,
replyTo: { ...message.replyTo, content: replyContent },
};
}
} catch {
/* keep ciphertext / locked preview */
}
queryClient.setQueryData(
chatThreadQueryKey(senderId, receiverId),
(oldData) => upsertMessageInThreadCache(oldData, message)
);
setToEnd(true);
onIncomingMessageRef.current?.(decrypted);
if (String(message.senderId) === String(receiverId)) {
socket.emit("messageSeen", {
messageId: message._id,
senderId: message.senderId,
receiverId: senderId,
});
}
queryClient.setQueryData(
chatThreadQueryKey(senderId, receiverId),
(oldData: ChatThreadData | undefined) =>
upsertMessageInThreadCache(oldData, decrypted)
);
setToEnd(true);
if (String(message.senderId) === String(receiverId)) {
socket.emit("messageSeen", {
messageId: message._id,
senderId: message.senderId,
receiverId: senderId,
});
}
})();
};
socket.on("newMessage", onNewMessage);

View File

@@ -15,6 +15,8 @@ import ChatRoomMemberPicker, {
} from "@/components/chat/ChatRoomMemberPicker";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
import { getStoredUserId } from "@/lib/auth/session";
import { ensureIdentityKeys, ensureRoomAesKey } from "@/lib/e2ee";
type Props = {
open: boolean;
@@ -113,7 +115,21 @@ export default function CreateChatRoomModal({ open, onClose, onCreated }: Props)
}
if (imageFile) form.append("room_image", imageFile);
await request("POST", "/chat-rooms", form);
const created = await request<{ room?: { _id?: string } }>(
"POST",
"/chat-rooms",
form
);
const newRoomId = created?.room?._id;
const uid = getStoredUserId();
if (newRoomId && uid) {
try {
await ensureIdentityKeys(String(uid));
await ensureRoomAesKey(String(uid), String(newRoomId));
} catch {
/* e2ee bootstrap best-effort */
}
}
toast.success(t("chats.toast.roomCreated"));
onCreated();
onClose();

View File

@@ -8,6 +8,7 @@ import useAxios from "@/hooks/useAxios";
import toast from "react-hot-toast";
import IOSSpinner from "@/components/ui/IOSSpinner";
import { useTranslation } from "react-i18next";
import { encryptDmContent, ensureIdentityKeys } from "@/lib/e2ee";
export interface ChatUserItem {
_id: string;
@@ -91,15 +92,27 @@ export default function ForwardMessageModal({
try {
const targets = Array.from(selected);
await ensureIdentityKeys(currentUserId);
await Promise.all(
targets.map((receiverId) =>
request("POST", "/chat", {
content,
targets.map(async (receiverId) => {
let sendContent = content;
try {
const encrypted = await encryptDmContent(
currentUserId,
receiverId,
content
);
if (encrypted) sendContent = encrypted;
} catch {
/* plaintext fallback */
}
return request("POST", "/chat", {
content: sendContent,
receiverId,
senderId: currentUserId,
forwardedFrom: message.forwardedFrom,
})
)
});
})
);
toast.success(t("chats.toast.forwardSent", { count: selected.size }));
onClose();

View File

@@ -40,7 +40,13 @@ export default function MediaPreviewModal({
const isVideo = kind === "video";
return (
<Modal isOpen={isOpen} onClose={onCancel} height="min(88vh, 680px)">
<Modal
isOpen={isOpen}
onClose={onCancel}
elevated
height="min(calc(100dvh - 4.5rem), 720px)"
panelClassName="!pb-0"
>
<div className="flex h-full min-h-0 flex-col">
<div className="flex items-center justify-between px-4 pb-3 pt-1">
<button
@@ -88,7 +94,7 @@ export default function MediaPreviewModal({
)}
</div>
<div className="flex flex-col items-center gap-3 border-t border-black/5 px-4 pb-4 pt-3 dark:border-white/10">
<div className="relative z-[1] flex flex-col items-center gap-3 border-t border-black/5 px-4 pb-[calc(1.25rem+env(safe-area-inset-bottom,0px))] pt-3 dark:border-white/10">
{isVideo && (
<div className="flex w-full max-w-xs items-center justify-center gap-5">
{onSelfDestructChange && (
@@ -117,9 +123,9 @@ export default function MediaPreviewModal({
<button
type="button"
onClick={onConfirm}
className="chat-header-glass gentle-transition flex w-full max-w-xs items-center justify-center gap-2 rounded-full py-3 text-sm font-semibold text-neutral-800 active:scale-[0.98] dark:text-neutral-100"
className="ig-dm-send-btn gentle-transition flex w-full max-w-xs items-center justify-center gap-2 rounded-full py-3 text-sm font-semibold text-white shadow-lg active:scale-[0.98]"
>
<ChatBoldIcon name="send" size={18} className="-mr-0.5" />
<ChatBoldIcon name="send" size={18} className="text-white -mr-0.5" />
{t("chats.actions.send")}
</button>
</div>

View File

@@ -41,8 +41,14 @@ export default function MultiImageModal({
const single = files.length === 1;
return (
<Modal isOpen={isOpen} onClose={onCancel} height="min(88vh, 680px)">
<div className="flex h-full flex-col">
<Modal
isOpen={isOpen}
onClose={onCancel}
elevated
height="min(calc(100dvh - 4.5rem), 720px)"
panelClassName="!pb-0"
>
<div className="flex h-full min-h-0 flex-col">
<div className="flex items-center justify-between px-4 pb-3 pt-1">
<button
type="button"
@@ -109,7 +115,7 @@ export default function MultiImageModal({
))}
</div>
<div className="flex flex-col items-center gap-3 border-t border-black/5 px-4 pb-4 pt-3 dark:border-white/10">
<div className="relative z-[1] flex flex-col items-center gap-3 border-t border-black/5 px-4 pb-[calc(1.25rem+env(safe-area-inset-bottom,0px))] pt-3 dark:border-white/10">
<div className="flex w-full max-w-xs items-center justify-center gap-5">
{onSelfDestructChange && (
<div className="flex flex-col items-center gap-1">

View File

@@ -20,6 +20,7 @@ export function parseSharedPost(content: string): SharedPostPayload | null {
try {
const line = content.split("\n")[0];
const j = JSON.parse(line);
if (j?.e2ee) return null;
return j.sharedPost ?? null;
} catch {
return null;

View File

@@ -20,6 +20,7 @@ export function parseSharedStory(content: string): SharedStoryPayload | null {
try {
const line = content.split("\n")[0];
const j = JSON.parse(line);
if (j?.e2ee) return null;
return j.sharedStory ?? null;
} catch {
return null;
@@ -48,7 +49,11 @@ export default function SharedStoryBubble({
<div className="block overflow-hidden rounded-xl border border-white/20 bg-black/10">
<div className="flex items-center gap-1.5 px-2 pt-2 text-[10px] font-semibold opacity-80">
<BoldIcon name="video-vertical" size={14} tinted className="text-current" />
<span>{t("chats.shared.storyReply")}</span>
<span>
{data.reactionType
? t("chats.shared.storyReply")
: t("chats.shared.story")}
</span>
{data.ownerDisplayName || data.ownerUserName ? (
<Link
href={`/users/${data.ownerUserName}`}

View File

@@ -19,7 +19,6 @@ import useAxios from "@/hooks/useAxios";
import SubscriptionCard from "./SubscriptionCard";
import Cookies from "js-cookie";
import { useTranslation } from "react-i18next";
function appendCourseFormFields(formData: FormData, data: any) {
formData.append("is_free", data.is_free ? "true" : "false");
formData.append("price", data.is_free ? "0" : String(data.price ?? "0"));
@@ -362,18 +361,9 @@ export function CourseManager() {
if (paymentUrl) {
console.log("هدایت به درگاه:", paymentUrl);
// باز کردن درگاه پرداخت
const newWindow = window.open(paymentUrl, "_blank");
if (!newWindow) {
toast.error(t("academyCourse.popupBlocked"));
return;
}
window.open(paymentUrl, "_blank", "noopener,noreferrer");
toast.success(t("academyCourse.redirectedToPayment"));
// بستن دیالوگ بعد از باز شدن درگاه (اختیاری)
setTimeout(() => {
setIsDialogOpen1?.(false);
}, 1000);
@@ -486,7 +476,7 @@ export function CourseManager() {
};
return (
<div className="container mx-auto p-6">
<div className="container mx-auto px-4 py-6 lg:px-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">{t("academyCourse.manageTitle")}</h1>
<Button

View File

@@ -40,7 +40,7 @@ const Modal: React.FC<ModalProps> = ({
onClick={onClose}
className={cn(
"glass-modal-overlay fixed inset-0 flex items-end",
elevated ? "z-[120]" : "z-50"
elevated ? "z-[220]" : "z-40"
)}
>
<div

View File

@@ -9,6 +9,7 @@ import { buildStorageUrl } from "@/components/main/BaseUrl";
import BoldIcon from "@/components/ui/BoldIcon";
import { formatLikeDisplay } from "@/lib/formatLikeDisplay";
import ExploreAuthor from "@/components/explore/ExploreAuthor";
import ExploreVideoThumb from "@/components/explore/ExploreVideoThumb";
import { ExploreFilterId } from "@/constants/exploreFilters";
const ASPECT_RATIOS = [
@@ -31,21 +32,35 @@ function hashAspect(id: string): (typeof ASPECT_RATIOS)[number] {
function getAcademyMedia(item: AcademyExploreItem): {
kind: "video" | "image";
src: string;
poster?: string;
} | null {
const file = item.files?.[0];
const files = item.files || [];
const firstVideo = files.find((f) => f.type === "video");
const firstImage = files.find((f) => f.type === "image");
const cover =
firstImage?.path || item.course_images?.[0] || item.course_image;
if (item.type === "video" || firstVideo || item.course_video) {
const videoPath =
firstVideo?.path || item.course_video || files[0]?.path;
if (!videoPath && !cover) return null;
return {
kind: "video",
src: buildStorageUrl(videoPath || cover || ""),
poster: cover ? buildStorageUrl(cover) : undefined,
};
}
if (cover) {
return { kind: "image", src: buildStorageUrl(cover) };
}
const file = files[0];
if (file?.path) {
const src = buildStorageUrl(file.path);
const isVideo = item.type === "video" || file.type === "video";
return { kind: isVideo ? "video" : "image", src };
}
if (item.course_video) {
return { kind: "video", src: buildStorageUrl(item.course_video) };
}
const imagePath = item.course_images?.[0] || item.course_image;
if (imagePath) {
return { kind: "image", src: buildStorageUrl(imagePath) };
return {
kind: file.type === "video" ? "video" : "image",
src: buildStorageUrl(file.path),
};
}
return null;
@@ -105,13 +120,7 @@ export default function ExploreAcademyCard({
className={`relative w-full overflow-hidden rounded-2xl bg-neutral-900 ${aspect}`}
>
{media.kind === "video" ? (
<video
src={media.src}
preload="metadata"
muted
playsInline
className="absolute inset-0 h-full w-full object-cover"
/>
<ExploreVideoThumb src={media.src} poster={media.poster} />
) : (
<Image
src={media.src}

View File

@@ -19,6 +19,10 @@ import ExploreAuthor from "@/components/explore/ExploreAuthor";
import ExploreAcademyCard from "@/components/explore/ExploreAcademyCard";
import { ExploreFilterId } from "@/constants/exploreFilters";
import { buildExplorePostFilters } from "@/lib/explore/buildPostFilters";
import {
isPublicAcademyStatus,
isPublicPostStatus,
} from "@/lib/moderationStatus";
import { getPostMedia } from "@/lib/explore/postMedia";
import ExploreVideoThumb from "@/components/explore/ExploreVideoThumb";
import { cacheReelsSeedPost } from "@/lib/reelsSeedPost";
@@ -227,6 +231,7 @@ const EMPTY_FILTER_KEYS: Partial<Record<ExploreFilterId, string>> = {
near_me: "explore.empty.near_me",
makeup: "explore.empty.makeup",
trending: "explore.empty.trending",
predict_trends: "explore.empty.predict_trends",
best_month: "explore.empty.best_month",
};
@@ -368,7 +373,7 @@ export default function ExploreGrid({
const pageAcademy: ExploreFeedItem[] = [];
for (const post of page.posts) {
if (post.status !== "accept" || !getPostMedia(post)) continue;
if (!isPublicPostStatus(post.status) || !getPostMedia(post)) continue;
const id = `post-${post._id}`;
if (seen.has(id)) continue;
seen.add(id);
@@ -382,7 +387,7 @@ export default function ExploreGrid({
if (includeAcademy) {
for (const academy of page.academyItems) {
if (academy.status && academy.status !== "accept") continue;
if (academy.status && !isPublicAcademyStatus(academy.status)) continue;
const id = `academy-${academy._id}`;
if (seen.has(id)) continue;
seen.add(id);

View File

@@ -25,6 +25,10 @@ import ReelsAcademyCard from "@/components/explore/ReelsAcademyCard";
import PageLoader from "@/components/ui/PageLoader";
import { ExploreFilterId } from "@/constants/exploreFilters";
import { buildExplorePostFilters } from "@/lib/explore/buildPostFilters";
import {
isPublicAcademyStatus,
isPublicPostStatus,
} from "@/lib/moderationStatus";
import {
postMatchesReelsMediaType,
resolveReelsMediaType,
@@ -194,7 +198,7 @@ export default function ExploreReelsView({
const pageAcademy: ExploreReelItem[] = [];
for (const post of page.posts) {
if (post.status !== "accept") continue;
if (!isPublicPostStatus(post.status)) continue;
if (
initialType === "post" &&
seedMediaType &&
@@ -210,7 +214,7 @@ export default function ExploreReelsView({
if (includeAcademy) {
for (const academy of page.academyItems) {
if (academy.status && academy.status !== "accept") continue;
if (academy.status && !isPublicAcademyStatus(academy.status)) continue;
const key = `academy-${academy._id}`;
if (seen.has(key)) continue;
seen.add(key);

View File

@@ -11,6 +11,10 @@ type ExploreVideoThumbProps = {
className?: string;
};
/**
* پیش‌نمایش زنده ویدیو در اکسپلور (مثل قبل):
* وقتی در ویوپورت است پخش بی‌صدا می‌شود.
*/
export default function ExploreVideoThumb({
src,
poster,
@@ -22,6 +26,10 @@ export default function ExploreVideoThumb({
const [frameReady, setFrameReady] = useState(!!poster);
const [playing, setPlaying] = useState(false);
useEffect(() => {
setFrameReady(!!poster);
}, [poster, src]);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
@@ -69,7 +77,8 @@ export default function ExploreVideoThumb({
if (!video) return;
if (mounted) {
video.play()
video
.play()
.then(() => setPlaying(true))
.catch(() => setPlaying(false));
} else {

View File

@@ -54,6 +54,19 @@ export function buildStorageUrl(path: string | null | undefined): string {
let normalized = String(path).replace(/\\/g, "/");
normalized = normalized.replace("/root/modstagram-back/storage", "");
// مسیر مطلق قدیمی thumbnail: .../storage/posts/thumbnails/x.jpg
const absStorage = normalized.indexOf("/storage/");
if (absStorage >= 0) {
normalized = normalized.slice(absStorage + "/storage".length);
}
const thumbMatch =
normalized.match(/\/posts\/thumbnails\/([^/?#]+)$/i) ||
normalized.match(/\/thumbnails\/([^/?#]+)$/i);
if (thumbMatch) {
normalized = `/posts/thumbnails/${thumbMatch[1]}`;
}
if (normalized.startsWith("/storage/")) {
normalized = normalized.slice("/storage".length);
} else if (normalized.startsWith("storage/")) {

View File

@@ -122,7 +122,7 @@ function Header() {
return (
<header
className={cn(
"sticky top-0 z-[999] pt-[env(safe-area-inset-top)] transition-all duration-300 ease-in-out will-change-transform",
"sticky top-0 z-[999] pt-[env(safe-area-inset-top,0px)] transition-all duration-300 ease-in-out will-change-transform",
compact ? "py-2" : "py-3",
hidden
? "pointer-events-none -translate-y-[130%] opacity-0"

View File

@@ -1,7 +1,7 @@
"use client";
import AuthInput from "@/components/auth/AuthInput";
import AuthNextButton from "@/components/auth/AuthNextButton";
import Modal from "@/components/elements/Modal";
import RoundedInput from "@/components/elements/RoundedInput";
import RoundedButton from "@/components/elements/RoundedButton";
import { Service } from "@/types/types";
import React, { useState } from "react";
import { IMAGE_BASE_URL } from "../BaseUrl";
@@ -45,7 +45,8 @@ const AddServiceModal: React.FC<AddServiceModalProps> = ({
image: serviceImage,
discountPercentage: discountPrice
? Math.round(
((Number(originalPrice) - Number(discountPrice)) / Number(originalPrice)) *
((Number(originalPrice) - Number(discountPrice)) /
Number(originalPrice)) *
100
)
: 0,
@@ -72,36 +73,36 @@ const AddServiceModal: React.FC<AddServiceModalProps> = ({
isOpen={isModalOpen}
onClose={onClose}
height="auto"
elevated
// هم‌سطح منوبار (z-50) تا منوبار مثل بقیه مودال‌های ثبت بیلبورد روی شیت بماند
panelClassName="max-h-[min(78dvh,calc(100dvh-6rem))] overflow-y-auto pb-[calc(5.5rem+env(safe-area-inset-bottom))]"
>
<div className="flex flex-col items-center w-full gap-4 max-w-[500px] mx-auto">
<h2 className="text-xl font-bold mb-4">{t("serviceModal.addTitle")}</h2>
<AuthInput
<div className="mx-auto flex w-full max-w-[500px] flex-col items-center gap-3">
<h2 className="mb-2 text-xl font-bold">{t("serviceModal.addTitle")}</h2>
<RoundedInput
type="text"
placeholder={t("serviceModal.titlePlaceholder")}
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full py-2 max-w-[992px] text-right"
className="max-w-full text-right"
/>
<div className="flex gap-4 w-full">
<AuthInput
<div className="flex w-full gap-3">
<RoundedInput
type="number"
placeholder={t("serviceModal.originalPrice")}
value={originalPrice}
onChange={(e) => setOriginalPrice(e.target.value)}
className="w-full py-2 max-w-[992px] text-right"
className="max-w-full text-right"
/>
<AuthInput
<RoundedInput
type="number"
placeholder={t("serviceModal.discountPrice")}
value={discountPrice}
onChange={(e) => setDiscountPrice(e.target.value)}
className="w-full py-2 max-w-[992px] text-right"
className="max-w-full text-right"
/>
</div>
<div className="relative w-[100px] h-[100px] flex items-center justify-center text-white rounded-3xl border border-[#676767] overflow-hidden mt-4">
<div className="relative mt-2 flex h-[100px] w-[100px] items-center justify-center overflow-hidden rounded-full border border-neutral-950 dark:border-neutral-400">
{serviceImage ? (
<>
<img
@@ -110,47 +111,53 @@ const AddServiceModal: React.FC<AddServiceModalProps> = ({
? serviceImage
: IMAGE_BASE_URL + serviceImage
}
alt="serviceImage"
className="w-full h-full object-cover"
alt=""
className="h-full w-full object-cover"
/>
<button onClick={removeImage} className="p-2 absolute top-0 right-0">
<button
type="button"
onClick={removeImage}
className="absolute right-0 top-0 p-2"
>
<Image
src="/images/icons/close-circle.svg"
width={25}
height={25}
alt="remove profile icon"
alt=""
/>
</button>
</>
) : (
<label
htmlFor="fileInput"
className="cursor-pointer w-[100px] h-[100px] flex items-center justify-center text-white rounded-3xl border border-[#676767]"
htmlFor="serviceFileInput"
className="flex h-full w-full cursor-pointer items-center justify-center"
>
<Image
src="/images/icons/gallery-add.svg"
width={50}
height={50}
alt="add profile icon"
width={40}
height={40}
alt=""
className="dark:invert"
/>
</label>
)}
</div>
<input
id="fileInput"
id="serviceFileInput"
type="file"
accept="image/*"
className="hidden"
onChange={selectImage}
/>
<AuthNextButton
onClick={handleSubmit}
className="mb-4 mt-4 !text-[#0066FF] !border-[#0066FF] w-32"
<RoundedButton
type="button"
variant="primary"
onClick={handleSubmit}
className="mt-3 h-10 px-8"
>
{t("serviceModal.add")}
</AuthNextButton>
</RoundedButton>
</div>
</Modal>
);

View File

@@ -23,17 +23,15 @@ type VerificationBadgeProps = {
export function resolveVerificationStatus(
isVerified?: string | null,
isRegister?: string | boolean | null
_isRegister?: string | boolean | null
): string | null {
if (isVerified && isVerified !== "none") {
return isVerified;
// تیک خاکستری فقط با is_verified=pending (یعنی کارت ملی ارسال شده)
// تکمیل ثبت‌نام بدون کارت ملی نباید تیک نشان دهد
if (!isVerified || isVerified === "none" || isVerified === "rejected") {
return null;
}
if (isRegister === true || isRegister === "true") {
return "pending";
}
return null;
return isVerified;
}
export function getVerificationIcon(

View File

@@ -140,7 +140,11 @@ export default function InfinitePosts({
// خط پایین اضافه شده برای هشتگ:
const matchesHashtag = filters.hashtag ? post.caption?.includes(`#${filters.hashtag}`) : true;
return post.status === "accept" && matchesType && matchesHashtag;
return (
(post.status === "accept" || post.status === "temp_accept") &&
matchesType &&
matchesHashtag
);
});
return (

View File

@@ -29,6 +29,9 @@ import {
import { cn } from "@/lib/utils";
import { getPostSubExpertiseLabels } from "@/lib/subExpertiseDisplay";
import { useTranslation } from "react-i18next";
import { getPostVideoSrc } from "@/lib/videoPreload";
import { getVideoPoster } from "@/lib/explore/postMedia";
import { resolvePostMediaType } from "@/lib/reelsMediaType";
// ایجاد Context برای به اشتراک گذاشتن وضعیت muted بین همه کارت‌ها
const MutedContext = createContext<{
@@ -112,6 +115,12 @@ function MainModelCard({
src: buildStorageUrl(file.path),
})) || [];
const mediaType = resolvePostMediaType(postData) ?? type;
const videoSrc =
mediaType === "video" ? getPostVideoSrc(postData) : undefined;
const videoPoster =
mediaType === "video" ? getVideoPoster(postData) : undefined;
// تابع برای ایجاد افکت ripple و تغییر وضعیت صدا
const handleVideoClick = (e: React.MouseEvent<HTMLVideoElement>) => {
const v = videoRef.current;
@@ -391,14 +400,14 @@ function MainModelCard({
tabIndex={onMediaClick ? 0 : undefined}
aria-label={onMediaClick ? t("models.fullscreen") : undefined}
>
{type === "image" && mediaFiles.length > 0 && (
{mediaType === "image" && mediaFiles.length > 0 && (
<SwipeImageSlider
mediaFiles={mediaFiles}
setImageLoading={setImageLoading}
/>
)}
{type === "video" && mediaFiles.length > 0 && (
{mediaType === "video" && videoSrc && (
<div className="relative w-full rounded-2xl overflow-hidden bg-black">
<video
onClick={(e) => {
@@ -410,7 +419,8 @@ function MainModelCard({
handleVideoClick(e);
}}
ref={videoRef}
src={mediaFiles[0].src}
src={videoSrc}
poster={videoPoster}
loop
playsInline
className="w-full h-auto object-contain"

View File

@@ -13,6 +13,9 @@ import Slider from "react-slick";
import "slick-carousel/slick/slick.css";
import "slick-carousel/slick/slick-theme.css";
import { useTranslation } from "react-i18next";
import { getPostVideoSrc } from "@/lib/videoPreload";
import { getVideoPoster } from "@/lib/explore/postMedia";
import { resolvePostMediaType } from "@/lib/reelsMediaType";
// ایجاد Context برای به اشتراک گذاشتن وضعیت muted بین همه کارت‌ها
export const MutedContext = createContext<{
@@ -77,6 +80,12 @@ function MainModelCard({ postData }: { postData: Post }) {
src: buildStorageUrl(file.path),
})) || [];
const mediaType = resolvePostMediaType(postData) ?? type;
const videoSrc =
mediaType === "video" ? getPostVideoSrc(postData) : undefined;
const videoPoster =
mediaType === "video" ? getVideoPoster(postData) : undefined;
const sliderSettings = {
dots: mediaFiles.length > 1,
infinite: mediaFiles.length > 1,
@@ -367,12 +376,13 @@ function MainModelCard({ postData }: { postData: Post }) {
)}
</div>
)}
{type === "video" && mediaFiles.length > 0 && (
{mediaType === "video" && videoSrc && (
<div className="relative w-full h-[calc(100vh-150px)] overflow-hidden">
<video
onClick={handleVideoClick}
ref={videoRef}
src={mediaFiles[0].src}
src={videoSrc}
poster={videoPoster}
loop
playsInline
className="w-full h-full object-contain"

View File

@@ -20,6 +20,8 @@ import useAxios from "@/hooks/useAxios";
import { cn } from "@/lib/utils";
import { profileActionBtnClass } from "@/lib/ui/buttonStyles";
import { useTranslation } from "react-i18next";
import ShareProfileModal from "@/components/profile/ShareProfileModal";
import { formatFullName } from "@/lib/formatFullName";
const LocationModal = dynamic(() => import("./LocationModal"), {
@@ -50,6 +52,7 @@ function ModelContent({ user, searchParams }: ModelContentProps) {
const [showProjects] = useState<boolean>(false);
const [showModelDetailModal, setShowModelDetailModal] = useState<boolean>(false);
const [showLocationModal, setShowLocationModal] = useState<boolean>(false);
const [showShareProfile, setShowShareProfile] = useState(false);
const [usertype, setUsertype] = useState<string | null>(null);
const [userListKind, setUserListKind] = useState<
"following" | "followers" | "blocked" | null
@@ -213,7 +216,7 @@ function ModelContent({ user, searchParams }: ModelContentProps) {
}, 300);
return (
<div className="px-4 py-2 w-full text-xs md:text-sm font-semibold">
<div className="w-full py-2 text-xs font-semibold md:text-sm">
<ProfileUserListModal
open={userListKind !== null}
kind={userListKind}
@@ -265,6 +268,13 @@ function ModelContent({ user, searchParams }: ModelContentProps) {
}}
/>
)}
<ShareProfileModal
open={showShareProfile}
onClose={() => setShowShareProfile(false)}
userName={user?.user_name}
displayName={formatFullName(user?.first_name, user?.last_name)}
profileImage={user?.profile_image}
/>
{user?.user_type == "user" ? (
<div className="grid grid-cols-3 gap-1 md:gap-4">
{isOwnProfile ? (
@@ -354,7 +364,7 @@ function ModelContent({ user, searchParams }: ModelContentProps) {
<div
className={cn(
"mt-2 grid gap-1 md:mt-4 md:gap-4",
!user?.show_location ? "grid-cols-2" : "grid-cols-3"
user?.show_location ? "grid-cols-2" : "grid-cols-3"
)}
>
<button
@@ -371,6 +381,13 @@ function ModelContent({ user, searchParams }: ModelContentProps) {
>
{t("models.actions.sendMessage")}
</button>
<button
type="button"
onClick={() => setShowShareProfile(true)}
className={profileActionBtnClass}
>
{t("models.actions.shareProfile")}
</button>
<button
type="button"
onClick={openLocationModal}
@@ -384,7 +401,7 @@ function ModelContent({ user, searchParams }: ModelContentProps) {
</button>
</div>
) : (
<div className="mx-auto mt-2 grid max-w-xl grid-cols-2 justify-center gap-1 md:gap-2">
<div className="mx-auto mt-2 grid max-w-xl grid-cols-3 justify-center gap-1 md:gap-2">
<button
type="button"
onClick={openCollaboration}
@@ -399,6 +416,13 @@ function ModelContent({ user, searchParams }: ModelContentProps) {
>
{t("models.actions.sendMessage")}
</button>
<button
type="button"
onClick={() => setShowShareProfile(true)}
className={profileActionBtnClass}
>
{t("models.actions.shareProfile")}
</button>
</div>
)}

View File

@@ -8,7 +8,7 @@ import { useRouter } from "next/navigation";
import { fetchPosts } from "@/api/fetchPosts";
import { Post, User } from "@/types/types";
import axios from "axios";
import { BASE_URL, IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
import { BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
import Image from "next/image";
import BoldIcon from "@/components/ui/BoldIcon";
import { ChatListSkeleton } from "@/components/ui/ChatSkeletons";
@@ -140,6 +140,9 @@ function ModelContentPosts({
})) || [];
const firstMedia = mediaFiles[0]?.src || "/images/placeholder.png";
const poster =
post.post_images?.[0] ||
mediaFiles.find((f) => f.type === "image")?.src;
return (
<button
@@ -159,6 +162,20 @@ function ModelContentPosts({
loading="lazy"
unoptimized
/>
) : poster ? (
<Image
src={
typeof poster === "string" && poster.startsWith("http")
? poster
: buildStorageUrl(String(poster))
}
alt={post.caption || t("models.postAlt", { id: post._id })}
width={300}
height={300}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105 group-active:scale-100"
loading="lazy"
unoptimized
/>
) : (
<video
src={firstMedia}
@@ -175,6 +192,15 @@ function ModelContentPosts({
</div>
</div>
)}
{post.is_pinned ? (
<span
className="pointer-events-none absolute left-1.5 top-1.5 flex h-6 w-6 items-center justify-center rounded-full bg-black/55 text-white backdrop-blur-sm"
title={t("posts.pinnedBadge")}
aria-label={t("posts.pinnedBadge")}
>
<BoldIcon name="bookmark" size={14} tinted className="text-white" />
</span>
) : null}
<span className="pointer-events-none absolute bottom-1.5 right-1.5 flex items-center gap-0.5 rounded-md bg-black/55 px-1.5 py-0.5 text-[10px] font-semibold text-white backdrop-blur-sm">
<BoldIcon name="eye" size={11} tinted className="text-white" />
{formatViewCount(post.viewsCount ?? 0)}

View File

@@ -37,7 +37,8 @@ function ModelHead({ user, item2 }: ModelHeadProps) {
is_Register,
} = user;
const { t } = useTranslation("common");
const { t, i18n } = useTranslation("common");
const isFa = (i18n.language || "fa").toLowerCase().startsWith("fa");
const [listKind, setListKind] = useState<
"followers" | "following" | null
>(null);
@@ -48,7 +49,7 @@ function ModelHead({ user, item2 }: ModelHeadProps) {
item2?.followingCount ?? user.followingCount ?? 0;
return (
<div className="w-full">
<div className="w-full" dir={isFa ? "ltr" : "rtl"}>
<ProfileUserListModal
open={listKind !== null}
kind={listKind}
@@ -125,8 +126,9 @@ function ModelHead({ user, item2 }: ModelHeadProps) {
user_score={user_score}
rate={rate}
user_id={_id}
isFa={isFa}
/>
<ModelHeadRowThree user_level={user_level} bio={bio} />
<ModelHeadRowThree user_level={user_level} bio={bio} isFa={isFa} />
</div>
);
}

View File

@@ -2,27 +2,53 @@
import ExpandableBio from "@/components/profile/ExpandableBio";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
interface ProfileHeadRowThreeProps {
user_level: string | undefined;
bio: string | undefined;
isFa?: boolean;
}
function ModelHeadRowThree({ user_level, bio }: ProfileHeadRowThreeProps) {
function ModelHeadRowThree({
user_level,
bio,
isFa = true,
}: ProfileHeadRowThreeProps) {
const { t } = useTranslation("common");
const levelLabel = t("models.level", {
level: user_level ? user_level : t("models.newcomer"),
});
const hasBio = Boolean(bio?.trim());
return (
<div className="py-2 w-full text-xs md:text-sm font-semibold">
<div className="flex justify-between items-start gap-3">
<h4 className="min-w-0 flex-1">
<ExpandableBio bio={bio} />
</h4>
<div className="flex shrink-0 flex-col items-end">
<h6 className="text-[#387E65]">
{t("models.level", {
level: user_level ? user_level : t("models.newcomer"),
})}
</h6>
<div className="w-full py-2 text-xs font-semibold md:text-sm">
<div
className={cn(
"flex w-full gap-3",
hasBio ? "items-baseline justify-between" : "items-center justify-end"
)}
>
{hasBio ? (
<div
className={cn(
"min-w-0 flex-1 leading-6",
isFa ? "text-right" : "text-left"
)}
>
<ExpandableBio
bio={bio}
className={cn("leading-6", isFa ? "text-right" : "text-left")}
/>
</div>
) : null}
<div
className={cn(
"shrink-0 leading-6 text-[#387E65]",
isFa ? "text-right" : "text-left"
)}
>
{levelLabel}
</div>
</div>
</div>

View File

@@ -2,6 +2,7 @@ import { useUserById } from "@/hooks/getUserById";
import VerificationBadge from "@/components/main/VerificationBadge";
import Image from "next/image";
import React from "react";
import { cn } from "@/lib/utils";
interface ProfileHeadRowTwoProps {
first_name: string;
@@ -12,6 +13,7 @@ interface ProfileHeadRowTwoProps {
user_score?: string;
rate?: string;
user_id: string;
isFa?: boolean;
}
function ModelHeadRowTwo({
@@ -23,12 +25,13 @@ function ModelHeadRowTwo({
user_id,
is_verified: isVerifiedProp,
is_Register: isRegisterProp,
isFa = true,
}: ProfileHeadRowTwoProps) {
const user = useUserById(user_id);
const is_verified = isVerifiedProp ?? user?.is_verified;
const is_Register = isRegisterProp ?? user?.is_Register;
return (
<div className="px-4 py-2 grid w-full grid-cols-3 items-end text-xs md:text-sm font-semibold">
<div className="py-2 grid w-full grid-cols-3 items-end text-xs md:text-sm font-semibold">
<div className="flex items-center">
<>
<span>{user_score ? user_score : "0"}</span>
@@ -51,12 +54,27 @@ function ModelHeadRowTwo({
/>
</>
</div>
<div className="flex flex-col items-end min-w-0 max-w-full">
<h3 className="truncate max-w-full whitespace-nowrap text-right">
<div
className={cn(
"flex min-w-0 max-w-full flex-col",
isFa ? "items-end" : "items-start"
)}
>
<h3
className={cn(
"truncate max-w-full whitespace-nowrap",
isFa ? "text-right" : "text-left"
)}
>
{first_name} {last_name}
</h3>
<div className="flex items-center gap-1">
<h3 className="flex items-center gap-1 flex-row-reverse">
<h3
className={cn(
"flex items-center gap-1",
isFa ? "flex-row-reverse" : "flex-row"
)}
>
{user_name}
<VerificationBadge
isVerified={is_verified}

View File

@@ -14,6 +14,7 @@ import {
import { isModelExpertise } from "@/lib/isModelExpertise";
import { readLocalStorage } from "@/lib/safeStorage";
import { useTranslation } from "react-i18next";
import { validateMinMaxPairs } from "@/lib/rangeValidation";
const TOOLBAR_ICON_SIZE = 25;
const toolbarIconButtonClass =
@@ -77,6 +78,17 @@ function ModelsFilter({ expertise }: ModelsFilterProps) {
};
const handleFilterChange = () => {
if (
validateMinMaxPairs([
{ min: heightMin, max: heightMax },
{ min: weightMin, max: weightMax },
{ min: sizeMin, max: sizeMax },
])
) {
toast.error(t("filters.maxMustBeGreater"));
return;
}
const currentExpertise = selectedExpertise || "";
const currentLevel = userLevel;

View File

@@ -24,6 +24,7 @@ import {
} from "@/lib/reelsMediaType";
import { buildExplorePostFilters } from "@/lib/explore/buildPostFilters";
import { ExploreFilterId } from "@/constants/exploreFilters";
import { isPublicPostStatus } from "@/lib/moderationStatus";
import { useReelsSnapScroll, REELS_SCROLL_CLASS, getReelsStepHeight } from "@/hooks/useReelsSnapScroll";
import ReelsScrollSlot from "./ReelsScrollSlot";
import { ExploreReelsUiProvider } from "@/contexts/ExploreReelsUiContext";
@@ -356,7 +357,7 @@ export default function PostFeedView({
?.flatMap((p) => p.posts)
?.filter(
(p) =>
(p as Post).status === "accept" &&
isPublicPostStatus((p as Post).status) &&
postMatchesReelsMediaType(p as Post, reelsMediaType)
) ?? [];
@@ -394,7 +395,7 @@ export default function PostFeedView({
const addPost = (post: Post) => {
const id = String(post._id);
if (seen.has(id)) return;
if (post.status !== "accept") return;
if (!isPublicPostStatus(post.status)) return;
if (!postMatchesReelsMediaType(post, reelsMediaType)) return;
seen.add(id);
merged.push(post);

View File

@@ -0,0 +1,122 @@
"use client";
/* eslint-disable @next/next/no-img-element */
import BoldIcon from "@/components/ui/BoldIcon";
import { useTranslation } from "react-i18next";
export type PostGalleryItem = {
id: string;
preview: string;
file?: File | null;
existingPath?: string;
};
type Props = {
items: PostGalleryItem[];
onChange: (items: PostGalleryItem[]) => void;
max?: number;
};
export default function PostImageGallery({
items,
onChange,
max = 10,
}: Props) {
const { t } = useTranslation("common");
const move = (index: number, dir: -1 | 1) => {
const next = index + dir;
if (next < 0 || next >= items.length) return;
const copy = [...items];
const tmp = copy[index];
copy[index] = copy[next];
copy[next] = tmp;
onChange(copy);
};
const removeAt = (index: number) => {
const target = items[index];
if (target?.preview?.startsWith("blob:")) {
URL.revokeObjectURL(target.preview);
}
onChange(items.filter((_, i) => i !== index));
};
const addFiles = (files: FileList | null) => {
if (!files?.length) return;
const room = max - items.length;
if (room <= 0) return;
const picked = Array.from(files)
.filter((f) => f.type.startsWith("image/"))
.slice(0, room)
.map((file) => ({
id: `new-${Date.now()}-${Math.random()}`,
file,
preview: URL.createObjectURL(file),
}));
if (picked.length) onChange([...items, ...picked]);
};
return (
<div className="space-y-2 border-t border-neutral-200 p-3 dark:border-neutral-800">
<div className="grid grid-cols-4 gap-2">
{items.map((item, index) => (
<div
key={item.id}
className="relative aspect-square overflow-hidden rounded-md bg-neutral-200 dark:bg-neutral-800"
>
<img
src={item.preview}
alt=""
className="h-full w-full object-cover"
/>
<div className="absolute inset-x-0 bottom-0 flex justify-between gap-0.5 bg-black/50 p-0.5">
<button
type="button"
onClick={() => move(index, -1)}
disabled={index === 0}
className="rounded bg-white/20 px-1 text-[10px] text-white disabled:opacity-30"
aria-label={t("posts.moveRight")}
>
</button>
<button
type="button"
onClick={() => removeAt(index)}
className="rounded bg-red-500/80 px-1 text-[10px] text-white"
aria-label={t("posts.removeMedia")}
>
×
</button>
<button
type="button"
onClick={() => move(index, 1)}
disabled={index === items.length - 1}
className="rounded bg-white/20 px-1 text-[10px] text-white disabled:opacity-30"
aria-label={t("posts.moveLeft")}
>
</button>
</div>
</div>
))}
{items.length < max ? (
<label className="flex aspect-square cursor-pointer items-center justify-center rounded-md border border-dashed border-neutral-300 dark:border-neutral-700">
<BoldIcon name="add" size={20} className="block dark:invert" />
<input
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => {
addFiles(e.target.files);
e.target.value = "";
}}
/>
</label>
) : null}
</div>
</div>
);
}

View File

@@ -1,6 +1,6 @@
"use client";
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import Modal from "@/components/elements/Modal";
import RoundedButton from "@/components/elements/RoundedButton";
import BoldIcon from "@/components/ui/BoldIcon";
@@ -8,10 +8,12 @@ import { FiMoreHorizontal } from "react-icons/fi";
import { Post } from "@/types/types";
import useAxios from "@/hooks/useAxios";
import toast from "react-hot-toast";
import { getStoredUserId } from "@/lib/auth/session";
import ReelsPostAnalyticsModal from "./ReelsPostAnalyticsModal";
import { useTranslation } from "react-i18next";
import { useAppLanguage } from "@/contexts/LanguageProvider";
import { useQueryClient } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { canEditPostWithinWindow } from "@/lib/postEditWindow";
const REPORT_REASON_IDS = [
"inappropriate",
@@ -38,12 +40,20 @@ export default function ReelsPostOptionsMenu({
const { language } = useAppLanguage();
const numberLocale = language === "en" ? "en-US" : "fa-IR";
const { request } = useAxios();
const queryClient = useQueryClient();
const router = useRouter();
const canEdit = isOwnPost && canEditPostWithinWindow(postData.createdAt);
const [open, setOpen] = useState(false);
const [reportOpen, setReportOpen] = useState(false);
const [analyticsOpen, setAnalyticsOpen] = useState(false);
const [reportReason, setReportReason] = useState("");
const [reportText, setReportText] = useState("");
const [submitting, setSubmitting] = useState(false);
const [isPinned, setIsPinned] = useState(Boolean(postData.is_pinned));
useEffect(() => {
setIsPinned(Boolean(postData.is_pinned));
}, [postData.is_pinned, postData._id]);
const submitReport = async () => {
if (!reportReason.trim()) {
@@ -76,16 +86,26 @@ export default function ReelsPostOptionsMenu({
const pinPost = async () => {
try {
await request(
const res = await request<{
unpinned?: boolean;
pinned_post_ids?: string[];
message?: string;
}>(
"POST",
"/account/pin-post",
{ postId: postData._id },
{ noToast: true }
);
toast.success(t("posts.postPinned"));
const unpinned = Boolean(res?.unpinned);
setIsPinned(!unpinned);
toast.success(unpinned ? t("posts.postUnpinned") : t("posts.postPinned"));
setOpen(false);
} catch {
toast.error(t("posts.pinFailed"));
void queryClient.invalidateQueries({ queryKey: ["posts"] });
} catch (err: unknown) {
const message = (
err as { response?: { data?: { message?: string } } }
)?.response?.data?.message;
toast.error(message || t("posts.pinFailed"));
}
};
@@ -146,6 +166,18 @@ export default function ReelsPostOptionsMenu({
</button>
{isOwnPost ? (
<>
{canEdit ? (
<button
type="button"
onClick={() => {
setOpen(false);
router.push(`/new-post?edit=${postData._id}`);
}}
className="px-5 py-3.5 text-right text-sm active:bg-neutral-100 dark:active:bg-neutral-800"
>
{t("posts.editPost")}
</button>
) : null}
<button
type="button"
onClick={() => {
@@ -161,7 +193,7 @@ export default function ReelsPostOptionsMenu({
onClick={() => void pinPost()}
className="px-5 py-3.5 text-right text-sm active:bg-neutral-100 dark:active:bg-neutral-800"
>
{t("posts.pinPost")}
{isPinned ? t("posts.unpinPost") : t("posts.pinPost")}
</button>
</>
) : null}

View File

@@ -10,38 +10,56 @@ import { useTranslation } from "react-i18next";
type Props = {
videoUrl: string;
onCoverChange: (file: File | null) => void;
initialCoverUrl?: string;
};
export default function VideoCoverPicker({ videoUrl, onCoverChange }: Props) {
export default function VideoCoverPicker({
videoUrl,
onCoverChange,
initialCoverUrl,
}: Props) {
const { t } = useTranslation("common");
const videoRef = useRef<HTMLVideoElement>(null);
const autoCapturedRef = useRef(false);
const [duration, setDuration] = useState(0);
const [time, setTime] = useState(0);
const [coverPreview, setCoverPreview] = useState<string>("");
const [coverPreview, setCoverPreview] = useState<string>(initialCoverUrl || "");
const [mode, setMode] = useState<"frame" | "upload">("frame");
useEffect(() => {
autoCapturedRef.current = false;
setDuration(0);
setTime(0);
setMode("frame");
setCoverPreview(initialCoverUrl || "");
if (!initialCoverUrl) onCoverChange(null);
// eslint-disable-next-line react-hooks/exhaustive-deps -- reset cover when video changes
}, [videoUrl, initialCoverUrl]);
useEffect(() => {
return () => {
if (coverPreview) URL.revokeObjectURL(coverPreview);
};
}, [coverPreview]);
const applyFrameAt = async (seconds: number) => {
const applyFrameAt = async (seconds: number, silent = false) => {
const video = videoRef.current;
if (!video) return;
if (!video) return false;
try {
const blob = await captureVideoFrame(video, seconds);
const file = new File([blob], `cover-${Date.now()}.webp`, {
type: "image/webp",
});
if (coverPreview) URL.revokeObjectURL(coverPreview);
if (coverPreview?.startsWith("blob:")) URL.revokeObjectURL(coverPreview);
const preview = URL.createObjectURL(blob);
setCoverPreview(preview);
onCoverChange(file);
setMode("frame");
toast.success(t("posts.coverSelected"));
if (!silent) toast.success(t("posts.coverSelected"));
return true;
} catch {
toast.error(t("posts.framePickFailed"));
if (!silent) toast.error(t("posts.framePickFailed"));
return false;
}
};
@@ -51,7 +69,7 @@ export default function VideoCoverPicker({ videoUrl, onCoverChange }: Props) {
toast.error(t("posts.imagesOnly"));
return;
}
if (coverPreview) URL.revokeObjectURL(coverPreview);
if (coverPreview?.startsWith("blob:")) URL.revokeObjectURL(coverPreview);
setCoverPreview(URL.createObjectURL(picked));
onCoverChange(picked);
setMode("upload");
@@ -104,6 +122,11 @@ export default function VideoCoverPicker({ videoUrl, onCoverChange }: Props) {
setDuration(d);
setTime(0);
}}
onLoadedData={() => {
if (autoCapturedRef.current || initialCoverUrl) return;
autoCapturedRef.current = true;
void applyFrameAt(0.1, true);
}}
/>
{coverPreview ? (
<div className="absolute right-2 top-2 h-16 w-16 overflow-hidden rounded-lg border-2 border-white shadow-lg">

View File

@@ -0,0 +1,128 @@
"use client";
import { useMemo, useState } from "react";
import Image from "next/image";
import Modal from "@/components/elements/Modal";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import RoundedButton from "@/components/elements/RoundedButton";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { copyTextToClipboard } from "@/lib/chat/getCopyableMessageText";
type ShareProfileModalProps = {
open: boolean;
onClose: () => void;
userName?: string | null;
displayName?: string | null;
profileImage?: string | null;
};
export default function ShareProfileModal({
open,
onClose,
userName,
displayName,
profileImage,
}: ShareProfileModalProps) {
const { t } = useTranslation("common");
const [copied, setCopied] = useState(false);
const profileUrl = useMemo(() => {
const handle = String(userName || "").trim();
if (!handle) return "";
if (typeof window !== "undefined") {
return `${window.location.origin}/users/${encodeURIComponent(handle)}`;
}
return `https://modstagram.com/users/${encodeURIComponent(handle)}`;
}, [userName]);
const qrSrc = useMemo(() => {
if (!profileUrl) return "";
return `https://api.qrserver.com/v1/create-qr-code/?size=220x220&margin=12&data=${encodeURIComponent(profileUrl)}`;
}, [profileUrl]);
const handleCopy = async () => {
if (!profileUrl) return;
const ok = await copyTextToClipboard(profileUrl);
if (ok) {
setCopied(true);
toast.success(t("models.shareProfile.copied"));
window.setTimeout(() => setCopied(false), 2000);
} else {
toast.error(t("models.shareProfile.copyFailed"));
}
};
return (
<Modal
isOpen={open}
onClose={onClose}
elevated
height="fit"
panelClassName="!p-0 max-h-[90dvh] w-full max-w-sm overflow-y-auto"
>
<div className="px-5 py-6 text-center">
<h2 className="mb-5 text-base font-bold">
{t("models.shareProfile.title")}
</h2>
<div className="mb-4 flex flex-col items-center gap-2">
<ProfileAvatar
src={profileImage || undefined}
alt={userName || "profile"}
size="md"
rounded="2xl"
/>
{displayName ? (
<p className="text-sm font-semibold">{displayName}</p>
) : null}
{userName ? (
<p className="text-xs text-neutral-500 dark:text-neutral-400">
@{userName}
</p>
) : null}
</div>
{qrSrc ? (
<div className="mx-auto mb-5 flex h-[220px] w-[220px] items-center justify-center rounded-2xl bg-white p-3">
<Image
src={qrSrc}
alt={t("models.shareProfile.qrAlt")}
width={196}
height={196}
unoptimized
className="h-full w-full object-contain"
/>
</div>
) : null}
<p className="mb-2 text-xs text-neutral-500 dark:text-neutral-400">
{t("models.shareProfile.linkLabel")}
</p>
<div className="mb-4 break-all rounded-xl border border-neutral-200 bg-neutral-50 px-3 py-2 text-xs dark:border-neutral-700 dark:bg-neutral-900">
{profileUrl || "—"}
</div>
<div className="flex flex-col gap-2">
<RoundedButton
type="button"
variant="primary"
className="w-full"
onClick={() => void handleCopy()}
>
{copied
? t("models.shareProfile.copied")
: t("models.shareProfile.copyLink")}
</RoundedButton>
<RoundedButton
type="button"
className="w-full"
onClick={onClose}
>
{t("models.shareProfile.close")}
</RoundedButton>
</div>
</div>
</Modal>
);
}

View File

@@ -1,12 +1,14 @@
"use client";
import { Project } from "@/types/types";
import React from "react";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import RoundedDiv from "../elements/RoundedDiv";
import Image from "next/image";
import Link from "next/link";
import ProjectCreator from "./ProjectPage/ProjectCreator";
import LocationModal from "@/components/models/ModelPage/LocationModal";
import { cn } from "@/lib/utils";
function MainProjectCard({
project,
@@ -17,6 +19,9 @@ function MainProjectCard({
textColor,
isSample,
sampleType,
borderless,
onRoleSelect,
selectedRoleId,
}: {
project: Project;
statusType?: string;
@@ -26,8 +31,12 @@ function MainProjectCard({
full?: boolean;
isSample?: boolean;
sampleType?: string;
borderless?: boolean;
onRoleSelect?: (roleId: string) => void;
selectedRoleId?: string | null;
}) {
const { t } = useTranslation("common");
const [showLocation, setShowLocation] = useState(false);
const getAgeLabel = (age?: string) => {
if (!age || age === "all") return t("filters.all");
@@ -39,25 +48,42 @@ function MainProjectCard({
const getGenderLabel = (gender?: string) => {
if (gender === "male") return t("auth.male");
if (gender === "female") return t("auth.female");
if (gender === "any") return t("projects.newProject.genderAny");
return "";
};
const roles = project?.roles || [];
const expertiseLabel =
roles.length > 0
? roles.map((r) => r.expertise).join(" / ")
: project?.expertise;
return (
<div
className={`w-full px-5 py-4 border bg-tertiary-light dark:bg-tertiary-dark border-border-secondary-dark dark:border-border-secondary-dark rounded-3xl my-4 md:my-5 relative text-xs md:text-sm font-semibold ${
project?.project_type === "highlight" ||
(isSample && sampleType == "highlight")
? "!bg-highlight-light dark:!bg-highlight-dark"
className={cn(
"relative my-4 w-full text-xs font-semibold md:my-5 md:text-sm",
borderless
? "rounded-none border-0 bg-transparent px-0 py-2"
: "rounded-3xl border border-border-secondary-dark bg-tertiary-light px-5 py-4 dark:bg-tertiary-dark dark:border-border-secondary-dark",
!borderless &&
(project?.project_type === "highlight" ||
(isSample && sampleType == "highlight"))
? "!border-rose-300 !bg-gradient-to-r !from-rose-50 !to-pink-100 text-rose-800 shadow-sm dark:!border-rose-700 dark:!from-rose-950 dark:!to-pink-900 dark:text-rose-200"
: "",
borderless &&
(project?.project_type === "highlight" ||
(isSample && sampleType == "highlight"))
? "rounded-2xl border border-rose-300 bg-gradient-to-r from-rose-50 to-pink-100 px-4 py-3 text-rose-800 dark:border-rose-700 dark:from-rose-950 dark:to-pink-900 dark:text-rose-200"
: ""
}`}
)}
>
{project?.project_type === "force" && (
<RoundedDiv className="bg-[#FFBDBD] dark:bg-[#794a4a] !border-[#FFBDBD] h-7 w-28 absolute left-4 -top-3">
<RoundedDiv className="absolute left-4 -top-3 h-7 w-28 bg-[#FFBDBD] !border-[#FFBDBD] dark:bg-[#794a4a]">
{t("projects.urgent")}
</RoundedDiv>
)}
{isSample && sampleType == "force" && (
<RoundedDiv className="bg-[#FFBDBD] dark:bg-[#794a4a] !border-[#FFBDBD] h-7 w-28 absolute left-4 -top-3">
<RoundedDiv className="absolute left-4 -top-3 h-7 w-28 bg-[#FFBDBD] !border-[#FFBDBD] dark:bg-[#794a4a]">
{t("projects.urgent")}
</RoundedDiv>
)}
@@ -68,7 +94,7 @@ function MainProjectCard({
borderColor: borderColor ? borderColor : "",
color: textColor ? textColor : "",
}}
className="h-6 w-28 absolute right-4 -top-3"
className="absolute right-4 -top-3 h-6 w-28"
>
{statusType}
</RoundedDiv>
@@ -77,13 +103,13 @@ function MainProjectCard({
href={`/projects/${project?._id}/${project?.title}`}
key={project._id}
>
<h4 className="text-text-blue-light dark:text-text-blue-dark font-semibold">
<h4 className="font-semibold text-text-blue-light dark:text-text-blue-dark">
{project?.title}
</h4>
</Link>
<h5 className="text-text-red-light dark:text-text-red-dark font-semibold line-clamp-2 mt-2">
{project?.expertise}
{project?.sub_expertise ? (
<h5 className="mt-2 line-clamp-2 font-semibold text-text-red-light dark:text-text-red-dark">
{expertiseLabel}
{!roles.length && project?.sub_expertise ? (
<>
{" _ "}
{project?.sub_expertise.length !== 0 &&
@@ -98,30 +124,46 @@ function MainProjectCard({
<span>{project?.sub_expertise}</span>
)}
</>
) : (
""
)}
{project?.gender && (
) : null}
{project?.gender && project.gender !== "any" ? (
<span> {getGenderLabel(project.gender)}</span>
)}
) : null}
</h5>
{full && <ProjectCreator creator={project?.creator} />}
<p className={`mt-2 leading-5 ${full ? "" : "line-clamp-2"}`}>
{project?.description}
</p>
<div className="grid grid-cols-2 gap-2 mt-3">
<div className="flex md:justify-center md:my-1 items-center gap-1 text-[.7rem] md:text-sm">
<Image
width={23}
height={23}
alt="location icon"
src="/images/icons/location.svg"
className="dark:invert"
/>
<span>{t("projects.locationCity")} </span>
<span>{project?.city && project?.city?.name}</span>
</div>
<div className="flex md:justify-center md:my-1 items-center gap-1 text-[.7rem] md:text-sm">
<div className="mt-3 grid grid-cols-2 gap-2">
{full ? (
<button
type="button"
onClick={() => setShowLocation(true)}
className="flex items-center gap-1 text-[.7rem] md:my-1 md:justify-center md:text-sm"
>
<Image
width={23}
height={23}
alt="location icon"
src="/images/icons/location.svg"
className="dark:invert"
/>
<span>{t("projects.locationCity")} </span>
<span>{project?.city && project?.city?.name}</span>
</button>
) : (
<div className="flex items-center gap-1 text-[.7rem] md:my-1 md:justify-center md:text-sm">
<Image
width={23}
height={23}
alt="location icon"
src="/images/icons/location.svg"
className="dark:invert"
/>
<span>{t("projects.locationCity")} </span>
<span>{project?.city && project?.city?.name}</span>
</div>
)}
<div className="flex items-center gap-1 text-[.7rem] md:my-1 md:justify-center md:text-sm">
<Image
width={23}
height={23}
@@ -134,7 +176,7 @@ function MainProjectCard({
{project?.offer_time} {t("projects.days")}
</span>
</div>
<div className="flex md:justify-center md:my-1 items-center gap-1 text-[.7rem] md:text-sm">
<div className="flex items-center gap-1 text-[.7rem] md:my-1 md:justify-center md:text-sm">
<Image
width={23}
height={23}
@@ -143,9 +185,13 @@ function MainProjectCard({
className="dark:invert"
/>
<span>{t("projects.ageRange")}</span>
<span>{getAgeLabel(project?.age)}</span>
<span>
{project?.age_min != null || project?.age_max != null
? `${project?.age_min ?? "—"}-${project?.age_max ?? "—"}`
: getAgeLabel(project?.age)}
</span>
</div>
<div className="flex md:justify-center md:my-1 items-center gap-1 text-[.7rem] md:text-sm">
<div className="flex items-center gap-1 text-[.7rem] md:my-1 md:justify-center md:text-sm">
<Image
width={23}
height={23}
@@ -154,29 +200,68 @@ function MainProjectCard({
className="dark:invert"
/>
<span>{t("filters.gender")}:</span>
<span>{getGenderLabel(project?.gender)}</span>
<span>{getGenderLabel(project?.gender) || t("filters.all")}</span>
</div>
</div>
<div className="flex max-w-lg mx-auto mt-4 gap-4">
<div className="mx-auto mt-4 flex max-w-lg gap-4">
<RoundedDiv
className={`text-[#007019] border-[#007019] w-full h-8 text-[.7rem] md:text-sm ${
project?.project_type === "highlight"
? "dark:bg-[#007019] dark:text-white"
className={`h-8 w-full text-[.7rem] text-[#007019] border-[#007019] md:text-sm ${
project?.project_type === "highlight" ||
(isSample && sampleType === "highlight")
? "border-rose-400 text-rose-700 dark:border-rose-500 dark:bg-rose-800 dark:text-rose-100"
: ""
}`}
>
{t("projects.budget")} {Number(project?.offer_price).toLocaleString()}
</RoundedDiv>
<RoundedDiv
className={`text-[#17337B] border-[#17337B] w-full h-8 text-[.7rem] md:text-sm ${
project?.project_type === "highlight"
? "dark:bg-[#17337B] dark:text-white"
className={`h-8 w-full text-[.7rem] text-[#17337B] border-[#17337B] md:text-sm ${
project?.project_type === "highlight" ||
(isSample && sampleType === "highlight")
? "border-pink-400 text-pink-700 dark:border-pink-500 dark:bg-pink-800 dark:text-pink-100"
: ""
}`}
>
{t("projects.offerCount")} {project?.requested_users?.length}
</RoundedDiv>
</div>
{full && roles.length > 0 ? (
<div className="mt-4">
<p className="mb-2 text-center text-xs font-semibold">
{t("projects.rolesTitle")}
</p>
<div className="flex flex-wrap justify-center gap-2">
{roles.map((role) => (
<button
key={role._id}
type="button"
onClick={() => onRoleSelect?.(role._id)}
className={cn(
"rounded-full border px-3 py-1.5 text-xs font-semibold",
selectedRoleId === role._id
? "border-[#FF107D] bg-[#FF107D]/10 text-[#FF107D]"
: "border-border-secondary-light dark:border-border-secondary-dark"
)}
>
{role.expertise}
</button>
))}
</div>
</div>
) : null}
<LocationModal
isOpen={showLocation}
onClose={() => setShowLocation(false)}
location={{
address: project?.address || project?.neighborhood || undefined,
lat: project?.lat != null ? String(project.lat) : undefined,
lng: project?.lng != null ? String(project.lng) : undefined,
city: project?.city,
province: project?.province,
}}
/>
</div>
);
}

View File

@@ -1,18 +1,71 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import dynamic from "next/dynamic";
import { useSearchParams } from "next/navigation";
import Step1 from "./Step1";
import Step2 from "./Step2";
import Step3 from "./Step3";
import { useProjectForm } from "@/contexts/ProjectFormContext";
import { getProjectDraft, saveProjectDraft } from "@/lib/projectDrafts";
const Step2 = dynamic(() => import("./Step2"), { ssr: false });
const Step3 = dynamic(() => import("./Step3"), { ssr: false });
const Step4 = dynamic(() => import("./Step4"), { ssr: false });
const MultiStepForm = () => {
const searchParams = useSearchParams();
const { formData, updateForm, draftId, setDraftId, isEditing } =
useProjectForm();
const [step, setStep] = useState(1);
const [ready, setReady] = useState(false);
useEffect(() => {
const draftParam = searchParams.get("draft");
if (draftParam) {
const draft = getProjectDraft(draftParam);
if (draft) {
updateForm(draft.formData);
setDraftId(draft.id);
setStep(Math.min(Math.max(draft.step, 1), 4));
}
}
setReady(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams]);
const persistDraft = (nextStep: number, patch?: Partial<typeof formData>) => {
if (isEditing) return;
const merged = { ...formData, ...patch };
const saved = saveProjectDraft({
id: draftId,
step: nextStep,
formData: merged,
});
setDraftId(saved.id);
};
const goNext = (fromStep: number, patch?: Partial<typeof formData>) => {
if (patch) updateForm(patch);
const next = fromStep + 1;
persistDraft(next, patch);
setStep(next);
};
if (!ready) return null;
return (
<div className="p-4 flex flex-col items-center w-full">
{step === 1 && <Step1 nextStep={() => setStep(2)} />}
{step === 2 && <Step2 nextStep={() => setStep(3)} />}
{step === 3 && <Step3 />}
<div className="flex w-full flex-col items-center p-4">
{step === 1 && (
<Step1
nextStep={(values) => goNext(1, values)}
/>
)}
{step === 2 && (
<Step2 nextStep={(values) => goNext(2, values)} />
)}
{step === 3 && (
<Step3 nextStep={(values) => goNext(3, values)} />
)}
{step === 4 && <Step4 />}
</div>
);
};

View File

@@ -0,0 +1,308 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Modal from "@/components/elements/Modal";
import RoundedButton from "@/components/elements/RoundedButton";
import RoundedInput from "@/components/elements/RoundedInput";
import ExpertiseSelector from "./step1/ExpertiseSelector";
import AppearanceColorSelect from "@/components/models/AppearanceColorSelect";
import useAxios from "@/hooks/useAxios";
import { IExpertise } from "@/types/types";
import { ProjectRoleForm } from "@/contexts/ProjectFormContext";
import {
EYE_COLOR_OPTIONS,
HAIR_COLOR_OPTIONS,
} from "@/lib/appearanceOptions";
import { toggleBtnClass } from "@/lib/ui/buttonStyles";
import { cn } from "@/lib/utils";
import {
PROJECT_FIELD_CLASS,
PROJECT_TOGGLE_BTN,
PROJECT_TOGGLE_ROW,
} from "@/lib/projectFormStyles";
import { useTranslation } from "react-i18next";
import { isModelExpertise } from "@/lib/isModelExpertise";
import { validateMinMaxPairs } from "@/lib/rangeValidation";
type RoleModalProps = {
isOpen: boolean;
onClose: () => void;
onSave: (role: ProjectRoleForm) => void;
};
function RangeField({
label,
min,
max,
onMinChange,
onMaxChange,
minPlaceholder,
maxPlaceholder,
}: {
label: string;
min: string;
max: string;
onMinChange: (v: string) => void;
onMaxChange: (v: string) => void;
minPlaceholder: string;
maxPlaceholder: string;
}) {
return (
<div className="w-full">
<p className="mb-1 text-center text-xs font-semibold text-neutral-500">
{label}
</p>
<div className={cn(PROJECT_TOGGLE_ROW, "mt-0 grid-cols-2")}>
<RoundedInput
type="number"
className={cn("text-xs", PROJECT_FIELD_CLASS)}
placeholder={minPlaceholder}
value={min}
onChange={(e) => onMinChange(e.target.value)}
/>
<RoundedInput
type="number"
className={cn("text-xs", PROJECT_FIELD_CLASS)}
placeholder={maxPlaceholder}
value={max}
onChange={(e) => onMaxChange(e.target.value)}
/>
</div>
</div>
);
}
export default function RoleModal({ isOpen, onClose, onSave }: RoleModalProps) {
const { t } = useTranslation("common");
const { request } = useAxios();
const [expertiseList, setExpertiseList] = useState<IExpertise[] | null>(null);
const [expertise, setExpertise] = useState("");
const [subExpertise, setSubExpertise] = useState<string[]>([]);
const [numberOfPerson, setNumberOfPerson] = useState("");
const [heightMin, setHeightMin] = useState("");
const [heightMax, setHeightMax] = useState("");
const [weightMin, setWeightMin] = useState("");
const [weightMax, setWeightMax] = useState("");
const [sizeMin, setSizeMin] = useState("");
const [sizeMax, setSizeMax] = useState("");
const [eyeColor, setEyeColor] = useState("");
const [hairColor, setHairColor] = useState("");
const [portfolioRequired, setPortfolioRequired] = useState(false);
const [error, setError] = useState("");
const isModel = useMemo(() => isModelExpertise(expertise), [expertise]);
useEffect(() => {
if (!isOpen) return;
const fetchData = async () => {
try {
const response = await request<{ expertises: IExpertise[] }>(
"GET",
"/expertise"
);
setExpertiseList(response?.expertises || null);
} catch (err) {
console.log(err);
}
};
void fetchData();
}, [isOpen, request]);
useEffect(() => {
if (!isOpen) {
setExpertise("");
setSubExpertise([]);
setNumberOfPerson("");
setHeightMin("");
setHeightMax("");
setWeightMin("");
setWeightMax("");
setSizeMin("");
setSizeMax("");
setEyeColor("");
setHairColor("");
setPortfolioRequired(false);
setError("");
}
}, [isOpen]);
const handleSave = () => {
if (!expertise) {
setError(t("projects.newProject.validation.expertiseRequired"));
return;
}
const subList =
expertiseList?.find((item) => item.expertise === expertise)
?.sub_expertise ?? [];
if (subList.length > 0 && !subExpertise.length) {
setError(t("projects.newProject.validation.subExpertiseRequired"));
return;
}
if (!numberOfPerson || Number(numberOfPerson) < 1) {
setError(t("projects.newProject.validation.headcountRequired"));
return;
}
if (
validateMinMaxPairs([
{ min: heightMin, max: heightMax },
{ min: weightMin, max: weightMax },
{ min: sizeMin, max: sizeMax },
])
) {
setError(t("filters.maxMustBeGreater"));
return;
}
const roleId =
typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `role-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
onSave({
id: roleId,
expertise,
subExpertise,
numberOfPerson,
heightMin,
heightMax,
weightMin,
weightMax,
sizeMin,
sizeMax,
eyeColor,
hairColor,
portfolioRequired,
});
onClose();
};
const dummyFormik = {
values: { expertise, subExpertise },
setFieldValue: (field: string, value: string | string[]) => {
if (field === "expertise") setExpertise(String(value));
if (field === "subExpertise") setSubExpertise(value as string[]);
},
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
height="fit"
elevated
panelClassName="max-h-[90vh] overflow-y-auto"
>
<div className="flex w-full flex-col items-center gap-3 pb-4 text-sm">
<h3 className="text-base font-bold">
{t("projects.newProject.selectRole")}
</h3>
<ExpertiseSelector
expertiseList={expertiseList}
expertise={expertise}
setExpertise={setExpertise}
subExpertise={subExpertise}
setSubExpertise={setSubExpertise}
setFieldValue={dummyFormik.setFieldValue}
formik={dummyFormik}
/>
{isModel ? (
<div className="mt-2 w-full space-y-3 border-t border-neutral-200 pt-3 dark:border-neutral-700">
<RangeField
label={t("filters.heightCm")}
min={heightMin}
max={heightMax}
onMinChange={setHeightMin}
onMaxChange={setHeightMax}
minPlaceholder={t("filters.min")}
maxPlaceholder={t("filters.max")}
/>
<RangeField
label={t("filters.weightKg")}
min={weightMin}
max={weightMax}
onMinChange={setWeightMin}
onMaxChange={setWeightMax}
minPlaceholder={t("filters.min")}
maxPlaceholder={t("filters.max")}
/>
<RangeField
label={t("filters.size")}
min={sizeMin}
max={sizeMax}
onMinChange={setSizeMin}
onMaxChange={setSizeMax}
minPlaceholder={t("filters.min")}
maxPlaceholder={t("filters.max")}
/>
<AppearanceColorSelect
label={t("filters.hairColor")}
placeholder={t("filters.selectHairColor")}
value={hairColor}
options={HAIR_COLOR_OPTIONS}
onChange={setHairColor}
className="max-w-full"
/>
<AppearanceColorSelect
label={t("filters.eyeColor")}
placeholder={t("filters.selectEyeColor")}
value={eyeColor}
options={EYE_COLOR_OPTIONS}
onChange={setEyeColor}
className="max-w-full"
/>
</div>
) : null}
<RoundedInput
type="number"
min={1}
className={cn("mt-2 w-full text-xs", PROJECT_FIELD_CLASS)}
placeholder={t("projects.newProject.fields.headcountRequired")}
value={numberOfPerson}
onChange={(e) => setNumberOfPerson(e.target.value)}
/>
<div className="mt-2 w-full">
<p className="mb-2 text-center text-xs font-semibold text-neutral-500">
{t("projects.newProject.fields.portfolioRequired")}
</p>
<div className={cn(PROJECT_TOGGLE_ROW, "mt-0 grid-cols-2")}>
<RoundedButton
type="button"
className={cn(
toggleBtnClass(portfolioRequired),
PROJECT_TOGGLE_BTN,
"!max-w-none"
)}
onClick={() => setPortfolioRequired(true)}
>
{t("projects.newProject.portfolioNeeded")}
</RoundedButton>
<RoundedButton
type="button"
className={cn(
toggleBtnClass(!portfolioRequired),
PROJECT_TOGGLE_BTN,
"!max-w-none"
)}
onClick={() => setPortfolioRequired(false)}
>
{t("projects.newProject.portfolioNotNeeded")}
</RoundedButton>
</div>
</div>
{error ? <p className="text-red-500">{error}</p> : null}
<RoundedButton
type="button"
variant="primary"
className="mt-2 px-8 py-2"
onClick={handleSave}
>
{t("projects.newProject.registerRole")}
</RoundedButton>
</div>
</Modal>
);
}

View File

@@ -1,135 +1,137 @@
"use client";
import RoundedButton from "@/components/elements/RoundedButton";
import { useProjectForm } from "@/contexts/ProjectFormContext";
import useAxios from "@/hooks/useAxios";
import { IExpertise } from "@/types/types";
import RoundedInput from "@/components/elements/RoundedInput";
import { ProjectFormData, useProjectForm } from "@/contexts/ProjectFormContext";
import {
PROJECT_FIELD_CLASS,
PROJECT_FIELD_ERROR_CLASS,
} from "@/lib/projectFormStyles";
import { useFormik } from "formik";
import { useEffect, useMemo, useState } from "react";
import { useMemo } from "react";
import * as Yup from "yup";
import { useTranslation } from "react-i18next";
import GenderSelection from "./step1/GenderSelection";
import AgeSelection from "./step1/AgeSelection";
import PublicTypeSelection from "./step1/PublicTypeSelection";
import LocationSelector from "./step1/LocationSelector";
import ExpertiseSelector from "./step1/ExpertiseSelector";
import { isMaxGreaterThanMin } from "@/lib/rangeValidation";
const Step1 = ({ nextStep }: { nextStep: () => void }) => {
const Step1 = ({
nextStep,
}: {
nextStep: (values: Partial<ProjectFormData>) => void;
}) => {
const { t } = useTranslation("common");
const { formData, updateForm } = useProjectForm();
const { request } = useAxios();
const [expertiseList, setExpertiseList] = useState<IExpertise[] | null>(null);
const [expertise, setExpertise] = useState<string>("");
const [subExpertise, setSubExpertise] = useState<string[]>([]);
const { formData } = useProjectForm();
const validationSchema = useMemo(
() =>
Yup.object({
cityId: Yup.string().required(t("projects.newProject.validation.cityRequired")),
stateId: Yup.string().required(t("projects.newProject.validation.stateRequired")),
publicType: Yup.string().required(
t("projects.newProject.validation.publicTypeRequired")
projTitle: Yup.string().required(
t("projects.newProject.validation.titleRequired")
),
age: Yup.string().required(t("projects.newProject.validation.ageRequired")),
gender: Yup.string().required(t("projects.newProject.validation.genderRequired")),
expertise: Yup.string().required(
t("projects.newProject.validation.expertiseRequired")
description: Yup.string().required(
t("projects.newProject.validation.descriptionRequired")
),
gender: Yup.string(),
ageMin: Yup.string(),
ageMax: Yup.string().test(
"max-gt-min",
t("filters.maxMustBeGreater"),
function (value) {
return isMaxGreaterThanMin(this.parent.ageMin || "", value || "");
}
),
subExpertise: Yup.array()
.of(
Yup.string().required(t("projects.newProject.validation.subExpertiseRequired"))
)
.min(1, t("projects.newProject.validation.subExpertiseRequired")),
}),
[t]
);
useEffect(() => {
const fetchData = async () => {
try {
const response = await request<{ expertises: IExpertise[] }>(
"GET",
"/expertise"
);
setExpertiseList(response?.expertises);
} catch (err) {
console.log(err);
}
};
fetchData();
}, []);
const formik = useFormik({
initialValues: {
cityId: formData.cityId || "",
stateId: formData.stateId || "",
publicType: formData.publicType || "",
expertise: formData.expertise || "",
projTitle: formData.projTitle || "",
description: formData.description || "",
gender: formData.gender || "",
age: formData.age || "",
subExpertise: formData.subExpertise || [],
ageMin: formData.ageMin || "",
ageMax: formData.ageMax || "",
},
enableReinitialize: true,
validationSchema,
onSubmit: (values) => {
updateForm(values);
nextStep();
nextStep({
...values,
age:
values.ageMin || values.ageMax
? `${values.ageMin || ""}-${values.ageMax || ""}`
: "",
});
},
});
const fieldClass = (touched?: boolean, error?: string) =>
`mt-2 max-w-full text-xs ${
touched && error ? PROJECT_FIELD_ERROR_CLASS : PROJECT_FIELD_CLASS
}`;
return (
<form
onSubmit={formik.handleSubmit}
className="flex flex-col gap-2 w-full items-center text-sm"
className="flex w-full flex-col items-center gap-2 text-sm"
>
<h6 className="font-bold text-xl mb-2">{t("projects.newProject.title")}</h6>
<ExpertiseSelector
expertiseList={expertiseList}
expertise={expertise}
setExpertise={setExpertise}
subExpertise={subExpertise}
setSubExpertise={setSubExpertise}
setFieldValue={formik.setFieldValue}
formik={formik}
<h6 className="mb-2 text-xl font-bold">{t("projects.newProject.title")}</h6>
<RoundedInput
className={fieldClass(formik.touched.projTitle, formik.errors.projTitle)}
type="text"
placeholder={t("projects.newProject.fields.projectTitle")}
{...formik.getFieldProps("projTitle")}
/>
{formik.touched.expertise && formik.errors.expertise && (
<p className="text-red-500">{formik.errors.expertise}</p>
)}
{formik.touched.subExpertise && formik.errors.subExpertise && (
<p className="text-red-500">{formik.errors.subExpertise}</p>
{formik.touched.projTitle && formik.errors.projTitle && (
<p className="text-red-500">{formik.errors.projTitle}</p>
)}
<hr className="w-full my-2" />
<textarea
className={`mt-2 h-28 w-full rounded-3xl bg-secondary-light p-4 font-medium dark:bg-secondary-dark ${
formik.touched.description && formik.errors.description
? PROJECT_FIELD_ERROR_CLASS
: PROJECT_FIELD_CLASS
}`}
placeholder={t("projects.newProject.fields.description")}
{...formik.getFieldProps("description")}
/>
{formik.touched.description && formik.errors.description && (
<p className="text-red-500">{formik.errors.description}</p>
)}
<hr className="my-2 w-full" />
<p className="text-xs font-semibold text-neutral-500">
{t("projects.newProject.fields.genderOptional")}
</p>
<GenderSelection
selectedGender={formik.values.gender}
setSelectedGender={(gender) => formik.setFieldValue("gender", gender)}
setFieldValue={formik.setFieldValue}
/>
{formik.touched.gender && formik.errors.gender && (
<p className="text-red-500">{formik.errors.gender}</p>
<hr className="my-2 w-full" />
<p className="text-xs font-semibold text-neutral-500">
{t("projects.newProject.fields.ageRangeOptional")}
</p>
<div className="grid w-full grid-cols-2 gap-2">
<RoundedInput
type="number"
className={`text-xs ${PROJECT_FIELD_CLASS}`}
placeholder={t("projects.newProject.fields.ageMin")}
{...formik.getFieldProps("ageMin")}
/>
<RoundedInput
type="number"
className={`text-xs ${PROJECT_FIELD_CLASS}`}
placeholder={t("projects.newProject.fields.ageMax")}
{...formik.getFieldProps("ageMax")}
/>
</div>
{formik.touched.ageMax && formik.errors.ageMax && (
<p className="text-red-500">{formik.errors.ageMax}</p>
)}
<hr className="w-full my-2" />
<AgeSelection
selectedAge={formik.values.age}
setSelectedAge={(age) => formik.setFieldValue("age", age)}
setFieldValue={formik.setFieldValue}
/>
{formik.touched.age && formik.errors.age && (
<p className="text-red-500">{formik.errors.age}</p>
)}
<hr className="w-full my-2" />
<PublicTypeSelection
selectedPublicType={formik.values.publicType}
setSelectedPublicType={(type) =>
formik.setFieldValue("publicType", type)
}
setFieldValue={formik.setFieldValue}
/>
{formik.touched.publicType && formik.errors.publicType && (
<p className="text-red-500">{formik.errors.publicType}</p>
)}
<hr className="w-full my-2" />
<LocationSelector formik={formik} />
<RoundedButton type="submit" variant="primary" className="mt-4 px-8 py-2">
{t("projects.newProject.submitContinue")}
</RoundedButton>

View File

@@ -1,184 +1,67 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import RoundedButton from "@/components/elements/RoundedButton";
import { useProjectForm } from "@/contexts/ProjectFormContext";
import { ProjectFormData, useProjectForm } from "@/contexts/ProjectFormContext";
import { useFormik } from "formik";
import * as Yup from "yup";
import RoundedInput from "@/components/elements/RoundedInput";
import { useMemo, useState } from "react";
import Modal from "@/components/elements/Modal";
import useAxios from "@/hooks/useAxios";
import { useRouter } from "next/navigation";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import LocationSelector from "./step1/LocationSelector";
const Step2 = ({ nextStep }: { nextStep: () => void }) => {
const Step2 = ({
nextStep,
}: {
nextStep: (values: Partial<ProjectFormData>) => void;
}) => {
const { t } = useTranslation("common");
const { formData, updateForm, isEditing } = useProjectForm();
const [showConfirmModal, setShowConfirmModal] = useState<boolean>(false);
const { request } = useAxios();
const router = useRouter();
const { formData } = useProjectForm();
const validationSchema = useMemo(
() =>
Yup.object({
description: Yup.string().required(
t("projects.newProject.validation.descriptionRequired")
cityId: Yup.string().required(
t("projects.newProject.validation.cityRequired")
),
offerPrice: Yup.string().required(
t("projects.newProject.validation.budgetRequired")
stateId: Yup.string().required(
t("projects.newProject.validation.stateRequired")
),
projectTime: Yup.string().required(
t("projects.newProject.validation.projectTimeRequired")
),
projTitle: Yup.string().required(
t("projects.newProject.validation.titleRequired")
),
numberOfPerson: Yup.string(),
markerCoordinate: Yup.array()
.of(Yup.number())
.min(2, t("projects.newProject.validation.mapRequired")),
neighborhood: Yup.string(),
address: Yup.string(),
}),
[t]
);
const formik = useFormik({
initialValues: {
description: formData.description || "",
offerPrice: formData.offerPrice || "",
projectTime: formData.projectTime || "",
projTitle: formData.projTitle || "",
numberOfPerson: formData.numberOfPerson || "",
cityId: formData.cityId || "",
stateId: formData.stateId || "",
markerCoordinate: formData.markerCoordinate || [],
neighborhood: formData.neighborhood || "",
address: formData.address || "",
},
enableReinitialize: true,
validationSchema,
onSubmit: async (values) => {
updateForm(values);
if (isEditing) {
try {
await request("POST", "/projects/edit", {
projectId: formData?._id,
title: values?.projTitle,
expertise: formData?.expertise,
sub_expertise: formData?.subExpertise,
gender: formData?.gender,
age: formData?.age,
conversation_projects: formData?.publicType,
province: formData?.stateId,
city: formData?.cityId,
offer_time: values?.projectTime,
offer_price: values?.offerPrice,
description: values?.description,
project_type: formData?.selectedType,
public_status: formData?.userId ? "private" : "public",
number_of_person:
values?.numberOfPerson && values?.numberOfPerson.length !== 0
? values?.numberOfPerson
: 1,
});
localStorage.removeItem("projectForm");
router.push("/settings/workroom");
} catch (err: any) {
console.log("Unhandled error:", err?.message);
}
} else {
updateForm(values);
}
setShowConfirmModal(true);
onSubmit: (values) => {
nextStep(values);
},
});
return (
<form
onSubmit={formik.handleSubmit}
className="flex flex-col gap-2 w-full items-center text-sm"
className="flex w-full flex-col items-center gap-2 text-sm"
>
<h6 className="font-bold text-xl mb-2">{t("projects.newProject.title")}</h6>
<RoundedInput
className={`max-w-full text-xs mt-2 ${
formik.touched.projTitle && formik.errors.projTitle
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
type="text"
placeholder={t("projects.newProject.fields.projectTitle")}
{...formik.getFieldProps("projTitle")}
/>
{formik.touched.projTitle && formik.errors.projTitle && (
<p className="text-red-500">{formik.errors.projTitle}</p>
)}
<div className="relative w-full mt-2">
<RoundedInput
className={`max-w-full text-xs${
formik.touched.projectTime && formik.errors.projectTime
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
type="text"
placeholder={t("projects.newProject.fields.projectDuration")}
{...formik.getFieldProps("projectTime")}
/>
<span className="absolute left-5 top-2 text-gray-400">
({t("projects.days")})
</span>
</div>
{formik.touched.projectTime && formik.errors.projectTime && (
<p className="text-red-500">{formik.errors.projectTime}</p>
)}
<RoundedInput
className={`max-w-full text-xs mt-2 ${
formik.touched.offerPrice && formik.errors.offerPrice
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
type="number"
placeholder={t("projects.newProject.fields.budgetToman")}
{...formik.getFieldProps("offerPrice")}
/>
{formik.touched.offerPrice && formik.errors.offerPrice && (
<p className="text-red-500">{formik.errors.offerPrice}</p>
)}
<RoundedInput
type="text"
className="text-xs mt-2"
placeholder={t("projects.newProject.fields.hiringCount")}
{...formik.getFieldProps("numberOfPerson")}
/>
<textarea
className={`w-full p-4 mt-2 h-24 rounded-3xl border border-border-secondary-light dark:border-border-secondary-dark bg-secondary-light dark:bg-secondary-dark font-medium
${
formik.touched.description && formik.errors.description
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}
`}
placeholder={t("projects.newProject.fields.description")}
{...formik.getFieldProps("description")}
></textarea>
{formik.touched.description && formik.errors.description && (
<p className="text-red-500">{formik.errors.description}</p>
)}
<h6 className="mb-2 text-xl font-bold">{t("projects.newProject.title")}</h6>
<p className="mb-2 text-center text-xs text-neutral-500">
{t("projects.newProject.locationStepHint")}
</p>
<LocationSelector formik={formik} />
<RoundedButton type="submit" variant="primary" className="mt-4 px-8 py-2">
{t("projects.newProject.submitContinue")}
</RoundedButton>
{showConfirmModal && (
<Modal
isOpen={showConfirmModal}
onClose={() => setShowConfirmModal(false)}
height="280px"
>
<div className="flex flex-col items-center text-center">
<p className="mt-5">{t("projects.newProject.dailyLimitModal")}</p>
<RoundedButton
onClick={() => {
nextStep();
}}
className="bg-sky-200 py-2 px-5 mt-5 rounded-xl min-w-[120px] text-sky-600 text-center"
>
{t("settings.confirm")}
</RoundedButton>
</div>
</Modal>
)}
</form>
);
};

View File

@@ -1,136 +1,326 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import RoundedButton from "@/components/elements/RoundedButton";
import { useProjectForm } from "@/contexts/ProjectFormContext";
import RoundedInput from "@/components/elements/RoundedInput";
import SelectBox from "@/components/elements/SelectBox";
import {
ProjectFormData,
ProjectRoleForm,
useProjectForm,
} from "@/contexts/ProjectFormContext";
import { useFormik } from "formik";
import * as Yup from "yup";
import { useEffect, useMemo, useState } from "react";
import useAxios from "@/hooks/useAxios";
import { IProjectType } from "@/types/types";
import { sampleProject } from "@/constants";
import MainProjectCard from "../MainProjectCard";
import RoundedDiv from "@/components/elements/RoundedDiv";
import { selectionCardClass } from "@/lib/ui/buttonStyles";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import DatePicker from "react-multi-date-picker";
import persian from "react-date-object/calendars/persian";
import persian_fa from "react-date-object/locales/persian_fa";
import Image from "next/image";
import RoleModal from "./RoleModal";
import {
PROJECT_FIELD_CLASS,
PROJECT_FIELD_ERROR_CLASS,
} from "@/lib/projectFormStyles";
const Step3 = () => {
const PAYMENT_OPTIONS = [
"monthly",
"daily",
"hourly",
"commission",
"negotiable",
] as const;
const COLLAB_OPTIONS = [
"full_time",
"part_time",
"project",
"internship",
] as const;
const EXPERIENCE_OPTIONS = [
"none",
"lt1",
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"10",
"gt10",
] as const;
const Step3 = ({
nextStep,
}: {
nextStep: (values: Partial<ProjectFormData>) => void;
}) => {
const { t } = useTranslation("common");
const router = useRouter();
const { request } = useAxios();
const { formData, updateForm } = useProjectForm();
const [selectedType, setSelectedType] = useState("normal");
const [typeList, setTypeList] = useState<IProjectType[] | null>(null);
const fetchStates = async () => {
try {
const response = await request<{ projectTypes: IProjectType[] }>(
"GET",
"/projects/types"
);
setTypeList(response?.projectTypes || null);
} catch (err) {
console.log(err);
}
};
useEffect(() => {
fetchStates();
}, []);
const { formData } = useProjectForm();
const [roles, setRoles] = useState<ProjectRoleForm[]>(formData.roles || []);
const [showRoleModal, setShowRoleModal] = useState(false);
const [roleError, setRoleError] = useState("");
const validationSchema = useMemo(
() =>
Yup.object({
selectedType: Yup.string().required(
t("projects.newProject.validation.typeRequired")
offerPrice: Yup.string().required(
t("projects.newProject.validation.budgetRequired")
),
paymentMethod: Yup.string().required(
t("projects.newProject.validation.paymentMethodRequired")
),
projectTime: Yup.string().required(
t("projects.newProject.validation.projectTimeRequired")
),
startDate: Yup.string().required(
t("projects.newProject.validation.startDateRequired")
),
workHoursStart: Yup.string().required(
t("projects.newProject.validation.workHoursRequired")
),
workHoursEnd: Yup.string().required(
t("projects.newProject.validation.workHoursRequired")
),
collaborationType: Yup.string().required(
t("projects.newProject.validation.collaborationRequired")
),
experience: Yup.string(),
}),
[t]
);
const getDisplayTypeLabel = (name: string) => {
if (name === "normal" || name === "free") {
return t("projects.newProject.displaySimple");
}
if (name === "force") {
return t("projects.newProject.displayUrgent");
}
return t("projects.newProject.displayHighlight");
};
const formik = useFormik({
initialValues: {
selectedType: formData.selectedType || "",
offerPrice: formData.offerPrice || "",
paymentMethod: formData.paymentMethod || "",
projectTime: formData.projectTime || "",
startDate: formData.startDate || "",
workHoursStart: formData.workHoursStart || "",
workHoursEnd: formData.workHoursEnd || "",
collaborationType: formData.collaborationType || "",
experience: formData.experience || "",
},
enableReinitialize: true,
validationSchema,
onSubmit: async (values) => {
updateForm(values);
try {
await request("POST", "/projects/create", {
title: formData?.projTitle,
expertise: formData?.expertise,
sub_expertise: formData?.subExpertise,
gender: formData?.gender,
age: formData?.age,
conversation_projects: formData?.publicType,
province: formData?.stateId,
city: formData?.cityId,
offer_time: formData?.projectTime,
offer_price: formData?.offerPrice,
description: formData?.description,
project_type: selectedType,
public_status: formData?.userId ? "private" : "public",
number_of_person:
formData?.numberOfPerson &&
formData?.numberOfPerson.length !== 0 &&
Number(formData?.numberOfPerson) < 11
? formData?.numberOfPerson
: 1,
});
localStorage.removeItem("projectForm");
router.push("/settings/workroom");
} catch (err: any) {
console.log("Unhandled error:", err?.message);
onSubmit: (values) => {
if (!roles.length) {
setRoleError(t("projects.newProject.validation.roleRequired"));
return;
}
nextStep({
...values,
roles,
expertise: roles[0]?.expertise || null,
subExpertise: roles[0]?.subExpertise || [],
});
},
});
const handleAddRole = (role: ProjectRoleForm) => {
if (roles.length >= 5) {
setRoleError(t("projects.newProject.validation.maxRoles"));
return;
}
setRoles((prev) => [...prev, role]);
setRoleError("");
};
const removeRole = (id: string) => {
setRoles((prev) => prev.filter((r) => r.id !== id));
};
return (
<form
onSubmit={formik.handleSubmit}
className="flex flex-col gap-2 w-full items-center text-sm"
className="flex w-full flex-col items-center gap-2 text-sm"
>
<h6 className="font-bold text-xl mb-2">{t("projects.newProject.title")}</h6>
<p>{t("projects.newProject.displayTypeHint")}</p>
{typeList?.map((item: IProjectType) => {
return (
<div
onClick={() => {
setSelectedType(item?.name);
formik.setFieldValue("selectedType", item?.name);
}}
className="w-full flex flex-col items-center"
key={item?._id}
>
<MainProjectCard isSample={true} sampleType={item?.name} project={sampleProject} />
<RoundedDiv
className={selectionCardClass(selectedType == item?.name)}
>
{getDisplayTypeLabel(item?.name)}:
{item?.price !== 0
? ` ${Number(item?.price).toLocaleString()} ${t("settings.toman")}`
: ` ${t("projects.newProject.free")}`}
</RoundedDiv>
</div>
);
})}
{formik.touched.selectedType && formik.errors.selectedType && (
<p className="text-red-500">{formik.errors.selectedType}</p>
)}
<RoundedButton type="submit" variant="primary" className="mt-4 px-8 py-2">
{t("projects.newProject.submitRequest")}
<h6 className="mb-2 text-xl font-bold">{t("projects.newProject.title")}</h6>
<RoundedButton
type="button"
variant="primary"
className="w-full max-w-sm py-2"
onClick={() => setShowRoleModal(true)}
disabled={roles.length >= 5}
>
{t("projects.newProject.selectRole")}
</RoundedButton>
{roles.length > 0 ? (
<div className="mt-2 flex w-full flex-col gap-2">
{roles.map((role) => (
<div
key={role.id}
className="flex items-center justify-between rounded-2xl border border-border-secondary-light px-3 py-2 dark:border-border-secondary-dark"
>
<div>
<p className="font-semibold">{role.expertise}</p>
<p className="text-xs text-neutral-500">
{role.subExpertise.join(" _ ")} · {role.numberOfPerson}{" "}
{t("projects.newProject.person")}
</p>
</div>
<button
type="button"
className="text-xs text-red-500"
onClick={() => removeRole(role.id)}
>
{t("projects.newProject.removeRole")}
</button>
</div>
))}
</div>
) : null}
{roleError ? <p className="text-red-500">{roleError}</p> : null}
<hr className="my-2 w-full" />
<RoundedInput
className={`mt-2 max-w-full text-xs ${
formik.touched.offerPrice && formik.errors.offerPrice
? PROJECT_FIELD_ERROR_CLASS
: PROJECT_FIELD_CLASS
}`}
type="number"
placeholder={t("projects.newProject.fields.budgetToman")}
{...formik.getFieldProps("offerPrice")}
/>
{formik.touched.offerPrice && formik.errors.offerPrice && (
<p className="text-red-500">{formik.errors.offerPrice}</p>
)}
<SelectBox
className={`mt-2 w-full max-w-full ${PROJECT_FIELD_CLASS}`}
value={formik.values.paymentMethod}
onChange={(e) => formik.setFieldValue("paymentMethod", e.target.value)}
>
<option value="">
{t("projects.newProject.fields.paymentMethod")}
</option>
{PAYMENT_OPTIONS.map((key) => (
<option key={key} value={key}>
{t(`projects.newProject.paymentMethods.${key}`)}
</option>
))}
</SelectBox>
{formik.touched.paymentMethod && formik.errors.paymentMethod && (
<p className="text-red-500">{formik.errors.paymentMethod}</p>
)}
<div className="relative mt-2 w-full">
<RoundedInput
className={`max-w-full text-xs ${PROJECT_FIELD_CLASS}`}
type="text"
placeholder={t("projects.newProject.fields.projectDuration")}
{...formik.getFieldProps("projectTime")}
/>
<span className="absolute left-5 top-2 text-gray-400">
({t("projects.days")})
</span>
</div>
{formik.touched.projectTime && formik.errors.projectTime && (
<p className="text-red-500">{formik.errors.projectTime}</p>
)}
<div className="project-date-field relative mt-2 w-full">
<DatePicker
calendar={persian}
locale={persian_fa}
value={formik.values.startDate}
onChange={(value) => {
formik.setFieldValue(
"startDate",
value?.format("YYYY/MM/DD") || ""
);
}}
calendarPosition="bottom-center"
placeholder={t("projects.newProject.fields.startDate")}
containerClassName="w-full"
inputClass={`w-full max-w-full h-10 rounded-3xl bg-secondary-light px-12 text-center font-medium text-xs dark:bg-secondary-dark ${PROJECT_FIELD_CLASS}`}
style={{ width: "100%" }}
/>
<span className="pointer-events-none absolute right-4 top-1/2 -translate-y-1/2">
<Image
width={22}
height={22}
alt=""
src="/images/icons/calendar.svg"
/>
</span>
</div>
{formik.touched.startDate && formik.errors.startDate && (
<p className="text-red-500">{formik.errors.startDate}</p>
)}
<div className="mt-2 grid w-full grid-cols-2 gap-2">
<RoundedInput
type="time"
className={`text-xs ${PROJECT_FIELD_CLASS}`}
placeholder={t("projects.newProject.fields.workStart")}
{...formik.getFieldProps("workHoursStart")}
/>
<RoundedInput
type="time"
className={`text-xs ${PROJECT_FIELD_CLASS}`}
placeholder={t("projects.newProject.fields.workEnd")}
{...formik.getFieldProps("workHoursEnd")}
/>
</div>
{(formik.touched.workHoursStart && formik.errors.workHoursStart) ||
(formik.touched.workHoursEnd && formik.errors.workHoursEnd) ? (
<p className="text-red-500">
{formik.errors.workHoursStart || formik.errors.workHoursEnd}
</p>
) : null}
<SelectBox
className={`mt-2 w-full max-w-full ${PROJECT_FIELD_CLASS}`}
value={formik.values.collaborationType}
onChange={(e) =>
formik.setFieldValue("collaborationType", e.target.value)
}
>
<option value="">
{t("projects.newProject.fields.collaborationType")}
</option>
{COLLAB_OPTIONS.map((key) => (
<option key={key} value={key}>
{t(`projects.newProject.collaborationTypes.${key}`)}
</option>
))}
</SelectBox>
{formik.touched.collaborationType && formik.errors.collaborationType && (
<p className="text-red-500">{formik.errors.collaborationType}</p>
)}
<SelectBox
className={`mt-2 w-full max-w-full ${PROJECT_FIELD_CLASS}`}
value={formik.values.experience}
onChange={(e) => formik.setFieldValue("experience", e.target.value)}
>
<option value="">
{t("projects.newProject.fields.experienceOptional")}
</option>
{EXPERIENCE_OPTIONS.map((key) => (
<option key={key} value={key}>
{t(`projects.newProject.experienceOptions.${key}`)}
</option>
))}
</SelectBox>
<RoundedButton type="submit" variant="primary" className="mt-4 px-8 py-2">
{t("projects.newProject.submitContinue")}
</RoundedButton>
<RoleModal
isOpen={showRoleModal}
onClose={() => setShowRoleModal(false)}
onSave={handleAddRole}
/>
</form>
);
};

View File

@@ -0,0 +1,192 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import RoundedButton from "@/components/elements/RoundedButton";
import { useProjectForm } from "@/contexts/ProjectFormContext";
import { useFormik } from "formik";
import * as Yup from "yup";
import { useEffect, useMemo, useState } from "react";
import useAxios from "@/hooks/useAxios";
import { IProjectType } from "@/types/types";
import { sampleProject } from "@/constants";
import MainProjectCard from "../MainProjectCard";
import RoundedDiv from "@/components/elements/RoundedDiv";
import { selectionCardClass } from "@/lib/ui/buttonStyles";
import { useRouter } from "next/navigation";
import { useTranslation } from "react-i18next";
import { removeProjectDraft, saveProjectDraft } from "@/lib/projectDrafts";
const Step4 = () => {
const { t } = useTranslation("common");
const router = useRouter();
const { request } = useAxios();
const { formData, updateForm, isEditing, draftId, resetForm } =
useProjectForm();
const [selectedType, setSelectedType] = useState(
formData.selectedType || "normal"
);
const [typeList, setTypeList] = useState<IProjectType[] | null>(null);
useEffect(() => {
const fetchStates = async () => {
try {
const response = await request<{ projectTypes: IProjectType[] }>(
"GET",
"/projects/types"
);
setTypeList(response?.projectTypes || null);
} catch (err) {
console.log(err);
}
};
void fetchStates();
}, [request]);
const validationSchema = useMemo(
() =>
Yup.object({
selectedType: Yup.string().required(
t("projects.newProject.validation.typeRequired")
),
}),
[t]
);
const getDisplayTypeLabel = (name: string) => {
if (name === "normal" || name === "free") {
return t("projects.newProject.displaySimple");
}
if (name === "force") {
return t("projects.newProject.displayUrgent");
}
return t("projects.newProject.displayHighlight");
};
const formik = useFormik({
initialValues: {
selectedType: formData.selectedType || "normal",
},
validationSchema,
onSubmit: async (values) => {
updateForm(values);
if (!isEditing) {
saveProjectDraft({
id: draftId,
step: 4,
formData: { ...formData, ...values },
});
}
const coords = formData.markerCoordinate || [];
const rolesPayload = (formData?.roles || []).map((role) => ({
expertise: role.expertise,
sub_expertise: role.subExpertise,
number_of_person: Number(role.numberOfPerson) || 1,
height_min: role.heightMin || null,
height_max: role.heightMax || null,
weight_min: role.weightMin || null,
weight_max: role.weightMax || null,
size_min: role.sizeMin || null,
size_max: role.sizeMax || null,
eye_color: role.eyeColor || null,
hair_color: role.hairColor || null,
portfolio_required: role.portfolioRequired,
}));
const payload = {
title: formData?.projTitle,
expertise: formData?.expertise || formData?.roles?.[0]?.expertise,
sub_expertise:
formData?.subExpertise || formData?.roles?.[0]?.subExpertise || [],
gender: formData?.gender || "any",
age: formData?.age || "all",
age_min: formData?.ageMin || null,
age_max: formData?.ageMax || null,
conversation_projects: true,
province: formData?.stateId,
city: formData?.cityId,
lat: coords[1] ?? null,
lng: coords[0] ?? null,
neighborhood: formData?.neighborhood || null,
address: formData?.address || null,
offer_time: formData?.projectTime,
offer_price: formData?.offerPrice,
description: formData?.description,
project_type: selectedType,
public_status: formData?.userId ? "private" : "public",
payment_method: formData?.paymentMethod || null,
start_date: formData?.startDate || null,
work_hours_start: formData?.workHoursStart || null,
work_hours_end: formData?.workHoursEnd || null,
collaboration_type: formData?.collaborationType || null,
experience: formData?.experience || null,
roles: rolesPayload,
};
try {
if (isEditing && formData?._id) {
await request("POST", "/projects/edit", {
...payload,
projectId: formData._id,
});
resetForm();
router.push("/settings/workroom");
return;
}
const response = await request<{ id?: string; message?: string }>(
"POST",
"/projects/create",
payload
);
if (draftId) removeProjectDraft(draftId);
resetForm();
const projectId = response?.id;
if (projectId) {
router.push(`/projects/new/${projectId}`);
} else {
router.push("/settings/workroom");
}
} catch (err: any) {
console.log("Unhandled error:", err?.message);
}
},
});
return (
<form
onSubmit={formik.handleSubmit}
className="flex w-full flex-col items-center gap-2 text-sm"
>
<h6 className="mb-2 text-xl font-bold">{t("projects.newProject.title")}</h6>
<p>{t("projects.newProject.displayTypeHint")}</p>
{typeList?.map((item: IProjectType) => (
<div
onClick={() => {
setSelectedType(item?.name);
formik.setFieldValue("selectedType", item?.name);
}}
className="flex w-full flex-col items-center"
key={item?._id}
>
<MainProjectCard
isSample={true}
sampleType={item?.name}
project={sampleProject}
/>
<RoundedDiv className={selectionCardClass(selectedType == item?.name)}>
{getDisplayTypeLabel(item?.name)}:
{item?.price !== 0
? ` ${Number(item?.price).toLocaleString()} ${t("settings.toman")}`
: ` ${t("projects.newProject.free")}`}
</RoundedDiv>
</div>
))}
{formik.touched.selectedType && formik.errors.selectedType && (
<p className="text-red-500">{formik.errors.selectedType}</p>
)}
<RoundedButton type="submit" variant="primary" className="mt-4 px-8 py-2">
{t("projects.newProject.submitRequest")}
</RoundedButton>
</form>
);
};
export default Step4;

View File

@@ -5,6 +5,10 @@ import { IExpertise } from "@/types/types";
import RoundedButton from "@/components/elements/RoundedButton";
import { toggleBtnClass } from "@/lib/ui/buttonStyles";
import { cn } from "@/lib/utils";
import {
PROJECT_TOGGLE_BTN,
PROJECT_TOGGLE_ROW,
} from "@/lib/projectFormStyles";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
@@ -58,13 +62,14 @@ const ExpertiseSelector = ({
<p className="mb-2 text-center text-xs font-semibold text-neutral-500">
{t("projects.newProject.mainExpertise")}
</p>
<div className="grid grid-cols-3 gap-2">
<div className={cn(PROJECT_TOGGLE_ROW, "mt-0 grid-cols-3")}>
{expertiseList?.map((item) => (
<RoundedButton
key={item._id}
className={cn(
toggleBtnClass(expertise === item.expertise),
"min-w-24 overflow-hidden rounded-3xl p-1"
PROJECT_TOGGLE_BTN,
"max-w-none overflow-hidden"
)}
onClick={() => handleButtonPress(item.expertise)}
type="button"
@@ -79,13 +84,14 @@ const ExpertiseSelector = ({
<p className="mb-2 mt-4 text-center text-xs font-semibold text-neutral-500">
{t("projects.newProject.subExpertise")}
</p>
<div className="grid grid-cols-3 gap-2">
<div className={cn(PROJECT_TOGGLE_ROW, "mt-0 grid-cols-3")}>
{subList.map((item) => (
<RoundedButton
key={item._id}
className={cn(
toggleBtnClass(subExpertise.includes(item.name)),
"min-w-24 overflow-hidden rounded-3xl p-1"
PROJECT_TOGGLE_BTN,
"max-w-none overflow-hidden"
)}
onClick={() => handleSubButtonPress(item.name)}
type="button"

View File

@@ -4,6 +4,10 @@ import RoundedButton from "@/components/elements/RoundedButton";
import Image from "next/image";
import { toggleBtnClass } from "@/lib/ui/buttonStyles";
import { cn } from "@/lib/utils";
import {
PROJECT_TOGGLE_BTN,
PROJECT_TOGGLE_ROW,
} from "@/lib/projectFormStyles";
import { useTranslation } from "react-i18next";
interface GenderSelectionProps {
@@ -19,35 +23,68 @@ const GenderSelection = ({
}: GenderSelectionProps) => {
const { t } = useTranslation("common");
const pick = (gender: string) => {
setSelectedGender(gender);
setFieldValue("gender", gender);
};
return (
<div className="mt-4 grid grid-cols-2 gap-2">
<RoundedButton
className={cn(
toggleBtnClass(selectedGender === "male"),
"flex min-w-32 items-center justify-center gap-1 p-1"
)}
onClick={(e) => {
e.preventDefault();
setSelectedGender("male");
setFieldValue("gender", "male");
}}
>
{t("auth.male")}
<Image src="/images/icons/man.svg" width={25} height={25} alt="gender icon" />
</RoundedButton>
<div className={cn(PROJECT_TOGGLE_ROW, "grid-cols-3")}>
<RoundedButton
type="button"
className={cn(
toggleBtnClass(selectedGender === "female"),
"flex min-w-32 items-center justify-center gap-1 p-1"
PROJECT_TOGGLE_BTN,
"max-w-none"
)}
onClick={(e) => {
e.preventDefault();
setSelectedGender("female");
setFieldValue("gender", "female");
pick("female");
}}
>
{t("auth.female")}
<Image src="/images/icons/woman.svg" width={25} height={25} alt="gender icon" />
<Image
src="/images/icons/woman.svg"
width={20}
height={20}
alt=""
className="dark:invert"
/>
</RoundedButton>
<RoundedButton
type="button"
className={cn(
toggleBtnClass(selectedGender === "male"),
PROJECT_TOGGLE_BTN,
"max-w-none"
)}
onClick={(e) => {
e.preventDefault();
pick("male");
}}
>
{t("auth.male")}
<Image
src="/images/icons/man.svg"
width={20}
height={20}
alt=""
className="dark:invert"
/>
</RoundedButton>
<RoundedButton
type="button"
className={cn(
toggleBtnClass(selectedGender === "any"),
PROJECT_TOGGLE_BTN,
"max-w-none"
)}
onClick={(e) => {
e.preventDefault();
pick("any");
}}
>
{t("projects.newProject.genderAny")}
</RoundedButton>
</div>
);

View File

@@ -3,9 +3,17 @@
import { ICity, IProvince } from "@/types/types";
import SelectBox from "@/components/elements/SelectBox";
import RoundedInput from "@/components/elements/RoundedInput";
import { useState, useEffect } from "react";
import useAxios from "@/hooks/useAxios";
import Map, { GeolocateControl, Marker } from "react-map-gl";
import "mapbox-gl/dist/mapbox-gl.css";
import Image from "next/image";
import { useTranslation } from "react-i18next";
import {
PROJECT_FIELD_CLASS,
PROJECT_FIELD_ERROR_CLASS,
} from "@/lib/projectFormStyles";
interface LocationSelectorProps {
formik: any;
@@ -16,6 +24,15 @@ const LocationSelector: React.FC<LocationSelectorProps> = ({ formik }) => {
const { request } = useAxios();
const [allStates, setAllStates] = useState<IProvince[] | null>(null);
const [cities, setCities] = useState<ICity[] | null>(null);
const [selectedLocation, setSelectedLocation] = useState<{
lat: number;
lng: number;
} | null>(null);
const [mapReady, setMapReady] = useState(false);
useEffect(() => {
setMapReady(true);
}, []);
const fetchStates = async () => {
try {
@@ -51,20 +68,39 @@ const LocationSelector: React.FC<LocationSelectorProps> = ({ formik }) => {
}
}, [formik?.values?.stateId]);
useEffect(() => {
const coords = formik.values.markerCoordinate;
if (Array.isArray(coords) && coords.length >= 2) {
setSelectedLocation({
lat: Number(coords[1]),
lng: Number(coords[0]),
});
}
}, [formik?.values?.markerCoordinate]);
const handleProvinceChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const selectedProvinceId = e.target.value;
formik.setFieldValue("stateId", selectedProvinceId);
formik.setFieldValue("cityId", "");
fetchCities(selectedProvinceId);
};
const handleMapClick = (event: any) => {
const { lngLat } = event;
setSelectedLocation({
lat: lngLat.lat,
lng: lngLat.lng,
});
formik.setFieldValue("markerCoordinate", [lngLat.lng, lngLat.lat]);
};
const selectClass = (touched?: boolean, error?: string) =>
`max-w-full ${touched && error ? PROJECT_FIELD_ERROR_CLASS : PROJECT_FIELD_CLASS}`;
return (
<>
<SelectBox
className={`${
formik.touched.stateId && formik.errors.stateId
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
className={selectClass(formik.touched.stateId, formik.errors.stateId)}
value={formik.values.stateId}
onChange={handleProvinceChange}
>
@@ -78,17 +114,13 @@ const LocationSelector: React.FC<LocationSelectorProps> = ({ formik }) => {
))}
</SelectBox>
{formik.touched.stateId && formik.errors.stateId && (
<small className="text-red-500 block text-center">
<small className="block text-center text-red-500">
{formik.errors.stateId}
</small>
)}
<SelectBox
className={`${
formik.touched.cityId && formik.errors.cityId
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
className={selectClass(formik.touched.cityId, formik.errors.cityId)}
value={formik.values.cityId}
onChange={(e) => formik.setFieldValue("cityId", e.target.value)}
>
@@ -102,10 +134,64 @@ const LocationSelector: React.FC<LocationSelectorProps> = ({ formik }) => {
))}
</SelectBox>
{formik.touched.cityId && formik.errors.cityId && (
<small className="text-red-500 block text-center">
<small className="block text-center text-red-500">
{formik.errors.cityId}
</small>
)}
<div className="mt-2 w-full overflow-hidden rounded-2xl">
{mapReady ? (
<Map
style={{ height: "240px" }}
initialViewState={{
longitude: selectedLocation?.lng || 51.375433528216654,
latitude: selectedLocation?.lat || 35.73356434056531,
zoom: 11,
}}
mapboxAccessToken="pk.eyJ1IjoiYWxkZjkyIiwiYSI6ImNscHh5ZG56dTByMXAycnJ2cDNmdDQ5M3YifQ.a_sUt1rLFMfA0jHrETcM7A"
mapStyle="mapbox://styles/mapbox/streets-v11"
onClick={handleMapClick}
>
<GeolocateControl />
{selectedLocation && (
<Marker
latitude={selectedLocation.lat}
longitude={selectedLocation.lng}
>
<Image
alt="location icon"
className="-mt-5"
width={25}
height={25}
src={"/images/icons/location.svg"}
/>
</Marker>
)}
</Map>
) : (
<div className="flex h-[240px] items-center justify-center bg-neutral-100 text-xs dark:bg-neutral-800">
</div>
)}
{formik.touched.markerCoordinate && formik.errors.markerCoordinate && (
<p className="text-xs text-red-500">
{formik.errors.markerCoordinate as string}
</p>
)}
</div>
<RoundedInput
className={`mt-2 max-w-full text-xs ${PROJECT_FIELD_CLASS}`}
type="text"
placeholder={t("projects.newProject.fields.neighborhoodOptional")}
{...formik.getFieldProps("neighborhood")}
/>
<textarea
className={`mt-2 h-24 w-full rounded-3xl bg-secondary-light p-4 font-medium dark:bg-secondary-dark ${PROJECT_FIELD_CLASS}`}
placeholder={t("projects.newProject.fields.addressOptional")}
{...formik.getFieldProps("address")}
/>
</>
);
};

View File

@@ -1,7 +1,7 @@
"use client";
import RoundedInput from "@/components/elements/RoundedInput";
import React, { useMemo } from "react";
import React, { useMemo, useState } from "react";
import useAxios from "@/hooks/useAxios";
import RoundedButton from "@/components/elements/RoundedButton";
import { useRouter } from "next/navigation";
@@ -11,18 +11,26 @@ import Cookies from "js-cookie";
import toast from "react-hot-toast";
import { useUser } from "@/hooks/useUser";
import { useTranslation } from "react-i18next";
import { ProjectRole } from "@/types/types";
function ProjectRequestForm({
projectId,
creatorId,
selectedRole,
portfolioRequired,
}: {
projectId: string;
creatorId?: string;
selectedRole?: ProjectRole | null;
portfolioRequired?: boolean;
}) {
const { t } = useTranslation("common");
const router = useRouter();
const { request } = useAxios();
const user = useUser();
const [portfolio, setPortfolio] = useState<
{ data: string; type: "image" | "video" }[]
>([]);
const isOwnProject =
user?._id && creatorId && String(user._id) === String(creatorId);
@@ -40,6 +48,31 @@ function ProjectRequestForm({
[t]
);
const handlePortfolioUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
if (!event.target.files) return;
const files = Array.from(event.target.files);
if (portfolio.length + files.length > 5) {
toast.error(t("projects.portfolio.maxFiles"));
return;
}
files.forEach((file) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
if (typeof reader.result === "string") {
setPortfolio((prev) => [
...prev,
{
data: reader.result as string,
type: file.type.startsWith("video/") ? "video" : "image",
},
]);
}
};
});
};
const formik = useFormik({
initialValues: {
offerPrice: "",
@@ -51,11 +84,17 @@ function ProjectRequestForm({
toast.error(t("projects.loginRequired"));
return;
}
if (portfolioRequired && portfolio.length < 1) {
toast.error(t("projects.portfolio.required"));
return;
}
try {
await request("POST", "/projects/request", {
project_id: projectId,
request_time: values.projectTime,
offer_price: values.offerPrice,
role_id: selectedRole?._id || null,
portfolio,
});
router.push("/settings/workroom");
} catch (err: unknown) {
@@ -77,6 +116,77 @@ function ProjectRequestForm({
onSubmit={formik.handleSubmit}
className="mx-auto my-5 flex w-full max-w-sm flex-col items-center"
>
{selectedRole ? (
<div className="mb-3 w-full rounded-2xl border border-border-secondary-light p-3 text-xs dark:border-border-secondary-dark">
<p className="font-bold">{selectedRole.expertise}</p>
{selectedRole.sub_expertise?.length ? (
<p className="mt-1 text-neutral-500">
{selectedRole.sub_expertise.join(" _ ")}
</p>
) : null}
<p className="mt-1">
{t("projects.newProject.fields.headcount")}:{" "}
{selectedRole.number_of_person || 1}
</p>
{selectedRole.portfolio_required ? (
<p className="mt-1 text-[#FF107D]">
{t("projects.portfolio.requiredHint")}
</p>
) : null}
</div>
) : null}
{(portfolioRequired || selectedRole?.portfolio_required) && (
<div className="mb-3 w-full">
<p className="mb-2 text-center text-xs font-semibold">
{t("projects.portfolio.uploadTitle")}
</p>
<div className="flex flex-wrap gap-2">
{portfolio.map((item, index) => (
<div
key={`${item.type}-${index}`}
className="relative h-20 w-20 overflow-hidden rounded-lg border"
>
{item.type === "video" ? (
<video src={item.data} className="h-full w-full object-cover" />
) : (
// eslint-disable-next-line @next/next/no-img-element
<img
src={item.data}
alt=""
className="h-full w-full object-cover"
/>
)}
<button
type="button"
className="absolute right-1 top-1 rounded-full bg-black/50 px-1.5 text-xs text-white"
onClick={() =>
setPortfolio((prev) => prev.filter((_, i) => i !== index))
}
>
×
</button>
</div>
))}
{portfolio.length < 5 && (
<label className="flex h-20 w-20 cursor-pointer items-center justify-center rounded-lg border-2 border-dashed text-xl">
<input
type="file"
multiple
accept="image/*,video/*"
className="hidden"
onChange={handlePortfolioUpload}
/>
+
</label>
)}
</div>
<p className="mt-1 text-center text-[11px] text-neutral-500">
{t("projects.portfolio.limits")}
</p>
</div>
)}
<RoundedInput
type="number"
name="projectTime"
@@ -113,7 +223,7 @@ function ProjectRequestForm({
{formik.errors.offerPrice}
</small>
)}
<RoundedButton className="mt-4 h-10 w-40" type="submit">
<RoundedButton type="submit" variant="primary" className="mt-6 px-8 py-2">
{t("projects.submitRequest")}
</RoundedButton>
</form>

View File

@@ -1,6 +1,7 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import React, { useEffect, useState } from "react";
import Image from "next/image";
import BoldIcon from "@/components/ui/BoldIcon";
import { getCreateProjectPath } from "@/lib/getCreateContentPath";
import FilterModal from "./FilterModal";
@@ -83,6 +84,20 @@ function ProjectsFilter({ expertise }: ProjectsFilterProps) {
return (
<div>
<div className="flex items-center justify-end gap-3 py-1 text-sm">
<button
type="button"
onClick={() => router.push("/projects/specialists")}
aria-label={t("projects.specialists.title")}
className={toolbarIconButtonClass}
>
<Image
src="/images/icons/slider-horizontal.png"
alt=""
width={TOOLBAR_ICON_SIZE}
height={TOOLBAR_ICON_SIZE}
className="block dark:invert"
/>
</button>
<button
type="button"
onClick={handleCreateProject}

View File

@@ -0,0 +1,503 @@
"use client";
import React, { useCallback, useRef, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { AnimatePresence, motion, PanInfo } from "framer-motion";
import Modal from "@/components/elements/Modal";
import FilterModal from "@/components/models/FilterModal";
import BoldIcon from "@/components/ui/BoldIcon";
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 (
<div className="relative h-full w-full overflow-hidden rounded-3xl bg-neutral-900 shadow-2xl">
{previewUrl ? (
user.previewPost?.type === "video" ? (
<video
src={previewUrl}
className="h-full w-full object-cover"
muted
playsInline
autoPlay={isMain}
loop
preload={isMain ? "auto" : "metadata"}
/>
) : (
<Image
src={previewUrl}
alt=""
fill
className="object-cover"
sizes={isMain ? "280px" : "120px"}
unoptimized
/>
)
) : (
<div className="h-full w-full bg-neutral-800" />
)}
<div
className={cn(
"absolute inset-0 bg-gradient-to-t from-black/85 via-black/20 to-black/30",
!isMain && "from-black/70"
)}
/>
{isMain && onSkip ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onSkip();
}}
className="absolute left-3 top-3 flex h-8 w-8 items-center justify-center rounded-full bg-black/40 text-white backdrop-blur-sm"
aria-label="skip"
>
<BoldIcon name="close-circle" size={18} tinted className="text-white" />
</button>
) : null}
<div
className={cn(
"absolute inset-x-0 bottom-0 flex flex-col items-center pt-6",
isMain ? "px-4 pb-5" : "px-2 pb-3"
)}
>
<div
className={cn(
"relative mb-2 overflow-hidden rounded-full ring-2 ring-white/90",
isMain ? "h-16 w-16" : "h-9 w-9"
)}
>
{profileUrl ? (
<Image
src={profileUrl}
alt=""
fill
className="object-cover"
sizes={isMain ? "64px" : "36px"}
unoptimized
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-neutral-700 text-white">
{displayName.charAt(0)}
</div>
)}
</div>
{isMain ? (
<>
<Link href={profileHref} className="text-center">
<p className="text-base font-bold text-white">{displayName}</p>
{user.user_name ? (
<p className="text-xs text-white/70">@{user.user_name}</p>
) : null}
</Link>
{user.expertise ? (
<p className="mt-1 text-xs text-white/55">{user.expertise}</p>
) : null}
<Link
href={profileHref}
className="mt-4 w-full max-w-[200px] rounded-full bg-[#fe2c55] py-2.5 text-center text-sm font-bold text-white transition active:scale-95"
>
{viewProfileLabel}
</Link>
</>
) : (
<p className="line-clamp-1 text-center text-[10px] font-semibold text-white">
{displayName}
</p>
)}
</div>
</div>
);
}
interface SpecialistsSearchModalProps {
isOpen: boolean;
onClose: () => void;
}
function SpecialistsSearchModal({ isOpen, onClose }: SpecialistsSearchModalProps) {
const { t } = useTranslation("common");
const { request } = useAxios();
const [showFilter, setShowFilter] = useState(true);
const [showResults, setShowResults] = useState(false);
const applyingFilterRef = useRef(false);
const [users, setUsers] = useState<SuggestedFollowUser[]>([]);
const [loading, setLoading] = useState(false);
const [[index, direction], setSlide] = useState<[number, number]>([0, 0]);
const [stateId, setStateId] = useState("");
const [cityId, setCityId] = useState("");
const [rateFilter, setRateFilter] = useState("");
const [userLevel, setUserLevel] = useState("");
const [selectedExpertise, setSelectedExpertise] = useState("");
const [heightMin, setHeightMin] = useState("");
const [heightMax, setHeightMax] = useState("");
const [weightMin, setWeightMin] = useState("");
const [weightMax, setWeightMax] = useState("");
const [sizeMin, setSizeMin] = useState("");
const [sizeMax, setSizeMax] = useState("");
const [hairColor, setHairColor] = useState("");
const [eyeColor, setEyeColor] = useState("");
const resetAndClose = () => {
setShowFilter(true);
setShowResults(false);
setUsers([]);
setSlide([0, 0]);
onClose();
};
const clearFilters = () => {
setStateId("");
setCityId("");
setRateFilter("");
setUserLevel("");
setSelectedExpertise("");
setHeightMin("");
setHeightMax("");
setWeightMin("");
setWeightMax("");
setSizeMin("");
setSizeMax("");
setHairColor("");
setEyeColor("");
};
const fetchUsers = useCallback(async () => {
setLoading(true);
try {
const qs = new URLSearchParams({ page: "1", limit: "20" });
if (selectedExpertise) qs.set("expertise", selectedExpertise);
if (stateId) qs.set("province", stateId);
if (cityId) qs.set("city", cityId);
if (userLevel) qs.set("userLevel", userLevel);
if (rateFilter) qs.set("rateFilter", rateFilter);
if (heightMin) qs.set("heightMin", heightMin);
if (heightMax) qs.set("heightMax", heightMax);
if (weightMin) qs.set("weightMin", weightMin);
if (weightMax) qs.set("weightMax", weightMax);
if (sizeMin) qs.set("sizeMin", sizeMin);
if (sizeMax) qs.set("sizeMax", sizeMax);
if (hairColor) qs.set("hair_color", hairColor);
if (eyeColor) qs.set("eye_color", eyeColor);
const response = await request<{
posts?: Post[];
feedMeta?: { suggestedUsers?: SuggestedFollowUser[] };
}>("GET", `/users/web?${qs.toString()}`, null, { noToast: true });
const suggested = response?.feedMeta?.suggestedUsers;
if (Array.isArray(suggested) && suggested.length) {
setUsers(suggested);
} else {
const posts = response?.posts || [];
const map = new Map<string, SuggestedFollowUser>();
posts.forEach((post) => {
const id = String(post.userId || post.user_id || "");
if (!id || map.has(id)) return;
map.set(id, {
_id: id,
user_name: post.user_name,
first_name: post.first_name,
last_name: post.last_name,
expertise: post.expertise,
profile_image: post.profile_image,
previewPost: {
_id: post._id,
type: post.type,
files: post.files,
caption: post.caption,
},
is_following: post.is_following,
});
});
setUsers(Array.from(map.values()));
}
setSlide([0, 0]);
} catch {
setUsers([]);
} finally {
setLoading(false);
}
}, [
request,
selectedExpertise,
stateId,
cityId,
userLevel,
rateFilter,
heightMin,
heightMax,
weightMin,
weightMax,
sizeMin,
sizeMax,
hairColor,
eyeColor,
]);
const handleFilterChange = () => {
const invalid = validateMinMaxPairs([
{ min: heightMin, max: heightMax },
{ min: weightMin, max: weightMax },
{ min: sizeMin, max: sizeMax },
]);
if (invalid) {
toast.error(t("filters.maxMustBeGreater"));
return;
}
applyingFilterRef.current = true;
setShowResults(true);
setShowFilter(false);
void fetchUsers();
};
const current = users[index];
const paginate = useCallback(
(step: number) => {
if (users.length <= 1) return;
setSlide(([i]) => [
(i + step + users.length) % users.length,
step > 0 ? 1 : -1,
]);
},
[users.length]
);
const handleDragEnd = (_: unknown, info: PanInfo) => {
if (info.offset.x < -SWIPE_THRESHOLD) paginate(1);
else if (info.offset.x > SWIPE_THRESHOLD) paginate(-1);
};
const prevUser =
users.length > 1
? users[(index - 1 + users.length) % users.length]
: null;
const nextUser =
users.length > 1 ? users[(index + 1) % users.length] : null;
if (!isOpen) return null;
return (
<>
{showFilter ? (
<FilterModal
setShowFilterModal={(open) => {
if (open) {
setShowFilter(true);
return;
}
setShowFilter(false);
// بعد از «اعمال فیلتر» فقط فیلتر بسته شود، نه کل فلوی متخصصان
if (applyingFilterRef.current) {
applyingFilterRef.current = false;
return;
}
resetAndClose();
}}
showFilterModal={showFilter}
setStateId={setStateId}
setCityId={setCityId}
setRateFilter={setRateFilter}
setUserLevel={setUserLevel}
selectedExpertise={selectedExpertise}
setSelectedExpertise={setSelectedExpertise}
heightMin={heightMin}
setHeightMin={setHeightMin}
heightMax={heightMax}
setHeightMax={setHeightMax}
weightMin={weightMin}
setWeightMin={setWeightMin}
weightMax={weightMax}
setWeightMax={setWeightMax}
sizeMin={sizeMin}
setSizeMin={setSizeMin}
sizeMax={sizeMax}
setSizeMax={setSizeMax}
hairColor={hairColor}
setHairColor={setHairColor}
eyeColor={eyeColor}
setEyeColor={setEyeColor}
stateId={stateId}
cityId={cityId}
rateFilter={rateFilter}
userLevel={userLevel}
handleFilterChange={handleFilterChange}
clearFilters={clearFilters}
/>
) : null}
{showResults ? (
<Modal
isOpen={showResults}
onClose={resetAndClose}
height="fit"
elevated
panelClassName="max-h-[92vh] overflow-y-auto !bg-black text-white"
>
<div className="flex w-full flex-col items-center pb-4">
<div className="relative mb-4 w-full text-center">
<button
type="button"
onClick={resetAndClose}
className="absolute left-0 top-0 flex h-9 w-9 items-center justify-center rounded-full bg-white/10"
aria-label={t("projects.specialists.close")}
>
<BoldIcon
name="close-circle"
size={20}
tinted
className="text-white"
/>
</button>
<h3 className="text-base font-bold text-white">
{t("projects.specialists.title")}
</h3>
<p className="mt-1 text-xs text-white/60">
{t("projects.specialists.resultsHint")}
</p>
<button
type="button"
className="mt-2 text-xs text-[#fe2c55]"
onClick={() => {
setShowResults(false);
setShowFilter(true);
}}
>
{t("projects.specialists.editFilter")}
</button>
</div>
{loading ? (
<p className="py-10 text-sm text-white/60">
{t("projects.specialists.loading")}
</p>
) : !users.length || !current ? (
<p className="py-10 text-sm text-white/60">
{t("projects.specialists.empty")}
</p>
) : (
<div
dir="ltr"
className="relative mx-auto h-[min(52dvh,420px)] w-full max-w-[420px] overflow-hidden"
>
{prevUser ? (
<button
type="button"
onClick={() => paginate(-1)}
className="absolute left-1 top-1/2 z-10 h-[88%] w-[22%] -translate-y-1/2 overflow-hidden rounded-2xl opacity-55"
>
<SpecialistCard
user={prevUser}
variant="peek"
userFallback={t("common.user")}
viewProfileLabel={t("projects.specialists.viewProfile")}
/>
</button>
) : null}
{nextUser ? (
<button
type="button"
onClick={() => paginate(1)}
className="absolute right-1 top-1/2 z-10 h-[88%] w-[22%] -translate-y-1/2 overflow-hidden rounded-2xl opacity-55"
>
<SpecialistCard
user={nextUser}
variant="peek"
userFallback={t("common.user")}
viewProfileLabel={t("projects.specialists.viewProfile")}
/>
</button>
) : null}
<AnimatePresence initial={false} custom={direction} mode="popLayout">
<motion.div
key={current._id}
custom={direction}
variants={slideVariants}
initial="enter"
animate="center"
exit="exit"
transition={{ type: "spring", stiffness: 320, damping: 30 }}
drag="x"
dragConstraints={{ left: 0, right: 0 }}
dragElastic={0.18}
onDragEnd={handleDragEnd}
className="absolute left-1/2 top-0 z-20 h-full w-[62%] -translate-x-1/2"
>
<SpecialistCard
user={current}
variant="main"
onSkip={() => paginate(1)}
userFallback={t("common.user")}
viewProfileLabel={t("projects.specialists.viewProfile")}
/>
</motion.div>
</AnimatePresence>
</div>
)}
</div>
</Modal>
) : null}
</>
);
}
export default SpecialistsSearchModal;

View File

@@ -4,12 +4,14 @@ import Modal from "@/components/elements/Modal";
import RoundedButton from "@/components/elements/RoundedButton";
import RoundedDiv from "@/components/elements/RoundedDiv";
import UserInfo from "@/components/main/UserInfo";
import { buildStorageUrl } from "@/components/main/BaseUrl";
import useAxios from "@/hooks/useAxios";
import { IProjectRequest } from "@/types/types";
import Image from "next/image";
import { useRouter } from "next/navigation";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import BoldIcon from "@/components/ui/BoldIcon";
function ProjectRequestsAction({
projectRequests,
@@ -23,8 +25,18 @@ function ProjectRequestsAction({
const { t } = useTranslation("common");
const { request } = useAxios();
const router = useRouter();
const [showConfirmModal, setShowConfirmModal] = useState<boolean>(false);
const [showConfirmModal, setShowConfirmModal] = useState(false);
const [itemToAccept, setItemToAccept] = useState<IProjectRequest>();
const [viewerUrl, setViewerUrl] = useState<string | null>(null);
const [viewerType, setViewerType] = useState<"image" | "video">("image");
const canAccept = ![
"ongoing",
"done",
"cancled",
"pre_payment",
].includes(status);
const acceptHandler = async () => {
try {
await request<{ type?: string }>(
@@ -40,6 +52,12 @@ function ProjectRequestsAction({
console.log(error);
}
};
const openMedia = (path: string, type: "image" | "video") => {
setViewerUrl(buildStorageUrl(path));
setViewerType(type);
};
return (
<>
{projectRequests
@@ -47,14 +65,12 @@ function ProjectRequestsAction({
return (
<div
key={item?._id}
className={`flex p-2 rounded-3xl my-4 justify-between items-center w-full max-w-sm mx-auto md:max-w-md ${
className={`my-4 flex w-full max-w-sm cursor-pointer items-center justify-between rounded-3xl p-2 md:max-w-md ${
item?.status === "accepted" ? "border border-[#0C8002]" : ""
}`}
onClick={() => {
if (status == "accepted") {
setItemToAccept(item);
setShowConfirmModal(true);
}
setItemToAccept(item);
setShowConfirmModal(true);
}}
>
<UserInfo
@@ -66,7 +82,7 @@ function ProjectRequestsAction({
user_name={item?.user?.user_name}
/>
<div className="flex flex-col sm:text-xs text-[10px] gap-1.5 items-start">
<div className="flex flex-col items-start gap-1.5 text-[10px] sm:text-xs">
<span>
{t("projects.workroom.offerPrice", {
amount: Number(item?.price).toLocaleString(),
@@ -75,34 +91,35 @@ function ProjectRequestsAction({
<span>
{t("projects.workroom.suggestedTime", { days: item?.time })}
</span>
{item?.portfolio?.length ? (
<span className="text-[#FF107D]">
{t("projects.portfolio.count", {
count: item.portfolio.length,
})}
</span>
) : null}
<div className="flex gap-3">
<div className="flex items-center justify-center gap-0.5">
<>
<span>
{item?.user?.user_score
? item?.user?.user_score
: "0"}
</span>
<Image
width={21}
height={21}
alt={"verify icon"}
src={`/images/icons/medal-star.png`}
className="pb-1"
/>
</>
<span>
{item?.user?.user_score ? item?.user?.user_score : "0"}
</span>
<Image
width={21}
height={21}
alt=""
src={`/images/icons/medal-star.png`}
className="pb-1"
/>
</div>
<div className="flex items-center gap-0.5">
<>
<span>{item?.user?.rate ? item?.user?.rate : "0"}</span>
<Image
width={21}
height={21}
alt={"verify icon"}
src={`/images/icons/star1.png`}
className="pb-1"
/>
</>
<span>{item?.user?.rate ? item?.user?.rate : "0"}</span>
<Image
width={21}
height={21}
alt=""
src={`/images/icons/star1.png`}
className="pb-1"
/>
</div>
</div>
</div>
@@ -114,10 +131,12 @@ function ProjectRequestsAction({
<Modal
isOpen={showConfirmModal}
onClose={() => setShowConfirmModal(false)}
height="430px"
height="fit"
elevated
panelClassName="max-h-[90vh] overflow-y-auto"
>
{itemToAccept && (
<div className="flex flex-col items-center max-w-sm pt-6 mx-auto gap-4">
<div className="mx-auto flex max-w-sm flex-col items-center gap-4 pt-4">
<UserInfo
first_name={itemToAccept?.user?.first_name}
is_verified={itemToAccept?.user?.is_verified}
@@ -126,7 +145,7 @@ function ProjectRequestsAction({
last_name={itemToAccept?.user?.last_name}
user_name={itemToAccept?.user?.user_name}
/>
<RoundedDiv className="w-full mt-5 p-2 text-xs">
<RoundedDiv className="mt-2 w-full p-2 text-xs">
{t("projects.workroom.offerPrice", {
amount: Number(itemToAccept?.price).toLocaleString(),
})}
@@ -136,19 +155,97 @@ function ProjectRequestsAction({
days: itemToAccept?.time,
})}
</RoundedDiv>
<RoundedButton
onClick={() => {
acceptHandler();
setShowConfirmModal(false);
}}
className="w-full p-2 mt-5 text-sm "
>
{t("projects.workroom.accept")}
</RoundedButton>
{itemToAccept.portfolio?.length ? (
<div className="w-full">
<p className="mb-2 text-center text-xs font-semibold">
{t("projects.portfolio.uploadTitle")}
</p>
<div className="flex flex-wrap justify-center gap-2">
{itemToAccept.portfolio.map((file, index) => {
const url = buildStorageUrl(file.path);
return (
<button
key={`${file.path}-${index}`}
type="button"
className="relative h-20 w-20 overflow-hidden rounded-lg border"
onClick={(e) => {
e.stopPropagation();
openMedia(file.path, file.type);
}}
>
{file.type === "video" ? (
<video
src={url}
className="h-full w-full object-cover"
muted
/>
) : (
// eslint-disable-next-line @next/next/no-img-element
<img
src={url}
alt=""
className="h-full w-full object-cover"
/>
)}
</button>
);
})}
</div>
</div>
) : null}
{canAccept ? (
<RoundedButton
onClick={() => {
void acceptHandler();
setShowConfirmModal(false);
}}
className="mt-3 w-full p-2 text-sm"
>
{t("projects.workroom.accept")}
</RoundedButton>
) : null}
</div>
)}
</Modal>
)}
{viewerUrl ? (
<div
className="fixed inset-0 z-[300] flex items-center justify-center bg-black/90"
onClick={() => setViewerUrl(null)}
>
<button
type="button"
className="absolute left-4 top-4 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-white/15 text-white"
onClick={() => setViewerUrl(null)}
aria-label={t("projects.specialists.close")}
>
<BoldIcon name="close-circle" size={24} tinted className="text-white" />
</button>
<div
className="relative max-h-[90vh] max-w-[92vw]"
onClick={(e) => e.stopPropagation()}
>
{viewerType === "video" ? (
<video
src={viewerUrl}
controls
autoPlay
className="max-h-[90vh] max-w-[92vw] rounded-xl"
/>
) : (
// eslint-disable-next-line @next/next/no-img-element
<img
src={viewerUrl}
alt=""
className="max-h-[90vh] max-w-[92vw] rounded-xl object-contain"
/>
)}
</div>
</div>
) : null}
</>
);
}

View File

@@ -0,0 +1,121 @@
"use client";
import { useEffect, useState } from "react";
import Image from "next/image";
import { PWA_THEME_COLOR } from "@/lib/pwaConfig";
import { isStandaloneMode } from "@/lib/pwa/install";
const SPLASH_KEY = "modstagram-pwa-splash-v2";
type Phase = "logo" | "fill" | "hold" | "out" | "done";
/**
* اسپلش PWA شبیه WebView:
* ۱) لوگو کامل ظاهر می‌شود
* ۲) پس‌زمینه با انیمیشن همرنگ لوگو می‌شود
* ۳) لوگو وسط می‌ماند
* ۴) محو و ورود سایت
*/
export default function PwaBootSplash() {
const [phase, setPhase] = useState<Phase>("done");
const [visible, setVisible] = useState(false);
useEffect(() => {
if (typeof window === "undefined") return;
const params = new URLSearchParams(window.location.search);
const fromPwa = params.get("source") === "pwa";
const standalone = isStandaloneMode();
if (!standalone && !fromPwa) return;
try {
if (sessionStorage.getItem(SPLASH_KEY) === "1") return;
sessionStorage.setItem(SPLASH_KEY, "1");
} catch {
/* ignore */
}
setVisible(true);
setPhase("logo");
const t1 = window.setTimeout(() => setPhase("fill"), 420);
const t2 = window.setTimeout(() => setPhase("hold"), 980);
const t3 = window.setTimeout(() => setPhase("out"), 1550);
const t4 = window.setTimeout(() => {
setPhase("done");
setVisible(false);
}, 2000);
return () => {
window.clearTimeout(t1);
window.clearTimeout(t2);
window.clearTimeout(t3);
window.clearTimeout(t4);
};
}, []);
if (!visible || phase === "done") return null;
const filled = phase === "fill" || phase === "hold" || phase === "out";
const fading = phase === "out";
return (
<div
className="fixed inset-0 z-[9999] flex items-center justify-center overflow-hidden"
style={{
opacity: fading ? 0 : 1,
transition: "opacity 420ms ease",
pointerEvents: fading ? "none" : "auto",
}}
aria-hidden
>
<div
className="absolute inset-0 bg-white"
style={{
opacity: filled ? 0 : 1,
transition: "opacity 380ms ease",
}}
/>
<div
className="absolute left-1/2 top-1/2"
style={{
width: filled ? "220vmax" : "0px",
height: filled ? "220vmax" : "0px",
marginLeft: filled ? "-110vmax" : 0,
marginTop: filled ? "-110vmax" : 0,
borderRadius: "50%",
background: PWA_THEME_COLOR,
transition:
"width 620ms cubic-bezier(0.22, 1, 0.36, 1), height 620ms cubic-bezier(0.22, 1, 0.36, 1), margin 620ms cubic-bezier(0.22, 1, 0.36, 1)",
}}
/>
<div
className="relative z-[1]"
style={{
opacity: phase === "logo" ? 1 : 1,
transform: phase === "logo" ? "scale(1)" : "scale(1)",
transition: "transform 360ms ease, opacity 360ms ease",
animation: "modSplashLogoIn 420ms ease both",
}}
>
<Image
src="/images/icons/512x512.png"
alt="Modstagram"
width={148}
height={148}
priority
className="h-[148px] w-[148px] object-contain drop-shadow-md"
/>
</div>
<style>{`
@keyframes modSplashLogoIn {
from { opacity: 0; transform: scale(0.86); }
to { opacity: 1; transform: scale(1); }
}
`}</style>
</div>
);
}

View File

@@ -35,14 +35,22 @@ function hashAspect(postId: string): (typeof ASPECT_RATIOS)[number] {
function getPostMedia(post: Post): {
kind: "video" | "image";
src: string;
poster?: string;
} | null {
const file = post.files?.[0];
if (!file?.path) return null;
const src = buildStorageUrl(file.path);
const isVideo = post.type === "video" || file.type === "video";
const posterPath =
post.post_images?.[0] ||
post.files?.find((f) => f.type === "image")?.path;
return { kind: isVideo ? "video" : "image", src };
return {
kind: isVideo ? "video" : "image",
src,
poster: posterPath ? buildStorageUrl(posterPath) : undefined,
};
}
function FavoriteCard({ post }: { post: Post }) {
@@ -65,7 +73,16 @@ function FavoriteCard({ post }: { post: Post }) {
<div
className={`relative w-full overflow-hidden rounded-2xl bg-neutral-900 ${aspect}`}
>
{media.kind === "video" ? (
{media.kind === "video" && media.poster ? (
<Image
src={media.poster}
alt=""
fill
className="object-cover"
sizes="50vw"
unoptimized
/>
) : media.kind === "video" ? (
<video
src={media.src}
preload="metadata"
@@ -111,69 +128,58 @@ export default function FavoritesGrid() {
const { t } = useTranslation("common");
const token = Cookies.get("token") || "";
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } =
useInfiniteQuery({
queryKey: ["favorite-posts"],
enabled: Boolean(token),
queryFn: async ({ pageParam = 1 }) => {
const page = pageParam as number;
const result = await fetchBookmarkPosts(page, 15, token);
return { page, ...result };
},
getNextPageParam: (lastPage, allPages) =>
lastPage.hasMore ? allPages.length + 1 : undefined,
initialPageParam: 1,
});
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
isError,
} = useInfiniteQuery({
queryKey: ["bookmark-posts"],
queryFn: ({ pageParam }) =>
fetchBookmarkPosts({ pageParam: pageParam as number, token }),
initialPageParam: 1,
getNextPageParam: (last) => last.nextPage,
enabled: Boolean(token),
});
const handleScroll = useCallback(() => {
if (
window.innerHeight + window.scrollY >=
document.body.offsetHeight - 800
) {
if (hasNextPage && !isFetchingNextPage) fetchNextPage();
}
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
const posts = data?.pages.flatMap((p) => p.posts) ?? [];
const onScroll = useCallback(() => {
if (!hasNextPage || isFetchingNextPage) return;
const nearBottom =
window.innerHeight + window.scrollY >= document.body.offsetHeight - 400;
if (nearBottom) void fetchNextPage();
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
useEffect(() => {
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, [handleScroll]);
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, [onScroll]);
if (!token) {
if (isLoading) return <PageLoader className="py-10" />;
if (isError) {
return (
<p className="py-16 text-center text-sm text-neutral-500">
{t("settings.favorites.loginRequired")}
<p className="py-8 text-center text-sm text-neutral-500">
{t("common.error")}
</p>
);
}
if (isLoading) return <PageLoader className="min-h-[40vh]" />;
const posts =
data?.pages.flatMap((p) => p.posts).filter((post) => {
return post.status === "accept" && getPostMedia(post);
}) ?? [];
if (!posts.length) {
return (
<p className="py-16 text-center text-sm text-neutral-500">
{t("settings.favorites.empty")}
<p className="py-8 text-center text-sm text-neutral-500">
{t("settings.favoritesEmpty")}
</p>
);
}
return (
<div className="px-2 pb-2">
<div className="columns-2 gap-2">
{posts.map((post) => (
<FavoriteCard key={post._id} post={post} />
))}
</div>
{isFetchingNextPage && (
<div className="py-4">
<PageLoader className="min-h-[80px]" />
</div>
)}
<div className="columns-2 gap-2 px-1 sm:columns-3">
{posts.map((post) => (
<FavoriteCard key={post._id} post={post} />
))}
{isFetchingNextPage ? <PageLoader className="col-span-full py-4" /> : null}
</div>
);
}

View File

@@ -4,14 +4,15 @@ type Props = {
label: string;
};
/** تاریخ/ساعت وسط خط جداکننده (مثل قبل) */
export default function ListDateSeparator({ label }: Props) {
return (
<div className="relative my-4 flex items-center">
<div className="flex-1 border-t border-border-primary-light dark:border-neutral-800" />
<span className="shrink-0 px-3 text-xs font-medium text-neutral-500">
<div className="mb-3 mt-5 flex items-center gap-3 first:mt-1">
<div className="h-px min-w-0 flex-1 bg-neutral-200 dark:bg-neutral-700" />
<span className="shrink-0 text-xs font-medium text-neutral-500">
{label}
</span>
<div className="flex-1 border-t border-border-primary-light dark:border-neutral-800" />
<div className="h-px min-w-0 flex-1 bg-neutral-200 dark:bg-neutral-700" />
</div>
);
}

View File

@@ -22,22 +22,25 @@ function UserDetails() {
<div>
<Link
href={"/settings/profile"}
className="flex items-center text-xs md:text-sm font-semibold"
className="flex items-center gap-3 text-xs font-semibold md:text-sm"
>
<ProfileAvatar
src={user?.profile_image}
alt={user?.user_name || "user profile"}
size="md"
rounded="2xl"
className="ml-3 md:h-[120px] md:w-[120px]"
className="shrink-0 md:h-[120px] md:w-[120px]"
/>
<div className="flex flex-col gap-1">
<div className="flex min-w-0 flex-col gap-1">
<span className="text-[#387E65]">
{t("settings.level")}: {user?.user_level ? user?.user_level : t("settings.newcomer")}
{t("settings.level")}:{" "}
{user?.user_level ? user?.user_level : t("settings.newcomer")}
</span>
<span>{formatFullName(user?.first_name, user?.last_name)}</span>
<div className="flex items-center gap-1 mt-1">
<span className="truncate">
{formatFullName(user?.first_name, user?.last_name)}
</span>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="inline-flex items-center gap-1">
{user?.user_name}
<VerificationBadge
@@ -45,7 +48,11 @@ function UserDetails() {
isRegister={user?.is_Register}
/>
</span>
<span className="mr-4">{user?.mobile}</span>
{user?.mobile ? (
<span className="text-neutral-500 dark:text-neutral-400">
{user.mobile}
</span>
) : null}
</div>
</div>
</Link>

View File

@@ -27,7 +27,7 @@ function ProfileContent({ user, onRefresh }: ProfileProfileContentProps) {
);
return (
<div className="px-4 py-2 w-full text-xs md:text-sm font-semibold">
<div className="w-full py-2 text-xs font-semibold md:text-sm">
<AccountActionModals
kind={actionKind}
username={user?.user_name || ""}

View File

@@ -0,0 +1,231 @@
"use client";
import { btnPrimary } from "@/lib/ui/buttonStyles";
import { cn } from "@/lib/utils";
import { useEffect, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import Image from "next/image";
import { 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;
user_name: string;
first_name: string;
last_name: string;
profile_image?: string;
}
interface SendStoryModalProps {
open: boolean;
onClose: () => void;
storyId: string;
previewPath?: string;
mediaType?: "image" | "video";
currentUserId: string;
}
export default function SendStoryModal({
open,
onClose,
storyId,
previewPath,
mediaType = "image",
currentUserId,
}: SendStoryModalProps) {
const { t } = useTranslation("common");
const { request } = useAxios();
const [users, setUsers] = useState<ChatUserItem[]>([]);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(false);
const [sending, setSending] = useState(false);
const loadUsers = async () => {
setLoading(true);
try {
const res = await request<{ filteredUsersData: ChatUserItem[] }>(
"GET",
"/messages?limit=100",
null,
{ noToast: true }
);
setUsers(
(res?.filteredUsersData || []).filter((u) => u._id !== currentUserId)
);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (open) {
void loadUsers();
setSelected(new Set());
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const toggle = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else if (next.size < 50) next.add(id);
else toast.error(t("posts.maxUsers"));
return next;
});
};
const send = async () => {
if (selected.size === 0) return;
setSending(true);
try {
const res = await request<{ sentCount?: number }>(
"POST",
"/stories/send",
{
storyId,
receiverIds: Array.from(selected),
},
{ noToast: true }
);
toast.success(
t("stories.sentToCount", { count: res?.sentCount ?? selected.size })
);
onClose();
setSelected(new Set());
} catch {
toast.error(t("stories.sendStoryError"));
} finally {
setSending(false);
}
};
const preview = previewPath ? buildStorageUrl(previewPath) : null;
return (
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="glass-modal-overlay fixed inset-0 z-[200] flex items-end justify-center sm:items-center"
onClick={onClose}
>
<motion.div
initial={{ y: 40, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 40, opacity: 0 }}
className="glass-modal-panel w-full max-w-md rounded-t-3xl p-4 sm:rounded-3xl"
onClick={(e) => e.stopPropagation()}
>
<h3 className="mb-3 text-center text-sm font-bold">
{t("stories.sendStory")}
</h3>
<div className="mb-4 flex items-center gap-3 rounded-xl bg-neutral-100 p-2 dark:bg-neutral-800">
{preview && (
<div className="relative h-12 w-12 shrink-0 overflow-hidden rounded-lg">
{mediaType === "video" ? (
<video
src={preview}
className="h-full w-full object-cover"
muted
playsInline
/>
) : (
<Image
src={preview}
alt=""
fill
className="object-cover"
unoptimized
/>
)}
</div>
)}
<p className="line-clamp-2 text-xs text-neutral-600 dark:text-neutral-300">
{t("stories.sendStoryHint")}
</p>
</div>
{loading ? (
<div className="flex justify-center py-8">
<IOSSpinner />
</div>
) : users.length === 0 ? (
<p className="py-8 text-center text-sm text-neutral-500">
{t("posts.noChatUsers")}
</p>
) : (
<ul className="max-h-64 space-y-1 overflow-y-auto">
{users.map((u) => {
const checked = selected.has(u._id);
return (
<li key={u._id}>
<button
type="button"
onClick={() => toggle(u._id)}
className={cn(
"flex w-full items-center gap-3 rounded-xl px-2 py-2 text-right",
checked
? "bg-[#FF107D]/10"
: "hover:bg-neutral-100 dark:hover:bg-neutral-800"
)}
>
<div className="relative h-10 w-10 overflow-hidden rounded-full bg-neutral-200">
{u.profile_image ? (
<Image
src={buildStorageUrl(u.profile_image)}
alt=""
fill
className="object-cover"
unoptimized
/>
) : null}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">
{u.first_name} {u.last_name}
</p>
<p className="truncate text-xs text-neutral-500">
@{u.user_name}
</p>
</div>
<span
className={cn(
"flex h-5 w-5 items-center justify-center rounded-full border text-[10px]",
checked
? "border-[#FF107D] bg-[#FF107D] text-white"
: "border-neutral-300"
)}
>
{checked ? "✓" : ""}
</span>
</button>
</li>
);
})}
</ul>
)}
<button
type="button"
disabled={selected.size === 0 || sending}
onClick={() => void send()}
className={cn(btnPrimary, "mt-4 w-full disabled:opacity-40")}
>
{sending
? t("posts.sending")
: t("posts.sendWithCount", { count: selected.size })}
</button>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@@ -22,6 +22,7 @@ import { buildStorageUrl } from "@/components/main/BaseUrl";
import BoldIcon from "@/components/ui/BoldIcon";
import StoryEditor from "@/components/stories/StoryEditor";
import StoryRing from "@/components/stories/StoryRing";
import SendStoryModal from "@/components/stories/SendStoryModal";
import useAxios from "@/hooks/useAxios";
import { useTranslation } from "react-i18next";
@@ -58,6 +59,7 @@ export default function StoryViewer({
const [liking, setLiking] = useState(false);
const [sendingComment, setSendingComment] = useState(false);
const [viewersOpen, setViewersOpen] = useState(false);
const [sendOpen, setSendOpen] = useState(false);
const [viewers, setViewers] = useState<StoryViewerUser[]>([]);
const [loadingViewers, setLoadingViewers] = useState(false);
const [followingMap, setFollowingMap] = useState<Record<string, boolean>>({});
@@ -524,7 +526,20 @@ export default function StoryViewer({
{!editing && !viewersOpen && (
<div className="absolute bottom-0 left-0 right-0 z-20 px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))]">
{isOwnStory ? (
<div className="flex justify-end">
<div className="flex items-center justify-between gap-2">
<button
type="button"
onClick={() => {
setPaused(true);
if (videoRef.current) videoRef.current.pause();
setSendOpen(true);
}}
className="flex h-11 items-center gap-2 rounded-full bg-black/45 px-4 text-sm font-semibold text-white backdrop-blur-md"
aria-label={t("stories.sendStory")}
>
<BoldIcon name="send-2" size={20} tinted className="text-white" />
{t("stories.sendStory")}
</button>
<button
type="button"
onClick={openViewers}
@@ -677,6 +692,24 @@ export default function StoryViewer({
/>
</div>
)}
{viewerId && currentStory ? (
<SendStoryModal
open={sendOpen}
onClose={() => {
setSendOpen(false);
setPaused(false);
startRef.current = Date.now();
if (videoRef.current && currentStory.media_type === "video") {
videoRef.current.play().catch(() => {});
}
}}
storyId={currentStory._id}
previewPath={currentStory.media_path}
mediaType={currentStory.media_type}
currentUserId={viewerId}
/>
) : null}
</div>
);
}

View File

@@ -5,6 +5,7 @@ export type ExploreFilterId =
| "hairstylist"
| "academy"
| "trending"
| "predict_trends"
| "makeup"
| "professional"
| "best_month"
@@ -22,6 +23,7 @@ export const EXPLORE_FILTERS: ExploreFilterItem[] = [
{ id: "hairstylist", color: "#23bace" },
{ id: "academy", color: "#38bdf8" },
{ id: "trending", color: "#f97316" },
{ id: "predict_trends", color: "#22d3ee" },
{ id: "makeup", color: "#ec4899" },
{ id: "professional", color: "#a78bfa" },
{ id: "best_month", color: "#fbbf24" },

View File

@@ -126,6 +126,9 @@ export function getNotificationActionMeta(type: string): {
if (type === "accept-post" || type === "accept-project" || type === "request_accepted" || type === "vitrine") {
return { icon: "tick-circle", tint: "green", labelKey: "constants.notificationActions.accept" };
}
if (type === "verify") {
return { icon: "tick-circle", tint: "green", labelKey: "constants.notificationActions.accept" };
}
if (type === "reject-post" || type === "reject-project" || type === "reject-user") {
return { icon: "close-circle", tint: "red", labelKey: "constants.notificationActions.reject" };
}

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