full update

This commit is contained in:
payacom
2026-07-18 14:29:05 +03:30
parent 17f334d0f1
commit af5b27b610
239 changed files with 10283 additions and 3041 deletions

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.24 2H5.34C3.15 2 2 3.15 2 5.33V7.23C2 9.41 3.15 10.56 5.33 10.56H7.23C9.41 10.56 10.56 9.41 10.56 7.23V5.33C10.57 3.15 9.42 2 7.24 2Z" fill="#292D32"/>
<path d="M18.6699 2H16.7699C14.5899 2 13.4399 3.15 13.4399 5.33V7.23C13.4399 9.41 14.5899 10.56 16.7699 10.56H18.6699C20.8499 10.56 21.9999 9.41 21.9999 7.23V5.33C21.9999 3.15 20.8499 2 18.6699 2Z" fill="#292D32"/>
<path d="M18.6699 13.4302H16.7699C14.5899 13.4302 13.4399 14.5802 13.4399 16.7602V18.6602C13.4399 20.8402 14.5899 21.9902 16.7699 21.9902H18.6699C20.8499 21.9902 21.9999 20.8402 21.9999 18.6602V16.7602C21.9999 14.5802 20.8499 13.4302 18.6699 13.4302Z" fill="#292D32"/>
<path d="M7.24 13.4302H5.34C3.15 13.4302 2 14.5802 2 16.7602V18.6602C2 20.8502 3.15 22.0002 5.33 22.0002H7.23C9.41 22.0002 10.56 20.8502 10.56 18.6702V16.7702C10.57 14.5802 9.42 13.4302 7.24 13.4302Z" fill="#292D32"/>
</svg>

After

Width:  |  Height:  |  Size: 969 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.8199 1.91016H7.17995C5.05995 1.91016 3.31995 3.65016 3.31995 5.77016V19.8602C3.31995 21.6602 4.60995 22.4202 6.18995 21.5502L11.0699 18.8402C11.5899 18.5502 12.4299 18.5502 12.9399 18.8402L17.8199 21.5502C19.3999 22.4302 20.6899 21.6702 20.6899 19.8602V5.77016C20.6799 3.65016 18.9499 1.91016 16.8199 1.91016ZM15.6199 9.03016L11.6199 13.0302C11.4699 13.1802 11.2799 13.2502 11.0899 13.2502C10.8999 13.2502 10.7099 13.1802 10.5599 13.0302L9.05995 11.5302C8.76995 11.2402 8.76995 10.7602 9.05995 10.4702C9.34995 10.1802 9.82995 10.1802 10.1199 10.4702L11.0899 11.4402L14.5599 7.97016C14.8499 7.68016 15.3299 7.68016 15.6199 7.97016C15.9099 8.26016 15.9099 8.74016 15.6199 9.03016Z" fill="#292D32"/>
</svg>

After

Width:  |  Height:  |  Size: 813 B

View File

@@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 5.12012C5.59 5.12012 5.25 4.78012 5.25 4.37012V2.62012C5.25 2.21012 5.59 1.87012 6 1.87012C6.41 1.87012 6.75 2.21012 6.75 2.62012V4.37012C6.75 4.79012 6.41 5.12012 6 5.12012Z" fill="#292D32"/>
<path d="M10 5.12012C9.59 5.12012 9.25 4.78012 9.25 4.37012V2.62012C9.25 2.21012 9.59 1.87012 10 1.87012C10.41 1.87012 10.75 2.21012 10.75 2.62012V4.37012C10.75 4.79012 10.41 5.12012 10 5.12012Z" fill="#292D32"/>
<path d="M14 5.12012C13.59 5.12012 13.25 4.78012 13.25 4.37012V2.62012C13.25 2.21012 13.59 1.87012 14 1.87012C14.41 1.87012 14.75 2.21012 14.75 2.62012V4.37012C14.75 4.79012 14.41 5.12012 14 5.12012Z" fill="#292D32"/>
<path d="M22.25 13.2498C22.25 10.6298 20.22 8.50977 17.66 8.28977C16.92 7.07977 15.6 6.25977 14.08 6.25977H6.71C4.39 6.25977 2.5 8.14977 2.5 10.4698V10.9998H18.29V10.4698C18.29 10.2798 18.26 10.0898 18.23 9.90977C19.68 10.3398 20.75 11.6598 20.75 13.2498C20.75 14.8198 19.71 16.1298 18.29 16.5698V11.9998H2.5V17.7898C2.5 20.1098 4.39 21.9998 6.71 21.9998H14.08C16.28 21.9998 18.07 20.2998 18.25 18.1398C20.53 17.6798 22.25 15.6598 22.25 13.2498Z" fill="#292D32"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 2H6C4.34 2 3 3.33 3 4.97V15.88C3 17.52 4.34 18.85 6 18.85H6.76C7.56 18.85 8.32 19.16 8.88 19.72L10.59 21.41C11.37 22.18 12.64 22.18 13.42 21.41L15.13 19.72C15.69 19.16 16.46 18.85 17.25 18.85H18C19.66 18.85 21 17.52 21 15.88V4.97C21 3.33 19.66 2 18 2ZM12 5.75C13.29 5.75 14.33 6.79 14.33 8.08C14.33 9.37 13.29 10.41 12 10.41C10.71 10.41 9.67 9.36 9.67 8.08C9.67 6.79 10.71 5.75 12 5.75ZM14.68 15.06H9.32C8.51 15.06 8.04 14.16 8.49 13.49C9.17 12.48 10.49 11.8 12 11.8C13.51 11.8 14.83 12.48 15.51 13.49C15.96 14.16 15.48 15.06 14.68 15.06Z" fill="#292D32"/>
</svg>

After

Width:  |  Height:  |  Size: 673 B

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.14 2.96004L7.11 5.96004C1.04 7.99004 1.04 11.3 7.11 13.32L9.79 14.21L10.68 16.89C12.7 22.96 16.02 22.96 18.04 16.89L21.05 7.87004C22.39 3.82004 20.19 1.61004 16.14 2.96004ZM16.46 8.34004L12.66 12.16C12.51 12.31 12.32 12.38 12.13 12.38C11.94 12.38 11.75 12.31 11.6 12.16C11.31 11.87 11.31 11.39 11.6 11.1L15.4 7.28004C15.69 6.99004 16.17 6.99004 16.46 7.28004C16.75 7.57004 16.75 8.05004 16.46 8.34004Z" fill="#292D32"/>
</svg>

After

Width:  |  Height:  |  Size: 536 B

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 24 24" style="enable-background:new 0 0 24 24;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFF00;}
</style>
<path class="st0" d="M13.9,2.9l1.9,3.8c0.3,0.5,0.9,1,1.5,1.1l3.4,0.6c2.2,0.4,2.7,1.9,1.1,3.5l-2.7,2.7c-0.4,0.4-0.7,1.3-0.6,1.9
l0.8,3.3c0.6,2.6-0.8,3.6-3.1,2.3l-3.2-1.9c-0.6-0.3-1.5-0.3-2.1,0l-3.2,1.9c-2.3,1.4-3.7,0.3-3.1-2.3l0.8-3.3
c0.1-0.6-0.1-1.5-0.6-2l-2.7-2.7c-1.6-1.6-1.1-3.2,1.1-3.5l3.4-0.6C7.3,7.7,8,7.2,8.2,6.6l1.9-3.8C11.2,0.8,12.8,0.8,13.9,2.9z"/>
</svg>

After

Width:  |  Height:  |  Size: 768 B

View File

@@ -1,21 +1,41 @@
{
"name": "Modstagram",
"short_name": "Modstagram",
"description": "Modstagram App",
"name": "مدستاگرام",
"short_name": "مدستاگرام",
"description": "شبکه تخصصی مدل، عکاس و آرایشگر",
"lang": "fa",
"dir": "rtl",
"scope": "/",
"start_url": "/",
"id": "/",
"display": "standalone",
"orientation": "portrait-primary",
"icons": [
{
"src": "/images/icons/192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/images/icons/256x256.png",
"sizes": "256x256",
"type": "image/png"
"type": "image/png",
"purpose": "any"
},
{
"src": "/images/icons/512x512.png",
"sizes": "512x512",
"type": "image/png"
"type": "image/png",
"purpose": "any"
},
{
"src": "/images/icons/512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone",
"start_url": "/"
"categories": ["social", "business"]
}

View File

@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
const CACHE_NAME = "pwa-cache-v2";
const CACHE_NAME = "pwa-cache-v4";
// نصب Service Worker
self.addEventListener("install", (event) => {
@@ -23,42 +23,39 @@ self.addEventListener("install", (event) => {
self.addEventListener("activate", (event) => {
console.log("Service Worker activated.");
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys
.filter((key) => key !== CACHE_NAME)
.map((key) => caches.delete(key))
caches
.keys()
.then((keys) =>
Promise.all(
keys
.filter((key) => key !== CACHE_NAME)
.map((key) => caches.delete(key))
)
)
).then(() => self.clients.claim())
.then(() => self.clients.claim())
);
});
// هندل درخواست‌ها
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
// استراتژی network-first برای درخواست‌های HTML (navigation requests)
if (event.request.mode === "navigate") {
event.respondWith(
fetch(event.request)
.then(async (response) => {
const cache = await caches.open(CACHE_NAME);
await cache.put(event.request, response.clone());
return response;
})
.catch(() => caches.match(event.request))
);
return;
}
// آیکون‌ها: network-first تا تغییرات فوری اعمال شوند
// هندل درخواست‌ها
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
// navigation: همیشه از شبکه — HTML کش نشود تا title/meta همیشه تازه باشد
if (event.request.mode === "navigate") {
event.respondWith(fetch(event.request));
return;
}
// آیکون‌ها: network-first
if (url.pathname.startsWith("/images/icons/")) {
event.respondWith(
fetch(event.request)
.then((res) => {
if (res.ok) {
const clone = res.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
caches
.open(CACHE_NAME)
.then((cache) => cache.put(event.request, clone));
}
return res;
})
@@ -82,7 +79,9 @@ self.addEventListener("activate", (event) => {
const networkFetch = fetch(event.request).then((res) => {
if (res.ok) {
const clone = res.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
caches
.open(CACHE_NAME)
.then((cache) => cache.put(event.request, clone));
}
return res;
});

View File

@@ -1,12 +1,50 @@
<?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/sitemap.xml</loc><lastmod>2026-07-16T17:09:58.119Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://modstagram.com/robots.txt</loc><lastmod>2026-07-16T17:09:58.120Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://modstagram.com/academy/payment/success</loc><lastmod>2026-07-16T17:09:58.120Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://modstagram.com/academy/payment/failed</loc><lastmod>2026-07-16T17:09:58.120Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://modstagram.com/about-us</loc><lastmod>2026-07-16T17:09:58.120Z</lastmod><changefreq>monthly</changefreq><priority>0.8</priority></url>
<url><loc>https://modstagram.com/explore</loc><lastmod>2026-07-16T17:09:58.120Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://modstagram.com/icon.png</loc><lastmod>2026-07-17T12:03:53.291Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://modstagram.com/robots.txt</loc><lastmod>2026-07-17T12:03:53.292Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://modstagram.com/sitemap.xml</loc><lastmod>2026-07-17T12:03:53.292Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://modstagram.com/academy/payment/failed</loc><lastmod>2026-07-17T12:03:53.292Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://modstagram.com/academy/payment/success</loc><lastmod>2026-07-17T12:03:53.292Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://modstagram.com/about-us</loc><lastmod>2026-07-17T12:03:53.292Z</lastmod><changefreq>monthly</changefreq><priority>0.8</priority></url>
<url><loc>https://modstagram.com/explore</loc><lastmod>2026-07-17T12:03:53.292Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://modstagram.com</loc><changefreq>daily</changefreq><priority>1</priority></url>
<url><loc>https://modstagram.com/projects</loc><changefreq>daily</changefreq><priority>0.9</priority></url>
<url><loc>https://modstagram.com/billboards</loc><changefreq>daily</changefreq><priority>0.9</priority></url>
<url><loc>https://modstagram.com/billboards/6a0d848b12cadb57c40ecd20/%D8%B9%DA%A9%D8%A7%D8%B3%DB%8C%20%D9%88%20%D9%85%D8%AF%D9%84%DB%8C%D9%86%DA%AF%20%DA%A9%D8%AA%DB%8C%20%D9%85%D8%B7%D9%87%D8%B1%DB%8C</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>https://modstagram.com/billboards/6a0d6dd112cadb57c40ebba3/%D9%85%D8%AC%D9%85%D9%88%D8%B9%D9%87%20%D9%84%D9%88%DA%A9%D8%B3%E2%80%8C%20%D8%B2%DB%8C%D8%A8%D8%A7%DB%8C%DB%8C%20%7C%20%D8%A7%D9%84%D9%87%D9%87%20%D8%AC%D8%B9%D9%81%D8%B1%DB%8C</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>https://modstagram.com/billboards/69a1cf1c1cd06873681b4271/%D9%86%D8%AF%D8%A7%20%D8%AF%D9%87%D9%82%D8%A7%D9%86%20%7C%20%D9%85%DB%8C%DA%A9%D8%A7%D9%BE%20%D8%B9%D8%B1%D9%88%D8%B3%20%D8%A7%D8%B5%D9%81%D9%87%D8%A7%D9%86</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>https://modstagram.com/billboards/69a1c3411cd06873681b39d6/%D9%81%D8%B1%D8%B2%D8%A7%D9%86%D9%87%20%D8%AC%D9%86%D8%AA%DB%8C%D8%8C%D8%B3%D8%A7%D9%84%D9%86%20%D8%B9%D8%B1%D9%88%D8%B3%D8%8C%D8%A7%D9%93%D9%85%D9%88%D8%B2%D8%B4%20%D9%85%DB%8C%DA%A9%D8%A7%D9%BE%20%D8%A7%D8%B5%D9%81%D9%87%D8%A7%D9%86</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>https://modstagram.com/billboards/69a1bfd91cd06873681b360d/%D8%A2%D9%85%D9%88%D8%B2%D8%B4%DA%AF%D8%A7%D9%87%20%D9%88%20%D8%B3%D8%A7%D9%84%D9%86%20%D8%B2%DB%8C%D8%A8%D8%A7%DB%8C%DB%8C%20%D9%81%D9%88%D9%82%20%D8%AA%D8%AE%D8%B5%D8%B5%DB%8C%20%DA%A9%D9%85%D9%86%D8%AF</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>https://modstagram.com/billboards/69a1b6d11cd06873681b26f3/%D8%B2%DB%8C%D8%A8%D8%A7%DB%8C%DB%8C%20%D9%85%D8%AD%D8%B4%D8%B1</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>https://modstagram.com/billboards/69a1afa01cd06873681b246d/%D8%B3%D8%A7%D9%84%D9%86%20%D8%B2%DB%8C%D8%A8%D8%A7%DB%8C%DB%8C%20%D8%B1%D8%A7%D9%85%DB%8C%D9%86%20%D9%81%D8%B1</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>https://modstagram.com/billboards/69a0206a83d4cf28444356d6/%D8%A2%D9%85%D9%88%D8%B2%D8%B4%DA%AF%D8%A7%D9%87%20%DB%8C%D8%B9%D9%82%D9%88%D8%A8%20%D9%84%D9%88%20(%D8%AD%DB%8C%D8%B1%D8%A7)</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>https://modstagram.com/billboards/69a0078d83d4cf2844434af8/%D9%81%D8%A7%D8%B7%D9%85%D9%87%20%D9%85%D8%B1%D9%88%D8%AA%DB%8C%7C%D9%85%DB%8C%DA%A9%D8%A7%D9%BE%20%D8%A7%D8%B1%D8%AF%D8%A8%DB%8C%D9%84%7C%D9%85%DB%8C%DA%A9%D8%A7%D9%BE%20%D8%B9%D8%B1%D9%88%D8%B3%7C%D8%A2%D9%85%D9%88%D8%B2%D8%B4%7C%D8%B3%D8%A7%D9%84%D9%86%20%D8%B2%DB%8C%D8%A8%D8%A7%DB%8C%DB%8C%7C</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>https://modstagram.com/billboards/69a0053783d4cf2844434615/%D8%B3%D8%A7%D9%84%D9%86%20%D8%B2%DB%8C%D8%A8%D8%A7%DB%8C%DB%8C%20%D8%A7%D8%B1%D8%AF%D8%A8%DB%8C%D9%84%20%7C%D9%85%D8%AF%DB%8C%D8%B3%D8%A7%20%D8%A7%D8%B5%D9%88%D9%84%DB%8C</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>https://modstagram.com/billboards/699ffbef83d4cf2844433523/%D8%B3%D8%A7%D9%84%D9%86%20%D8%B2%DB%8C%D8%A8%D8%A7%DB%8C%DB%8C%20%D8%B2%D9%87%D8%B1%D8%A7%20%D9%86%D9%88%DB%8C%D8%AF</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>https://modstagram.com/billboards/699ff8d383d4cf28444330f2/%D8%B3%D8%A7%D9%84%D9%86%20%D8%B2%DB%8C%D8%A8%D8%A7%DB%8C%DB%8C%20%D8%B4%D8%A7%D9%86%D9%84</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>https://modstagram.com/billboards/699ff68b83d4cf2844432964/%F0%9F%91%91%D8%B3%D8%A7%D9%84%D9%86%20%D8%B2%DB%8C%D8%A8%D8%A7%DB%8C%DB%8C%20%D8%AA%D9%88%D8%AA%20%D9%81%D8%B1%D9%86%DA%AF%DB%8C%20%D8%A7%D8%B1%D8%AF%D8%A8%DB%8C%D9%84%F0%9F%91%91</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>https://modstagram.com/billboards/699ff2eb83d4cf2844432403/%D8%B9%D8%B1%D9%88%D8%B3%20%D8%B3%D8%B1%D8%A7%DB%8C%20%D8%A7%D9%84%20%D8%A2%DB%8C%20%7C%20%D8%A7%D8%B9%D8%B8%D9%85%20%D8%AC%D9%84%DB%8C%D9%84%20%D8%B2%D8%A7%D8%AF%D9%87%20%7C%20%D8%A7%D8%B1%D8%AF%D8%A8%DB%8C%D9%84</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>https://modstagram.com/billboards/699eca0c9ed07500ab2e0e17/%D8%B3%D8%A7%D9%84%D9%86%20%D8%B2%DB%8C%D8%A8%D8%A7%DB%8C%DB%8C%20%D9%BE%D8%AF%DB%8C%D8%AF%D9%87%20%D8%B4%D9%87%D8%B1(%D9%85%DB%8C%D9%86%D8%A7%20%D9%85%D9%87%D8%AC%D9%88%D8%B1)</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>https://modstagram.com/billboards/699ec6b59ed07500ab2e0856/%F0%9F%92%87%F0%9F%8F%BC%E2%80%8D%E2%99%80%EF%B8%8F%D9%85%D8%AC%D9%85%D9%88%D8%B9%D9%87%20%D8%B2%DB%8C%D8%A8%D8%A7%DB%8C%DB%8C%20%D8%AA%D8%A7%D8%AC%F0%9F%91%91</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>https://modstagram.com/billboards/699ebafb9ed07500ab2e01ab/%D9%85%D8%AC%D9%85%D9%88%D8%B9%D9%87%20%D8%B3%DB%8C%D9%85%D8%A7%20%D8%AD%D9%85%D8%AF%D8%A7%D9%84%D9%84%D9%87%DB%8C%7C%D8%AA%D8%A8%D8%B1%DB%8C%D8%B2</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>https://modstagram.com/billboards/699eaf959ed07500ab2df952/%D8%B3%D8%A7%D9%84%D9%86%20%D8%B2%DB%8C%D8%A8%D8%A7%DB%8C%DB%8C%20%DA%A9%D8%A7%D8%B1%DB%8C%D8%B2%D9%85%D8%A7</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>https://modstagram.com/billboards/699eaa049ed07500ab2df3a4/%D8%B3%D8%A7%D9%84%D9%86%20%D8%B2%DB%8C%D8%A8%D8%A7%DB%8C%DB%8C%20%D9%85%D9%88%D8%AA%D8%A7%D8%A8</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>https://modstagram.com/billboards/69942dd60711bf017b2069e2/%F0%9F%AA%84%D8%B3%D8%A7%D9%84%D9%86%20%D8%B2%DB%8C%D8%A8%D8%A7%DB%8C%DB%8C%20%D8%B4%D9%87%D8%B1%D8%B2%D8%A7%D8%AF%F0%9F%AA%84%7C%20shahrzaad%20beauty</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>https://modstagram.com/billboards/694fad80d93828c638fdf612/Hamid%20Eskandari%20%D9%81%DB%8C%D9%84%D9%85%D8%A8%D8%B1%D8%AF%D8%A7%D8%B1%DB%8C%20%D9%88%20%D8%B9%DA%A9%D8%A7%D8%B3%DB%8C</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>https://modstagram.com/billboards/693447f0a468f7683cca4e55/%E2%9A%9C%EF%B8%8F%20%D8%A8%D8%A7%D9%86%D9%88%20%D8%B1%D8%B6%D8%A7%DB%8C%DB%8C%20%7C%20vip%20salon%20%E2%9A%9C%EF%B8%8F</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>https://modstagram.com/billboards/6934170662e821773ad7a8f3/%D8%B3%D8%A7%D9%84%D9%86%20%D8%B2%DB%8C%D8%A8%D8%A7%DB%8C%DB%8C%20%D9%85%D8%B1%D8%AC%D8%A7%D9%86%F0%9F%91%B8</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>https://modstagram.com/billboards/69340f3e62e821773ad7a555/%D8%B3%D8%A7%D9%84%D9%86%20%D8%B2%DB%8C%D8%A8%D8%A7%DB%8C%DB%8C%20%D9%88%20%D9%85%D8%B1%DA%A9%D8%B2%20%D8%A7%DA%A9%D8%B3%D8%AA%D9%86%D8%B4%D9%86%20%DA%AF%D9%84</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>https://modstagram.com/billboards/691c3ff8e24a347e31d006a6/%D8%B3%D8%A7%D9%84%D9%86%20%D8%B2%DB%8C%D8%A8%D8%A7%DB%8C%DB%8C%20vip%20%D8%B1%DB%8C%D8%AD%D8%A7%D9%86</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>https://modstagram.com/billboards/691c3a88e24a347e31d001ea/%D8%B9%D9%85%D8%A7%D8%B1%D8%AA%20%D8%B2%DB%8C%D8%A8%D8%A7%DB%8C%DB%8C%20%DA%86%D9%8E%D9%85%20%7C%20Chambeautysalon</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>https://modstagram.com/billboards/691c33eee24a347e31cffc27/%D9%81%D8%B1%D9%86%D8%A7%D8%B2%20%D8%B5%D9%81%D8%B1%DB%8C%20%7C%D9%85%DB%8C%DA%A9%D8%A7%D9%BE%20%D8%A7%D8%B1%D8%AA%DB%8C%D8%B3%D8%AA%20%D8%B9%D8%B1%D9%88%D8%B3%7C%D9%85%D8%AF%D8%B1%D8%B3%20%D8%AA%D8%AE%D8%B5%D8%B5%DB%8C</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>https://modstagram.com/billboards/691af3c9e24a347e31cfedef/%D8%B3%D8%A7%D9%84%D9%86%20%D9%85%D9%85%D8%AA%D8%A7%D8%B2%20%D8%B4%D8%B1%D9%82%20%D8%AA%D9%87%D8%B1%D8%A7%D9%86%20%C2%AB%DA%AF%D9%84%20%DA%AF%DB%8C%D8%B3%C2%BB</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>https://modstagram.com/billboards/691ae483e24a347e31cfe822/%D8%BA%D8%B2%D9%84%20%D8%B2%D8%B1%DA%AF%D8%B1%DB%8C%D8%A7%D9%86%20%7C%20%D9%85%DB%8C%DA%A9%D8%B1%D9%88%D8%A8%D9%84%DB%8C%D8%AF%DB%8C%D9%86%DA%AF%20%7C%20%D9%81%DB%8C%D8%A8%D8%B1%D9%88%D8%B2</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>https://modstagram.com/billboards/6918a635e24a347e31cf8b17/%D8%B3%D8%A7%D9%84%D9%86%20%D8%B2%DB%8C%D8%A8%D8%A7%DB%8C%DB%8C%20%D8%B3%D9%BE%DB%8C%D8%AF%D9%87%20%D9%85%D9%88%D8%B3%D9%88%DB%8C</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>https://modstagram.com/billboards/6918a247e24a347e31cf883f/%D8%B3%D8%A7%D9%84%D9%86%20%D8%B2%DB%8C%D8%A8%D8%A7%DB%8C%DB%8C%20%DA%AF%D9%84%D8%B3%D8%A7%D9%86%20%F0%9F%AA%B7</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>https://modstagram.com/billboards/691891a8e24a347e31cf7feb/%D8%B3%D8%A7%D9%84%D9%86%20%D8%B2%DB%8C%D8%A8%D8%A7%DB%8C%DB%8C%20%DA%AF%D9%84%20%D8%B3%D8%B1%D8%AE</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>https://modstagram.com/billboards/69188517e24a347e31cf7241/%D8%B3%D8%A7%D9%84%D9%86%20%D8%B2%DB%8C%D8%A8%D8%A7%DB%8C%DB%8C%20%D9%BE%D8%B1%D9%86%D8%B3%D8%B3</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>https://modstagram.com/billboards/691880ffe24a347e31cf6e55/%D8%B3%D8%A7%D9%84%D9%86%20%D8%B2%D9%8A%D8%A8%D8%A7%DB%8C%DB%8C%20%D9%86%D9%82%D8%B1%D9%87%20%D9%86%DA%AF%D8%A7%D8%B1</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>https://modstagram.com/billboards/69187bfbe24a347e31cf6b00/%D8%B3%D8%A7%D9%84%D9%86%20%D8%B2%DB%8C%D8%A8%D8%A7%DB%8C%DB%8C%20%D8%A2%D9%86%D8%AC%D9%84</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>https://modstagram.com/billboards/691332c9e24a347e31cef09f/%D8%AE%D8%AF%D9%85%D8%A7%D8%AA%20%D8%B3%D8%A7%D9%84%D9%86%20%D8%B2%DB%8C%D8%A8%D8%A7%DB%8C%DB%8C%20%D8%AF%DA%A9%D8%AA%D8%B1%20%D8%B2%D8%A7%D8%B1%D8%A7</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
<url><loc>https://modstagram.com/billboards/6a0ef05d12cadb57c40ee4b2/%D8%B9%DA%A9%D8%A7%D8%B3%DB%8C%20%D8%A8%D8%A8%D8%B1%DB%8C%20%D8%A2%D8%B1%D8%AA</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
</urlset>

View File

@@ -0,0 +1,32 @@
import { getApiBaseUrl } from "@/components/main/BaseUrl";
import { Post } from "@/types/types";
export async function fetchBookmarkPosts(
page: number,
limit: number,
token: string
): Promise<{ posts: Post[]; hasMore: boolean; totalItems: number }> {
const base = getApiBaseUrl();
const res = await fetch(
`${base}/posts/bookmarks?page=${page}&limit=${limit}`,
{
cache: "no-store",
headers: { Authorization: `Bearer ${token}` },
}
);
if (!res.ok) {
return { posts: [], hasMore: false, totalItems: 0 };
}
const data = await res.json();
const posts = (data?.posts ?? []) as Post[];
const totalItems = data?.totalItems ?? posts.length;
const totalPages = data?.totalPages ?? 1;
return {
posts,
hasMore: page < totalPages,
totalItems,
};
}

View File

@@ -11,7 +11,7 @@ export async function fetchPosts(
userLevel?: string;
rateFilter?: string;
_id?: string;
type?: string;
type?: "image" | "video";
exploreFilter?: string;
subExpertise?: string;
lat?: string;
@@ -19,6 +19,17 @@ export async function fetchPosts(
feedMode?: "grid" | "reels";
seedPostId?: string;
sort?: "latest";
reelsTab?: "for_you" | "following" | "saved";
heightMin?: string;
heightMax?: string;
weightMin?: string;
weightMax?: string;
sizeMin?: string;
sizeMax?: string;
hair_color?: string;
eye_color?: string;
q?: string;
hashtag?: string;
},
token: string
) {
@@ -53,5 +64,16 @@ export async function fetchPosts(
throw new Error(`Network response was not ok: ${response.status}`);
}
return response.json();
return response.json() as Promise<{
posts: unknown[];
totalPages?: number;
totalItems?: number;
feedMeta?: {
isColdStart?: boolean;
emptyFollowing?: boolean;
emptySaved?: boolean;
requiresAuth?: boolean;
suggestedUsers?: unknown[];
};
}>;
}

23
src/api/fetchProject.ts Normal file
View File

@@ -0,0 +1,23 @@
import { getApiBaseUrl } from "@/components/main/BaseUrl";
import { Project } from "@/types/types";
export async function fetchProject(
id: string,
token = ""
): Promise<Project | null> {
try {
const response = await fetch(`${getApiBaseUrl()}/projects/get/web/${id}`, {
cache: "no-store",
headers: {
Authorization: token ? `Bearer ${token}` : "",
},
});
if (!response.ok) return null;
const data = await response.json();
return data?.project ?? null;
} catch {
return null;
}
}

View File

@@ -19,6 +19,8 @@ export type StoryItem = {
createdAt?: string;
expires_at?: string;
viewed?: boolean;
liked?: boolean;
likes_count?: number;
};
export type StoryFeedUser = {
@@ -103,3 +105,64 @@ export async function updateStoryOverlays(
throw new Error(json?.message || "خطا در ویرایش استوری");
}
}
export type StoryViewerUser = {
_id: string;
user_name: string;
first_name: string;
last_name: string;
profile_image?: string;
viewed_at?: string;
};
export async function likeStory(
storyId: string,
token: string
): Promise<{ liked: boolean; already?: boolean }> {
const res = await fetch(`${getApiBaseUrl()}/stories/like`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ storyId }),
});
if (!res.ok) {
const json = await res.json().catch(() => ({}));
throw new Error(json?.message || "خطا در لایک استوری");
}
return res.json();
}
export async function commentOnStory(
storyId: string,
comment: string,
token: string
): Promise<void> {
const res = await fetch(`${getApiBaseUrl()}/stories/comment`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ storyId, comment }),
});
if (!res.ok) {
const json = await res.json().catch(() => ({}));
throw new Error(json?.message || "خطا در ارسال کامنت");
}
}
export async function fetchStoryViewers(
storyId: string,
token: string
): Promise<{ viewers: StoryViewerUser[]; total: number }> {
const res = await fetch(`${getApiBaseUrl()}/stories/${storyId}/viewers`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
const json = await res.json().catch(() => ({}));
throw new Error(json?.message || "خطا در دریافت بازدیدکنندگان");
}
return res.json();
}

View File

@@ -8,7 +8,7 @@ import AuthPasswordInput from "@/components/auth/AuthPasswordInput";
import Container from "@/components/elements/Container";
import AuthRules from "@/components/auth/AuthRules";
import Link from "next/link";
import React, { useEffect, useState } from "react";
import React, { useState } from "react";
import useAxios from "@/hooks/useAxios";
import { useFormik } from "formik";
import * as yup from "yup";
@@ -19,8 +19,8 @@ import {
getAxiosErrorMessage,
getSafeRedirectPath,
} from "@/lib/auth/postLogin";
import { clearAuthSession } from "@/lib/auth/session";
import toast from "react-hot-toast";
import { useAuthSessionRedirect } from "@/hooks/useAuthSessionRedirect";
const schema = yup.object().shape({
username: yup.string().required("نام کاربری الزامی است"),
@@ -38,10 +38,7 @@ function LoginWithUsername() {
const [errorMessage, setErrorMessage] = useState("");
const { request, loading } = useAxios();
const redirectPath = getSafeRedirectPath();
useEffect(() => {
void clearAuthSession();
}, []);
useAuthSessionRedirect();
const formik = useFormik({
initialValues: {

View File

@@ -24,11 +24,13 @@ const schema = yup.object({
import { getSafeRedirectPath } from "@/lib/auth/postLogin";
import GoogleSignInButton from "@/components/auth/GoogleSignInButton";
import { MOBILE_NUMERIC_INPUT_PROPS } from "@/lib/auth/inputProps";
import { useAuthSessionRedirect } from "@/hooks/useAuthSessionRedirect";
function Login() {
const router = useRouter();
const [isModalOpen, setModalOpen] = useState(false);
const { request, loading } = useAxios();
useAuthSessionRedirect();
const redirectPath = getSafeRedirectPath();
const usernameLoginHref =
redirectPath !== "/"

View File

@@ -14,9 +14,10 @@ import { useFormik } from "formik";
import * as yup from "yup";
import { useRouter } from "next/navigation";
import { IVerifyOtp } from "@/types/types";
import { setAuthSession } from "@/lib/auth/session";
import { AUTH_SUCCESS_DELAY_MS, pause } from "@/lib/auth/feedback";
import { getRegistrationRoute } from "@/lib/auth/registrationRoutes";
import {
completeLogin,
getSafeRedirectPath,
} from "@/lib/auth/postLogin";
// Validation schema using Yup
const schema = yup.object({
@@ -57,26 +58,14 @@ function VerifyOtp() {
switch (response?.page) {
case "home":
await setAuthSession(response.token, {
id: response.id,
user_type: response.user_type,
step: response.step,
auth_provider: response.auth_provider,
email: response.email ?? undefined,
});
setIsSuccess(true);
await pause(AUTH_SUCCESS_DELAY_MS);
router.refresh();
router.push("/");
await completeLogin(router, response, {
redirectTo: getSafeRedirectPath(),
});
break;
case "auth-page":
await setAuthSession(response.token, {
id: response.id,
step: response.step,
});
setIsSuccess(true);
await pause(AUTH_SUCCESS_DELAY_MS);
router.push(getRegistrationRoute(response.step));
await completeLogin(router, response);
break;
default:
break;

View File

@@ -15,9 +15,7 @@ import { useFormik } from "formik";
import * as yup from "yup";
import { IVerifyOtp } from "@/types/types";
import { useRouter } from "next/navigation";
import { setAuthSession } from "@/lib/auth/session";
import { AUTH_SUCCESS_DELAY_MS, pause } from "@/lib/auth/feedback";
import { getRegistrationRoute } from "@/lib/auth/registrationRoutes";
import { completeLogin } from "@/lib/auth/postLogin";
// Validation schema using Yup
const schema = yup.object({
@@ -56,22 +54,8 @@ function RegisterOtp() {
otp: values.otp.trim(),
})) as IVerifyOtp;
await localStorage.setItem("otp", values.otp);
await setAuthSession(response.token, {
id: response.id,
step: response.step,
user_type: response.user_type,
auth_provider: response.auth_provider,
email: response.email ?? undefined,
});
setIsSuccess(true);
await pause(AUTH_SUCCESS_DELAY_MS);
if (response.page === "home") {
router.push("/");
return;
}
router.push(getRegistrationRoute(response.step));
await completeLogin(router, response);
} catch (err: any) {
setOtpError(true);
setIsSuccess(false);

View File

@@ -54,7 +54,7 @@ function RegisterCompletePage() {
<AuthNextButton
type="button"
onClick={() => finishRegistration("continue")}
className="!border-[#0C8002] !text-[#0C8002] w-full sm:w-auto"
className=" w-full sm:w-auto"
disabled={loading}
loading={loading && action === "continue"}
>

View File

@@ -152,7 +152,7 @@ function FullNamePage() {
<AuthNextButton
type="button"
onClick={() => finishRegistration("continue")}
className="!border-[#0C8002] !text-[#0C8002] w-full sm:w-auto"
className=" w-full sm:w-auto"
disabled={Boolean(choiceLoading)}
loading={choiceLoading === "continue"}
>

View File

@@ -1,11 +1,16 @@
"use client";
import { GoogleAuthRootProvider } from "@/components/auth/GoogleSignInButton";
import RegisterRouteGuard from "@/components/auth/RegisterRouteGuard";
export default function AuthProviders({
children,
}: {
children: React.ReactNode;
}) {
return <GoogleAuthRootProvider>{children}</GoogleAuthRootProvider>;
return (
<GoogleAuthRootProvider>
<RegisterRouteGuard>{children}</RegisterRouteGuard>
</GoogleAuthRootProvider>
);
}

View File

@@ -4,7 +4,6 @@ import AuthProviders from "./AuthProviders";
export const metadata: Metadata = {
title: {
default: "ورود و ثبت‌نام | مدستاگرام",
template: "%s | مدستاگرام",
},
description: "ورود یا ثبت‌نام در مدستاگرام",
robots: { index: false, follow: false },

View File

@@ -5,7 +5,7 @@ import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import useAxios from "@/hooks/useAxios";
import * as yup from "yup";
import { useRouter } from "next/navigation";
@@ -28,14 +28,16 @@ function Colors() {
const { request, loading } = useAxios();
const mobile =
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
const [expertise] = useState<string>(
typeof window !== "undefined" ? localStorage.getItem("expertise") || "" : ""
);
if (expertise !== "مدل") {
router.push("/verify/public-relations");
const [expertise] = useState<string>(
typeof window !== "undefined" ? localStorage.getItem("expertise") || "" : ""
);
useEffect(() => {
if (expertise && expertise !== "مدل") {
router.replace("/verify/public-relations");
}
}, [expertise, router]);
const handleCheck = async () => {
try {
await schema.validate({ selectedHairImage, selectedEyeImage });
@@ -105,7 +107,7 @@ function Colors() {
<button
className={`p-1 rounded-full w-full border text-sm flex items-center justify-center gap-1 ${
selectedButton === 1
? "bg-[#FC8EAC] border-[#FC8EAC]"
? "btn-modern--selected text-white"
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
}`}
onClick={() => handleButtonPress(1)}
@@ -115,7 +117,7 @@ function Colors() {
<button
className={`p-1 rounded-full w-full border text-sm flex items-center justify-center gap-1 ${
selectedButton === 2
? "bg-[#FC8EAC] border-[#FC8EAC]"
? "btn-modern--selected text-white"
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
}`}
onClick={() => handleButtonPress(2)}

View File

@@ -69,7 +69,7 @@ function CooperationType() {
onClick={() => formik.setFieldValue("selectedButton", true)}
className={`mt-5 text-xs w-full max-w-[250px] flex items-center justify-center flex-row-reverse gap-2 px-1 ${
formik.values.selectedButton === true
? "!bg-[#FC8EAC] !border-[#FC8EAC] !text-white"
? "btn-modern--selected text-white"
: ""
}`}
>
@@ -80,7 +80,7 @@ function CooperationType() {
onClick={() => formik.setFieldValue("selectedButton", false)}
className={`mt-5 text-xs w-full max-w-[250px] flex items-center justify-center flex-row-reverse gap-2 px-1 ${
formik.values.selectedButton === false
? "!bg-[#FC8EAC] !border-[#FC8EAC] !text-white"
? "btn-modern--selected text-white"
: ""
}`}
>

View File

@@ -71,7 +71,7 @@ function Gender() {
onClick={() => formik.setFieldValue("selectedButton", "female")}
className={`mt-5 w-full max-w-[250px] !border-[#FC8EAC] flex items-center justify-center flex-row-reverse gap-2 ${
formik.values.selectedButton === "female"
? "!bg-[#FC8EAC] !text-white"
? "btn-modern--selected text-white"
: ""
}`}
>

View File

@@ -5,7 +5,7 @@ import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import useAxios from "@/hooks/useAxios";
import * as yup from "yup";
import { useRouter } from "next/navigation";
@@ -37,9 +37,11 @@ function Sizes() {
typeof window !== "undefined" ? localStorage.getItem("expertise") || "" : ""
);
if (expertise !== "مدل") {
router.push("/verify/public-relations");
}
useEffect(() => {
if (expertise && expertise !== "مدل") {
router.replace("/verify/public-relations");
}
}, [expertise, router]);
const handleCheck = async () => {
try {
@@ -90,7 +92,7 @@ function Sizes() {
<button
className={`p-1 rounded-full w-1/3 border text-sm flex items-center justify-center gap-1 ${
selectedButton === 1
? "bg-[#FC8EAC] border-[#FC8EAC]"
? "btn-modern--selected text-white"
: "bg-tertiary-light dark:bg-tertiary-dark text-black border-gray-400"
}`}
onClick={() => handleButtonPress(1)}
@@ -107,7 +109,7 @@ function Sizes() {
<button
className={`p-1 rounded-full w-1/3 border text-sm flex items-center justify-center gap-1 ${
selectedButton === 2
? "bg-[#FC8EAC] border-[#FC8EAC]"
? "btn-modern--selected text-white"
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
}`}
onClick={() => handleButtonPress(2)}
@@ -124,7 +126,7 @@ function Sizes() {
<button
className={`p-1 rounded-full w-1/3 border text-sm flex items-center justify-center gap-1 ${
selectedButton === 3
? "bg-[#FC8EAC] border-[#FC8EAC]"
? "btn-modern--selected text-white"
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
}`}
onClick={() => handleButtonPress(3)}
@@ -167,7 +169,7 @@ function Sizes() {
key={sizeOption}
className={`p-2 py-1 rounded-full border text-sm full ${
size === sizeOption
? "bg-[#FC8EAC] text-white border-[#FC8EAC]"
? "btn-modern--selected text-white"
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
}`}
onClick={() => handleSizeSelection(sizeOption)}

View File

@@ -5,8 +5,12 @@ import { Metadata } from "next";
import AcademyFilter from "@/components/academy/AcademyFilter";
import { pageSeo } from "@/config/pageSeo";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
import {
buildAcademyListSeo,
AcademyListFilters,
} from "@/lib/buildAcademyListSeo";
interface IProjectsProps {
interface IAcademyListProps {
params: Promise<{ slug?: string[] }>;
searchParams: Promise<{
search?: string;
@@ -15,26 +19,47 @@ interface IProjectsProps {
maxPrice?: string;
hasOffer?: string;
type?: string;
isFree?: string;
sortBy?: string;
sortOrder?: string;
}>;
};
function normalizeAcademyFilters(
filters: Awaited<IAcademyListProps["searchParams"]>
): AcademyListFilters {
let sortBy = filters.sortBy || "createdAt";
let sortOrder = filters.sortOrder || "desc";
if (sortBy.includes("_")) {
const [field, order] = sortBy.split("_");
sortBy = field;
sortOrder = order || "desc";
}
return {
search: filters.search || "",
category: filters.category || "",
type: filters.type || "",
isFree: filters.isFree === "true" || filters.type === "free",
sortBy,
sortOrder,
};
}
export async function generateMetadata({
searchParams,
}: IProjectsProps): Promise<Metadata> {
const filters = await searchParams;
}: IAcademyListProps): Promise<Metadata> {
const filters = normalizeAcademyFilters(await searchParams);
const hasFilters = Boolean(
filters.category ||
filters.type ||
filters.isFree ||
filters.search ||
(filters.sortBy && filters.sortBy !== "createdAt")
);
const isDefaultAcademy =
!filters.category &&
!filters.type &&
!filters.search &&
!filters.minPrice &&
!filters.maxPrice &&
!filters.hasOffer &&
!filters.sortBy;
if (isDefaultAcademy) {
if (!hasFilters) {
return generatePageMetadata({
title: pageSeo.academy.title,
description: pageSeo.academy.description,
@@ -43,41 +68,22 @@ export async function generateMetadata({
});
}
const categoryLabel = filters.category || "حوزه زیبایی";
const typeLabel = filters.type ? ` به صورت ${filters.type}` : "";
const descTypeLabel = filters.type ? ` بصورت ${filters.type}` : "";
const title = `آموزش ${categoryLabel}${typeLabel} | آموزشگاه مدستاگرام`;
const description = `آموزش تخصصی و حرفه‌ای ${categoryLabel}${descTypeLabel} در آموزشگاه مدستاگرام. پکیج‌های آموزشی زیبایی، میکاپ، مدلینگ و عکاسی.`;
const queryParams = new URLSearchParams();
if (filters.category) queryParams.append("category", filters.category);
if (filters.type) queryParams.append("type", filters.type);
const queryString = queryParams.toString();
const path = queryString
? `${pageSeo.academy.path}?${queryString}`
: pageSeo.academy.path;
return generatePageMetadata({ title, description, path });
const seo = buildAcademyListSeo(filters);
return generatePageMetadata({
title: seo.title,
description: seo.description,
path: seo.path,
keywords: [...pageSeo.academy.keywords],
});
}
export default async function Projects({
export default async function AcademyListPage({
searchParams,
}: {
searchParams: Promise<{
search?: string;
category?: string;
minPrice?: string;
maxPrice?: string;
hasOffer?: string;
type?: string;
sortBy?: string;
sortOrder?: string;
}>;
}) {
}: IAcademyListProps) {
const params = await searchParams;
const cookieStore = await cookies();
const token = cookieStore.get("token")?.value || "";
const seo = buildAcademyListSeo(normalizeAcademyFilters(params));
const filters = {
search: params.search || "",
@@ -85,16 +91,23 @@ export default async function Projects({
minPrice: params.minPrice ? Number(params.minPrice) : undefined,
maxPrice: params.maxPrice ? Number(params.maxPrice) : undefined,
hasOffer: params.hasOffer === "true",
type: params.type || "",
sortBy: params.sortBy || "createdAt",
sortOrder: params.sortOrder || "desc",
type: params.type === "free" ? "" : params.type || "",
isFree: params.isFree === "true" || params.type === "free",
...(() => {
let sortBy = params.sortBy || "createdAt";
let sortOrder = params.sortOrder || "desc";
if (sortBy.includes("_")) {
const [field, order] = sortBy.split("_");
sortBy = field;
sortOrder = order || "desc";
}
return { sortBy, sortOrder };
})(),
};
return (
<Container>
<h1 className="sr-only">
{pageSeo.academy.title.replace(" | مدستاگرام", "")}
</h1>
<h1 className="sr-only">{seo.h1}</h1>
<AcademyFilter />
<InfinitePosts filters={filters} token={token} />
</Container>

View File

@@ -16,7 +16,10 @@ import {
Tag,
FileVideo,
Star,
Phone,
} from "lucide-react";
import { btnPrimary, btnDefault } from "@/lib/ui/buttonStyles";
import { cn } from "@/lib/utils";
import { Course } from "@/types/types";
import useAxios from "@/hooks/useAxios";
import { Comments } from "@/components/academy/CommentsModal";
@@ -25,6 +28,8 @@ import VerificationBadge from "@/components/main/VerificationBadge";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import { usePathname } from "next/navigation";
import Cookies from "js-cookie";
import BillboardContactInfoModal from "@/components/billboards/BillboardPage/BillboardContactInfoModal";
import { AcademyCourseDetailSkeleton } from "@/components/academy/AcademySkeletons";
// ==================== دیتای فیک ====================
const MOCK_COURSE = {
@@ -36,7 +41,6 @@ const MOCK_COURSE = {
caption: "در حال بارگذاری...",
course_time: "در حال بارگذاری...",
teacher_name: "در حال بارگذاری...",
teacher_number: "در حال بارگذاری...",
course_image: "در حال بارگذاری...",
number_of_course_content: "0",
averageRate: 0,
@@ -96,6 +100,7 @@ export default function CourseDetail({
const [selectedVideo, setSelectedVideo] = useState(MOCK_VIDEOS[0]);
const [isPurchased, setIsPurchased] = useState(initialIsPurchased);
const [purchasing, setPurchasing] = useState(false);
const [showContactInfoModal, setShowContactInfoModal] = useState(false);
const [videos] = useState(MOCK_VIDEOS);
const [course] = useState(MOCK_COURSE);
@@ -104,6 +109,7 @@ export default function CourseDetail({
const id = match ? match[1] : null;
const [courses, setCourses] = useState<Course[]>([]);
const [courseLoading, setCourseLoading] = useState(true);
const { request } = useAxios();
const _id = courses[0]?._id;
@@ -337,6 +343,7 @@ export default function CourseDetail({
useEffect(() => {
const getAcademyCourses = async () => {
setCourseLoading(true);
try {
const response = await request(
"GET",
@@ -357,6 +364,8 @@ export default function CourseDetail({
console.log("get error:", err);
toast.error("خطا در دریافت اطلاعات");
setCourses([]);
} finally {
setCourseLoading(false);
}
};
@@ -397,6 +406,60 @@ export default function CourseDetail({
// };
const handleBuyCourse = async () => {
const token = Cookies.get("token");
if (!token) {
toast.error("لطفاً ابتدا وارد حساب کاربری خود شوید");
return;
}
setPurchasing(true);
const loadingToastId = toast.loading(
courses[0]?.is_free
? "در حال ثبت پکیج رایگان..."
: "در حال اتصال به درگاه پرداخت..."
);
try {
const response = await request("POST", "/academy/academy/course/payment-web", {
courseId: id,
});
toast.dismiss(loadingToastId);
if (response?.free) {
setIsPurchased(true);
toast.success(response?.message || "پکیج رایگان با موفقیت ثبت شد");
return;
}
const paymentUrl =
response?.paymentUrl ||
response?.data?.paymentUrl ||
(response?.authority
? `https://www.zarinpal.com/pg/StartPay/${response.authority}`
: response?.data?.authority
? `https://www.zarinpal.com/pg/StartPay/${response.data.authority}`
: null);
if (paymentUrl) {
window.location.href = paymentUrl;
return;
}
toast.error(
response?.message || response?.data?.message || "خطا در اتصال به درگاه پرداخت"
);
} catch (err: unknown) {
toast.dismiss(loadingToastId);
console.error("خطا در خرید:", err);
toast.error("خطا در شروع پرداخت");
} finally {
setPurchasing(false);
}
};
const handlePurchase = async (plan: {
duration: string;
label: string;
@@ -524,9 +587,19 @@ export default function CourseDetail({
toast.error("دانلود این ویدیو امکان پذیر نیست");
};
const finalPrice = courses[0]?.offerNumber > 0
? courses[0]?.price * (1 - courses[0]?.offerNumber / 100)
: course.price;
const finalPrice = courses[0]?.is_free
? 0
: courses[0]?.offerNumber > 0
? courses[0]?.price * (1 - courses[0]?.offerNumber / 100)
: course.price;
const courseContactInfo = courses[0]?.contactInfo;
const hasContactInfo = Boolean(
courseContactInfo?.mobile ||
courseContactInfo?.telegramLink ||
courseContactInfo?.whatsappNumber ||
courseContactInfo?.instagramLink
);
const freeVideosCount = videos.filter((v) => v.is_free).length;
const paidVideosCount = videos.filter((v) => !v.is_free).length;
@@ -558,6 +631,10 @@ useEffect(() => {
}
}, []);
if (courseLoading) {
return <AcademyCourseDetailSkeleton />;
}
return (
<div className="min-h-screen bg-white dark:bg-neutral-950">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
@@ -590,11 +667,14 @@ useEffect(() => {
برای مشاهده تمام ویدیوها دوره را تهیه کنید
</p>
<button
onClick={handlePurchase}
className="bg-blue-500 hover:bg-blue-600 text-white px-8 py-3 rounded-lg flex items-center gap-2 font-bold transition-colors"
type="button"
onClick={handleBuyCourse}
className={cn(btnPrimary, "flex items-center gap-2 px-8 py-3")}
>
<ShoppingCart className="w-5 h-5" />
خرید دوره با {Math.round(finalPrice).toLocaleString()} تومان
{courses[0]?.is_free
? "دریافت رایگان پکیج"
: `خرید دوره با ${Math.round(finalPrice).toLocaleString()} تومان`}
</button>
</div>
)}
@@ -754,7 +834,9 @@ useEffect(() => {
{/* قیمت */}
<div className="mb-4">
{courses[0]?.offerNumber > 0 ? (
{courses[0]?.is_free ? (
<span className="text-3xl font-bold text-green-600">رایگان</span>
) : courses[0]?.offerNumber > 0 ? (
<div className="space-y-2">
<div className="flex items-center gap-3 flex-wrap">
<span className="text-3xl font-bold text-[#FF107D]">
@@ -786,12 +868,29 @@ useEffect(() => {
</div>
) : (
<button
onClick={handlePurchase}
onClick={handleBuyCourse}
disabled={purchasing}
className="w-full bg-[#FF107D] hover:bg-[#FF107D80] text-white py-3 rounded-lg font-bold flex items-center justify-center gap-2 transition-colors disabled:opacity-50"
>
<ShoppingCart className="w-5 h-5" />
{purchasing ? "در حال اتصال به درگاه..." : "خرید دوره"}
{purchasing
? courses[0]?.is_free
? "در حال ثبت..."
: "در حال اتصال به درگاه..."
: courses[0]?.is_free
? "دریافت رایگان پکیج"
: "خرید دوره"}
</button>
)}
{hasContactInfo && courseContactInfo && (
<button
type="button"
onClick={() => setShowContactInfoModal(true)}
className="w-full mt-3 border border-[#FF107D] text-[#FF107D] py-3 rounded-lg font-bold flex items-center justify-center gap-2 transition-colors hover:bg-[#FF107D10]"
>
<Phone className="w-5 h-5" />
اطلاعات تماس
</button>
)}
@@ -905,6 +1004,20 @@ useEffect(() => {
</div>
</div>
</div>
{hasContactInfo && courseContactInfo && (
<BillboardContactInfoModal
showContactInfoModal={showContactInfoModal}
setShowContactInfoModal={setShowContactInfoModal}
contactInfo={{
mobile: courseContactInfo.mobile,
telegramLink: courseContactInfo.telegramLink,
whatsappNumber: courseContactInfo.whatsappNumber,
instagramLink: courseContactInfo.instagramLink,
saveInfoForNextAds: false,
}}
/>
)}
</div>
);
}

View File

@@ -1,6 +1,8 @@
import { Metadata } from "next";
import CourseDetail from "./CourseClient";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
import { getApiBaseUrl } from "@/components/main/BaseUrl";
import { buildCourseDetailSeo } from "@/lib/buildCourseDetailSeo";
type Props = {
params: Promise<{ id: string; title: string }>;
@@ -11,7 +13,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
try {
const res = await fetch(
`https://app.modstagram.ir/api/v1/academy/academy/get/curses?page=1&limit=20&status=accept&_id=${id}`,
`${getApiBaseUrl()}/academy/academy/get/curses?page=1&limit=20&status=accept&_id=${id}`,
{ cache: "no-store" }
);
@@ -22,19 +24,12 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
response?.[0];
if (course) {
const courseName = course.cuorse_name || "دوره آموزشی";
const category = course.category || "دسته‌بندی نامشخص";
const teacher = course.teacher_name || "مدرس نامشخص";
const rawCaption = course.caption || "";
const cleanCaption = rawCaption
.replace(/<[^>]*>?/gm, "")
.substring(0, 150);
const seo = buildCourseDetailSeo(course, id);
return generatePageMetadata({
title: `${courseName} | ${category} | آموزشگاه مدستاگرام`,
description: `${cleanCaption || courseName}. دسته: ${category}. مدرس: ${teacher}. پکیج آموزشی تخصصی در آموزشگاه مدستاگرام.`,
path: `/academy/${id}/${encodeURIComponent(slugTitle || courseName)}`,
title: seo.title,
description: seo.description,
path: seo.path,
type: "article",
});
}
@@ -43,8 +38,8 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
}
return generatePageMetadata({
title: "جزئیات دوره | آموزشگاه مدستاگرام",
description: "مشاهده جزئیات پکیج آموزشی در آموزشگاه مدستاگرام.",
title: "جزئیات پکیج | مدستاگرام",
description: "مشاهده جزئیات پکیج آموزشی در مدستاگرام.",
path: `/academy/${id}/${encodeURIComponent(slugTitle || "course")}`,
});
}

View File

@@ -107,7 +107,7 @@ function FailedProject() {
onClick={() => {
payHandler();
}}
className="w-32 h-9 !border-[#0C8002] text-[#0C8002]"
variant="primary" className="w-32 h-9"
>
پرداخت مجدد
</RoundedButton>

View File

@@ -80,7 +80,7 @@ function SuccessProject() {
پروژه شما پس از بررسی توسط کارشناسان ما منتشر خواهد شد
</p>
<Link href={"/settings/workroom"}>
<RoundedButton className="w-32 h-9 !border-[#0C8002] text-[#0C8002]">
<RoundedButton variant="primary" className="w-32 h-9">
اتاق کار
</RoundedButton>
</Link>

View File

@@ -0,0 +1,333 @@
"use client";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import VerificationBadge from "@/components/main/VerificationBadge";
import AcademyPackageItem from "@/components/academy/AcademyPackageItem";
import {
AcademyPackageListSkeleton,
AcademyProfileHeadSkeleton,
} from "@/components/academy/AcademySkeletons";
import Container from "@/components/elements/Container";
import RoundedButton from "@/components/elements/RoundedButton";
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import useAxios from "@/hooks/useAxios";
import { useUserById } from "@/hooks/getUserById";
import { Course, IContactInfo } from "@/types/types";
import Image from "next/image";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
interface AcademyStats {
packagesCount: number;
soldCount: number;
totalVideos: number;
}
interface AcademyOwner {
_id: string;
user_name?: string;
first_name?: string;
last_name?: string;
is_verified?: string;
is_Register?: string | boolean;
user_score?: string | number;
}
interface AcademyData {
_id: string;
userId?: string;
academy_name?: string;
academy_image?: string;
bio?: string;
tag?: string;
rate?: number;
number_of_rate?: number;
stats?: AcademyStats;
owner?: AcademyOwner;
contactInfo?: IContactInfo;
}
function openContact(
type: "mobile" | "whatsapp" | "telegram" | "instagram",
contact?: IContactInfo
) {
if (!contact) {
toast.error("اطلاعات تماس ثبت نشده است.");
return;
}
if (type === "mobile") {
const phone = contact.mobile || contact.phone;
if (!phone) {
toast.error("شماره موبایل ثبت نشده است.");
return;
}
window.open(`tel:${phone}`, "_self");
return;
}
if (type === "whatsapp") {
if (!contact.whatsappNumber) {
toast.error("شماره واتساپ ثبت نشده است.");
return;
}
window.open(
`https://wa.me/${contact.whatsappNumber.replace(/\D/g, "")}`,
"_blank"
);
return;
}
if (type === "telegram") {
if (!contact.telegramLink) {
toast.error("آیدی تلگرام ثبت نشده است.");
return;
}
window.open(`https://t.me/${contact.telegramLink.replace("@", "")}`, "_blank");
return;
}
if (!contact.instagramLink) {
toast.error("آیدی اینستاگرام ثبت نشده است.");
return;
}
window.open(
`https://instagram.com/${contact.instagramLink.replace("@", "")}`,
"_blank"
);
}
export default function AcademyProfileClient() {
const params = useParams();
const academyId = params?.academyId as string;
const { request } = useAxios();
const [academy, setAcademy] = useState<AcademyData | null>(null);
const [courses, setCourses] = useState<Course[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isLoadingCourses, setIsLoadingCourses] = useState(true);
const ownerUserId = academy?.owner?._id || academy?.userId || "";
const ownerFromApi = useUserById(ownerUserId);
useEffect(() => {
const fetchAcademy = async () => {
if (!academyId) {
setIsLoading(false);
return;
}
setIsLoading(true);
try {
const response = await request("POST", "/academy/findAcademyById", {
_id: academyId,
});
const academyData =
response?.academy || response?.data?.academy || response;
setAcademy(academyData);
} catch {
toast.error("خطا در دریافت اطلاعات آموزشگاه");
} finally {
setIsLoading(false);
}
};
void fetchAcademy();
}, [academyId, request]);
useEffect(() => {
const fetchCourses = async () => {
if (!academyId) return;
setIsLoadingCourses(true);
try {
const response = await request(
"GET",
`/academy/academy/get/getAcademyCourse/${academyId}?page=1&limit=50&status=accept`
);
const list =
response?.data?.courses || response?.courses || [];
setCourses(list);
} catch {
toast.error("خطا در دریافت پکیج‌ها");
setCourses([]);
} finally {
setIsLoadingCourses(false);
}
};
if (academyId) void fetchCourses();
}, [academyId, request]);
if (isLoading) {
return (
<Container>
<AcademyProfileHeadSkeleton />
<AcademyPackageListSkeleton />
</Container>
);
}
if (!academy) {
return (
<Container>
<div className="py-20 text-center text-neutral-500">آموزشگاه یافت نشد.</div>
</Container>
);
}
const academyName = academy.academy_name || "آموزشگاه";
const owner = academy.owner;
const displayUserName = owner?.user_name || ownerFromApi?.user_name;
const displayUserScore =
owner?.user_score ?? ownerFromApi?.user_score ?? 0;
const isVerified = owner?.is_verified ?? ownerFromApi?.is_verified;
const isRegister = owner?.is_Register ?? ownerFromApi?.is_Register;
const stats = academy.stats;
const shareUrl = `modstagram.com/academy/profile/${academyId}`;
return (
<Container className="pb-28">
<div className="w-full">
<div
className="flex items-center justify-end cursor-pointer gap-1 px-4 py-2 text-xs md:text-sm font-semibold"
onClick={() => {
navigator.clipboard.writeText(shareUrl);
toast.success("لینک آموزشگاه کپی شد");
}}
>
<Image
width={22}
height={22}
alt="copy icon"
src="/images/icons/copy.svg"
className="dark:invert"
/>
<span>{shareUrl}</span>
</div>
<div className="flex w-full items-center justify-between px-4 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]">
<span>{stats?.totalVideos ?? 0}</span>
<span>کل ویدئوها</span>
</div>
<div className="font-semibold flex flex-col items-center text-[#0C8002] max-sm:text-[10px]">
<span>{stats?.packagesCount ?? 0}</span>
<span>تعداد پکیجها</span>
</div>
<div className="font-semibold flex flex-col items-center text-[#3A59A9] max-sm:text-[10px]">
<span>{stats?.soldCount ?? 0}</span>
<span>تعداد فروخته شده</span>
</div>
</div>
</div>
<ProfileAvatar
src={
academy.academy_image
? `${IMAGE_BASE_URL}${academy.academy_image}`
: undefined
}
alt={academyName}
size="md"
rounded="xl"
className="md:h-[120px] md:w-[120px]"
/>
</div>
<div className="flex justify-between items-end px-4 py-2 w-full text-xs md:text-sm font-semibold">
<div className="flex items-center gap-3">
<div className="flex items-center gap-1">
<span>{Number(academy.rate || 0).toFixed(1)}</span>
<Image
width={22}
height={22}
alt="امتیاز"
src="/images/icons/star1.svg"
/>
</div>
<div className="flex items-center gap-1">
<span>{displayUserScore || 0}</span>
<Image
width={22}
height={22}
alt="امتیاز کاربر"
src="/images/icons/medal-star.svg"
/>
</div>
</div>
<div className="flex flex-col items-end text-right">
<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"
>
{displayUserName}
<VerificationBadge
isVerified={isVerified}
isRegister={isRegister}
/>
</Link>
) : null}
</div>
</div>
<div className="px-4 py-2 w-full text-xs md:text-sm font-semibold">
<p className="leading-relaxed text-neutral-700 dark:text-neutral-300">
{academy.bio || "توضیحاتی برای این آموزشگاه ثبت نشده است."}
</p>
</div>
<div className="px-4 py-2 w-full text-xs md:text-sm font-semibold">
<div className="grid grid-cols-2 md:grid-cols-4 gap-1 md:gap-4">
<button
type="button"
onClick={() => openContact("mobile", academy.contactInfo)}
className="text-[12px] md:text-sm h-7 md:h-8 max-sm:text-[8px] rounded-xl border border-neutral-200 bg-white px-2 dark:border-neutral-700 dark:bg-neutral-900 active:opacity-80"
>
موبایل
</button>
<button
type="button"
onClick={() => openContact("whatsapp", academy.contactInfo)}
className="text-[12px] md:text-sm h-7 md:h-8 max-sm:text-[8px] rounded-xl border border-neutral-200 bg-white px-2 dark:border-neutral-700 dark:bg-neutral-900 active:opacity-80"
>
واتساپ
</button>
<button
type="button"
onClick={() => openContact("telegram", academy.contactInfo)}
className="text-[12px] md:text-sm h-7 md:h-8 max-sm:text-[8px] rounded-xl border border-neutral-200 bg-white px-2 dark:border-neutral-700 dark:bg-neutral-900 active:opacity-80"
>
تلگرام
</button>
<RoundedButton
onClick={() => openContact("instagram", academy.contactInfo)}
className="text-[9px] md:text-sm h-7 md:h-8 max-sm:text-[8px]"
>
اینستاگرام
</RoundedButton>
</div>
</div>
</div>
<div className="px-4 mt-6">
<h2 className="font-bold text-base mb-4">پکیجهای آموزشی</h2>
{isLoadingCourses ? (
<AcademyPackageListSkeleton count={3} />
) : courses.length === 0 ? (
<p className="text-center text-gray-500 py-10">
هنوز پکیجی برای این آموزشگاه ثبت نشده است.
</p>
) : (
courses.map((course) => (
<AcademyPackageItem key={course._id} course={course} />
))
)}
</div>
</Container>
);
}

View File

@@ -1,381 +1,43 @@
"use client";
import { Metadata } from "next";
import AcademyProfileClient from "./AcademyProfileClient";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
import { getApiBaseUrl } from "@/components/main/BaseUrl";
import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
import React, { useEffect, useState } from "react";
import { Academy, Course } from "@/types/types";
import Container from "@/components/elements/Container";
import useAxios from "@/hooks/useAxios";
import { useParams, useRouter } from "next/navigation";
import toast from "react-hot-toast";
import Image from "next/image";
import Link from "next/link";
import { motion } from "framer-motion";
type Props = {
params: Promise<{ academyId: string }>;
};
interface Tag {
value: string;
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { academyId } = await params;
interface PaginationType {
currentPage: number;
totalPages: number;
totalItems: number;
hasNextPage: boolean;
hasPrevPage: boolean;
}
function AcademyPage() {
const params = useParams();
const academyId = params?.academyId as string;
const { request } = useAxios();
const [academy, setAcademy] = useState<Academy | null>(null);
const [courses, setCourses] = useState<Course[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isLoadingCourses, setIsLoadingCourses] = useState(false);
const [pagination, setPagination] = useState<PaginationType>({
currentPage: 1,
totalPages: 1,
totalItems: 0,
hasNextPage: false,
hasPrevPage: false,
try {
const res = await fetch(`${getApiBaseUrl()}/academy/findAcademyById`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ _id: academyId }),
cache: "no-store",
});
const data = await res.json();
const academy = data?.academy;
if (academy?.academy_name) {
return generatePageMetadata({
title: `${academy.academy_name} | مدستاگرام`,
description: academy.bio || academy.academy_name,
path: `/academy/profile/${academyId}`,
});
}
} catch {
// fallback below
}
return generatePageMetadata({
title: "آموزشگاه | مدستاگرام",
description: "صفحه آموزشگاه در مدستاگرام",
path: `/academy/profile/${academyId}`,
});
const router = useRouter();
// دریافت اطلاعات آکادمی
useEffect(() => {
const fetchAcademy = async () => {
if (!academyId) {
console.error("academyId یافت نشد");
setIsLoading(false);
return;
}
setIsLoading(true);
try {
const response = await request("POST", `/academy/findAcademyById`, {
_id: academyId
});
let academyData = null;
if (response?.academy) {
academyData = response.academy;
} else if (response?.data?.academy) {
academyData = response.data.academy;
} else {
academyData = response;
}
setAcademy(academyData);
} catch (error) {
console.error("خطا در دریافت اطلاعات:", error);
toast.error("خطا در دریافت اطلاعات آکادمی");
} finally {
setIsLoading(false);
}
};
fetchAcademy();
}, [academyId]);
// دریافت دوره‌های آکادمی
const fetchCourses = async (pageNum: number = 1) => {
if (!academyId) return;
setIsLoadingCourses(true);
try {
const response = await request(
"GET",
`/academy/academy/get/getAcademyCourse/${academyId}?page=${pageNum}&limit=6&status=accept`
);
console.log("دوره‌های آکادمی:", response);
if (response?.success && response?.data?.courses) {
setCourses(response.data.courses);
if (response.data.pagination) {
setPagination({
currentPage: response.data.pagination.currentPage,
totalPages: response.data.pagination.totalPages,
totalItems: response.data.pagination.totalItems,
hasNextPage: response.data.pagination.hasNextPage,
hasPrevPage: response.data.pagination.hasPrevPage,
});
}
} else if (response?.data?.courses) {
setCourses(response.data.courses);
} else {
setCourses([]);
}
} catch (err) {
console.error("خطا در دریافت دوره‌ها:", err);
toast.error("خطا در دریافت دوره‌های آکادمی");
setCourses([]);
} finally {
setIsLoadingCourses(false);
}
};
// بارگذاری اولیه دوره‌ها بعد از دریافت آکادمی
useEffect(() => {
if (academyId) {
fetchCourses(1);
}
}, [academyId]);
// تغییر صفحه
const handlePageChange = (newPage: number) => {
if (newPage >= 1 && newPage <= pagination.totalPages) {
fetchCourses(newPage);
window.scrollTo({ top: 600, behavior: "smooth" });
}
};
// پردازش تگ‌ها
const parseTags = (tagString: string): Tag[] => {
if (!tagString) return [];
try {
return JSON.parse(tagString);
} catch {
return [];
}
};
const tags = parseTags(academy?.tag as string || "[]");
if (isLoading) {
return (
<Container>
<div className="flex justify-center items-center min-h-[60vh]">
<div className="relative w-24 h-24">
<div className="absolute inset-0 rounded-full border-4 border-gray-200 dark:border-gray-700"></div>
<div className="absolute inset-0 rounded-full border-4 border-t-pink-500 border-r-transparent border-b-transparent border-l-transparent animate-spin"></div>
</div>
<p className="mr-4 text-gray-600 dark:text-gray-400">در حال بارگذاری...</p>
</div>
</Container>
);
}
if (!academy && !isLoading) {
return (
<Container>
<div className="py-20 text-center">
<div className="text-6xl mb-4">🏫</div>
<h2 className="text-2xl font-bold text-gray-800 dark:text-gray-200 mb-2">
آکادمی یافت نشد
</h2>
<p className="text-gray-600 dark:text-gray-400">
متأسفیم، آکادمی مورد نظر شما وجود ندارد یا حذف شده است.
</p>
<button
onClick={() => router.push("/")}
className="mt-6 px-6 py-2 bg-pink-500 text-white rounded-lg hover:bg-pink-600 transition-colors"
>
بازگشت به صفحه اصلی
</button>
</div>
</Container>
);
}
const academyName = academy?.academy_name || "آکادمی";
const academyImage = academy?.academy_image
? `${IMAGE_BASE_URL}${academy.academy_image}`
: "/images/default-academy.jpg";
const academyBio = academy?.bio || "این آکادمی هنوز توضیحاتی اضافه نکرده است.";
const academyRate = academy?.rate || 0;
const numberOfRate = academy?.number_of_rate || 0;
const totalCourses = pagination.totalItems;
const createdAt = academy?.createdAt ? new Date(academy.createdAt).toLocaleDateString("fa-IR") : "نامشخص";
return (
<Container >
{/* هدر حرفه‌ای کامل با تمام اطلاعات */}
<div className="relative mb-8 ">
{/* Background Cover with Gradient */}
<div className="absolute inset-0 rounded-2xl overflow-hidden">
<div className="absolute inset-0 "></div>
<div className="absolute inset-0 bg-[url('/images/pattern.png')] opacity-10"></div>
</div>
{/* Content */}
<div className="relative p-6 md:p-8">
<div className="flex flex-col md:flex-row items-center gap-6 md:gap-8">
{/* لوگو آکادمی - سمت راست در دسکتاپ */}
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.5 }}
className="flex-shrink-0"
>
<div className="relative w-28 h-28 md:w-32 md:h-32 rounded-2xl overflow-hidden shadow-2xl ">
<Image
src={academyImage}
alt={academyName}
fill
className="object-cover"
onError={(e) => {
(e.target as HTMLImageElement).src =
"/images/default-academy.jpg";
}}
/>
</div>
</motion.div>
{/* اطلاعات آکادمی - سمت چپ در دسکتاپ */}
<motion.div
initial={{ x: -20, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
transition={{ duration: 0.5, delay: 0.1 }}
className="flex-1 text-center md:text-right"
>
<h1 className="text-2xl md:text-3xl lg:text-4xl font-bold text-white mb-2">
{academyName}
</h1>
{/* توضیحات آکادمی */}
<p className="text-white/90 leading-relaxed text-sm md:text-base mb-3 max-w-2xl">
{academyBio}
</p>
{/* تگ‌ها */}
{tags.length > 0 && (
<div className="flex flex-wrap justify-center md:justify-start gap-2">
{tags.map((tag, index) => (
<span
key={index}
className="px-2 py-0.5 bg-white/20 backdrop-blur-sm rounded-full text-xs text-white"
>
#{tag.value}
</span>
))}
</div>
)}
</motion.div>
</div>
</div>
</div>
{/* کارت‌های آمار جزئی */}
<motion.div
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.5, delay: 0.2 }}
className="grid p-3 grid-cols-2 md:grid-cols-4 gap-4 mb-8 -mt-6 relative z-10"
>
<div className="bg-white dark:bg-neutral-800 rounded-xl p-4 shadow-lg text-center hover:shadow-xl transition-shadow">
<div className="text-3xl mb-2">📚</div>
<div className="text-2xl font-bold text-gray-800 dark:text-gray-100">
{totalCourses}
</div>
<div className="text-sm text-gray-500">دوره آموزشی</div>
</div>
<div className="bg-white dark:bg-neutral-800 rounded-xl p-4 shadow-lg text-center hover:shadow-xl transition-shadow">
<div className="text-3xl mb-2">📅</div>
<div className="text-2xl font-bold text-gray-800 dark:text-gray-100">
{createdAt || "جدید"}
</div>
<div className="text-sm text-gray-500">تاسیس</div>
</div>
</motion.div>
{/* لیست دوره‌ها */}
<motion.section
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, delay: 0.3 }}
className="p-3"
>
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-bold text-gray-800 dark:text-gray-100">
📚 دورههای آموزشی
</h2>
<span className="text-sm text-gray-500">{totalCourses} دوره</span>
</div>
{courses.length === 0 ? (
<div className="text-center py-12 bg-gray-50 dark:bg-gray-800/50 rounded-2xl">
<div className="text-6xl mb-4">📚</div>
<p className="text-gray-600 dark:text-gray-400">
هنوز دورهای برای این آکادمی ثبت نشده است.
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{courses.map((course, index) => (
<motion.div
key={course._id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
>
<Link
href={`/academy/${course._id}/course`}
className="group block bg-white dark:bg-neutral-800 rounded-xl overflow-hidden shadow-md hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1"
>
<div className="relative h-48 w-full overflow-hidden">
<Image
src={
course.course_image
? `${IMAGE_BASE_URL}${course.course_image}`
: "/images/default-course.jpg"
}
alt={course.cuorse_name}
fill
className="object-cover group-hover:scale-110 transition-transform duration-500"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
{course.offer && parseInt(course.offer) > 0 && (
<div className="absolute top-2 right-2 bg-red-500 text-white text-xs font-bold px-2 py-1 rounded-full z-10">
🔥 {course.offer}% تخفیف
</div>
)}
</div>
<div className="p-4">
<h3 className="font-bold text-lg text-gray-800 dark:text-gray-100 mb-1 line-clamp-1">
{course.cuorse_name}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3 line-clamp-1">
{course.caption}
</p>
<div className="flex justify-between items-center mt-3 pt-3 border-t border-gray-100 dark:border-gray-700">
<div className="flex items-center gap-1 text-sm text-gray-500">
<span>👨🏫</span>
<span>{course.teacher_name}</span>
</div>
<div className="text-lg font-bold text-pink-500">
{course.offer && parseInt(course.offer) > 0 ? (
<>
<span className="line-through text-xs text-gray-400 ml-1">
{parseInt(course.price).toLocaleString()}
</span>
{(
(parseInt(course.price) *
(100 - parseInt(course.offer))) /
100
).toLocaleString()}
</>
) : (
parseInt(course.price).toLocaleString()
)}
<span className="text-xs"> تومان</span>
</div>
</div>
</div>
</Link>
</motion.div>
))}
</div>
)}
</motion.section>
</Container>
);
}
export default AcademyPage;
export default function AcademyProfilePage() {
return <AcademyProfileClient />;
}

View File

@@ -1,8 +1,11 @@
import { Metadata } from "next";
import { Suspense } from "react";
import { cookies } from "next/headers";
import ProjectDetailClient from "./ProjectDetailClient";
import PageLoader from "@/components/ui/PageLoader";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
import { fetchProject } from "@/api/fetchProject";
import { buildProjectDetailSeo } from "@/lib/buildProjectDetailSeo";
type ProjectDetailPageProps = {
params: Promise<{ id: string; title: string }>;
@@ -12,11 +15,23 @@ export async function generateMetadata({
params,
}: ProjectDetailPageProps): Promise<Metadata> {
const { id, title } = await params;
const decodedTitle = decodeURIComponent(title || "پروژه");
const token = (await cookies()).get("token")?.value || "";
const project = await fetchProject(id, token);
if (project) {
const seo = buildProjectDetailSeo(project);
return generatePageMetadata({
title: seo.title,
description: seo.description,
path: seo.path,
type: "article",
});
}
const decodedTitle = decodeURIComponent(title || "پروژه");
return generatePageMetadata({
title: `${decodedTitle} | پروژه‌های مدستاگرام`,
description: `جزئیات پروژه «${decodedTitle}» در مدستاگرام. مشاهده شرایط همکاری، بودجه و ثبت پیشنهاد.`,
title: `${decodedTitle} - مدستاگرام`,
description: `جزئیات پروژه «${decodedTitle}» در مدستاگرام.`,
path: `/projects/${id}/${encodeURIComponent(decodedTitle)}`,
});
}

View File

@@ -6,13 +6,10 @@ import { cookies } from "next/headers";
import { Metadata } from "next";
import { pageSeo } from "@/config/pageSeo";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
export const metadata: Metadata = generatePageMetadata({
title: pageSeo.projects.title,
description: pageSeo.projects.description,
path: pageSeo.projects.path,
keywords: [...pageSeo.projects.keywords],
});
import {
buildProjectsListSeo,
ProjectListFilters,
} from "@/lib/buildProjectsListSeo";
type ProjectsPageProps = {
searchParams: Promise<{
@@ -24,27 +21,56 @@ type ProjectsPageProps = {
}>;
};
export default async function ProjectsPage({ searchParams }: ProjectsPageProps) {
const filters = await searchParams;
const cookieStore = await cookies();
const token = cookieStore.get("token")?.value || "";
const projectFilters = {
function normalizeProjectFilters(
filters: Awaited<ProjectsPageProps["searchParams"]>
): ProjectListFilters {
return {
expertise: filters.expertise || "",
most_requests: filters.most_requests || "",
most_price: filters.most_price || "",
age: filters.age || "",
gender: filters.gender || "",
};
}
export async function generateMetadata({
searchParams,
}: ProjectsPageProps): Promise<Metadata> {
const filters = normalizeProjectFilters(await searchParams);
const hasFilters = Object.values(filters).some(Boolean);
if (!hasFilters) {
return generatePageMetadata({
title: pageSeo.projects.title,
description: pageSeo.projects.description,
path: pageSeo.projects.path,
keywords: [...pageSeo.projects.keywords],
});
}
const seo = buildProjectsListSeo(filters);
return generatePageMetadata({
title: seo.title,
description: seo.description,
path: seo.path,
keywords: [...pageSeo.projects.keywords],
});
}
export default async function ProjectsPage({ searchParams }: ProjectsPageProps) {
const rawFilters = await searchParams;
const projectFilters = normalizeProjectFilters(rawFilters);
const seo = buildProjectsListSeo(projectFilters);
const cookieStore = await cookies();
const token = cookieStore.get("token")?.value || "";
const initialData = await fetchProjects(1, 10, projectFilters, token);
return (
<Container className="pb-28">
<h1 className="sr-only">
{pageSeo.projects.title.replace(" | مدستاگرام", "")}
</h1>
<ProjectsFilter expertise={filters.expertise || ""} />
<h1 className="sr-only">{seo.h1}</h1>
<ProjectsFilter expertise={rawFilters.expertise || ""} />
<InfiniteProjects
initialData={initialData}
filters={projectFilters}

View File

@@ -13,6 +13,7 @@ import { generatePageMetadata } from "@/utils/generatePageMetadata";
import provinces from "@/data/provinces.json";
import cities from "@/data/cities.json";
import { normalizeUserLevel, parseUserLevelFromSlug } from "@/lib/userLevel";
import { parseExpertiseFromSlug } from "@/lib/expertiseColors";
interface IModelsProps {
params: Promise<{ slug?: string[] }>;
@@ -22,7 +23,15 @@ interface IModelsProps {
city?: string;
userLevel?: string;
rateFilter?: string;
hashtag?: string; // این خط را اضافه کنید
hashtag?: string;
heightMin?: string;
heightMax?: string;
weightMin?: string;
weightMax?: string;
sizeMin?: string;
sizeMax?: string;
hair_color?: string;
eye_color?: string;
}>;
}
@@ -123,11 +132,10 @@ export default async function Models({ params, searchParams }: IModelsProps) {
if (urlParams.slug && urlParams.slug.length > 0) {
const fullText = decodeURIComponent(urlParams.slug[0]).replace(/-/g, ' ');
// استخراج تخصص (فقط اگر در متن موجود باشد)
if (fullText.includes("مدل")) expertise = "مدل";
else if (fullText.includes("عکاس")) expertise = "عکاس";
else if (fullText.includes("آرایشگر")) expertise = "آرایشگر";
else if (fullText.includes("متخصصین")) expertise = ""; // حالت بدون تخصص
// استخراج تخصص از slug (مدل، عکاس، پوشاک و هر تخصص جدید)
const parsedExpertise = parseExpertiseFromSlug(fullText);
if (parsedExpertise) expertise = parsedExpertise;
else if (fullText.includes("متخصصین")) expertise = "";
const parsedLevel = parseUserLevelFromSlug(fullText);
if (parsedLevel) userLevel = parsedLevel;
@@ -163,7 +171,7 @@ export default async function Models({ params, searchParams }: IModelsProps) {
return (
<>
<Header />
<Container>
<Container shell>
{/* H1 مخفی برای بهبود سئو بر اساس آدرس صفحه */}
<h1 className="sr-only">
{(!urlParams.slug || urlParams.slug.length === 0) &&
@@ -184,14 +192,21 @@ export default async function Models({ params, searchParams }: IModelsProps) {
<InfinitePosts
filters={{
...filters,
hashtag: filters.hashtag, // این خط را اضافه کن
hashtag: filters.hashtag,
province: provinceId,
city: cityId,
userLevel: userLevel || normalizeUserLevel(filters.userLevel),
expertise: expertise || filters.expertise
expertise: expertise || filters.expertise,
heightMin: filters.heightMin,
heightMax: filters.heightMax,
weightMin: filters.weightMin,
weightMax: filters.weightMax,
sizeMin: filters.sizeMin,
sizeMax: filters.sizeMax,
hair_color: filters.hair_color,
eye_color: filters.eye_color,
}}
token={token}
// مقدار typeFilter را حذف کنید تا همه انواع نمایش داده شود
/>
</Container>
<TabNavigation currentPage="/" />

View File

@@ -101,8 +101,9 @@ function BillboardPayment({ params }: IBillboardProps) {
مبلغ قابل پرداخت: {Number(price).toLocaleString()} تومان
</RoundedDiv>
<RoundedButton
variant="primary"
onClick={payHandler}
className="w-32 h-9 !border-[#0C8002] text-[#0C8002]"
className="h-9 w-32"
>
پرداخت
</RoundedButton>

View File

@@ -137,7 +137,7 @@ function FailedBillboard() {
payHandler();
}
}}
className="w-32 h-9 !border-[#0C8002] text-[#0C8002]"
variant="primary" className="w-32 h-9"
>
پرداخت مجدد
</RoundedButton>

View File

@@ -81,7 +81,7 @@ function SuccessBillboard() {
خواهد شد.
</p>
<Link href={"/settings/my-billboards"}>
<RoundedButton className="w-32 h-9 !border-[#0C8002] text-[#0C8002]">
<RoundedButton variant="primary" className="w-32 h-9">
بیلورد من
</RoundedButton>
</Link>

View File

@@ -0,0 +1,54 @@
"use client";
import { Suspense } from "react";
import { useSearchParams } from "next/navigation";
import ExploreReelsView from "@/components/explore/ExploreReelsView";
import PostFeedView, { PostFeedSearchParams } from "@/components/posts/PostFeedView";
import PageLoader from "@/components/ui/PageLoader";
function toExploreFeedParams(
searchParams: URLSearchParams
): PostFeedSearchParams {
return {
feed: "explore",
filter: searchParams.get("filter") || undefined,
lat: searchParams.get("lat") || undefined,
lng: searchParams.get("lng") || undefined,
q: searchParams.get("q") || undefined,
hashtag: searchParams.get("hashtag") || undefined,
};
}
function ExploreReelContent({ id }: { id: string }) {
const searchParams = useSearchParams();
const typeParam = searchParams.get("type");
if (typeParam === "academy") {
return (
<ExploreReelsView
initialId={id}
initialType="academy"
showClose
/>
);
}
return (
<PostFeedView
initialPostId={id}
showClose
feedMode="explore"
searchParams={toExploreFeedParams(searchParams)}
/>
);
}
export default function ExploreReelClient({ id }: { id: string }) {
return (
<main className="min-h-[100dvh]">
<Suspense fallback={<PageLoader className="min-h-[100dvh]" />}>
<ExploreReelContent id={id} />
</Suspense>
</main>
);
}

View File

@@ -1,36 +1,42 @@
"use client";
import { Metadata } from "next";
import { fetchPostById } from "@/api/fetchPostById";
import { buildStorageUrl } from "@/components/main/BaseUrl";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
import {
buildPostPath,
buildPostSeoDescription,
buildPostSeoTitle,
} from "@/lib/postSlug";
import ExploreReelClient from "./ExploreReelClient";
import { Suspense, use } from "react";
import { useSearchParams } from "next/navigation";
import ExploreReelsView from "@/components/explore/ExploreReelsView";
import PageLoader from "@/components/ui/PageLoader";
function ExploreReelContent({ id }: { id: string }) {
const searchParams = useSearchParams();
const typeParam = searchParams.get("type");
const initialType = typeParam === "academy" ? "academy" : "post";
return (
<ExploreReelsView
initialId={id}
initialType={initialType}
showClose
/>
);
}
export default function ExploreReelPage({
params,
}: {
type Props = {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
};
return (
<main className="min-h-[100dvh]">
<Suspense fallback={<PageLoader className="min-h-[100dvh]" />}>
<ExploreReelContent id={id} />
</Suspense>
</main>
);
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params;
const post = await fetchPostById(id);
const title = post ? buildPostSeoTitle(post) : "پست | مدستاگرام";
const description = post
? buildPostSeoDescription(post)
: "مشاهده پست در اکسپلور مدستاگرام";
const ogImage = post?.files?.[0]?.path
? buildStorageUrl(post.files[0].path)
: undefined;
return generatePageMetadata({
title,
description,
path: buildPostPath(id, post ?? undefined),
type: "article",
imageUrl: ogImage,
imageAlt: post?.caption?.slice(0, 80) || "پست مدستاگرام",
publishedTime: post?.createdAt,
modifiedTime: post?.updatedAt,
});
}
export default async function ExploreReelPage({ params }: Props) {
const { id } = await params;
return <ExploreReelClient id={id} />;
}

View File

@@ -10,12 +10,33 @@ import { pageSeo } from "@/config/pageSeo";
import { useCallback, useState } from "react";
import toast from "react-hot-toast";
function parseSearchQuery(input: string): { q?: string; hashtag?: string } {
const trimmed = input.trim();
if (!trimmed) return {};
if (trimmed.startsWith("#")) {
const tag = trimmed.slice(1).trim();
return tag ? { hashtag: tag } : {};
}
return { q: trimmed };
}
export default function ExplorePage() {
const [activeFilter, setActiveFilter] = useState<ExploreFilterId>("all");
const [coords, setCoords] = useState<{ lat: number; lng: number } | null>(
null
);
const [loadingLocation, setLoadingLocation] = useState(false);
const [searchInput, setSearchInput] = useState("");
const [searchQuery, setSearchQuery] = useState<{
q?: string;
hashtag?: string;
}>({});
const handleSearchSubmit = useCallback((value?: string) => {
const nextValue = value ?? searchInput;
if (value !== undefined) setSearchInput(value);
setSearchQuery(parseSearchQuery(nextValue));
}, [searchInput]);
const handleFilterChange = useCallback((filter: ExploreFilterId) => {
if (filter === "near_me") {
@@ -62,8 +83,15 @@ export default function ExplorePage() {
activeFilter={activeFilter}
onFilterChange={handleFilterChange}
loadingLocation={loadingLocation}
searchValue={searchInput}
onSearchChange={setSearchInput}
onSearchSubmit={handleSearchSubmit}
/>
<ExploreGrid
activeFilter={activeFilter}
coords={coords}
searchQuery={searchQuery}
/>
<ExploreGrid activeFilter={activeFilter} coords={coords} />
</Container>
<TabNavigation currentPage="/explore" />
</>

View File

@@ -404,6 +404,37 @@ select {
transition: all 0.35s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
/* ─── Reels / post feed vertical scroll (Instagram-like snap) ─── */
.reels-vertical-scroll {
scroll-snap-type: y mandatory;
scroll-behavior: auto;
overscroll-behavior-y: contain;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.reels-vertical-scroll > * {
scroll-snap-align: start;
scroll-snap-stop: always;
}
.reels-vertical-scroll::-webkit-scrollbar {
display: none;
}
.reels-horizontal-scroll {
scroll-snap-type: x mandatory;
scroll-behavior: smooth;
overscroll-behavior-x: contain;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.reels-horizontal-scroll > * {
scroll-snap-align: start;
scroll-snap-stop: always;
}
.reels-horizontal-scroll::-webkit-scrollbar {
display: none;
}
/* ─── Chat scrollbar ─── */
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
@@ -656,4 +687,88 @@ select {
.profile-avatar {
aspect-ratio: 1 / 1;
object-fit: cover;
}
/* ─── Design system: buttons & modals ─── */
:root {
--btn-border-color: #C3C3C3;
--btn-accent: #FC8EAC;
--btn-accent-hover: #f07898;
--btn-accent-text: #ffffff;
}
.btn-modern {
border: 1px solid var(--btn-border-color);
transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.btn-modern:hover:not(:disabled) {
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.1);
}
.btn-modern:active:not(:disabled) {
transform: scale(0.98);
}
.dark .btn-modern {
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.28);
}
.dark .btn-modern:hover:not(:disabled) {
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.38);
}
.btn-modern--primary {
background: var(--btn-accent);
color: var(--btn-accent-text);
border-color: var(--btn-border-color);
}
.btn-modern--primary:hover:not(:disabled) {
background: var(--btn-accent-hover);
}
.btn-modern--selected {
background: var(--btn-accent);
color: var(--btn-accent-text);
border-color: var(--btn-accent);
}
.btn-modern--selected:hover:not(:disabled) {
background: var(--btn-accent-hover);
border-color: var(--btn-accent-hover);
}
.glass-modal-overlay {
background: rgba(8, 8, 10, 0.52);
backdrop-filter: blur(20px) saturate(120%);
-webkit-backdrop-filter: blur(20px) saturate(120%);
}
.dark .glass-modal-overlay {
background: rgba(0, 0, 0, 0.62);
backdrop-filter: blur(22px) saturate(110%);
-webkit-backdrop-filter: blur(22px) saturate(110%);
}
.glass-modal-overlay--dark {
background: rgba(0, 0, 0, 0.72);
backdrop-filter: blur(24px) saturate(110%);
-webkit-backdrop-filter: blur(24px) saturate(110%);
}
.dark .glass-modal-overlay--dark {
background: rgba(0, 0, 0, 0.82);
}
.glass-modal-panel {
background: rgba(255, 255, 255, 0.78);
backdrop-filter: blur(28px) saturate(180%);
-webkit-backdrop-filter: blur(28px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.5);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.14);
}
.dark .glass-modal-panel {
background: rgba(28, 28, 30, 0.84);
border-color: rgba(255, 255, 255, 0.14);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.48);
}
.glass-modal-panel--sheet {
border-radius: 1.5rem 1.5rem 0 0;
}
.glass-modal-panel--center {
border-radius: 1.5rem;
}

BIN
src/app/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -17,15 +17,13 @@ const defaultTitle = pageSeo.home.title;
export const metadata: Metadata = {
title: {
default: defaultTitle,
template: "%s",
},
description: pageSeo.home.description,
metadataBase: new URL("https://modstagram.com"),
keywords: siteKeywords,
authors: [{ name: "Modstagram", url: "https://modstagram.com" }],
creator: "Modstagram",
alternates: {
canonical: "/",
},
manifest: "/manifest.json",
icons: {
icon: [{ url: "/favicon.ico", type: "image/x-icon" }],
@@ -40,19 +38,16 @@ export const metadata: Metadata = {
statusBarStyle: "black",
},
openGraph: {
title: defaultSEOConfig.openGraph?.title || defaultTitle,
description: defaultSEOConfig.openGraph?.description || "",
url: defaultSEOConfig.openGraph?.url || "",
siteName: defaultSEOConfig.openGraph?.site_name || "",
siteName: defaultSEOConfig.openGraph?.site_name || "مدستاگرام",
locale: "fa_IR",
type: "website",
images: defaultSEOConfig.openGraph?.images || [],
},
robots: "index, follow",
twitter: {
card: "summary_large_image",
title: defaultTitle,
description: defaultSEOConfig.description || "",
creator: "@modstagram",
site: "@modstagram",
images: defaultSEOConfig.openGraph?.images?.map((img) => img.url) || [],
},
verification: {

View File

@@ -4,6 +4,7 @@
import Container from "@/components/elements/Container";
import RoundedButton from "@/components/elements/RoundedButton";
import RoundedDiv from "@/components/elements/RoundedDiv";
import { selectionCardClass } from "@/lib/ui/buttonStyles";
import { BASE_URL } from "@/components/main/BaseUrl";
import UserInfo from "@/components/main/UserInfo";
import PageTitle from "@/components/settings/PageTitle";
@@ -189,9 +190,9 @@ function TicketChat({ params }: ITicketChatProps) {
</div>
</div>
<RoundedDiv
className={`w-full max-w-full p-2 ${
selectedType === item?.name ? "bg-[#FC8EAC]" : ""
} ${isDisabled ? "opacity-50 cursor-not-allowed" : ""}`}
className={`${selectionCardClass(selectedType === item?.name)} ${
isDisabled ? "cursor-not-allowed opacity-50" : ""
}`}
>
{item.name === "normal"
? "درخواست همکاری"
@@ -208,7 +209,7 @@ function TicketChat({ params }: ITicketChatProps) {
);
})}
<RoundedButton type="submit" className="p-2 px-8 rounded mt-4">
<RoundedButton type="submit" variant="primary" className="mt-4 px-8 py-2">
ثبت درخواست
</RoundedButton>
</form>

View File

@@ -30,7 +30,7 @@ function FailedBillboard() {
className="flex items-center flex-col mt-8"
href={`/offer/${userId}`}
>
<RoundedButton className="w-32 h-9 !border-[#0C8002] text-[#0C8002]">
<RoundedButton variant="primary" className="w-32 h-9">
پرداخت مجدد
</RoundedButton>
</Link>

View File

@@ -47,12 +47,12 @@ function SuccessBillboard() {
</p>
<div className="grid grid-cols-2 gap-4">
<Link href={`/settings/chats/${userDetail?.user_name}/${userId}`}>
<RoundedButton className="w-32 h-9 !border-[#0C8002] text-[#0C8002]">
<RoundedButton variant="primary" className="w-32 h-9">
ارسال پیام
</RoundedButton>
</Link>
<Link href={"/"}>
<RoundedButton className="w-32 h-9 !border-[#0C8002] text-[#0C8002]">
<RoundedButton variant="primary" className="w-32 h-9">
بعدا
</RoundedButton>
</Link>

View File

@@ -0,0 +1,98 @@
import { Metadata } from "next";
import { fetchPostById } from "@/api/fetchPostById";
import PostFeedView, { PostFeedSearchParams } from "@/components/posts/PostFeedView";
import { buildStorageUrl } from "@/components/main/BaseUrl";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
import {
buildPostPath,
buildPostPublicUrl,
buildPostSeoDescription,
buildPostSeoTitle,
} from "@/lib/postSlug";
type Props = {
params: Promise<{ id: string; slug?: string[] }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
};
function pickQuery(
sp: Record<string, string | string[] | undefined>,
key: string
): string | undefined {
const v = sp[key];
return typeof v === "string" ? v : undefined;
}
function toFeedSearchParams(
sp: Record<string, string | string[] | undefined>
): PostFeedSearchParams {
return {
feed: pickQuery(sp, "feed"),
tab: pickQuery(sp, "tab"),
userId: pickQuery(sp, "userId"),
expertise: pickQuery(sp, "expertise"),
province: pickQuery(sp, "province"),
city: pickQuery(sp, "city"),
userLevel: pickQuery(sp, "userLevel"),
rateFilter: pickQuery(sp, "rateFilter"),
hashtag: pickQuery(sp, "hashtag"),
exploreFilter: pickQuery(sp, "exploreFilter"),
subExpertise: pickQuery(sp, "subExpertise"),
filter: pickQuery(sp, "filter"),
lat: pickQuery(sp, "lat"),
lng: pickQuery(sp, "lng"),
q: pickQuery(sp, "q"),
};
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params;
const post = await fetchPostById(id);
const title = post ? buildPostSeoTitle(post) : "پست | مدستاگرام";
const description = post
? buildPostSeoDescription(post)
: "مشاهده پست در مدستاگرام — پلتفرم تخصصی حوزه زیبایی، مدلینگ و عکاسی";
const ogImage = post?.files?.[0]?.path
? buildStorageUrl(post.files[0].path)
: undefined;
const authorName =
post &&
([post.first_name, post.last_name].filter(Boolean).join(" ") ||
post.user_name);
return generatePageMetadata({
title,
description,
path: buildPostPath(id, post ?? undefined),
type: "article",
imageUrl: ogImage,
imageAlt: post?.caption?.slice(0, 80) || "پست مدستاگرام",
keywords: [
"مدستاگرام",
post?.expertise || "",
authorName || "",
"پست",
"مدلینگ",
"زیبایی",
].filter(Boolean),
publishedTime: post?.createdAt,
modifiedTime: post?.updatedAt,
});
}
/** بدون await روی API — نمایش فوری از sessionStorage در PostFeedView */
export default async function PostPage({ params, searchParams }: Props) {
const { id } = await params;
const sp = await searchParams;
const feedSearchParams = toFeedSearchParams(sp);
return (
<main className="min-h-[100dvh]">
<PostFeedView
initialPostId={id}
showClose
searchParams={feedSearchParams}
/>
</main>
);
}

View File

@@ -1,69 +0,0 @@
import { Metadata } from "next";
import { fetchPostById } from "@/api/fetchPostById";
import PostFeedView from "@/components/posts/PostFeedView";
import { buildStorageUrl } from "@/components/main/BaseUrl";
import { Suspense } from "react";
import PageLoader from "@/components/ui/PageLoader";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
type Props = { params: Promise<{ id: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params;
const post = await fetchPostById(id);
const title = post?.caption
? `${post.caption.slice(0, 60)} | مدستاگرام`
: "پست | مدستاگرام";
const description =
post?.caption?.slice(0, 160) ||
"مشاهده پست در مدستاگرام — پلتفرم تخصصی حوزه زیبایی، مدلینگ و عکاسی";
const ogImage = post?.files?.[0]?.path
? buildStorageUrl(post.files[0].path)
: undefined;
return generatePageMetadata({
title,
description,
path: `/posts/${id}`,
type: "article",
imageUrl: ogImage,
imageAlt: post?.caption?.slice(0, 80) || "پست مدستاگرام",
});
}
export default async function PostPage({ params }: Props) {
const { id } = await params;
const post = await fetchPostById(id);
const userId = post?.userId || post?.user_id;
return (
<main className="min-h-[100dvh]">
{post && (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
"@context": "https://schema.org",
"@type": "SocialMediaPosting",
headline: post.caption?.slice(0, 110) || "پست مدستاگرام",
url: `https://modstagram.com/posts/${id}`,
datePublished: post.createdAt,
author: {
"@type": "Person",
name: `${post.first_name || ""} ${post.last_name || ""}`.trim(),
},
}),
}}
/>
)}
<Suspense fallback={<PageLoader className="min-h-[100dvh]" />}>
<PostFeedView
initialPostId={id}
initialPost={post}
userId={userId}
showClose
/>
</Suspense>
</main>
);
}

View File

@@ -1,5 +1,7 @@
"use client";
import { AcademyListSkeleton } from "@/components/academy/AcademySkeletons";
import useAxios from "@/hooks/useAxios";
import React, { useEffect, useState, useCallback } from "react";
import MainModelCard from "@/components/academy/MainModelCard";
@@ -80,16 +82,8 @@ export default function PurchasedCoursesPage() {
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<div className="relative w-24 h-24 mx-auto">
<div className="absolute inset-0 rounded-full border-4 border-gray-200 dark:border-gray-700"></div>
<div className="absolute inset-0 rounded-full border-4 border-t-blue-500 border-r-purple-500 border-b-pink-500 border-l-transparent animate-spin"></div>
</div>
<p className="mt-4 text-gray-600 dark:text-gray-300 font-medium">
در حال بارگذاری دورههای شما...
</p>
</div>
<div className="min-h-screen container mx-auto px-4 py-8">
<AcademyListSkeleton count={4} />
</div>
);
}

View File

@@ -29,6 +29,8 @@ import { useRouter } from "next/navigation";
import useAxios from "@/hooks/useAxios";
import { Academy } from "@/types/types";
import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
import { AcademyProfileHeadSkeleton } from "@/components/academy/AcademySkeletons";
import { Skeleton } from "@/components/ui/skeleton";
// Zod schema for store settings
const storeSchema = z.object({
@@ -197,8 +199,13 @@ export default function StoreSettings() {
// نمایش لودینگ در حین دریافت دیتا
if (isFetching) {
return (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
<div className="w-full p-6 max-w-4xl mx-auto mb-32">
<AcademyProfileHeadSkeleton />
<div className="space-y-4 mt-6">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-32 w-full" />
</div>
</div>
);
}

View File

@@ -2,6 +2,7 @@
import React, { useState, useEffect } from "react";
import { ThemeProvider, useTheme } from "next-themes";
import { Skeleton } from "@/components/ui/skeleton";
import {
Card,
@@ -211,8 +212,13 @@ const WalletDashboard = () => {
if (isLoading) {
return (
<div className="min-h-screen flex justify-center items-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-pink-500"></div>
<div className="container mx-auto p-6 space-y-4">
<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" />
<Skeleton className="h-28 w-full rounded-xl" />
</div>
<Skeleton className="h-64 w-full rounded-xl" />
</div>
);
}

View File

@@ -10,6 +10,7 @@ import {
} from "@/lib/chat/socketClient";
import MessageInput from "@/components/chat/MessageInput";
import MultiImageModal from "@/components/chat/MultiImageModal";
import MediaPreviewModal from "@/components/chat/MediaPreviewModal";
import ChatActionBar from "@/components/chat/ChatActionBar";
import ChatDeleteBar from "@/components/chat/ChatDeleteBar";
import { useUser } from "@/hooks/useUser";
@@ -26,7 +27,6 @@ import { chatThreadQueryKey } from "@/lib/chat/queryKeys";
import { normalizeThreadId, upsertMessageInThreadCache } from "@/lib/chat/threadCache";
import { useStableMessageKeys } from "@/hooks/useStableMessageKeys";
import { getStoredUserId } from "@/lib/auth/session";
import { buildExpiresAtIso } from "@/lib/chat/timedMessages";
import { isViewOnceMediaType } from "@/lib/chat/viewOnce";
import {
findPendingMatchForServer,
@@ -52,6 +52,10 @@ function TicketChat({ params }: ITicketChatProps) {
const [userTwoDetail, setUserTwoDetail] = useState<User>();
const [pendingImages, setPendingImages] = useState<File[]>([]);
const [showMultiModal, setShowMultiModal] = useState(false);
const [pendingMediaPreview, setPendingMediaPreview] = useState<{
file: File;
kind: "video" | "file";
} | null>(null);
const [pendingMessages, setPendingMessages] = useState<ChatMessage[]>([]);
const [replyingTo, setReplyingTo] = useState<ChatMessage | null>(null);
const [forwardMessage, setForwardMessage] = useState<ChatMessage | null>(null);
@@ -170,12 +174,12 @@ function TicketChat({ params }: ITicketChatProps) {
);
if (videos.length === 1 && images.length === 0 && others.length === 0) {
void processSendMessage(videos[0], "video");
setPendingMediaPreview({ file: videos[0], kind: "video" });
return;
}
if (others.length === 1 && images.length === 0 && videos.length === 0) {
void processSendMessage(others[0], "file");
setPendingMediaPreview({ file: others[0], kind: "file" });
return;
}
@@ -248,6 +252,11 @@ function TicketChat({ params }: ITicketChatProps) {
let textContent = contentOverride ?? newMessage;
if (textContent.trim() === "" && !fileToUpload) return;
if (userTwoDetail?.blocked_you || userTwoDetail?.is_blocked) {
toast.error("امکان ارسال پیام وجود ندارد.");
return;
}
const tempId = `temp-${Date.now()}-${Math.random()}`;
const replyPayload = buildReplyPayload(replySource);
@@ -265,7 +274,7 @@ function TicketChat({ params }: ITicketChatProps) {
senderId: currentUserId,
receiverId: targetReceiverId,
createdAt: new Date().toISOString(),
expiresAt: buildExpiresAtIso(selfDestructSeconds),
selfDestructSeconds: selfDestructSeconds || undefined,
status: "pending",
file: fileToUpload ? URL.createObjectURL(fileToUpload) : "",
fileType: fileType,
@@ -293,7 +302,6 @@ function TicketChat({ params }: ITicketChatProps) {
let messagePayload: Record<string, unknown> = {
content: textContent,
receiverId: targetReceiverId,
senderId: currentUserId,
};
if (replySource?._id && !replySource._id.startsWith("temp")) {
@@ -425,7 +433,7 @@ function TicketChat({ params }: ITicketChatProps) {
queryClient.setQueryData(
chatThreadQueryKey(
normalizeThreadId(user._id),
normalizeThreadId(chatPartnerId)
normalizeThreadId(userTwoDetail?._id ?? receiverId ?? chatPartnerId)
),
(oldData: { pages: { messages: ChatMessage[] }[] } | undefined) => {
if (!oldData) return oldData;
@@ -511,7 +519,7 @@ function TicketChat({ params }: ITicketChatProps) {
onLocationShare={handleLocationShare}
onTyping={handleTyping}
isChatThread
blocked_you={userTwoDetail?.blocked_you}
blocked_you={userTwoDetail?.blocked_you || userTwoDetail?.is_blocked}
replyingTo={replyingTo}
onCancelReply={() => {
setReplyingTo(null);
@@ -550,6 +558,22 @@ function TicketChat({ params }: ITicketChatProps) {
selfDestructSeconds={selfDestructSeconds}
onSelfDestructChange={setSelfDestructSeconds}
/>
<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 === "video" ? "video" : "file");
}}
viewOnceMedia={viewOnceMedia}
onViewOnceChange={setViewOnceMedia}
selfDestructSeconds={selfDestructSeconds}
onSelfDestructChange={setSelfDestructSeconds}
/>
{forwardMessage && user?._id && (
<ForwardMessageModal
open={!!forwardMessage}

View File

@@ -0,0 +1,12 @@
import type { Metadata } from "next";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
export const metadata: Metadata = generatePageMetadata({
title: "پیام‌ها | مدستاگرام",
description: "گفتگوها و پیام‌های خصوصی در مدستاگرام",
path: "/settings/chats",
});
export default function Layout({ children }: { children: React.ReactNode }) {
return children;
}

View File

@@ -30,7 +30,8 @@ export interface IMessage {
unread_messages_count?: number;
is_blocked: boolean | null;
blocked_you: boolean | null;
last_message_date?: string | null;
display_name?: string;
account_deactivated?: boolean;
}
function Chats() {
@@ -116,14 +117,15 @@ function Chats() {
<div className="h-12 w-12 rounded-2xl bg-neutral-200" />
)}
<div className="flex flex-col gap-1">
<span className="font-semibold">
{item.first_name} {item.last_name}
<span className="flex items-center gap-1 font-semibold">
{item.display_name ||
`${item.first_name} ${item.last_name}`.trim()}
<VerificationBadge isVerified={item.is_verified} />
</span>
<span className="text-neutral-500">{item.user_name}</span>
</div>
</div>
<div className="flex flex-col items-end gap-1">
<VerificationBadge isVerified={item.is_verified} />
{item.unread_messages_count ? (
<span className="rounded-full bg-[#387E65] px-2 py-0.5 text-xs text-white">
{item.unread_messages_count}

View File

@@ -93,7 +93,7 @@ function Colors() {
<button
className={`p-1 rounded-full w-full border text-sm flex items-center justify-center gap-1 ${
selectedButton === 1
? "bg-[#FC8EAC] border-[#FC8EAC]"
? "btn-modern--selected text-white"
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
}`}
onClick={() => handleButtonPress(1)}
@@ -103,7 +103,7 @@ function Colors() {
<button
className={`p-1 rounded-full w-full border text-sm flex items-center justify-center gap-1 ${
selectedButton === 2
? "bg-[#FC8EAC] border-[#FC8EAC]"
? "btn-modern--selected text-white"
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
}`}
onClick={() => handleButtonPress(2)}

View File

@@ -63,7 +63,7 @@ function CooperationType() {
onClick={() => formik.setFieldValue("selectedButton", true)}
className={`mt-5 text-xs w-full max-w-[250px] flex items-center justify-center flex-row-reverse gap-2 px-1 ${
formik.values.selectedButton === true
? "!bg-[#FC8EAC] !border-[#FC8EAC] !text-white"
? "btn-modern--selected text-white"
: ""
}`}
>
@@ -74,7 +74,7 @@ function CooperationType() {
onClick={() => formik.setFieldValue("selectedButton", false)}
className={`mt-5 text-xs w-full max-w-[250px] flex items-center justify-center flex-row-reverse gap-2 px-1 ${
formik.values.selectedButton === false
? "!bg-[#FC8EAC] !border-[#FC8EAC] !text-white"
? "btn-modern--selected text-white"
: ""
}`}
>

View File

@@ -0,0 +1,12 @@
import type { Metadata } from "next";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
export const metadata: Metadata = generatePageMetadata({
title: "ویرایش پروفایل | مدستاگرام",
description: "ویرایش اطلاعات و تنظیمات پروفایل در مدستاگرام",
path: "/settings/edit",
});
export default function Layout({ children }: { children: React.ReactNode }) {
return children;
}

View File

@@ -69,7 +69,7 @@ function PublicRelations() {
onClick={() => formik.setFieldValue("selectedButton", true)}
className={`mt-5 text-xs w-full max-w-[250px] flex items-center justify-center flex-row-reverse gap-2 px-1 ${
formik.values.selectedButton === true
? "!bg-[#FC8EAC] !border-[#FC8EAC] !text-white"
? "btn-modern--selected text-white"
: ""
}`}
>
@@ -80,7 +80,7 @@ function PublicRelations() {
onClick={() => formik.setFieldValue("selectedButton", false)}
className={`mt-5 text-xs w-full max-w-[250px] flex items-center justify-center flex-row-reverse gap-2 px-1 ${
formik.values.selectedButton === false
? "!bg-[#FC8EAC] !border-[#FC8EAC] !text-white"
? "btn-modern--selected text-white"
: ""
}`}
>

View File

@@ -82,7 +82,7 @@ function Sizes() {
<button
className={`p-1 rounded-full w-1/3 border text-sm flex items-center justify-center gap-1 ${
selectedButton === 1
? "bg-[#FC8EAC] border-[#FC8EAC]"
? "btn-modern--selected text-white"
: "bg-tertiary-light dark:bg-tertiary-dark text-black border-gray-400"
}`}
onClick={() => handleButtonPress(1)}
@@ -99,7 +99,7 @@ function Sizes() {
<button
className={`p-1 rounded-full w-1/3 border text-sm flex items-center justify-center gap-1 ${
selectedButton === 2
? "bg-[#FC8EAC] border-[#FC8EAC]"
? "btn-modern--selected text-white"
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
}`}
onClick={() => handleButtonPress(2)}
@@ -116,7 +116,7 @@ function Sizes() {
<button
className={`p-1 rounded-full w-1/3 border text-sm flex items-center justify-center gap-1 ${
selectedButton === 3
? "bg-[#FC8EAC] border-[#FC8EAC]"
? "btn-modern--selected text-white"
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
}`}
onClick={() => handleButtonPress(3)}
@@ -159,7 +159,7 @@ function Sizes() {
key={sizeOption}
className={`p-2 py-1 rounded-full border text-sm full ${
size === sizeOption
? "bg-[#FC8EAC] text-white border-[#FC8EAC]"
? "btn-modern--selected text-white"
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
}`}
onClick={() => handleSizeSelection(sizeOption)}

View File

@@ -0,0 +1,20 @@
import Container from "@/components/elements/Container";
import PageTitle from "@/components/settings/PageTitle";
import FavoritesGrid from "@/components/settings/FavoritesGrid";
import { Metadata } from "next";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
export const metadata: Metadata = generatePageMetadata({
title: "علاقمندی‌ها | مدستاگرام",
description: "پست‌های ذخیره‌شده در علاقمندی‌های شما در مدستاگرام",
path: "/settings/favorites",
});
export default function FavoritesPage() {
return (
<Container>
<PageTitle>علاقمندیها</PageTitle>
<FavoritesGrid />
</Container>
);
}

View File

@@ -0,0 +1,12 @@
import type { Metadata } from "next";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
export const metadata: Metadata = generatePageMetadata({
title: "امور مالی | مدستاگرام",
description: "تراکنش‌ها و امور مالی حساب کاربری در مدستاگرام",
path: "/settings/financial",
});
export default function Layout({ children }: { children: React.ReactNode }) {
return children;
}

View File

@@ -3,8 +3,7 @@ import type { Metadata } from "next";
export const metadata: Metadata = {
title: {
default: "تنظیمات",
template: "%s | مدستاگرام",
default: "تنظیمات | مدستاگرام",
},
description: "تنظیمات حساب کاربری در مدستاگرام",
robots: { index: false, follow: false },

View File

@@ -0,0 +1,12 @@
import type { Metadata } from "next";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
export const metadata: Metadata = generatePageMetadata({
title: "بیلبوردهای من | مدستاگرام",
description: "مدیریت بیلبوردهای تبلیغاتی شما در مدستاگرام",
path: "/settings/my-billboards",
});
export default function Layout({ children }: { children: React.ReactNode }) {
return children;
}

View File

@@ -0,0 +1,12 @@
import type { Metadata } from "next";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
export const metadata: Metadata = generatePageMetadata({
title: "اعلان‌ها | مدستاگرام",
description: "اعلان‌ها و پیام‌های سیستمی حساب کاربری در مدستاگرام",
path: "/settings/notifications",
});
export default function Layout({ children }: { children: React.ReactNode }) {
return children;
}

View File

@@ -9,6 +9,50 @@ import Image from "next/image";
import Link from "next/link";
import React, { useEffect, useState } from "react";
const LIKE_NOTIFICATION_TYPES = new Set([
"post_like",
"academy_like",
"billboard_like",
]);
const COMMENT_NOTIFICATION_TYPES = new Set([
"post_comment",
"profile_comment",
"user_comment",
"academy_comment",
"billboard_comment",
"vitrine-comment",
]);
const RATING_NOTIFICATION_TYPES = new Set([
"post_rating",
"profile_rating",
"academy_rating",
"billboard_rating",
]);
function getNotificationTitle(item: INotification): string {
if (LIKE_NOTIFICATION_TYPES.has(item.type)) return "پست شما لایک شد";
if (COMMENT_NOTIFICATION_TYPES.has(item.type)) {
return "یک کامنت برای شما ثبت شد";
}
if (RATING_NOTIFICATION_TYPES.has(item.type)) {
return "یک امتیاز برای شما ثبت شد";
}
return item.title;
}
function getNotificationDescription(item: INotification): string {
if (LIKE_NOTIFICATION_TYPES.has(item.type)) return "پست شما لایک شد";
if (COMMENT_NOTIFICATION_TYPES.has(item.type)) {
return "یک کامنت برای شما ثبت شد";
}
if (RATING_NOTIFICATION_TYPES.has(item.type)) {
return "یک امتیاز برای شما ثبت شد";
}
return item.description;
}
export interface INotification {
_id: string;
userId: string;
@@ -61,9 +105,14 @@ function Notifications() {
academy_like: `/academy/${item?.project_post_id}/course`,
billboard_like: `/settings/my-billboards/${item?.project_post_id}/b`,
post_comment: `/posts/${item?.project_post_id}`,
post_rating: `/posts/${item?.project_post_id}`,
post_tag: `/posts/${item?.project_post_id}`,
profile_comment: "/settings/profile",
profile_rating: "/settings/profile",
user_comment: "/settings/profile",
academy_comment: `/academy/${item?.project_post_id}/course`,
academy_rating: `/academy/${item?.project_post_id}/course`,
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/${
@@ -115,11 +164,11 @@ function Notifications() {
) : (
""
)}
<p>{item?.title}</p>
<p>{getNotificationTitle(item)}</p>
</div>
<span>{item?.createdAt}</span>
</div>
<p className="mt-4">{item?.description}</p>
<p className="mt-4">{getNotificationDescription(item)}</p>
</Link>
))}
</React.Fragment>

View File

@@ -0,0 +1,12 @@
import type { Metadata } from "next";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
export const metadata: Metadata = generatePageMetadata({
title: "درخواست‌های همکاری | مدستاگرام",
description: "مدیریت درخواست‌ها و پیشنهادهای همکاری در مدستاگرام",
path: "/settings/offers",
});
export default function Layout({ children }: { children: React.ReactNode }) {
return children;
}

View File

@@ -10,6 +10,8 @@ import UserDetails from "@/components/settings/UserDetails";
import useInfiniteScroll from "@/hooks/useInfiniteScroll";
import { IOffers } from "@/types/types";
import React, { useState } from "react";
import { toggleBtnClass } from "@/lib/ui/buttonStyles";
import { cn } from "@/lib/utils";
function Offers() {
const [filter, setFilter] = useState<string>("درخواست");
@@ -41,17 +43,13 @@ function Offers() {
<UserDetails />
<div className="grid grid-cols-2 w-full gap-4 mx-auto max-w-md mt-8">
<RoundedButton
className={`h-9 !border-[#0C8002] ${
filter == "درخواست" ? "bg-[#0C8002] text-white" : ""
}`}
className={cn(toggleBtnClass(filter == "درخواست"), "h-9")}
onClick={() => setFilter("درخواست")}
>
درخواست
</RoundedButton>
<RoundedButton
className={`h-9 !border-[#FF0000] ${
filter == "دریافت" ? "bg-[#FF000077] text-white" : ""
}`}
className={cn(toggleBtnClass(filter == "دریافت"), "h-9")}
onClick={() => setFilter("دریافت")}
>
دریافت
@@ -68,6 +66,7 @@ function Offers() {
<OffersItem
key={item?._id}
item={item}
mode={filter === "دریافت" ? "received" : "sent"}
actionHandler={actionHandler}
/>
))}
@@ -88,6 +87,7 @@ function Offers() {
isOpen={showConfirmModal}
onClose={() => setShowConfirmModal(false)}
item={itemToAction}
mode="received"
refetch={refetch}
/>
)}

View File

@@ -9,6 +9,14 @@ import { staticIconUrl } from "@/components/main/BaseUrl";
import Image from "next/image";
import Link from "next/link";
import React from "react";
import type { Metadata } from "next";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
export const metadata: Metadata = generatePageMetadata({
title: "تنظیمات | مدستاگرام",
description: "تنظیمات حساب کاربری در مدستاگرام",
path: "/settings",
});
function Settings() {
return (

View File

@@ -0,0 +1,46 @@
"use client";
import React, { useEffect, useState } from "react";
import Container from "@/components/elements/Container";
import ModelHead from "@/components/models/ModelPage/ModelHead";
import useAxios from "@/hooks/useAxios";
import ModelContent from "@/components/models/ModelPage/ModelContent";
import { User } from "@/types/types";
export default function ProfileClient() {
const [user, setUser] = useState<User | null>(null);
const { request, loading } = useAxios();
const fetchUser = async () => {
try {
const response = await request<{ user: User }>("GET", "/profile");
setUser(response?.user ?? null);
} catch (err) {
console.error("خطا در دریافت اطلاعات کاربر:", err);
}
};
useEffect(() => {
fetchUser();
}, []);
if (loading || !user) {
return (
<div className="flex h-screen items-center justify-center" />
);
}
return (
<Container>
<ModelHead
item2={{
postsCount: user.postsCount ?? 0,
followersCount: user.followersCount ?? 0,
followingCount: user.followingCount ?? 0,
}}
user={user}
/>
<ModelContent user={user} searchParams={{}} />
</Container>
);
}

View File

@@ -24,13 +24,13 @@ function ProfileInfo() {
<ModelHead
item2={{
postsCount: user?.postsCount ?? 0,
allProjectsCount: user?.allProjectsCount ?? 0,
successfulProjectsCount: user?.successfulProjectsCount ?? 0,
followersCount: user?.followersCount ?? 0,
followingCount: user?.followingCount ?? 0,
}}
user={user ?? ({} as User)}
/>
<ProfileContent user={user} />
<ProfileContent user={user} onRefresh={fetchStates} />
</Container>
);
}

View File

@@ -1,21 +1,7 @@
export const metadata = {
title: "پروفایل کاربر - مدستاگرام",
description: "مشاهده بیو، تخصص، و نمونه کارهای کاربر در پلتفرم مدستاگرام.",
other: {
"script:type": "application/ld+json",
"script:data": JSON.stringify({
"@context": "https://schema.org",
"@type": "Person",
"name": "نام کاربر",
"jobTitle": "تخصص کاربر",
"description": "بیوگرافی، تخصص و سطح فعالیت در مدستاگرام.",
"image": "https://modstagram.com/images/user-avatar.jpg",
"inLanguage": "fa"
})
}
};
export default function ProfileLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
export default function ProfileLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}

View File

@@ -1,57 +1,52 @@
"use client";
import React, { useEffect, useState } from "react";
import { cookies } from "next/headers";
import { Metadata } from "next";
import ProfileClient from "./ProfileClient";
import { BASE_URL } from "@/components/main/BaseUrl";
import { User } from "@/types/types";
import Container from "@/components/elements/Container";
import ModelHead from "@/components/models/ModelPage/ModelHead";
import useAxios from "@/hooks/useAxios";
import ModelContent from "@/components/models/ModelPage/ModelContent";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
function Profile() {
const [user, setUser] = useState<User | null>(null);
const { request, loading } = useAxios();
const fetchUser = async () => {
try {
const response = await request<{ user: User }>("GET", "/profile");
setUser(response?.user ?? null);
} catch (err) {
console.error("خطا در دریافت اطلاعات کاربر:", err);
export async function generateMetadata(): Promise<Metadata> {
const token = (await cookies()).get("token")?.value || "";
try {
const res = await fetch(`${BASE_URL}/profile`, {
cache: "no-store",
headers: { Authorization: token ? `Bearer ${token}` : "" },
});
const data = await res.json();
const user = data?.user as User;
if (!user) {
return generatePageMetadata({
title: "پروفایل | مدستاگرام",
description: "پروفایل کاربری در مدستاگرام",
path: "/settings/profile",
});
}
};
const fullName = `${user.first_name || ""} ${user.last_name || ""}`.trim();
const expertise = user.expertise || "";
const city = user.city?.name || "";
const bio = user.bio || "";
const username = user.user_name || "";
useEffect(() => {
fetchUser();
}, []);
const title = [username, fullName, expertise, city, "مدستاگرام"]
.filter(Boolean)
.join(" ");
const description = [fullName, expertise, bio].filter(Boolean).join(" ");
// لودینگ
if (loading || !user) {
return (
<div className="flex justify-center items-center h-screen">
</div>
);
return generatePageMetadata({
title,
description,
path: "/settings/profile",
type: "profile",
});
} catch {
return generatePageMetadata({
title: "پروفایل | مدستاگرام",
description: "پروفایل کاربری در مدستاگرام",
path: "/settings/profile",
});
}
return (
<Container>
<ModelHead
item2={{
postsCount: user.postsCount ?? 0,
allProjectsCount: user.allProjectsCount ?? 0,
successfulProjectsCount: user.successfulProjectsCount ?? 0,
}}
user={user}
/>
<ModelContent user={user} searchParams={{}} />
</Container>
);
}
export default Profile;
export default function ProfilePage() {
return <ProfileClient />;
}

View File

@@ -0,0 +1,12 @@
import type { Metadata } from "next";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
export const metadata: Metadata = generatePageMetadata({
title: "تنظیمات پروفایل | مدستاگرام",
description: "تنظیمات نمایش و حریم خصوصی پروفایل در مدستاگرام",
path: "/settings/profile/user-settings",
});
export default function Layout({ children }: { children: React.ReactNode }) {
return children;
}

View File

@@ -0,0 +1,144 @@
"use client";
import React, { useEffect, useState } from "react";
import Container from "@/components/elements/Container";
import PageTitle from "@/components/settings/PageTitle";
import useAxios from "@/hooks/useAxios";
import Link from "next/link";
import toast from "react-hot-toast";
export default function UserSettingsPage() {
const { request } = useAxios();
const [allowSave, setAllowSave] = useState(true);
const [ghostMode, setGhostMode] = useState(false);
const [loading, setLoading] = useState(true);
const [accountStatus, setAccountStatus] = useState<string>("active");
const [reactivationLockedUntil, setReactivationLockedUntil] = useState<string | null>(null);
const [reactivating, setReactivating] = useState(false);
useEffect(() => {
(async () => {
try {
const res = await request<{
account: {
allow_save_posts?: boolean;
ghost_mode?: boolean;
account_status?: string;
reactivation_locked_until?: string;
};
}>("GET", "/account/status", null, { noToast: true });
setAllowSave(res?.account?.allow_save_posts !== false);
setGhostMode(Boolean(res?.account?.ghost_mode));
setAccountStatus(res?.account?.account_status || "active");
setReactivationLockedUntil(res?.account?.reactivation_locked_until || null);
} finally {
setLoading(false);
}
})();
}, [request]);
const savePrivacy = async (patch: { allow_save_posts?: boolean; ghost_mode?: boolean }) => {
try {
const res = await request<{ allow_save_posts: boolean; ghost_mode: boolean }>(
"PATCH",
"/account/privacy",
patch,
{ noToast: true }
);
setAllowSave(res.allow_save_posts);
setGhostMode(res.ghost_mode);
toast.success("ذخیره شد");
} catch {
toast.error("خطا در ذخیره");
}
};
const reactivate = async () => {
setReactivating(true);
try {
await request("POST", "/account/reactivate", {});
setAccountStatus("active");
toast.success("حساب کاربری فعال شد");
} catch (err) {
toast.error(err instanceof Error ? err.message : "امکان فعال‌سازی نیست");
} finally {
setReactivating(false);
}
};
const lockActive =
accountStatus === "deactivated" &&
reactivationLockedUntil &&
new Date(reactivationLockedUntil) > new Date();
return (
<Container>
<PageTitle>تنظیمات کاربر</PageTitle>
<div className="space-y-4 px-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">
حساب شما غیرفعال است
</p>
{lockActive ? (
<p className="mt-1 text-xs text-amber-700 dark:text-amber-300">
تا{" "}
{new Date(reactivationLockedUntil!).toLocaleDateString("fa-IR")}{" "}
امکان فعالسازی مجدد وجود ندارد.
</p>
) : (
<button
type="button"
disabled={reactivating}
onClick={reactivate}
className="mt-3 rounded-lg bg-[#0095f6] px-4 py-2 text-xs font-bold text-white disabled:opacity-50"
>
{reactivating ? "…" : "فعال‌سازی مجدد حساب"}
</button>
)}
</div>
)}
<Link
href="/settings/profile/visitors"
className="flex items-center justify-between rounded-xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"
>
<span className="font-semibold">بازدیدکنندگان</span>
<span className="text-neutral-400"></span>
</Link>
<label className="flex items-center justify-between rounded-xl border border-neutral-200 px-4 py-3 dark:border-neutral-800">
<div>
<p className="font-semibold">ذخیره پستها</p>
<p className="mt-0.5 text-xs text-neutral-500">
اجازه ذخیره و دانلود پستهای شما برای دیگران
</p>
</div>
<input
type="checkbox"
checked={allowSave}
disabled={loading}
onChange={(e) => savePrivacy({ allow_save_posts: e.target.checked })}
className="h-5 w-5 accent-[#0095f6]"
/>
</label>
<label className="flex items-center justify-between rounded-xl border border-neutral-200 px-4 py-3 dark:border-neutral-800">
<div>
<p className="font-semibold">حالت روح</p>
<p className="mt-0.5 text-xs text-neutral-500">
پنهان کردن وضعیت آنلاین و بازدید پروفایل
</p>
</div>
<input
type="checkbox"
checked={ghostMode}
disabled={loading}
onChange={(e) => savePrivacy({ ghost_mode: e.target.checked })}
className="h-5 w-5 accent-[#0095f6]"
/>
</label>
</div>
</Container>
);
}

View File

@@ -0,0 +1,12 @@
import type { Metadata } from "next";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
export const metadata: Metadata = generatePageMetadata({
title: "بازدیدکنندگان پروفایل | مدستاگرام",
description: "لیست بازدیدکنندگان پروفایل شما در مدستاگرام",
path: "/settings/profile/visitors",
});
export default function Layout({ children }: { children: React.ReactNode }) {
return children;
}

View File

@@ -0,0 +1,88 @@
"use client";
import React, { useEffect, useState } from "react";
import Container from "@/components/elements/Container";
import PageTitle from "@/components/settings/PageTitle";
import useAxios from "@/hooks/useAxios";
import Image from "next/image";
import Link from "next/link";
import { buildStorageUrl } from "@/components/main/BaseUrl";
interface Visitor {
_id: string;
user_name: string;
first_name: string;
last_name: string;
profile_image?: string;
visited_at?: string;
}
export default function ProfileVisitorsPage() {
const { request } = useAxios();
const [visitors, setVisitors] = useState<Visitor[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
(async () => {
try {
const res = await request<{ visitors: Visitor[] }>(
"GET",
"/account/visitors?limit=50",
null,
{ noToast: true }
);
setVisitors(res?.visitors || []);
} finally {
setLoading(false);
}
})();
}, [request]);
return (
<Container>
<PageTitle>بازدیدکنندگان پروفایل</PageTitle>
<div className="px-4 pb-8">
{loading ? (
<p className="py-8 text-center text-sm text-neutral-500">در حال بارگذاری</p>
) : visitors.length === 0 ? (
<p className="py-8 text-center text-sm text-neutral-500">
هنوز بازدیدی ثبت نشده
</p>
) : (
<ul className="space-y-1">
{visitors.map((v) => {
const name =
[v.first_name, v.last_name].filter(Boolean).join(" ") || v.user_name;
return (
<li key={`${v._id}-${v.visited_at}`}>
<Link
href={`/users/${v.user_name}`}
className="flex items-center gap-3 rounded-xl px-2 py-2 hover:bg-neutral-100 dark:hover:bg-neutral-900"
>
<div className="relative h-11 w-11 overflow-hidden rounded-full bg-neutral-200">
<Image
src={
v.profile_image
? buildStorageUrl(v.profile_image)
: "/images/fake-avatar.png"
}
alt=""
fill
className="object-cover"
unoptimized
/>
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">{name}</p>
<p className="truncate text-xs text-neutral-500">@{v.user_name}</p>
</div>
</Link>
</li>
);
})}
</ul>
)}
</div>
</Container>
);
}

View File

@@ -0,0 +1,12 @@
import type { Metadata } from "next";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
export const metadata: Metadata = generatePageMetadata({
title: "تیکت‌های پشتیبانی | مدستاگرام",
description: "ارسال و پیگیری تیکت‌های پشتیبانی مدستاگرام",
path: "/settings/tickets",
});
export default function Layout({ children }: { children: React.ReactNode }) {
return children;
}

View File

@@ -0,0 +1,12 @@
import type { Metadata } from "next";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
export const metadata: Metadata = generatePageMetadata({
title: "اتاق کار | مدستاگرام",
description: "مدیریت پروژه‌ها و اتاق کار در مدستاگرام",
path: "/settings/workroom",
});
export default function Layout({ children }: { children: React.ReactNode }) {
return children;
}

View File

@@ -1,9 +1,23 @@
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
}
}
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = 'https://modstagram.com'
// ۱. صفحات اصلی و کلیدی
const staticRoutes = ['', '/videos', '/billboards', '/users', '/posts'].map((route) => ({
url: `${baseUrl}${route}`,
lastModified: new Date(),
@@ -11,86 +25,42 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
priority: route === '' ? 1.0 : 0.9,
}))
try {
// ۲. واکشی بیلبوردها (هماهنگ با منطق جدید URL اسلاگ)
const billboardsRes = await fetch('https://api.modstagram.ir/advertising/get/all/web', {
next: { revalidate: 3600 }
})
const billboardData = await billboardsRes.json()
const billboards = billboardData?.advertisings || []
const billboardRoutes = billboards.map((b: any) => {
// ساخت اسلاگ دقیقاً مشابه منطق استفاده شده در صفحه جزییات بیلبورد
const titleParts = [
b.title,
b.category,
b.province?.name,
b.city?.name,
b.neighbourhood !== b.city?.name ? b.neighbourhood : null,
].filter(Boolean);
const slug = titleParts.join(" ").trim().replace(/\s+/g, '-').replace(/-+/g, '-');
const billboardData = await fetchJsonSafe<{ advertisings?: Array<Record<string, unknown>> }>(
`${API_BASE}/advertising/web?page=1&limit=200`
)
const billboards = billboardData?.advertisings || []
return {
url: `${baseUrl}/billboards/${b._id}/${encodeURIComponent(slug)}`,
lastModified: new Date(b.updatedAt || new Date()),
priority: 0.8,
}
})
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 usersRes = await fetch('https://api.modstagram.ir/users/get/all/web', {
next: { revalidate: 3600 }
})
const userData = await usersRes.json()
const users = userData?.users || []
const slug = String(titleParts.join(' '))
.trim()
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
const userRoutes = users.map((u: any) => ({
url: `${baseUrl}/users/${u.username}`,
lastModified: new Date(), // یا تاریخ آخرین فعالیت کاربر
priority: 0.6,
}))
// ۴. واکشی ویدیوها (اگر API جداگانه دارند)
const videosRes = await fetch('https://api.modstagram.ir/videos/get/all/web', {
next: { revalidate: 3600 }
}).catch(() => null)
let videoRoutes: any[] = []
if (videosRes) {
const videoData = await videosRes.json()
const videos = videoData?.videos || []
videoRoutes = videos.map((v: any) => ({
url: `${baseUrl}/videos/${v._id}/${encodeURIComponent(v.title.replace(/\s+/g, '-'))}`,
lastModified: new Date(v.updatedAt || new Date()),
priority: 0.7,
}))
return {
url: `${baseUrl}/billboards/${b._id}/${encodeURIComponent(slug)}`,
lastModified: new Date(String(b.updatedAt || new Date())),
priority: 0.8,
}
})
// ۵. پست‌ها — از فید عمومی
let postRoutes: MetadataRoute.Sitemap = []
try {
const postsRes = await fetch(
'https://app.modstagram.ir/api/v1/users/web?page=1&limit=100',
{ next: { revalidate: 3600 } }
)
if (postsRes.ok) {
const postsData = await postsRes.json()
const posts = postsData?.posts || []
postRoutes = posts.map((p: { _id: string; updatedAt?: string }) => ({
url: `${baseUrl}/posts/${p._id}`,
lastModified: new Date(p.updatedAt || new Date()),
changeFrequency: 'weekly' as const,
priority: 0.65,
}))
}
} catch {
/* optional */
}
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,
}))
return [...staticRoutes, ...billboardRoutes, ...userRoutes, ...videoRoutes, ...postRoutes]
} catch (error) {
console.error("Sitemap error:", error)
return staticRoutes
}
}
return [...staticRoutes, ...billboardRoutes, ...postRoutes]
}

View File

@@ -4,5 +4,5 @@ type RouteContext = { params: Promise<{ path: string[] }> };
export async function GET(req: Request, context: RouteContext) {
const { path } = await context.params;
return proxyToUpstream(req, `/storage/${path.join("/")}`);
return proxyToUpstream(req, `/storage/${path.join("/")}`, { cacheable: true });
}

View File

@@ -1,4 +1,4 @@
import { BASE_URL, IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
import { buildStorageUrl } from "@/components/main/BaseUrl";
import React from "react";
import { cookies } from "next/headers";
import { User } from "@/types/types";
@@ -7,32 +7,50 @@ import ModelHead from "@/components/models/ModelPage/ModelHead";
import ModelContent from "@/components/models/ModelPage/ModelContent";
import ProfileVisitTracker from "@/components/explore/ProfileVisitTracker";
import { Metadata } from "next";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
import { fetchApiJson } from "@/lib/api/fetchApiJson";
interface IUserProps {
params: Promise<{ username: string }>;
}
type UserWebResponse = { user?: User };
export const dynamic = "force-dynamic";
async function loadUser(username: string, token: string) {
const { data } = await fetchApiJson<UserWebResponse>(
`/users/get/web?user_name=${encodeURIComponent(username)}`,
{
headers: token ? { Authorization: `Bearer ${token}` } : {},
}
);
return data?.user ?? null;
}
export async function generateMetadata({ params }: IUserProps): Promise<Metadata> {
const { username } = await params;
const token = (await cookies()).get("token")?.value || "";
try {
const res = await fetch(`${BASE_URL}/users/get/web?user_name=${username}`, {
cache: "no-store",
headers: { Authorization: token ? `Bearer ${token}` : "" },
});
const data = await res.json();
const user = data?.user as User;
const user = await loadUser(username, token);
if (!user) {
return { title: `${username} | مدستاگرام`, robots: "noindex" };
return generatePageMetadata({
title: `${username} | مدستاگرام`,
description: `پروفایل ${username} در مدستاگرام`,
path: `/users/${username}`,
type: "profile",
});
}
if (user.blocked_you) {
return {
return generatePageMetadata({
title: "پروفایل در دسترس نیست | مدستاگرام",
robots: "noindex, nofollow",
};
description: "این پروفایل در دسترس نیست.",
path: `/users/${username}`,
type: "profile",
});
}
const fullName = `${user.first_name || ""} ${user.last_name || ""}`.trim();
@@ -40,47 +58,27 @@ export async function generateMetadata({ params }: IUserProps): Promise<Metadata
const expertise = user.expertise || "متخصص";
const bio = user.bio || "پروفایل و نمونه‌کارهای حرفه‌ای در مدستاگرام";
const title = `${fullName} - ${username} - ${expertise} - ${city} - مدستاگرام`;
const description = `${expertise} - ${city} - ${bio}`.slice(0, 160);
const title = [username, fullName, expertise, city, "مدستاگرام"]
.filter(Boolean)
.join(" ");
const description = [fullName, expertise, bio].filter(Boolean).join(" ");
const profileImage = user.profile_image ? buildStorageUrl(user.profile_image) : "/images/logo.png";
return {
title, // حفظ تایتل درخواستی شما
description, // حفظ دیسکریپشن درخواستی شما
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-image-preview": "large",
"max-snippet": -1,
},
},
alternates: {
canonical: `https://modstagram.com/users/${username}`,
},
openGraph: {
title,
description,
url: `https://modstagram.com/users/${username}`,
siteName: "مدستاگرام",
images: [{ url: profileImage, width: 800, height: 600, alt: fullName }],
locale: "fa_IR",
type: "profile",
firstName: user.first_name || undefined,
lastName: user.last_name || undefined,
username: username,
},
twitter: {
card: "summary_large_image",
title,
description,
images: [profileImage],
},
};
} catch (e) {
return { title: `${username} | مدستاگرام` };
return generatePageMetadata({
title,
description,
path: `/users/${username}`,
type: "profile",
imageUrl: profileImage,
imageAlt: fullName || username,
});
} catch {
return generatePageMetadata({
title: `${username} | مدستاگرام`,
description: `پروفایل ${username} در مدستاگرام`,
path: `/users/${username}`,
type: "profile",
});
}
}
@@ -88,18 +86,7 @@ async function UserPage({ params }: IUserProps) {
const { username } = await params;
const token = (await cookies()).get("token")?.value || "";
const response = await fetch(
`${BASE_URL}/users/get/web?user_name=${username}`,
{
cache: "no-store",
headers: {
Authorization: token ? `Bearer ${token}` : "",
},
}
);
const data = await response.json();
const user = data.user as User;
const user = await loadUser(username, token);
if (!user) {
return (
@@ -162,8 +149,8 @@ async function UserPage({ params }: IUserProps) {
<ModelHead
item2={{
postsCount: user?.postsCount,
allProjectsCount: user?.allProjectsCount,
successfulProjectsCount: user?.successfulProjectsCount,
followersCount: user?.followersCount,
followingCount: user?.followingCount,
}}
user={user}
/>

View File

@@ -5,6 +5,8 @@ import { ReactQueryProvider } from "@/providers/ReactQueryProvider";
import { Toaster } from "react-hot-toast";
import BackgroundPrefetch from "@/components/BackgroundPrefetch";
import TitleGuardian from "@/components/TitleGuardian";
import AuthSessionSync from "@/components/auth/AuthSessionSync";
import PwaInstallPrompt from "@/components/pwa/PwaInstallPrompt";
interface ILayoutProps {
children: React.ReactNode;
@@ -15,6 +17,8 @@ function Layout({ children }: ILayoutProps) {
<ReactQueryProvider>
<TitleGuardian />
<Toaster />
<AuthSessionSync />
<PwaInstallPrompt />
<BackgroundPrefetch />
{children}
</ReactQueryProvider>

View File

@@ -132,7 +132,7 @@ console.log(categories);
{formik.touched.images && formik.errors.images && (
<p className="text-red-500 text-xs">{formik.errors.images}</p>
)}
<RoundedButton type="submit" className="p-2 px-8 rounded mt-4">
<RoundedButton type="submit" variant="primary" className="mt-4 px-8 py-2">
ثبت و ادامه
</RoundedButton>
</form>

View File

@@ -62,7 +62,7 @@ const Step2 = ({ nextStep }: { nextStep: () => void }) => {
)} */}
<hr />
<div className="flex gap-4 mb-4 ">
<RoundedButton type="submit" className="w-32 rounded h-9">
<RoundedButton type="submit" variant="primary" className="h-9 w-32">
ثبت و ادامه
</RoundedButton>
<RoundedButton

View File

@@ -164,7 +164,7 @@ const Step3 = ({ nextStep }: { nextStep: () => void }) => {
typeof formik.errors.selectedFeatures === "string" && (
<p className="text-red-500">{formik.errors.selectedFeatures}</p>
)}
<RoundedButton type="submit" className="w-32 rounded h-9 mt-4">
<RoundedButton type="submit" variant="primary" className="mt-4 h-9 w-32">
ثبت و ادامه
</RoundedButton>
</form>

View File

@@ -181,7 +181,7 @@ const Step4 = ({ nextStep }: { nextStep: () => void }) => {
</label>
<hr />
<RoundedButton type="submit" className="p-2 px-8 rounded mt-4">
<RoundedButton type="submit" variant="primary" className="mt-4 px-8 py-2">
ثبت و ادامه
</RoundedButton>
</form>

View File

@@ -10,6 +10,7 @@ import useAxios from "@/hooks/useAxios";
import { IAdvertisingType } from "@/types/types";
import { sampleAds, sampleAdsHighlight, sampleAdsSpecial } from "@/constants";
import RoundedDiv from "@/components/elements/RoundedDiv";
import { selectionCardClass } from "@/lib/ui/buttonStyles";
import { useRouter } from "next/navigation";
import MainBillboardCard from "../MainBillboardCard/MainBillboardCard";
import { dataURLtoBlob } from "@/helpers/helpers";
@@ -247,9 +248,7 @@ const Step5 = () => {
}
/>
<RoundedDiv
className={`w-full max-w-full p-2 ${
selectedType == item?.name ? "bg-[#FC8EAC]" : ""
}`}
className={selectionCardClass(selectedType == item?.name)}
>
{item.name === "normal"
? "نمایش ساده"
@@ -271,7 +270,7 @@ const Step5 = () => {
{formik.touched.selectedType && formik.errors.selectedType && (
<p className="text-red-500">{formik.errors.selectedType}</p>
)}
<RoundedButton type="submit" className="p-2 px-8 rounded mt-4">
<RoundedButton type="submit" variant="primary" className="mt-4 px-8 py-2">
ثبت درخواست
</RoundedButton>
</form>

View File

@@ -4,6 +4,7 @@ import Link from "next/link";
import { useEffect, useState } from "react";
import BoldIcon from "@/components/ui/BoldIcon";
import { cn } from "@/lib/utils";
import { PAGE_SHELL_CLASS } from "@/constants/pageLayout";
const tabs = [
{ href: "/", icon: "home-2", label: "خانه" },
@@ -43,8 +44,13 @@ export default function TabNavigation({ currentPage }: TabNavigationProps) {
if (!mounted) return null;
return (
<nav className="fixed bottom-2 left-1/2 z-50 w-full max-w-xl -translate-x-1/2 px-4 pb-2">
<div className="glass-panel gentle-transition flex justify-evenly rounded-full border border-white/40 p-2.5 shadow-lg dark:border-white/10">
<nav
className={cn(
"fixed bottom-2 left-1/2 z-50 -translate-x-1/2 pb-2",
PAGE_SHELL_CLASS
)}
>
<div className="glass-panel gentle-transition flex justify-evenly rounded-full border border-[#C3C3C3]/60 p-2.5 shadow-lg dark:border-white/10">
{tabs.map((tab) => {
const isActive =
currentPage === tab.href ||
@@ -58,11 +64,7 @@ export default function TabNavigation({ currentPage }: TabNavigationProps) {
<Link
key={tab.href}
href={tab.href}
className={cn(
"gentle-transition flex flex-col items-center gap-0.5 rounded-2xl px-2 py-1",
"hover:bg-black/5 active:scale-95 dark:hover:bg-white/10",
isActive && "tab-active-glass scale-105"
)}
className="gentle-transition flex flex-col items-center gap-0.5 px-2 py-1 active:scale-95"
>
<BoldIcon
name={tab.icon}

View File

@@ -2,13 +2,16 @@
import React, { useState, useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { motion } from "framer-motion";
import { Filter, X } from "lucide-react";
import Image from "next/image";
import toast from "react-hot-toast";
import BoldIcon from "@/components/ui/BoldIcon";
import useAxios from "@/hooks/useAxios";
import { getCreateAcademyPath } from "@/lib/getCreateContentPath";
import AcademyFilterModal, {
AcademyFilterValues,
} from "./AcademyFilterModal";
interface AcademyFilterProps {
onFilterChange?: (filters: any) => void;
onFilterChange?: (filters: Record<string, unknown>) => void;
}
interface IAcademyCategory {
@@ -33,6 +36,10 @@ interface CategoryOption {
color?: string;
}
const TOOLBAR_ICON_SIZE = 25;
const toolbarIconButtonClass =
"inline-flex h-[25px] w-[25px] items-center justify-center shrink-0 p-0 leading-none";
const sortOptions = [
{ value: "createdAt_desc", label: "جدیدترین" },
{ value: "createdAt_asc", label: "قدیمی‌ترین" },
@@ -45,32 +52,68 @@ const courseTypes = [
{ value: "", label: "همه دوره‌ها" },
{ value: "normal", label: "معمولی" },
{ value: "pro", label: "پرو" },
{ value: "free", label: "رایگان" },
];
const emptyFilters: AcademyFilterValues = {
search: "",
category: "",
minPrice: "",
maxPrice: "",
hasOffer: false,
type: "",
sortBy: "createdAt_desc",
};
function buildFinalFilters(filters: AcademyFilterValues) {
let sortBy = "createdAt";
let sortOrder = "desc";
if (filters.sortBy === "createdAt_asc") {
sortBy = "createdAt";
sortOrder = "asc";
} else if (filters.sortBy === "price_asc") {
sortBy = "price";
sortOrder = "asc";
} else if (filters.sortBy === "price_desc") {
sortBy = "price";
sortOrder = "desc";
} else if (filters.sortBy === "likes_desc") {
sortBy = "likes";
sortOrder = "desc";
}
return {
search: filters.search,
category: filters.category,
minPrice: filters.minPrice ? Number(filters.minPrice) : undefined,
maxPrice: filters.maxPrice ? Number(filters.maxPrice) : undefined,
hasOffer: filters.hasOffer,
type: filters.type,
sortBy,
sortOrder,
};
}
export default function AcademyFilter({ onFilterChange }: AcademyFilterProps) {
const router = useRouter();
const searchParams = useSearchParams();
const [isOpen, setIsOpen] = useState(false);
const [filters, setFilters] = useState({
search: "",
category: "",
minPrice: "",
maxPrice: "",
hasOffer: false,
type: "",
sortBy: "createdAt_desc",
});
const [showFilterModal, setShowFilterModal] = useState(false);
const [filters, setFilters] = useState<AcademyFilterValues>(emptyFilters);
const [categories, setCategories] = useState<CategoryOption[]>([
{ value: "", label: "همه دسته‌ها" }
{ value: "", label: "همه دسته‌ها" },
]);
const [categoriesLoading, setCategoriesLoading] = useState(false);
const [userType, setUserType] = useState<string | null>(null);
const { request } = useAxios();
// ==========================================
// دریافت دسته‌بندی‌های آکادمی از API جدید
// ==========================================
useEffect(() => {
if (typeof window !== "undefined") {
setUserType(localStorage.getItem("usertype"));
}
}, []);
useEffect(() => {
const fetchAcademyCategories = async () => {
setCategoriesLoading(true);
@@ -79,9 +122,9 @@ export default function AcademyFilter({ onFilterChange }: AcademyFilterProps) {
"GET",
"/academy/categories?status=active&limit=100"
);
let categoriesList: IAcademyCategory[] = [];
if (response?.data?.docs) {
categoriesList = response.data.docs;
} else if (response?.data && Array.isArray(response.data)) {
@@ -89,35 +132,28 @@ export default function AcademyFilter({ onFilterChange }: AcademyFilterProps) {
} else if (Array.isArray(response)) {
categoriesList = response;
}
console.log("دسته‌بندی‌های آکادمی:", categoriesList);
// ==========================================
// ✅ تغییر مهم: استفاده از title به عنوان value
// ==========================================
const formattedCategories: CategoryOption[] = [
{ value: "", label: "همه دسته‌ها" },
...categoriesList.map((cat) => ({
value: cat.title, // استفاده از عنوان دسته‌بندی
value: cat.title,
label: cat.title,
icon: cat.icon,
color: cat.color,
}))
})),
];
setCategories(formattedCategories);
console.log("دسته‌بندی فرمت شده:", formattedCategories);
} catch (err) {
console.log("خطا در دریافت دسته‌بندی آکادمی:", err);
} finally {
setCategoriesLoading(false);
}
};
fetchAcademyCategories();
}, []);
// خواندن فیلترها از URL
useEffect(() => {
setFilters({
search: searchParams.get("search") || "",
@@ -128,11 +164,11 @@ export default function AcademyFilter({ onFilterChange }: AcademyFilterProps) {
type: searchParams.get("type") || "",
sortBy: searchParams.get("sortBy") || "createdAt_desc",
});
}, []);
}, [searchParams]);
const updateURL = (newFilters: any) => {
const updateURL = (newFilters: AcademyFilterValues) => {
const params = new URLSearchParams();
if (newFilters.search) params.set("search", newFilters.search);
if (newFilters.category) params.set("category", newFilters.category);
if (newFilters.minPrice) params.set("minPrice", newFilters.minPrice);
@@ -142,227 +178,89 @@ export default function AcademyFilter({ onFilterChange }: AcademyFilterProps) {
if (newFilters.sortBy && newFilters.sortBy !== "createdAt_desc") {
params.set("sortBy", newFilters.sortBy);
}
const queryString = params.toString();
const newUrl = queryString ? `/academy?${queryString}` : "/academy";
router.push(newUrl, { scroll: false });
};
const handleInputChange = (name: string, value: any) => {
const newFilters = { ...filters, [name]: value };
setFilters(newFilters);
let sortBy = "createdAt";
let sortOrder = "desc";
if (newFilters.sortBy === "createdAt_asc") {
sortBy = "createdAt";
sortOrder = "asc";
} else if (newFilters.sortBy === "price_asc") {
sortBy = "price";
sortOrder = "asc";
} else if (newFilters.sortBy === "price_desc") {
sortBy = "price";
sortOrder = "desc";
} else if (newFilters.sortBy === "likes_desc") {
sortBy = "likes";
sortOrder = "desc";
}
const finalFilters = {
search: newFilters.search,
category: newFilters.category,
minPrice: newFilters.minPrice ? Number(newFilters.minPrice) : undefined,
maxPrice: newFilters.maxPrice ? Number(newFilters.maxPrice) : undefined,
hasOffer: newFilters.hasOffer,
type: newFilters.type,
sortBy,
sortOrder,
};
updateURL(newFilters);
if (onFilterChange) onFilterChange(finalFilters);
};
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
handleInputChange("search", filters.search);
const applyFilters = () => {
updateURL(filters);
onFilterChange?.(buildFinalFilters(filters));
setShowFilterModal(false);
};
const clearFilters = () => {
const emptyFilters = {
search: "",
category: "",
minPrice: "",
maxPrice: "",
hasOffer: false,
type: "",
sortBy: "createdAt_desc",
};
setFilters(emptyFilters);
router.push("/academy", { scroll: false });
if (onFilterChange) onFilterChange({});
onFilterChange?.({});
setShowFilterModal(false);
};
const activeFiltersCount = Object.values(filters).filter(v =>
v !== "" && v !== false && v !== "createdAt_desc"
const handleCreateAcademy = () => {
if (!userType) {
toast.error("لطفا ابتدا در سایت وارد شوید یا ثبت نام کنید.");
return;
}
router.push(getCreateAcademyPath());
};
const activeFiltersCount = Object.values(filters).filter(
(v) => v !== "" && v !== false && v !== "createdAt_desc"
).length;
const selectedCategory = categories.find(cat => cat.value === filters.category);
return (
<div className="z-30 backdrop-blur-lg border-b border-gray-200 dark:border-gray-700">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
{/* نوار جستجو و فیلتر */}
<div className="flex flex-col md:flex-row gap-4">
<form onSubmit={handleSearch} className="flex-1">
<div className="relative">
<input
type="text"
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
placeholder="جستجوی دوره، مدرس، ..."
className="w-full px-4 py-2 pr-10 border border-gray-300 dark:border-gray-600 rounded-xl bg-gray-50 dark:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-pink-500 focus:border-transparent"
/>
<Image
src="/images/icons/search-normal.svg"
alt=""
width={20}
height={20}
className="absolute left-3 top-2.5 opacity-60 dark:invert"
/>
</div>
</form>
<button
onClick={() => setIsOpen(!isOpen)}
className={`flex items-center gap-2 px-4 py-2 rounded-xl transition-all duration-200 ${
isOpen || activeFiltersCount > 0
? "bg-pink-500 text-white"
: "bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700"
}`}
>
<Filter className="w-5 h-5" />
<span>فیلترها</span>
{activeFiltersCount > 0 && (
<span className="bg-white text-pink-500 text-xs rounded-full px-2 py-0.5">
{activeFiltersCount}
</span>
)}
</button>
</div>
<motion.div
initial={false}
animate={{ height: isOpen ? "auto" : 0, opacity: isOpen ? 1 : 0 }}
transition={{ duration: 0.3 }}
className="overflow-hidden"
<div>
<div className="flex items-center justify-end gap-3 py-1 text-sm">
<button
type="button"
onClick={handleCreateAcademy}
aria-label="افزودن آموزشگاه"
className={toolbarIconButtonClass}
>
<div className="pt-4 pb-2 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* دسته بندی آکادمی */}
<div>
<label className="block text-sm font-medium mb-1">دسته بندی</label>
<select
value={filters.category}
onChange={(e) => handleInputChange("category", e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-50 dark:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-pink-500"
disabled={categoriesLoading}
>
{categoriesLoading ? (
<option value="" disabled>در حال بارگذاری...</option>
) : (
categories.map((cat) => (
<option key={cat.value} value={cat.value}>
{cat.icon && `${cat.icon} `} {cat.label}
</option>
))
)}
</select>
{selectedCategory && selectedCategory.value !== "" && (
<div className="mt-1 text-xs text-gray-500 flex items-center gap-1">
<span>انتخاب شده:</span>
{selectedCategory.icon && <span>{selectedCategory.icon}</span>}
<span style={{ color: selectedCategory.color }}>
{selectedCategory.label}
</span>
</div>
)}
</div>
{/* نوع دوره */}
<div>
<label className="block text-sm font-medium mb-1">نوع دوره</label>
<select
value={filters.type}
onChange={(e) => handleInputChange("type", e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-50 dark:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-pink-500"
>
{courseTypes.map((type) => (
<option key={type.value} value={type.value}>{type.label}</option>
))}
</select>
</div>
{/* محدوده قیمت */}
<div>
<label className="block text-sm font-medium mb-1">محدوده قیمت (تومان)</label>
<div className="flex gap-2">
<input
type="number"
placeholder="از"
value={filters.minPrice}
onChange={(e) => handleInputChange("minPrice", e.target.value)}
className="w-1/2 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-50 dark:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-pink-500"
/>
<input
type="number"
placeholder="تا"
value={filters.maxPrice}
onChange={(e) => handleInputChange("maxPrice", e.target.value)}
className="w-1/2 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-50 dark:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-pink-500"
/>
</div>
</div>
{/* مرتب سازی */}
<div>
<label className="block text-sm font-medium mb-1">مرتب سازی</label>
<select
value={filters.sortBy}
onChange={(e) => handleInputChange("sortBy", e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-50 dark:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-pink-500"
>
{sortOptions.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="hasOffer"
checked={filters.hasOffer}
onChange={(e) => handleInputChange("hasOffer", e.target.checked)}
className="w-4 h-4 text-pink-500 rounded focus:ring-pink-500"
/>
<label htmlFor="hasOffer" className="text-sm">فقط دورههای دارای تخفیف</label>
</div>
{activeFiltersCount > 0 && (
<button
onClick={clearFilters}
className="flex items-center gap-1 text-sm text-pink-500 hover:text-pink-600 transition"
>
<X className="w-4 h-4" />
پاک کردن همه فیلترها
</button>
)}
</div>
</motion.div>
<BoldIcon
name="add-square"
size={TOOLBAR_ICON_SIZE}
className="block"
/>
</button>
<button
type="button"
onClick={() => router.push("/search?type=user")}
aria-label="جستجو"
className={toolbarIconButtonClass}
>
<BoldIcon
name="search-normal"
size={TOOLBAR_ICON_SIZE}
className="block"
/>
</button>
<button
type="button"
onClick={() => setShowFilterModal(true)}
aria-label="فیلتر"
className={toolbarIconButtonClass}
>
<BoldIcon name="candle" size={TOOLBAR_ICON_SIZE} className="block" />
</button>
</div>
{showFilterModal && (
<AcademyFilterModal
showFilterModal={showFilterModal}
setShowFilterModal={setShowFilterModal}
filters={filters}
setFilters={setFilters}
categories={categories}
categoriesLoading={categoriesLoading}
sortOptions={sortOptions}
courseTypes={courseTypes}
onApply={applyFilters}
onClear={clearFilters}
activeFiltersCount={activeFiltersCount}
/>
)}
</div>
);
}
}

View File

@@ -0,0 +1,199 @@
"use client";
import React from "react";
import { X } from "lucide-react";
import Modal from "../elements/Modal";
import RoundedButton from "../elements/RoundedButton";
import { cn } from "@/lib/utils";
interface CategoryOption {
value: string;
label: string;
icon?: string;
color?: string;
}
export interface AcademyFilterValues {
search: string;
category: string;
minPrice: string;
maxPrice: string;
hasOffer: boolean;
type: string;
sortBy: string;
}
interface AcademyFilterModalProps {
showFilterModal: boolean;
setShowFilterModal: (open: boolean) => void;
filters: AcademyFilterValues;
setFilters: React.Dispatch<React.SetStateAction<AcademyFilterValues>>;
categories: CategoryOption[];
categoriesLoading: boolean;
sortOptions: { value: string; label: string }[];
courseTypes: { value: string; label: string }[];
onApply: () => void;
onClear: () => void;
activeFiltersCount: number;
}
export default function AcademyFilterModal({
showFilterModal,
setShowFilterModal,
filters,
setFilters,
categories,
categoriesLoading,
sortOptions,
courseTypes,
onApply,
onClear,
activeFiltersCount,
}: AcademyFilterModalProps) {
const selectedCategory = categories.find((cat) => cat.value === filters.category);
const handleFieldChange = (name: keyof AcademyFilterValues, value: string | boolean) => {
setFilters((prev) => ({ ...prev, [name]: value }));
};
return (
<Modal
isOpen={showFilterModal}
onClose={() => setShowFilterModal(false)}
height="auto"
panelClassName="max-h-[min(88vh,640px)] overflow-y-auto rounded-t-3xl sm:rounded-3xl"
>
<div className="mx-auto flex w-full max-w-md flex-col pb-4">
<span className="mb-4 text-center font-semibold">فیلتر دورهها</span>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium">دسته بندی</label>
<select
value={filters.category}
onChange={(e) => handleFieldChange("category", e.target.value)}
className="w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-pink-500 dark:border-gray-600 dark:bg-gray-800"
disabled={categoriesLoading}
>
{categoriesLoading ? (
<option value="" disabled>
در حال بارگذاری...
</option>
) : (
categories.map((cat) => (
<option key={cat.value} value={cat.value}>
{cat.icon && `${cat.icon} `}
{cat.label}
</option>
))
)}
</select>
{selectedCategory && selectedCategory.value !== "" && (
<div className="mt-1 flex items-center gap-1 text-xs text-gray-500">
<span>انتخاب شده:</span>
{selectedCategory.icon && <span>{selectedCategory.icon}</span>}
<span style={{ color: selectedCategory.color }}>
{selectedCategory.label}
</span>
</div>
)}
</div>
<div>
<label className="mb-1 block text-sm font-medium">نوع دوره</label>
<select
value={filters.type}
onChange={(e) => handleFieldChange("type", e.target.value)}
className="w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-pink-500 dark:border-gray-600 dark:bg-gray-800"
>
{courseTypes.map((type) => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-sm font-medium">
محدوده قیمت (تومان)
</label>
<div className="flex gap-2">
<input
type="number"
placeholder="از"
value={filters.minPrice}
onChange={(e) => handleFieldChange("minPrice", e.target.value)}
className="w-1/2 rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-pink-500 dark:border-gray-600 dark:bg-gray-800"
/>
<input
type="number"
placeholder="تا"
value={filters.maxPrice}
onChange={(e) => handleFieldChange("maxPrice", e.target.value)}
className="w-1/2 rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-pink-500 dark:border-gray-600 dark:bg-gray-800"
/>
</div>
</div>
<div>
<label className="mb-1 block text-sm font-medium">مرتب سازی</label>
<select
value={filters.sortBy}
onChange={(e) => handleFieldChange("sortBy", e.target.value)}
className="w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-pink-500 dark:border-gray-600 dark:bg-gray-800"
>
{sortOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="academy-hasOffer"
checked={filters.hasOffer}
onChange={(e) => handleFieldChange("hasOffer", e.target.checked)}
className="h-4 w-4 rounded text-pink-500 focus:ring-pink-500"
/>
<label htmlFor="academy-hasOffer" className="text-sm">
فقط دورههای دارای تخفیف
</label>
</div>
{activeFiltersCount > 0 && (
<button
type="button"
onClick={onClear}
className="flex items-center gap-1 text-sm text-pink-500 transition hover:text-pink-600"
>
<X className="h-4 w-4" />
پاک کردن همه فیلترها
</button>
)}
</div>
<div className="mt-6 flex w-full flex-col items-center gap-3 border-t border-neutral-200 pt-5 dark:border-neutral-700">
<RoundedButton
type="button"
variant="primary"
onClick={onApply}
className="h-10 w-full max-w-[200px] text-sm"
>
اعمال فیلتر
</RoundedButton>
<RoundedButton
type="button"
onClick={() => setShowFilterModal(false)}
className={cn("h-10 w-full max-w-[200px] text-sm")}
>
انصراف
</RoundedButton>
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,59 @@
"use client";
import { Course } from "@/types/types";
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import { formatCoursePriceLabel } from "@/lib/formatCoursePriceLabel";
import Image from "next/image";
import Link from "next/link";
import React from "react";
function AcademyPackageItem({
course,
}: {
course: Course;
}) {
const imageSrc = course.course_image
? `${IMAGE_BASE_URL}${course.course_image}`
: "/images/default-course.jpg";
return (
<Link
href={`/academy/${course._id}/${encodeURIComponent(course.cuorse_name || "course")}`}
className="mb-4 p-4 border rounded-3xl border-border-primary-light w-full block font-semibold"
>
<div className="flex w-full items-center justify-between max-[350px]:flex-col max-sm:gap-5">
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className="relative h-12 w-12 shrink-0 overflow-hidden rounded-xl shadow-md">
<Image
src={imageSrc}
alt={course.cuorse_name}
fill
className="object-cover"
/>
</div>
<div className="min-w-0">
<p className="font-bold truncate">{course.cuorse_name}</p>
<p className="text-xs text-gray-500 dark:text-gray-400 line-clamp-2 mt-1">
{course.caption}
</p>
</div>
</div>
<div className="w-full h-[1px] max-[350px]:flex hidden dark:bg-white/30 bg-black/30" />
<div className="flex max-[350px]:flex-row max-[350px]:w-full max-[350px]:justify-between max-[350px]:items-center flex-col items-end max-sm:text-[11px] max-sm:items-center shrink-0">
<span className="text-sm">{course.teacher_name}</span>
<div className="flex items-center gap-1 mt-3 max-[350px]:mt-0">
<span
className={
course.is_free ? "text-[#008D0E]" : "text-[#3A59A9]"
}
>
{formatCoursePriceLabel(course)}
</span>
</div>
</div>
</div>
</Link>
);
}
export default AcademyPackageItem;

View File

@@ -0,0 +1,96 @@
import { Skeleton } from "@/components/ui/skeleton";
export function AcademyListSkeleton({ count = 3 }: { count?: number }) {
return (
<div className="mt-5 space-y-6 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 mb-24">
<Skeleton className="h-4 w-32" />
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="space-y-3">
<div className="flex items-center gap-3">
<Skeleton className="h-12 w-12 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-1/3" />
<Skeleton className="h-3 w-1/4" />
</div>
</div>
<Skeleton className="h-64 w-full rounded-2xl" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
</div>
))}
</div>
);
}
export function AcademyProfileHeadSkeleton() {
return (
<div className="w-full px-4 py-2 space-y-4">
<div className="flex justify-end">
<Skeleton className="h-4 w-48" />
</div>
<div className="flex items-center justify-between">
<div className="flex gap-5">
<Skeleton className="h-10 w-14" />
<Skeleton className="h-10 w-14" />
<Skeleton className="h-10 w-14" />
</div>
<Skeleton className="h-[120px] w-[120px] rounded-xl" />
</div>
<div className="grid grid-cols-3 gap-4">
<Skeleton className="h-6 w-full" />
<Skeleton className="h-6 w-full" />
<Skeleton className="h-6 w-full" />
</div>
<Skeleton className="h-16 w-full" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
<Skeleton className="h-8 w-full rounded-xl" />
<Skeleton className="h-8 w-full rounded-xl" />
<Skeleton className="h-8 w-full rounded-xl" />
<Skeleton className="h-8 w-full rounded-xl" />
</div>
</div>
);
}
export function AcademyPackageListSkeleton({ count = 4 }: { count?: number }) {
return (
<div className="px-4 space-y-4 mt-4">
{Array.from({ length: count }).map((_, i) => (
<div
key={i}
className="p-4 border rounded-3xl border-border-primary-light w-full"
>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3 flex-1">
<Skeleton className="h-12 w-12 rounded-xl shrink-0" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-3 w-full" />
</div>
</div>
<div className="space-y-2 shrink-0">
<Skeleton className="h-3 w-16 ml-auto" />
<Skeleton className="h-4 w-20 ml-auto" />
</div>
</div>
</div>
))}
</div>
);
}
export function AcademyCourseDetailSkeleton() {
return (
<div className="max-w-7xl mx-auto px-4 py-6 space-y-6">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-4">
<Skeleton className="h-72 w-full rounded-xl" />
<Skeleton className="h-32 w-full rounded-xl" />
</div>
<div className="space-y-4">
<Skeleton className="h-96 w-full rounded-xl" />
</div>
</div>
</div>
);
}

View File

@@ -7,7 +7,7 @@ import RoundedInput from "@/components/elements/RoundedInput";
import useAxios from "@/hooks/useAxios";
import toast from "react-hot-toast";
import Rate from "rc-rate";
import "rc-rate/assets/index.css";
import "rc-rate/assets/index.css";
import "@/styles/rc-rate-custom.css";
import { useUser } from "@/hooks/useUser";
import { Course } from "@/types/types";
@@ -227,10 +227,6 @@ function CommentsModal({
toast.error("کامنت نمی‌تواند بیش از 500 کاراکتر باشد.");
return;
}
if (!hasRatedCourse && rating === 0) {
toast.error("برای اولین نظر، ثبت امتیاز الزامی است.");
return;
}
try {
const payload: Record<string, unknown> = {
@@ -470,7 +466,7 @@ function CommentsModal({
<button
onClick={sendMessageHandler}
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
disabled={!newMessage.trim() || (!hasRatedCourse && rating === 0)}
disabled={!newMessage.trim()}
>
<Image
width={24}

View File

@@ -7,6 +7,9 @@ import Image from "next/image";
import useAxios from "@/hooks/useAxios";
import toast from "react-hot-toast";
import { motion, AnimatePresence } from "framer-motion";
import { AcademyListSkeleton } from "./AcademySkeletons";
import { btnPrimary } from "@/lib/ui/buttonStyles";
import { cn } from "@/lib/utils";
interface InfinitePostsProps {
token: string;
@@ -25,6 +28,7 @@ interface InfinitePostsProps {
hasOffer?: boolean;
teacherName?: string;
type?: string;
isFree?: boolean;
sortBy?: string;
sortOrder?: string;
};
@@ -72,6 +76,9 @@ export default function InfinitePosts({ filters, token }: InfinitePostsProps) {
if (filters?.type && filters.type !== "") {
params.append("type", filters.type);
}
if (filters?.isFree) {
params.append("isFree", "true");
}
if (filters?.sortBy && filters.sortBy !== "createdAt") {
params.append("sortBy", filters.sortBy);
}
@@ -173,6 +180,7 @@ export default function InfinitePosts({ filters, token }: InfinitePostsProps) {
filters?.hasOffer,
filters?.teacherName,
filters?.type,
filters?.isFree,
filters?.sortBy,
filters?.sortOrder,
]);
@@ -208,14 +216,7 @@ export default function InfinitePosts({ filters, token }: InfinitePostsProps) {
]);
if (isInitialCoursesLoading) {
return (
<div className="flex justify-center items-center min-h-[400px]">
<div className="relative w-24 h-24">
<div className="absolute inset-0 rounded-full border-4 border-gray-200 dark:border-gray-700"></div>
<div className="absolute inset-0 rounded-full border-4 border-t-pink-500 border-r-transparent border-b-transparent border-l-transparent animate-spin"></div>
</div>
</div>
);
return <AcademyListSkeleton count={3} />;
}
if (courses.length === 0 && !isInitialCoursesLoading) {
@@ -262,17 +263,13 @@ export default function InfinitePosts({ filters, token }: InfinitePostsProps) {
className="flex justify-center items-center py-4"
>
{isLoadingMoreCourses ? (
<div className="flex justify-center items-center py-4 gap-2">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-pink-500"></div>
<span className="text-sm text-gray-500">
در حال بارگذاری دورههای بیشتر...
</span>
</div>
<AcademyListSkeleton count={1} />
) : (
<div className="text-center py-4">
<button
type="button"
onClick={() => fetchCourses(coursesPage + 1, true)}
className="px-4 py-2 bg-pink-500 text-white rounded-lg hover:bg-pink-600 transition-colors text-sm"
className={cn(btnPrimary, "px-4 py-2 text-sm")}
>
بارگذاری بیشتر
</button>

View File

@@ -20,6 +20,7 @@ import "slick-carousel/slick/slick-theme.css";
import useAxios from "@/hooks/useAxios";
import { useRouter } from "next/navigation";
import { forEach } from "lodash";
import { formatCoursePriceLabel } from "@/lib/formatCoursePriceLabel";
// ایجاد Context برای به اشتراک گذاشتن وضعیت muted بین همه کارت‌ها
const MutedContext = createContext<{
@@ -403,26 +404,12 @@ function MainModelCard({ postData }: { postData: Course }) {
</div>
<div className="flex justify-between items-center">
<span className="text-[#0090ff] text-xl font-semibold">
{offer && parseInt(offer) > 0 ? (
<>
{/* <span className="line-through text-muted-foreground text-xs">
{parseInt(price).toLocaleString()}
</span>{" "}
<span className=" text-muted-foreground text-xs">
تومان
</span> */}
{(
(parseInt(price) * (100 - parseInt(offer))) /
100
).toLocaleString()}
</>
) : (
parseInt(price).toLocaleString()
)}
<span className="text-[#0090ff] text-xl font-semibold">تومان</span>
<span className="text-[#ff0000] text-xl font-semibold">
{offer && Number(offer) > 0 ? `%${offer}` : ""}
</span>
{formatCoursePriceLabel(postData)}
{!postData.is_free && postData.offer && Number(postData.offer) > 0 ? (
<span className="text-[#ff0000] text-xl font-semibold mr-2">
%{postData.offer}
</span>
) : null}
</span>
</div>
<div className="flex items-center gap-1 mt-1">
@@ -453,20 +440,19 @@ function MainModelCard({ postData }: { postData: Course }) {
</div>
</Link>
<div onClick={() => router.push(`/academy/${postData._id}/course`)} className=" overflow-hidden rounded-2xl">
<div
className="w-full flex-shrink-0 flex justify-center items-center p-2"
style={{ width: "100%" }}
>
<Image
src={IMAGE_BASE_URL + course_image}
alt={`media-${course_image}`}
width={1000}
height={1000}
loading={"lazy"}
className="object-contain w-full min-h-[30vh] rounded-xl shadow-lg transition-opacity duration-300"
onLoadingComplete={() => setImageLoading?.(false)}
/>
<div onClick={() => router.push(`/academy/${postData._id}/course`)} className="overflow-hidden rounded-2xl">
<div className="w-full flex-shrink-0 flex justify-center items-center p-2">
<div className="w-full rounded-2xl overflow-hidden shadow-[0_8px_30px_rgba(0,0,0,0.12)] dark:shadow-[0_8px_30px_rgba(0,0,0,0.45)]">
<Image
src={IMAGE_BASE_URL + course_image}
alt={`media-${course_image}`}
width={1000}
height={1000}
loading={"lazy"}
className="object-contain w-full min-h-[30vh] rounded-xl shadow-lg transition-opacity duration-300"
onLoadingComplete={() => setImageLoading?.(false)}
/>
</div>
</div>
</div>

View File

@@ -15,6 +15,7 @@ import { AxiosError } from "axios";
import useInfiniteScroll from "@/hooks/useInfiniteScroll";
import { useRouter } from "next/navigation";
import { useRequireAuth } from "@/lib/auth/useRequireAuth";
import { SocialIconButton, SocialStatButton } from "@/components/ui/SocialStatButton";
function MainModelCardActions({ postData }: { postData: Course }) {
const { _id, user_id, likes } = postData;
@@ -169,29 +170,26 @@ function MainModelCardActions({ postData }: { postData: Course }) {
>
مشاهده پکیج
</RoundedButton>
<div className="flex items-center gap-5 md:gap-10">
<button
<div className="flex items-center gap-5 md:gap-8">
<SocialStatButton
count={totalComments}
onClick={() => setShowCommentsModal(!showCommentsModal)}
className="flex flex-col items-center text-xs font-semibold gap-1"
>
<Image
width={20}
height={20}
alt="message-text.svg"
src="/images/icons/message-text.svg"
className="dark:invert"
/>
<span>
{totalComments}
<span className="mr-1 max-sm:text-xs">کامنت</span>
</span>
</button>
<button
aria-label="کامنت"
icon={
<Image
width={20}
height={20}
alt=""
src="/images/icons/message-text.svg"
className="dark:invert"
/>
}
/>
<SocialIconButton
onClick={(e) => {
e.preventDefault();
const url = `${window.location.origin}/academy/${_id}/course`;
// روش اول: استفاده از Web Share API (بهترین برای موبایل)
if (navigator.share) {
navigator
.share({
@@ -201,45 +199,41 @@ function MainModelCardActions({ postData }: { postData: Course }) {
})
.catch((err) => {
console.log("Error sharing:", err);
// fallback
copyToClipboard(url);
});
} else {
// روش دوم: کپی در کلیپ‌بورد (برای دسکتاپ)
copyToClipboard(url);
}
}}
className="flex flex-col items-center text-xs font-semibold gap-1"
>
<Image
width={20}
height={20}
alt="share.svg"
src="/images/icons/share.svg"
className="dark:invert"
/>
<span className="max-sm:text-xs">اشتراکگذاری</span>
</button>
<button
aria-label="اشتراک‌گذاری"
icon={
<Image
width={20}
height={20}
alt=""
src="/images/icons/share.svg"
className="dark:invert"
/>
}
/>
<SocialStatButton
likeCount={likesCountState || 0}
onClick={toggleLike}
className="flex flex-col items-center text-xs font-semibold gap-1"
>
<Image
width={20}
height={20}
alt="heart.svg"
src={
liked
? "/images/icons/red-heart.svg"
: "/images/icons/heart.svg"
}
className="dark:invert"
/>
<span>
{likesCountState || 0}{" "}
<span className="max-sm:text-xs">لایک</span>
</span>
</button>
aria-label="لایک"
icon={
<Image
width={20}
height={20}
alt=""
src={
liked
? "/images/icons/red-heart.svg"
: "/images/icons/heart.svg"
}
className={liked ? "" : "dark:invert"}
/>
}
/>
</div>
</div>
</>

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