post
This commit is contained in:
BIN
modstagram-next.zip
Normal file
BIN
modstagram-next.zip
Normal file
Binary file not shown.
@@ -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,
|
||||
};
|
||||
|
||||
@@ -35,6 +35,12 @@ const nextConfig = {
|
||||
port: '',
|
||||
pathname: '/**',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'api.qrserver.com',
|
||||
port: '',
|
||||
pathname: '/**',
|
||||
},
|
||||
{
|
||||
protocol: 'http',
|
||||
hostname: 'localhost',
|
||||
|
||||
@@ -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": {
|
||||
|
||||
BIN
public/images/icons/radar.png
Normal file
BIN
public/images/icons/radar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
BIN
public/images/icons/slider-horizontal.png
Normal file
BIN
public/images/icons/slider-horizontal.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 436 B |
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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
22
robots.txt
Normal 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
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
153
src/app/(projects)/projects/new/[id]/page.tsx
Normal file
153
src/app/(projects)/projects/new/[id]/page.tsx
Normal 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;
|
||||
142
src/app/(projects)/projects/payment/failed/page.tsx
Normal file
142
src/app/(projects)/projects/payment/failed/page.tsx
Normal 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;
|
||||
104
src/app/(projects)/projects/payment/success/page.tsx
Normal file
104
src/app/(projects)/projects/payment/success/page.tsx
Normal 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;
|
||||
480
src/app/(projects)/projects/specialists/page.tsx
Normal file
480
src/app/(projects)/projects/specialists/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
411
src/app/settings/chats/nearby/page.tsx
Normal file
411
src/app/settings/chats/nearby/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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")}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
82
src/app/sitemap.xml/route.ts
Normal file
82
src/app/sitemap.xml/route.ts
Normal 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`,
|
||||
},
|
||||
});
|
||||
}
|
||||
100
src/app/sitemaps/[type]/[[...page]]/route.ts
Normal file
100
src/app/sitemaps/[type]/[[...page]]/route.ts
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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/")) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
122
src/components/posts/PostImageGallery.tsx
Normal file
122
src/components/posts/PostImageGallery.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
128
src/components/profile/ShareProfileModal.tsx
Normal file
128
src/components/profile/ShareProfileModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
308
src/components/projects/NewProject/RoleModal.tsx
Normal file
308
src/components/projects/NewProject/RoleModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
192
src/components/projects/NewProject/Step4.tsx
Normal file
192
src/components/projects/NewProject/Step4.tsx
Normal 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;
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
503
src/components/projects/SpecialistsSearchModal.tsx
Normal file
503
src/components/projects/SpecialistsSearchModal.tsx
Normal 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;
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
121
src/components/pwa/PwaBootSplash.tsx
Normal file
121
src/components/pwa/PwaBootSplash.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 || ""}
|
||||
|
||||
231
src/components/stories/SendStoryModal.tsx
Normal file
231
src/components/stories/SendStoryModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user