lang
@@ -18,6 +18,11 @@ module.exports = {
|
||||
NEXT_PUBLIC_SOCKET_URL: "https://api.modstagram.ir",
|
||||
NEXT_PUBLIC_IMAGE_BASE_URL: "https://api.modstagram.ir/storage",
|
||||
NEXT_PUBLIC_BASE_URL: "/api/v1",
|
||||
NEXT_PUBLIC_SITE_URL: "https://modstagram.com",
|
||||
GOOGLE_CLIENT_ID:
|
||||
"91028483292-vba2ktgbvus1p5ce18ir04h1b5c50gnn.apps.googleusercontent.com",
|
||||
NEXT_PUBLIC_GOOGLE_CLIENT_ID:
|
||||
"91028483292-vba2ktgbvus1p5ce18ir04h1b5c50gnn.apps.googleusercontent.com",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -65,7 +65,46 @@ const nextConfig = {
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: '/(.*)',
|
||||
source: "/service-worker.js",
|
||||
headers: [
|
||||
{
|
||||
key: "Content-Type",
|
||||
value: "application/javascript; charset=utf-8",
|
||||
},
|
||||
{
|
||||
key: "Cache-Control",
|
||||
value: "public, max-age=0, must-revalidate",
|
||||
},
|
||||
{
|
||||
key: "Service-Worker-Allowed",
|
||||
value: "/",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
source: "/manifest.webmanifest",
|
||||
headers: [
|
||||
{
|
||||
key: "Content-Type",
|
||||
value: "application/manifest+json; charset=utf-8",
|
||||
},
|
||||
{
|
||||
key: "Cache-Control",
|
||||
value: "no-cache, no-store, must-revalidate",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
source: "/manifest.json",
|
||||
headers: [
|
||||
{
|
||||
key: "Content-Type",
|
||||
value: "application/manifest+json; charset=utf-8",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
source: "/(.*)",
|
||||
headers: [
|
||||
{ key: 'X-DNS-Prefetch-Control', value: 'on' },
|
||||
{ key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' },
|
||||
@@ -73,6 +112,8 @@ const nextConfig = {
|
||||
{ key: 'X-Frame-Options', value: 'SAMEORIGIN' },
|
||||
{ key: 'X-Content-Type-Options', value: 'nosniff' },
|
||||
{ key: 'Referrer-Policy', value: 'origin-when-cross-origin' },
|
||||
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=(self), payment=()' },
|
||||
{ key: 'X-Permitted-Cross-Domain-Policies', value: 'none' },
|
||||
{ key: 'Cache-Control', value: 'public, s-maxage=1, stale-while-revalidate=59' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"start:local": "next start --port 3004 --hostname 0.0.0.0",
|
||||
"pm2:prod": "pm2 start ecosystem.config.cjs --update-env",
|
||||
"lint": "next lint",
|
||||
"postbuild": "next-sitemap"
|
||||
"postbuild": "next-sitemap",
|
||||
"generate:pwa-icons": "node scripts/generate-pwa-icons.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
|
||||
BIN
public/favicon-16.png
Normal file
|
After Width: | Height: | Size: 616 B |
BIN
public/favicon.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 8.2 KiB |
BIN
public/images/icons/carousel-next.png
Normal file
|
After Width: | Height: | Size: 744 B |
BIN
public/images/icons/carousel-prev.png
Normal file
|
After Width: | Height: | Size: 743 B |
BIN
public/images/icons/google.png
Normal file
|
After Width: | Height: | Size: 640 B |
18
public/images/icons/logo-mark.svg
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 62" role="img" aria-label="Modstagram">
|
||||
<style>.st0{fill:#FF107D;}</style>
|
||||
<path class="st0" d="M42.1,47.6h-0.3c-1.7,0-3.2-1.4-3.2-3.2V31.8c0-1.3-0.3-2.5-0.8-3.6c-0.5-1.1-1.1-2.1-2-3c-0.8-0.8-1.8-1.5-3-2
|
||||
c-1.1-0.5-2.3-0.8-3.6-0.8c-1.4,0-2.6,0.3-3.8,0.8c-1.1,0.5-2,1.2-2.9,2c-0.1,0.1-0.2,0.1-0.3,0c0,0,0,0-0.1,0
|
||||
c-0.2-0.2-0.4-0.4-0.7-0.6c0,0,0,0-0.1-0.1C20.9,24,20,23.5,19,23.1c-1-0.4-2-0.6-3.1-0.6c-1.3,0-2.5,0.3-3.7,0.8
|
||||
c-1.1,0.5-2.1,1.1-2.9,2c-0.8,0.8-1.5,1.8-2,3c-0.5,1.1-0.7,2.3-0.7,3.6v12.6c0,1.7-1.4,3.2-3.2,3.2H3.2c-1.7,0-3.2-1.4-3.2-3.2
|
||||
V31.8v-0.4c0-2.1,0.5-4.1,1.3-6c0.8-1.9,2-3.5,3.4-4.9c1.4-1.4,3.1-2.5,5-3.3c1.9-0.8,4-1.2,6.1-1.2c1.2,0,2.4,0.2,3.5,0.4
|
||||
c1.1,0.3,2.2,0.6,3.2,1.1c1-0.5,2.1-0.8,3.3-1.1c1.1-0.3,2.3-0.4,3.5-0.4c2.2,0,4.3,0.5,6.2,1.3c1.9,0.8,3.6,2,5.1,3.4
|
||||
c1.4,1.4,2.6,3.1,3.4,5.1c0.8,1.9,1.2,3.9,1.2,6.1v12.6C45.3,46.2,43.9,47.6,42.1,47.6z M64.6,16c2.1,0,4.2,0.4,6.1,1.3
|
||||
c1.9,0.8,3.6,1.9,5.1,3.3c1.4,1.4,2.6,3.1,3.4,5.1c0.8,1.9,1.3,3.9,1.3,6.1c0,2.2-0.4,4.3-1.3,6.2c-0.8,1.9-2,3.6-3.4,5
|
||||
c-1.4,1.4-3.1,2.5-5.1,3.3c-1.9,0.8-3.9,1.2-6.1,1.2c-2.2,0-4.3-0.4-6.2-1.2c-1.9-0.8-3.6-2-5-3.3c-1.4-1.4-2.5-3.1-3.3-5
|
||||
c-0.8-1.9-1.2-4-1.2-6.2c0-2.2,0.4-4.2,1.2-6.1c0.8-1.9,2-3.6,3.3-5.1c1.4-1.4,3.1-2.6,5-3.3C60.3,16.4,62.4,16,64.6,16z M64.6,41
|
||||
c1.3,0,2.5-0.2,3.6-0.7c1.1-0.5,2.1-1.2,2.9-2c0.8-0.8,1.5-1.8,2-2.9c0.5-1.1,0.8-2.3,0.8-3.6c0-1.3-0.3-2.5-0.8-3.6
|
||||
c-0.5-1.1-1.1-2.1-2-2.9c-0.8-0.8-1.8-1.5-2.9-2c-1.1-0.5-2.3-0.8-3.6-0.8s-2.5,0.3-3.6,0.8c-1.1,0.5-2.1,1.1-3,2
|
||||
c-0.8,0.8-1.5,1.8-2,2.9c-0.5,1.1-0.7,2.3-0.7,3.6c0,1.3,0.2,2.5,0.7,3.6c0.5,1.1,1.2,2.1,2,2.9c0.8,0.8,1.8,1.5,3,2
|
||||
C62.1,40.7,63.3,41,64.6,41z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
BIN
public/images/icons/maskable-192.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
public/images/icons/maskable-512.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
public/images/icons/medal-star.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
4
public/images/icons/reels-follow-add.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11.9999 15C6.98991 15 2.90991 18.36 2.90991 22.5C2.90991 22.78 3.12991 23 3.40991 23H20.5899C20.8699 23 21.0899 22.78 21.0899 22.5C21.0899 18.36 17.0099 15 11.9999 15Z" fill="#292D32"/>
|
||||
<path d="M15.71 3.66C14.81 2.64 13.47 2 12 2C10.6 2 9.32 2.57 8.41 3.51C7.54 4.41 7 5.65 7 7C7 7.94 7.26 8.82 7.73 9.57C7.98 10 8.3 10.39 8.68 10.71C9.55 11.51 10.71 12 12 12C13.83 12 15.41 11.02 16.28 9.57C16.54 9.14 16.74 8.66 16.85 8.16C16.95 7.79 17 7.4 17 7C17 5.72 16.51 4.55 15.71 3.66ZM13.87 7.92H12.94V8.89C12.94 9.41 12.52 9.83 12 9.83C11.48 9.83 11.06 9.41 11.06 8.89V7.92H10.13C9.61 7.92 9.19 7.5 9.19 6.98C9.19 6.46 9.61 6.04 10.13 6.04H11.06V5.15C11.06 4.63 11.48 4.21 12 4.21C12.52 4.21 12.94 4.63 12.94 5.15V6.04H13.87C14.39 6.04 14.81 6.46 14.81 6.98C14.81 7.5 14.39 7.92 13.87 7.92Z" fill="#292D32"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 918 B |
BIN
public/images/icons/reels-more.png
Normal file
|
After Width: | Height: | Size: 281 B |
BIN
public/images/icons/reels-share-story.png
Normal file
|
After Width: | Height: | Size: 904 B |
5
public/images/icons/reels-share-story.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20.97 1H18.03C17.16 1 16.52 1.36 16.23 2C16.07 2.29 16 2.63 16 3.03V5.97C16 7.24 16.76 8 18.03 8H20.97C21.37 8 21.71 7.93 22 7.77C22.64 7.48 23 6.84 23 5.97V3.03C23 1.76 22.24 1 20.97 1ZM21.91 4.93C21.81 5.03 21.66 5.1 21.5 5.11H20.09V5.62L20.1 6.5C20.09 6.67 20.03 6.81 19.91 6.93C19.81 7.03 19.66 7.1 19.5 7.1C19.17 7.1 18.9 6.83 18.9 6.5V5.1L17.5 5.11C17.17 5.11 16.9 4.83 16.9 4.5C16.9 4.17 17.17 3.9 17.5 3.9L18.38 3.91H18.9V2.51C18.9 2.18 19.17 1.9 19.5 1.9C19.83 1.9 20.1 2.18 20.1 2.51L20.09 3.22V3.9H21.5C21.83 3.9 22.1 4.17 22.1 4.5C22.09 4.67 22.02 4.81 21.91 4.93Z" fill="#292D32"/>
|
||||
<path d="M9 10.3801C10.3144 10.3801 11.38 9.31456 11.38 8.00012C11.38 6.68568 10.3144 5.62012 9 5.62012C7.68556 5.62012 6.62 6.68568 6.62 8.00012C6.62 9.31456 7.68556 10.3801 9 10.3801Z" fill="#292D32"/>
|
||||
<path d="M20.97 8H20.5V12.61L20.37 12.5C19.59 11.83 18.33 11.83 17.55 12.5L13.39 16.07C12.61 16.74 11.35 16.74 10.57 16.07L10.23 15.79C9.52 15.17 8.39 15.11 7.59 15.65L3.85 18.16C3.63 17.6 3.5 16.95 3.5 16.19V7.81C3.5 4.99 4.99 3.5 7.81 3.5H16V3.03C16 2.63 16.07 2.29 16.23 2H7.81C4.17 2 2 4.17 2 7.81V16.19C2 17.28 2.19 18.23 2.56 19.03C3.42 20.93 5.26 22 7.81 22H16.19C19.83 22 22 19.83 22 16.19V7.77C21.71 7.93 21.37 8 20.97 8Z" fill="#292D32"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
3
public/images/icons/shield-tick.svg
Normal 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.54 4.11984L13.04 2.05984C12.47 1.84984 11.54 1.84984 10.97 2.05984L5.47005 4.11984C4.41005 4.51984 3.55005 5.75984 3.55005 6.88984V14.9898C3.55005 15.7998 4.08005 16.8698 4.73005 17.3498L10.23 21.4598C11.2 22.1898 12.79 22.1898 13.76 21.4598L19.26 17.3498C19.91 16.8598 20.4401 15.7998 20.4401 14.9898V6.88984C20.4501 5.75984 19.59 4.51984 18.54 4.11984ZM15.48 9.71984L11.18 14.0198C11.03 14.1698 10.84 14.2398 10.65 14.2398C10.46 14.2398 10.27 14.1698 10.12 14.0198L8.52005 12.3998C8.23005 12.1098 8.23005 11.6298 8.52005 11.3398C8.81005 11.0498 9.29005 11.0498 9.58005 11.3398L10.66 12.4198L14.43 8.64984C14.72 8.35984 15.2 8.35984 15.49 8.64984C15.78 8.93984 15.78 9.42984 15.48 9.71984Z" fill="#292D32"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 825 B |
BIN
public/images/icons/star1.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
public/images/icons/story.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
@@ -5,10 +5,12 @@
|
||||
"lang": "fa",
|
||||
"dir": "rtl",
|
||||
"scope": "/",
|
||||
"start_url": "/",
|
||||
"id": "/",
|
||||
"start_url": "/?source=pwa&lang=fa",
|
||||
"id": "modstagram-pwa",
|
||||
"display": "standalone",
|
||||
"display_override": ["standalone", "minimal-ui", "browser"],
|
||||
"orientation": "portrait-primary",
|
||||
"prefer_related_applications": false,
|
||||
"icons": [
|
||||
{
|
||||
"src": "/images/icons/192x192.png",
|
||||
@@ -29,13 +31,19 @@
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/images/icons/512x512.png",
|
||||
"src": "/images/icons/maskable-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/images/icons/maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"theme_color": "#FF107D",
|
||||
"background_color": "#ffffff",
|
||||
"categories": ["social", "business"]
|
||||
}
|
||||
|
||||
@@ -1,61 +1,60 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
|
||||
const CACHE_NAME = "pwa-cache-v4";
|
||||
const CACHE_NAME = "pwa-cache-v10";
|
||||
|
||||
const PRECACHE_URLS = [
|
||||
"/",
|
||||
"/manifest.webmanifest",
|
||||
"/manifest.json",
|
||||
"/images/icons/192x192.png",
|
||||
"/images/icons/256x256.png",
|
||||
"/images/icons/512x512.png",
|
||||
"/images/icons/apple-touch-icon.png",
|
||||
"/images/icons/maskable-192.png",
|
||||
"/images/icons/maskable-512.png",
|
||||
"/favicon.png",
|
||||
];
|
||||
|
||||
// نصب Service Worker
|
||||
self.addEventListener("install", (event) => {
|
||||
console.log("Service Worker installing...");
|
||||
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
return cache.addAll([
|
||||
"/",
|
||||
"/manifest.json",
|
||||
"/images/icons/256x256.png",
|
||||
"/images/icons/512x512.png",
|
||||
]);
|
||||
})
|
||||
caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS))
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// فعالسازی Service Worker
|
||||
self.addEventListener("activate", (event) => {
|
||||
console.log("Service Worker activated.");
|
||||
event.waitUntil(
|
||||
caches
|
||||
.keys()
|
||||
.then((keys) =>
|
||||
Promise.all(
|
||||
keys
|
||||
.filter((key) => key !== CACHE_NAME)
|
||||
.map((key) => caches.delete(key))
|
||||
keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))
|
||||
)
|
||||
)
|
||||
.then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
// هندل درخواستها
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const url = new URL(event.request.url);
|
||||
|
||||
// navigation: همیشه از شبکه — HTML کش نشود تا title/meta همیشه تازه باشد
|
||||
if (event.request.mode === "navigate") {
|
||||
event.respondWith(fetch(event.request));
|
||||
return;
|
||||
}
|
||||
|
||||
// آیکونها: network-first
|
||||
if (url.pathname.startsWith("/images/icons/")) {
|
||||
if (
|
||||
url.pathname.startsWith("/images/icons/") ||
|
||||
url.pathname.endsWith("manifest.webmanifest") ||
|
||||
url.pathname.endsWith("manifest.json") ||
|
||||
url.pathname.endsWith("favicon.png")
|
||||
) {
|
||||
event.respondWith(
|
||||
fetch(event.request)
|
||||
.then((res) => {
|
||||
if (res.ok) {
|
||||
const clone = res.clone();
|
||||
caches
|
||||
.open(CACHE_NAME)
|
||||
.then((cache) => cache.put(event.request, clone));
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
|
||||
}
|
||||
return res;
|
||||
})
|
||||
@@ -64,15 +63,13 @@ self.addEventListener("fetch", (event) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// سایر فایلهای استاتیک: stale-while-revalidate
|
||||
if (
|
||||
url.pathname.startsWith("/images/") ||
|
||||
url.pathname.endsWith(".png") ||
|
||||
url.pathname.endsWith(".jpg") ||
|
||||
url.pathname.endsWith(".jpeg") ||
|
||||
url.pathname.endsWith(".gif") ||
|
||||
url.pathname.endsWith(".webp") ||
|
||||
url.pathname.endsWith("manifest.json")
|
||||
url.pathname.endsWith(".webp")
|
||||
) {
|
||||
event.respondWith(
|
||||
caches.match(event.request).then((cached) => {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
|
||||
<url><loc>https://modstagram.com/icon.png</loc><lastmod>2026-07-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/icon.png</loc><lastmod>2026-07-21T17:52:19.573Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://modstagram.com/robots.txt</loc><lastmod>2026-07-21T17:52:19.576Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://modstagram.com/sitemap.xml</loc><lastmod>2026-07-21T17:52:19.576Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://modstagram.com/academy/payment/success</loc><lastmod>2026-07-21T17:52:19.576Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://modstagram.com/academy/payment/failed</loc><lastmod>2026-07-21T17:52:19.576Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://modstagram.com/about-us</loc><lastmod>2026-07-21T17:52:19.576Z</lastmod><changefreq>monthly</changefreq><priority>0.8</priority></url>
|
||||
<url><loc>https://modstagram.com/explore</loc><lastmod>2026-07-21T17:52:19.576Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://modstagram.com</loc><changefreq>daily</changefreq><priority>1</priority></url>
|
||||
<url><loc>https://modstagram.com/projects</loc><changefreq>daily</changefreq><priority>0.9</priority></url>
|
||||
<url><loc>https://modstagram.com/billboards</loc><changefreq>daily</changefreq><priority>0.9</priority></url>
|
||||
|
||||
94
scripts/generate-pwa-icons.mjs
Normal file
@@ -0,0 +1,94 @@
|
||||
import sharp from "sharp";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import fs from "fs";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const iconsDir = path.join(__dirname, "../public/images/icons");
|
||||
const publicDir = path.join(__dirname, "../public");
|
||||
const source = path.join(iconsDir, "logo-mark.svg");
|
||||
|
||||
const BRAND = { r: 255, g: 16, b: 125, alpha: 1 };
|
||||
const WHITE = { r: 255, g: 255, b: 255, alpha: 1 };
|
||||
|
||||
async function renderSquareIcon(size, paddingRatio = 0.18) {
|
||||
const inner = Math.round(size * (1 - paddingRatio * 2));
|
||||
const padding = Math.round((size - inner) / 2);
|
||||
|
||||
const mark = await sharp(source, { density: 512 })
|
||||
.resize(inner, inner, {
|
||||
fit: "contain",
|
||||
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
return sharp({
|
||||
create: {
|
||||
width: size,
|
||||
height: size,
|
||||
channels: 4,
|
||||
background: WHITE,
|
||||
},
|
||||
})
|
||||
.composite([{ input: mark, left: padding, top: padding }])
|
||||
.png()
|
||||
.toFile(path.join(iconsDir, `${size}x${size}.png`));
|
||||
}
|
||||
|
||||
async function renderMaskableIcon(size) {
|
||||
const inner = Math.round(size * 0.52);
|
||||
const padding = Math.round((size - inner) / 2);
|
||||
|
||||
const mark = await sharp(source, { density: 512 })
|
||||
.resize(inner, inner, {
|
||||
fit: "contain",
|
||||
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
await sharp({
|
||||
create: {
|
||||
width: size,
|
||||
height: size,
|
||||
channels: 4,
|
||||
background: BRAND,
|
||||
},
|
||||
})
|
||||
.composite([{ input: mark, left: padding, top: padding }])
|
||||
.png()
|
||||
.toFile(path.join(iconsDir, `maskable-${size}.png`));
|
||||
|
||||
console.log(`Created maskable-${size}.png`);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(source)) {
|
||||
console.error("Missing logo-mark.svg");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
for (const size of [192, 256, 512]) {
|
||||
await renderSquareIcon(size);
|
||||
console.log(`Created ${size}x${size}.png`);
|
||||
}
|
||||
|
||||
await sharp(path.join(iconsDir, "512x512.png"))
|
||||
.resize(180, 180)
|
||||
.png()
|
||||
.toFile(path.join(iconsDir, "apple-touch-icon.png"));
|
||||
console.log("Created apple-touch-icon.png");
|
||||
|
||||
await renderMaskableIcon(192);
|
||||
await renderMaskableIcon(512);
|
||||
|
||||
await sharp(path.join(iconsDir, "192x192.png"))
|
||||
.resize(32, 32)
|
||||
.png()
|
||||
.toFile(path.join(publicDir, "favicon.png"));
|
||||
|
||||
await sharp(path.join(publicDir, "favicon.png"))
|
||||
.resize(16, 16)
|
||||
.toFile(path.join(publicDir, "favicon-16.png"));
|
||||
|
||||
console.log("Created favicon.png");
|
||||
@@ -19,7 +19,9 @@ export async function fetchPosts(
|
||||
feedMode?: "grid" | "reels";
|
||||
seedPostId?: string;
|
||||
sort?: "latest";
|
||||
reelsTab?: "for_you" | "following" | "saved";
|
||||
reelsTab?: "for_you" | "following" | "saved" | "near_me";
|
||||
lat?: string;
|
||||
lng?: string;
|
||||
heightMin?: string;
|
||||
heightMax?: string;
|
||||
weightMin?: string;
|
||||
|
||||
@@ -15,6 +15,7 @@ export type StoryItem = {
|
||||
_id: string;
|
||||
media_path: string;
|
||||
media_type: "image" | "video";
|
||||
linked_post_id?: string | null;
|
||||
overlays?: StoryTextOverlay[];
|
||||
createdAt?: string;
|
||||
expires_at?: string;
|
||||
@@ -33,6 +34,7 @@ export type StoryFeedUser = {
|
||||
};
|
||||
stories: StoryItem[];
|
||||
has_unviewed: boolean;
|
||||
is_following?: boolean;
|
||||
};
|
||||
|
||||
export type StoriesFeedResponse = {
|
||||
@@ -166,3 +168,21 @@ export async function fetchStoryViewers(
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function sharePostToStory(
|
||||
postId: string,
|
||||
token: string
|
||||
): Promise<void> {
|
||||
const res = await fetch(`${getApiBaseUrl()}/stories/share-post`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ postId }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const json = await res.json().catch(() => ({}));
|
||||
throw new Error(json?.message || "خطا در انتشار استوری");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,30 +12,11 @@ import { useFormik } from "formik";
|
||||
import * as yup from "yup";
|
||||
import { useRouter } from "next/navigation";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
const schema = yup.object().shape({
|
||||
otp: yup
|
||||
.string()
|
||||
.matches(/^\d{6}$/, "کد وارد شده صحیح نیست")
|
||||
.required("کد تایید را وارد کنید"),
|
||||
password: yup
|
||||
.string()
|
||||
.required("کلمه عبور نمی\u200Cتواند خالی باشد")
|
||||
.min(8, "کلمه عبور نباید کوتاه تر از 8 کاراکتر باشد")
|
||||
.matches(
|
||||
/^(?=.*[a-zA-Zآ-ی])(?=.*\d).{8,}$/,
|
||||
"کلمه عبور باید شامل حروف و اعداد باشد"
|
||||
),
|
||||
confirmPassword: yup
|
||||
.string()
|
||||
.required("تکرار کلمه عبور نمی\u200Cتواند خالی باشد")
|
||||
.oneOf(
|
||||
[yup.ref("password")],
|
||||
"تکرار کلمه عبور باید با کلمه عبور مطابقت داشته باشد"
|
||||
),
|
||||
});
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
|
||||
function ChangePassword() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const { request, loading } = useAxios();
|
||||
const [otpError, setOtpError] = useState(false);
|
||||
@@ -56,6 +37,29 @@ function ChangePassword() {
|
||||
);
|
||||
const isEmailReset = resetChannel === "email";
|
||||
|
||||
const schema = useMemo(
|
||||
() =>
|
||||
yup.object().shape({
|
||||
otp: yup
|
||||
.string()
|
||||
.matches(/^\d{6}$/, t("auth.otpInvalid"))
|
||||
.required(t("auth.otpRequired")),
|
||||
password: yup
|
||||
.string()
|
||||
.required(t("auth.passwordRequired"))
|
||||
.min(8, t("auth.passwordMin"))
|
||||
.matches(
|
||||
/^(?=.*[a-zA-Zآ-ی])(?=.*\d).{8,}$/,
|
||||
t("auth.passwordLettersNumbers")
|
||||
),
|
||||
confirmPassword: yup
|
||||
.string()
|
||||
.required(t("auth.confirmPasswordRequired"))
|
||||
.oneOf([yup.ref("password")], t("auth.confirmPasswordMismatch")),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
otp: "",
|
||||
@@ -65,7 +69,7 @@ function ChangePassword() {
|
||||
validationSchema: schema,
|
||||
onSubmit: async (values) => {
|
||||
if (!resetIdentifier) {
|
||||
toast.error("ابتدا از صفحه فراموشی رمز، کد دریافت کنید");
|
||||
toast.error(t("auth.getResetCodeFirst"));
|
||||
router.push("/forget-password");
|
||||
return;
|
||||
}
|
||||
@@ -81,107 +85,109 @@ function ChangePassword() {
|
||||
});
|
||||
localStorage.removeItem("reset_identifier");
|
||||
localStorage.removeItem("reset_channel");
|
||||
toast.success("کلمه عبور با موفقیت تغییر کرد");
|
||||
toast.success(t("auth.passwordChangedSuccess"));
|
||||
router.push("/login-with-username");
|
||||
} catch (err: unknown) {
|
||||
setOtpError(true);
|
||||
const message =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message || "تغییر کلمه عبور ناموفق بود";
|
||||
?.message || t("auth.changePasswordFailed");
|
||||
toast.error(message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<AuthHead title="تغییر کلمه عبور" />
|
||||
<p className="text-sm text-gray-500 text-center mb-4 max-w-sm">
|
||||
{isEmailReset
|
||||
? "کد ارسالشده به ایمیل و کلمه عبور جدید را وارد کنید."
|
||||
: "کد ارسالشده به موبایل و کلمه عبور جدید را وارد کنید."}
|
||||
</p>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
<AuthOtpInput
|
||||
className="mt-3"
|
||||
value={formik.values.otp}
|
||||
onChange={(otp) => {
|
||||
formik.setFieldValue("otp", otp);
|
||||
setOtpError(false);
|
||||
}}
|
||||
error={
|
||||
otpError || Boolean(formik.touched.otp && formik.errors.otp)
|
||||
}
|
||||
disabled={loading}
|
||||
/>
|
||||
{formik.touched.otp && formik.errors.otp && (
|
||||
<small className="text-red-500 mt-2">{formik.errors.otp}</small>
|
||||
)}
|
||||
|
||||
<AuthPasswordInput
|
||||
name="password"
|
||||
placeholder="کلمه عبور جدید"
|
||||
wrapperClassName="mt-4"
|
||||
className={`border ${
|
||||
formik.touched.password && formik.errors.password
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.password}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.password && formik.errors.password && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.password}
|
||||
</small>
|
||||
)}
|
||||
|
||||
<AuthPasswordInput
|
||||
name="confirmPassword"
|
||||
placeholder="تکرار کلمه عبور"
|
||||
wrapperClassName="mt-4"
|
||||
className={`border ${
|
||||
formik.touched.confirmPassword && formik.errors.confirmPassword
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.confirmPassword}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.confirmPassword && formik.errors.confirmPassword && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.confirmPassword}
|
||||
</small>
|
||||
)}
|
||||
|
||||
<AuthButton
|
||||
type="submit"
|
||||
className="mt-5"
|
||||
loading={loading}
|
||||
disabled={loading || formik.values.otp.length !== 6}
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<AuthHead title={t("auth.changePasswordTitle")} />
|
||||
<p className="text-sm text-gray-500 text-center mb-4 max-w-sm">
|
||||
{isEmailReset
|
||||
? t("auth.changePasswordEmailHint")
|
||||
: t("auth.changePasswordMobileHint")}
|
||||
</p>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
تغییر کلمه عبور
|
||||
</AuthButton>
|
||||
</form>
|
||||
<AuthOtpInput
|
||||
className="mt-3"
|
||||
value={formik.values.otp}
|
||||
onChange={(otp) => {
|
||||
formik.setFieldValue("otp", otp);
|
||||
setOtpError(false);
|
||||
}}
|
||||
error={
|
||||
otpError || Boolean(formik.touched.otp && formik.errors.otp)
|
||||
}
|
||||
disabled={loading}
|
||||
/>
|
||||
{formik.touched.otp && formik.errors.otp && (
|
||||
<small className="text-red-500 mt-2">{formik.errors.otp}</small>
|
||||
)}
|
||||
|
||||
<Link href="/forget-password">
|
||||
<small className="text-[#667AC1] font-bold text-xs mt-8 block">
|
||||
ارسال مجدد کد
|
||||
</small>
|
||||
</Link>
|
||||
<Link href="/login-with-username">
|
||||
<small className="text-[#FF5C00] font-bold text-xs mt-4 block">
|
||||
بازگشت به ورود
|
||||
</small>
|
||||
</Link>
|
||||
</div>
|
||||
</Container>
|
||||
<AuthPasswordInput
|
||||
name="password"
|
||||
placeholder={t("auth.newPasswordPlaceholder")}
|
||||
wrapperClassName="mt-4"
|
||||
className={`border ${
|
||||
formik.touched.password && formik.errors.password
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.password}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.password && formik.errors.password && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.password}
|
||||
</small>
|
||||
)}
|
||||
|
||||
<AuthPasswordInput
|
||||
name="confirmPassword"
|
||||
placeholder={t("auth.confirmPasswordPlaceholder")}
|
||||
wrapperClassName="mt-4"
|
||||
className={`border ${
|
||||
formik.touched.confirmPassword && formik.errors.confirmPassword
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.confirmPassword}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.confirmPassword && formik.errors.confirmPassword && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.confirmPassword}
|
||||
</small>
|
||||
)}
|
||||
|
||||
<AuthButton
|
||||
type="submit"
|
||||
className="mt-5"
|
||||
loading={loading}
|
||||
disabled={loading || formik.values.otp.length !== 6}
|
||||
>
|
||||
{t("auth.changePassword")}
|
||||
</AuthButton>
|
||||
</form>
|
||||
|
||||
<Link href="/forget-password">
|
||||
<small className="text-[#667AC1] font-bold text-xs mt-8 block">
|
||||
{t("auth.resendCodeLink")}
|
||||
</small>
|
||||
</Link>
|
||||
<Link href="/login-with-username">
|
||||
<small className="text-[#FF5C00] font-bold text-xs mt-4 block">
|
||||
{t("auth.backToLogin")}
|
||||
</small>
|
||||
</Link>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import AuthHead from "@/components/auth/AuthHead";
|
||||
import Container from "@/components/elements/Container";
|
||||
import AuthRules from "@/components/auth/AuthRules";
|
||||
import Link from "next/link";
|
||||
import React, { useState } from "react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import AuthOtpInput from "@/components/auth/AuthOtpInput";
|
||||
import AuthOtpStatus from "@/components/auth/AuthOtpStatus";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
@@ -16,16 +16,11 @@ import { useRouter } from "next/navigation";
|
||||
import { IVerifyOtp } from "@/types/types";
|
||||
import { setAuthSession } from "@/lib/auth/session";
|
||||
import { AUTH_SUCCESS_DELAY_MS, pause } from "@/lib/auth/feedback";
|
||||
|
||||
// Validation schema using Yup
|
||||
const schema = yup.object({
|
||||
otp: yup
|
||||
.string()
|
||||
.matches(/^\d{6}$/, "کد وارد شده صحیح نیست")
|
||||
.required("کد تایید را وارد کنید"),
|
||||
});
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
|
||||
function ForgetPasswordOtp() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const [isModalOpen, setModalOpen] = useState(false);
|
||||
const [otpExpired, setOtpExpired] = useState(false);
|
||||
@@ -35,6 +30,18 @@ function ForgetPasswordOtp() {
|
||||
const { request, loading } = useAxios();
|
||||
const mobile =
|
||||
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
|
||||
|
||||
const schema = useMemo(
|
||||
() =>
|
||||
yup.object({
|
||||
otp: yup
|
||||
.string()
|
||||
.matches(/^\d{6}$/, t("auth.otpInvalid"))
|
||||
.required(t("auth.otpRequired")),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
otp: "",
|
||||
@@ -67,67 +74,75 @@ function ForgetPasswordOtp() {
|
||||
});
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<AuthHead title="فراموش کردن کلمه عبور" />
|
||||
<AuthOtpStatus
|
||||
mobile={mobile}
|
||||
resendLoading={resendLoading}
|
||||
onExpiredChange={setOtpExpired}
|
||||
onResend={async () => {
|
||||
if (!mobile) return;
|
||||
setResendLoading(true);
|
||||
try {
|
||||
await request("POST", "/login", { mobile });
|
||||
formik.setFieldValue("otp", "");
|
||||
setOtpError(false);
|
||||
setIsSuccess(false);
|
||||
} finally {
|
||||
setResendLoading(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
<AuthOtpInput
|
||||
className="mt-3"
|
||||
value={formik.values.otp}
|
||||
onChange={(otp) => {
|
||||
formik.setFieldValue("otp", otp);
|
||||
setOtpError(false);
|
||||
setIsSuccess(false);
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<AuthHead title={t("auth.forgetPasswordTitle")} />
|
||||
<AuthOtpStatus
|
||||
mobile={mobile}
|
||||
resendLoading={resendLoading}
|
||||
onExpiredChange={setOtpExpired}
|
||||
onResend={async () => {
|
||||
if (!mobile) return;
|
||||
setResendLoading(true);
|
||||
try {
|
||||
await request("POST", "/login", { mobile });
|
||||
formik.setFieldValue("otp", "");
|
||||
setOtpError(false);
|
||||
setIsSuccess(false);
|
||||
} finally {
|
||||
setResendLoading(false);
|
||||
}
|
||||
}}
|
||||
onComplete={() => setTimeout(() => formik.submitForm(), 400)}
|
||||
error={otpError || Boolean(formik.touched.otp && formik.errors.otp)}
|
||||
success={isSuccess}
|
||||
disabled={loading || otpExpired || isSuccess}
|
||||
/>
|
||||
{formik.touched.otp && formik.errors.otp && (
|
||||
<small className="text-red-500 mt-2">{formik.errors.otp}</small>
|
||||
)}
|
||||
<AuthButton
|
||||
type="submit"
|
||||
className="mt-5"
|
||||
loading={loading} disabled={loading || otpExpired || isSuccess || formik.values.otp.length !== 6}
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
تایید کد
|
||||
</AuthButton>
|
||||
</form>
|
||||
<Link href={"/login-with-username"}>
|
||||
<small className="text-[#FF5C00] font-bold text-xs mt-8 block">
|
||||
با نام کاربری و کلمه عبور خود وارد شوید
|
||||
</small>
|
||||
</Link>
|
||||
<Link href={"/forget-password"}>
|
||||
<small className="text-[#667AC1] font-bold text-xs mt-8 block">
|
||||
اصلاح شماره موبایل
|
||||
</small>
|
||||
</Link>
|
||||
<AuthRules isModalOpen={isModalOpen} setModalOpen={setModalOpen} />
|
||||
</div>
|
||||
</Container>
|
||||
<AuthOtpInput
|
||||
className="mt-3"
|
||||
value={formik.values.otp}
|
||||
onChange={(otp) => {
|
||||
formik.setFieldValue("otp", otp);
|
||||
setOtpError(false);
|
||||
setIsSuccess(false);
|
||||
}}
|
||||
onComplete={() => setTimeout(() => formik.submitForm(), 400)}
|
||||
error={otpError || Boolean(formik.touched.otp && formik.errors.otp)}
|
||||
success={isSuccess}
|
||||
disabled={loading || otpExpired || isSuccess}
|
||||
/>
|
||||
{formik.touched.otp && formik.errors.otp && (
|
||||
<small className="text-red-500 mt-2">{formik.errors.otp}</small>
|
||||
)}
|
||||
<AuthButton
|
||||
type="submit"
|
||||
className="mt-5"
|
||||
loading={loading}
|
||||
disabled={
|
||||
loading ||
|
||||
otpExpired ||
|
||||
isSuccess ||
|
||||
formik.values.otp.length !== 6
|
||||
}
|
||||
>
|
||||
{t("auth.verifyCode")}
|
||||
</AuthButton>
|
||||
</form>
|
||||
<Link href={"/login-with-username"}>
|
||||
<small className="text-[#FF5C00] font-bold text-xs mt-8 block">
|
||||
{t("auth.loginWithUsername")}
|
||||
</small>
|
||||
</Link>
|
||||
<Link href={"/forget-password"}>
|
||||
<small className="text-[#667AC1] font-bold text-xs mt-8 block">
|
||||
{t("auth.editMobile")}
|
||||
</small>
|
||||
</Link>
|
||||
<AuthRules isModalOpen={isModalOpen} setModalOpen={setModalOpen} />
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,32 +5,39 @@ import AuthHead from "@/components/auth/AuthHead";
|
||||
import AuthInput from "@/components/auth/AuthInput";
|
||||
import Container from "@/components/elements/Container";
|
||||
import Link from "next/link";
|
||||
import React, { useState } from "react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useFormik } from "formik";
|
||||
import * as yup from "yup";
|
||||
import { useRouter } from "next/navigation";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
const schema = yup.object({
|
||||
identifier: yup
|
||||
.string()
|
||||
.required("ایمیل یا شماره موبایل الزامی است")
|
||||
.test("identifier", "ایمیل یا شماره موبایل معتبر نیست", (value) => {
|
||||
if (!value) return false;
|
||||
const trimmed = value.trim();
|
||||
return (
|
||||
/^(09\d{9})$/.test(trimmed) ||
|
||||
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)
|
||||
);
|
||||
}),
|
||||
});
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
|
||||
function ForgetPasswordPage() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const { request, loading } = useAxios();
|
||||
const [destination, setDestination] = useState("");
|
||||
|
||||
const schema = useMemo(
|
||||
() =>
|
||||
yup.object({
|
||||
identifier: yup
|
||||
.string()
|
||||
.required(t("auth.identifierRequired"))
|
||||
.test("identifier", t("auth.identifierInvalid"), (value) => {
|
||||
if (!value) return false;
|
||||
const trimmed = value.trim();
|
||||
return (
|
||||
/^(09\d{9})$/.test(trimmed) ||
|
||||
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)
|
||||
);
|
||||
}),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: { identifier: "" },
|
||||
validationSchema: schema,
|
||||
@@ -55,69 +62,70 @@ function ForgetPasswordPage() {
|
||||
setDestination(response.destination);
|
||||
}
|
||||
|
||||
toast.success(response.message || "کد بازیابی ارسال شد");
|
||||
toast.success(response.message || t("auth.resetCodeSentDefault"));
|
||||
router.push("/change-passowrd");
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message || "ارسال کد ناموفق بود";
|
||||
?.message || t("auth.sendCodeFailed");
|
||||
toast.error(message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<AuthHead title="فراموشی کلمه عبور" />
|
||||
<p className="text-sm text-gray-500 text-center mb-4 max-w-sm">
|
||||
ایمیل (برای حساب گوگل) یا شماره موبایل خود را وارد کنید تا کد بازیابی
|
||||
ارسال شود.
|
||||
</p>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
<AuthInput
|
||||
name="identifier"
|
||||
type="text"
|
||||
dir="ltr"
|
||||
placeholder="email@example.com یا 09xxxxxxxxx"
|
||||
className={`border ${
|
||||
formik.touched.identifier && formik.errors.identifier
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.identifier}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.identifier && formik.errors.identifier && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.identifier}
|
||||
</small>
|
||||
)}
|
||||
{destination && (
|
||||
<small className="text-green-600 mt-2 block text-center">
|
||||
کد به {destination} ارسال شد
|
||||
</small>
|
||||
)}
|
||||
<AuthButton
|
||||
type="submit"
|
||||
className="mt-5"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<AuthHead title={t("auth.forgetPasswordTitle")} />
|
||||
<p className="text-sm text-gray-500 text-center mb-4 max-w-sm">
|
||||
{t("auth.forgetPasswordDesc")}
|
||||
</p>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
ارسال کد بازیابی
|
||||
</AuthButton>
|
||||
</form>
|
||||
<Link href="/login-with-username">
|
||||
<small className="text-[#667AC1] font-bold text-xs mt-8 block">
|
||||
بازگشت به ورود
|
||||
</small>
|
||||
</Link>
|
||||
</div>
|
||||
</Container>
|
||||
<AuthInput
|
||||
name="identifier"
|
||||
type="text"
|
||||
dir="ltr"
|
||||
placeholder={t("auth.identifierPlaceholder")}
|
||||
className={`border ${
|
||||
formik.touched.identifier && formik.errors.identifier
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.identifier}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.identifier && formik.errors.identifier && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.identifier}
|
||||
</small>
|
||||
)}
|
||||
{destination && (
|
||||
<small className="text-green-600 mt-2 block text-center">
|
||||
{t("auth.codeSentTo", { destination })}
|
||||
</small>
|
||||
)}
|
||||
<AuthButton
|
||||
type="submit"
|
||||
className="mt-5"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("auth.sendResetCode")}
|
||||
</AuthButton>
|
||||
</form>
|
||||
<Link href="/login-with-username">
|
||||
<small className="text-[#667AC1] font-bold text-xs mt-8 block">
|
||||
{t("auth.backToLogin")}
|
||||
</small>
|
||||
</Link>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
85
src/app/(auth)/(login)/login-2fa/page.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import AuthHead from "@/components/auth/AuthHead";
|
||||
import AuthOtpInput from "@/components/auth/AuthOtpInput";
|
||||
import AuthButton from "@/components/auth/AuthButton";
|
||||
import Container from "@/components/elements/Container";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { completeLogin, getSafeRedirectPath } from "@/lib/auth/postLogin";
|
||||
import { IVerifyOtp } from "@/types/types";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function LoginTwoFactorPage() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const { request, loading } = useAxios();
|
||||
const [code, setCode] = useState("");
|
||||
const [tempToken, setTempToken] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const token =
|
||||
typeof window !== "undefined"
|
||||
? sessionStorage.getItem("login_2fa_temp")
|
||||
: null;
|
||||
if (!token) {
|
||||
router.replace("/login");
|
||||
return;
|
||||
}
|
||||
setTempToken(token);
|
||||
}, [router]);
|
||||
|
||||
const handleVerify = async () => {
|
||||
if (!tempToken || code.length !== 6) return;
|
||||
|
||||
try {
|
||||
const response = (await request("POST", "/auth/totp/verify-login", {
|
||||
temp_token: tempToken,
|
||||
code,
|
||||
})) as IVerifyOtp;
|
||||
|
||||
sessionStorage.removeItem("login_2fa_temp");
|
||||
|
||||
const loggedIn = await completeLogin(router, response, {
|
||||
redirectTo: getSafeRedirectPath(),
|
||||
});
|
||||
|
||||
if (!loggedIn) {
|
||||
toast.error(t("auth.invalidServerResponse"));
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("settings.edit.twoFactor.invalidCode"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<div className="flex h-screen flex-col items-center justify-center p-4">
|
||||
<AuthHead title={t("auth.twoFactorTitle")} />
|
||||
<p className="mb-6 max-w-sm text-center text-sm text-neutral-500">
|
||||
{t("auth.twoFactorHint")}
|
||||
</p>
|
||||
<AuthOtpInput
|
||||
value={code}
|
||||
onChange={setCode}
|
||||
onComplete={() => setTimeout(handleVerify, 300)}
|
||||
disabled={loading || !tempToken}
|
||||
/>
|
||||
<AuthButton
|
||||
type="button"
|
||||
className="mt-5"
|
||||
loading={loading}
|
||||
disabled={loading || code.length !== 6 || !tempToken}
|
||||
onClick={handleVerify}
|
||||
>
|
||||
{t("auth.verifyCode")}
|
||||
</AuthButton>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import AuthPasswordInput from "@/components/auth/AuthPasswordInput";
|
||||
import Container from "@/components/elements/Container";
|
||||
import AuthRules from "@/components/auth/AuthRules";
|
||||
import Link from "next/link";
|
||||
import React, { useState } from "react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useFormik } from "formik";
|
||||
import * as yup from "yup";
|
||||
@@ -21,16 +21,11 @@ import {
|
||||
} from "@/lib/auth/postLogin";
|
||||
import toast from "react-hot-toast";
|
||||
import { useAuthSessionRedirect } from "@/hooks/useAuthSessionRedirect";
|
||||
|
||||
const schema = yup.object().shape({
|
||||
username: yup.string().required("نام کاربری الزامی است"),
|
||||
password: yup
|
||||
.string()
|
||||
.required("کلمه عبور نمی\u200Cتواند خالی باشد")
|
||||
.min(8, "کلمه عبور نباید کوتاه تر از 8 کاراکتر باشد"),
|
||||
});
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
|
||||
function LoginWithUsername() {
|
||||
const { t, i18n } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const [isModalOpen, setModalOpen] = useState(false);
|
||||
const [isSuccess, setIsSuccess] = useState(false);
|
||||
@@ -40,6 +35,18 @@ function LoginWithUsername() {
|
||||
const redirectPath = getSafeRedirectPath();
|
||||
useAuthSessionRedirect();
|
||||
|
||||
const schema = useMemo(
|
||||
() =>
|
||||
yup.object().shape({
|
||||
username: yup.string().required(t("auth.usernameRequired")),
|
||||
password: yup
|
||||
.string()
|
||||
.required(t("auth.passwordRequired"))
|
||||
.min(8, t("auth.passwordMin")),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
username: "",
|
||||
@@ -66,7 +73,7 @@ function LoginWithUsername() {
|
||||
});
|
||||
|
||||
if (!loggedIn) {
|
||||
const message = "پاسخ سرور نامعتبر بود. دوباره تلاش کنید.";
|
||||
const message = t("auth.invalidServerResponse");
|
||||
setAuthError(true);
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
@@ -75,7 +82,10 @@ function LoginWithUsername() {
|
||||
|
||||
setIsSuccess(true);
|
||||
} catch (err: any) {
|
||||
const message = getAxiosErrorMessage(err);
|
||||
const message = getAxiosErrorMessage(
|
||||
err,
|
||||
i18n.language === "en" ? "en" : "fa"
|
||||
);
|
||||
setAuthError(true);
|
||||
setIsSuccess(false);
|
||||
setErrorMessage(message);
|
||||
@@ -85,88 +95,91 @@ function LoginWithUsername() {
|
||||
});
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<AuthHead title="ورود" />
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
<AuthInput
|
||||
name="username"
|
||||
type="text"
|
||||
placeholder="نام کاربری"
|
||||
success={isSuccess}
|
||||
error={
|
||||
authError ||
|
||||
Boolean(formik.touched.username && formik.errors.username)
|
||||
}
|
||||
value={formik.values.username}
|
||||
onChange={(e) => {
|
||||
setAuthError(false);
|
||||
setErrorMessage("");
|
||||
setIsSuccess(false);
|
||||
formik.handleChange(e);
|
||||
}}
|
||||
onBlur={formik.handleBlur}
|
||||
disabled={loading || isSuccess}
|
||||
/>
|
||||
{formik.touched.username && formik.errors.username && !authError && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.username}
|
||||
</small>
|
||||
)}
|
||||
<AuthPasswordInput
|
||||
name="password"
|
||||
placeholder="کلمه عبور"
|
||||
wrapperClassName="mt-4"
|
||||
success={isSuccess}
|
||||
error={
|
||||
authError ||
|
||||
Boolean(formik.touched.password && formik.errors.password)
|
||||
}
|
||||
value={formik.values.password}
|
||||
onChange={(e) => {
|
||||
setAuthError(false);
|
||||
setErrorMessage("");
|
||||
setIsSuccess(false);
|
||||
formik.handleChange(e);
|
||||
}}
|
||||
onBlur={formik.handleBlur}
|
||||
disabled={loading || isSuccess}
|
||||
/>
|
||||
{formik.touched.password && formik.errors.password && !authError && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.password}
|
||||
</small>
|
||||
)}
|
||||
{authError && errorMessage && (
|
||||
<small className="text-red-500 mt-2 block text-center">
|
||||
{errorMessage}
|
||||
</small>
|
||||
)}
|
||||
<AuthButton
|
||||
type="submit"
|
||||
className="mt-5"
|
||||
loading={loading}
|
||||
disabled={loading || isSuccess}
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<AuthHead title={t("auth.login")} />
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
ورود
|
||||
</AuthButton>
|
||||
</form>
|
||||
<Link href={"/forget-password"}>
|
||||
<small className="text-[#FF5C00] font-bold text-xs mt-8 block">
|
||||
کلمه عبور خود را فراموش کرده اید؟
|
||||
</small>
|
||||
</Link>
|
||||
<Link href={"/register"}>
|
||||
<small className="text-[#292D32] font-bold text-xs mt-8 block">
|
||||
حساب کاربری ندارید؟ <span className="text-[#0033EA]">ثبت نام</span>
|
||||
</small>
|
||||
</Link>
|
||||
<AuthRules isModalOpen={isModalOpen} setModalOpen={setModalOpen} />
|
||||
</div>
|
||||
</Container>
|
||||
<AuthInput
|
||||
name="username"
|
||||
type="text"
|
||||
placeholder={t("auth.usernamePlaceholder")}
|
||||
success={isSuccess}
|
||||
error={
|
||||
authError ||
|
||||
Boolean(formik.touched.username && formik.errors.username)
|
||||
}
|
||||
value={formik.values.username}
|
||||
onChange={(e) => {
|
||||
setAuthError(false);
|
||||
setErrorMessage("");
|
||||
setIsSuccess(false);
|
||||
formik.handleChange(e);
|
||||
}}
|
||||
onBlur={formik.handleBlur}
|
||||
disabled={loading || isSuccess}
|
||||
/>
|
||||
{formik.touched.username && formik.errors.username && !authError && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.username}
|
||||
</small>
|
||||
)}
|
||||
<AuthPasswordInput
|
||||
name="password"
|
||||
placeholder={t("auth.passwordPlaceholder")}
|
||||
wrapperClassName="mt-4"
|
||||
success={isSuccess}
|
||||
error={
|
||||
authError ||
|
||||
Boolean(formik.touched.password && formik.errors.password)
|
||||
}
|
||||
value={formik.values.password}
|
||||
onChange={(e) => {
|
||||
setAuthError(false);
|
||||
setErrorMessage("");
|
||||
setIsSuccess(false);
|
||||
formik.handleChange(e);
|
||||
}}
|
||||
onBlur={formik.handleBlur}
|
||||
disabled={loading || isSuccess}
|
||||
/>
|
||||
{formik.touched.password && formik.errors.password && !authError && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.password}
|
||||
</small>
|
||||
)}
|
||||
{authError && errorMessage && (
|
||||
<small className="text-red-500 mt-2 block text-center">
|
||||
{errorMessage}
|
||||
</small>
|
||||
)}
|
||||
<AuthButton
|
||||
type="submit"
|
||||
className="mt-5"
|
||||
loading={loading}
|
||||
disabled={loading || isSuccess}
|
||||
>
|
||||
{t("auth.login")}
|
||||
</AuthButton>
|
||||
</form>
|
||||
<Link href={"/forget-password"}>
|
||||
<small className="text-[#FF5C00] font-bold text-xs mt-8 block">
|
||||
{t("auth.forgotPassword")}
|
||||
</small>
|
||||
</Link>
|
||||
<Link href={"/register"}>
|
||||
<small className="text-[#292D32] font-bold text-xs mt-8 block">
|
||||
{t("auth.noAccount")}{" "}
|
||||
<span className="text-[#0033EA]">{t("auth.signUp")}</span>
|
||||
</small>
|
||||
</Link>
|
||||
<AuthRules isModalOpen={isModalOpen} setModalOpen={setModalOpen} />
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,27 +6,21 @@ import AuthInput from "@/components/auth/AuthInput";
|
||||
import Container from "@/components/elements/Container";
|
||||
import AuthRules from "@/components/auth/AuthRules";
|
||||
import Link from "next/link";
|
||||
import React, { useState } from "react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useFormik } from "formik";
|
||||
import * as yup from "yup";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { markOtpSent } from "@/lib/auth/otpTimer";
|
||||
|
||||
// Validation schema using Yup
|
||||
const schema = yup.object({
|
||||
mobile: yup
|
||||
.string()
|
||||
.matches(/^(09\d{9})$/, "شماره موبایل معتبر نیست")
|
||||
.required("شماره موبایل الزامی است"),
|
||||
});
|
||||
|
||||
import { getSafeRedirectPath } from "@/lib/auth/postLogin";
|
||||
import GoogleSignInButton from "@/components/auth/GoogleSignInButton";
|
||||
import { GoogleSignInButton } from "@/app/(auth)/AuthProviders";
|
||||
import { MOBILE_NUMERIC_INPUT_PROPS } from "@/lib/auth/inputProps";
|
||||
import { useAuthSessionRedirect } from "@/hooks/useAuthSessionRedirect";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
|
||||
function Login() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const [isModalOpen, setModalOpen] = useState(false);
|
||||
const { request, loading } = useAxios();
|
||||
@@ -37,6 +31,17 @@ function Login() {
|
||||
? `/login-with-username?redirect=${encodeURIComponent(redirectPath)}`
|
||||
: "/login-with-username";
|
||||
|
||||
const schema = useMemo(
|
||||
() =>
|
||||
yup.object({
|
||||
mobile: yup
|
||||
.string()
|
||||
.matches(/^(09\d{9})$/, t("auth.mobileInvalid"))
|
||||
.required(t("auth.mobileRequired")),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
mobile: "",
|
||||
@@ -55,51 +60,53 @@ function Login() {
|
||||
});
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<AuthHead title="ورود" />
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
<AuthInput
|
||||
name="mobile"
|
||||
maxLength={11}
|
||||
{...MOBILE_NUMERIC_INPUT_PROPS}
|
||||
dir="ltr"
|
||||
placeholder="09 _ _ _ _ _ _ _ _ _"
|
||||
className={`border ${
|
||||
formik.touched.mobile && formik.errors.mobile
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.mobile}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.mobile && formik.errors.mobile && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.mobile}
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<AuthHead title={t("auth.login")} />
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
<AuthInput
|
||||
name="mobile"
|
||||
maxLength={11}
|
||||
{...MOBILE_NUMERIC_INPUT_PROPS}
|
||||
dir="ltr"
|
||||
placeholder="09 _ _ _ _ _ _ _ _ _"
|
||||
className={`border ${
|
||||
formik.touched.mobile && formik.errors.mobile
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.mobile}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.mobile && formik.errors.mobile && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.mobile}
|
||||
</small>
|
||||
)}
|
||||
<AuthButton type="submit" className="mt-5" loading={loading} disabled={loading}>
|
||||
{t("auth.sendCode")}
|
||||
</AuthButton>
|
||||
</form>
|
||||
<GoogleSignInButton mode="login" />
|
||||
<Link href={usernameLoginHref}>
|
||||
<small className="text-[#FF5C00] font-bold text-xs mt-8 block">
|
||||
{t("auth.loginWithUsername")}
|
||||
</small>
|
||||
)}
|
||||
<AuthButton type="submit" className="mt-5" loading={loading} disabled={loading}>
|
||||
ارسال کد
|
||||
</AuthButton>
|
||||
</form>
|
||||
<GoogleSignInButton mode="login" />
|
||||
<Link href={usernameLoginHref}>
|
||||
<small className="text-[#FF5C00] font-bold text-xs mt-8 block">
|
||||
با نام کاربری و کلمه عبور خود وارد شوید
|
||||
</small>
|
||||
</Link>
|
||||
<button onClick={() => setModalOpen(true)} className="mb-2">
|
||||
<small className="text-[#FF5C00] font-bold text-xs mt-8 block">
|
||||
قوانین و مقررات
|
||||
</small>
|
||||
</button>
|
||||
<AuthRules isModalOpen={isModalOpen} setModalOpen={setModalOpen} />
|
||||
</div>
|
||||
</Container>
|
||||
</Link>
|
||||
<button onClick={() => setModalOpen(true)} className="mb-2">
|
||||
<small className="text-[#FF5C00] font-bold text-xs mt-8 block">
|
||||
{t("auth.rules")}
|
||||
</small>
|
||||
</button>
|
||||
<AuthRules isModalOpen={isModalOpen} setModalOpen={setModalOpen} />
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import AuthHead from "@/components/auth/AuthHead";
|
||||
import Container from "@/components/elements/Container";
|
||||
import AuthRules from "@/components/auth/AuthRules";
|
||||
import Link from "next/link";
|
||||
import React, { useState } from "react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import AuthOtpInput from "@/components/auth/AuthOtpInput";
|
||||
import AuthOtpStatus from "@/components/auth/AuthOtpStatus";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
@@ -18,16 +18,11 @@ import {
|
||||
completeLogin,
|
||||
getSafeRedirectPath,
|
||||
} from "@/lib/auth/postLogin";
|
||||
|
||||
// Validation schema using Yup
|
||||
const schema = yup.object({
|
||||
otp: yup
|
||||
.string()
|
||||
.matches(/^\d{6}$/, "کد وارد شده صحیح نیست")
|
||||
.required("کد تایید را وارد کنید"),
|
||||
});
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
|
||||
function VerifyOtp() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const [isModalOpen, setModalOpen] = useState(false);
|
||||
const [otpExpired, setOtpExpired] = useState(false);
|
||||
@@ -37,6 +32,18 @@ function VerifyOtp() {
|
||||
const { request, loading } = useAxios();
|
||||
const mobile =
|
||||
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
|
||||
|
||||
const schema = useMemo(
|
||||
() =>
|
||||
yup.object({
|
||||
otp: yup
|
||||
.string()
|
||||
.matches(/^\d{6}$/, t("auth.otpInvalid"))
|
||||
.required(t("auth.otpRequired")),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
otp: "",
|
||||
@@ -56,21 +63,13 @@ function VerifyOtp() {
|
||||
otp: values.otp.trim(),
|
||||
})) as IVerifyOtp;
|
||||
|
||||
switch (response?.page) {
|
||||
case "home":
|
||||
setIsSuccess(true);
|
||||
await completeLogin(router, response, {
|
||||
redirectTo: getSafeRedirectPath(),
|
||||
});
|
||||
break;
|
||||
case "auth-page":
|
||||
setIsSuccess(true);
|
||||
await completeLogin(router, response);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
const loggedIn = await completeLogin(router, response, {
|
||||
redirectTo: getSafeRedirectPath(),
|
||||
});
|
||||
|
||||
if (loggedIn) {
|
||||
setIsSuccess(true);
|
||||
}
|
||||
// router.push("/dashboard");
|
||||
} catch (err: any) {
|
||||
setOtpError(true);
|
||||
setIsSuccess(false);
|
||||
@@ -80,68 +79,76 @@ function VerifyOtp() {
|
||||
});
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<AuthHead title="ورود" />
|
||||
<AuthOtpStatus
|
||||
mobile={mobile}
|
||||
resendLoading={resendLoading}
|
||||
onExpiredChange={setOtpExpired}
|
||||
onResend={async () => {
|
||||
if (!mobile) return;
|
||||
setResendLoading(true);
|
||||
try {
|
||||
await request("POST", "/login", { mobile });
|
||||
formik.setFieldValue("otp", "");
|
||||
setOtpError(false);
|
||||
setIsSuccess(false);
|
||||
} finally {
|
||||
setResendLoading(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
<AuthOtpInput
|
||||
className="mt-3"
|
||||
value={formik.values.otp}
|
||||
onChange={(otp) => {
|
||||
formik.setFieldValue("otp", otp);
|
||||
setOtpError(false);
|
||||
setIsSuccess(false);
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<AuthHead title={t("auth.login")} />
|
||||
<AuthOtpStatus
|
||||
mobile={mobile}
|
||||
resendLoading={resendLoading}
|
||||
onExpiredChange={setOtpExpired}
|
||||
onResend={async () => {
|
||||
if (!mobile) return;
|
||||
setResendLoading(true);
|
||||
try {
|
||||
await request("POST", "/login", { mobile });
|
||||
formik.setFieldValue("otp", "");
|
||||
setOtpError(false);
|
||||
setIsSuccess(false);
|
||||
} finally {
|
||||
setResendLoading(false);
|
||||
}
|
||||
}}
|
||||
onComplete={() => setTimeout(() => formik.submitForm(), 400)}
|
||||
error={otpError || Boolean(formik.touched.otp && formik.errors.otp)}
|
||||
success={isSuccess}
|
||||
disabled={loading || otpExpired || isSuccess}
|
||||
/>
|
||||
|
||||
{formik.touched.otp && formik.errors.otp && (
|
||||
<small className="text-red-500 mt-2">{formik.errors.otp}</small>
|
||||
)}
|
||||
<AuthButton
|
||||
type="submit"
|
||||
className="mt-5"
|
||||
loading={loading} disabled={loading || otpExpired || isSuccess || formik.values.otp.length !== 6}
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
تایید کد
|
||||
</AuthButton>
|
||||
</form>
|
||||
<Link href={"/login-with-username"}>
|
||||
<small className="text-[#FF5C00] font-bold text-xs mt-8 block">
|
||||
با نام کاربری و کلمه عبور خود وارد شوید
|
||||
</small>
|
||||
</Link>
|
||||
<Link href={"/login"}>
|
||||
<small className="text-[#667AC1] font-bold text-xs mt-8 block">
|
||||
اصلاح شماره موبایل
|
||||
</small>
|
||||
</Link>
|
||||
<AuthRules isModalOpen={isModalOpen} setModalOpen={setModalOpen} />
|
||||
</div>
|
||||
</Container>
|
||||
<AuthOtpInput
|
||||
className="mt-3"
|
||||
value={formik.values.otp}
|
||||
onChange={(otp) => {
|
||||
formik.setFieldValue("otp", otp);
|
||||
setOtpError(false);
|
||||
setIsSuccess(false);
|
||||
}}
|
||||
onComplete={() => setTimeout(() => formik.submitForm(), 400)}
|
||||
error={otpError || Boolean(formik.touched.otp && formik.errors.otp)}
|
||||
success={isSuccess}
|
||||
disabled={loading || otpExpired || isSuccess}
|
||||
/>
|
||||
|
||||
{formik.touched.otp && formik.errors.otp && (
|
||||
<small className="text-red-500 mt-2">{formik.errors.otp}</small>
|
||||
)}
|
||||
<AuthButton
|
||||
type="submit"
|
||||
className="mt-5"
|
||||
loading={loading}
|
||||
disabled={
|
||||
loading ||
|
||||
otpExpired ||
|
||||
isSuccess ||
|
||||
formik.values.otp.length !== 6
|
||||
}
|
||||
>
|
||||
{t("auth.verifyCode")}
|
||||
</AuthButton>
|
||||
</form>
|
||||
<Link href={"/login-with-username"}>
|
||||
<small className="text-[#FF5C00] font-bold text-xs mt-8 block">
|
||||
{t("auth.loginWithUsername")}
|
||||
</small>
|
||||
</Link>
|
||||
<Link href={"/login"}>
|
||||
<small className="text-[#667AC1] font-bold text-xs mt-8 block">
|
||||
{t("auth.editMobile")}
|
||||
</small>
|
||||
</Link>
|
||||
<AuthRules isModalOpen={isModalOpen} setModalOpen={setModalOpen} />
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import AuthHead from "@/components/auth/AuthHead";
|
||||
import Container from "@/components/elements/Container";
|
||||
import AuthRules from "@/components/auth/AuthRules";
|
||||
import Link from "next/link";
|
||||
import React, { useState } from "react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import AuthOtpInput from "@/components/auth/AuthOtpInput";
|
||||
import AuthOtpStatus from "@/components/auth/AuthOtpStatus";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
@@ -16,16 +16,11 @@ import * as yup from "yup";
|
||||
import { IVerifyOtp } from "@/types/types";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { completeLogin } from "@/lib/auth/postLogin";
|
||||
|
||||
// Validation schema using Yup
|
||||
const schema = yup.object({
|
||||
otp: yup
|
||||
.string()
|
||||
.matches(/^\d{6}$/, "کد وارد شده صحیح نیست")
|
||||
.required("کد تایید را وارد کنید"),
|
||||
});
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
|
||||
function RegisterOtp() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const [isModalOpen, setModalOpen] = useState(false);
|
||||
const [otpExpired, setOtpExpired] = useState(false);
|
||||
@@ -35,6 +30,18 @@ function RegisterOtp() {
|
||||
const { request, loading } = useAxios();
|
||||
const mobile =
|
||||
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
|
||||
|
||||
const schema = useMemo(
|
||||
() =>
|
||||
yup.object({
|
||||
otp: yup
|
||||
.string()
|
||||
.matches(/^\d{6}$/, t("auth.otpInvalid"))
|
||||
.required(t("auth.otpRequired")),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
otp: "",
|
||||
@@ -65,62 +72,70 @@ function RegisterOtp() {
|
||||
});
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<AuthHead title="ثبت نام" />
|
||||
<AuthOtpStatus
|
||||
mobile={mobile}
|
||||
resendLoading={resendLoading}
|
||||
onExpiredChange={setOtpExpired}
|
||||
onResend={async () => {
|
||||
if (!mobile) return;
|
||||
setResendLoading(true);
|
||||
try {
|
||||
await request("POST", "/register", { mobile });
|
||||
formik.setFieldValue("otp", "");
|
||||
setOtpError(false);
|
||||
setIsSuccess(false);
|
||||
} finally {
|
||||
setResendLoading(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
<AuthOtpInput
|
||||
className="mt-3"
|
||||
value={formik.values.otp}
|
||||
onChange={(otp) => {
|
||||
formik.setFieldValue("otp", otp);
|
||||
setOtpError(false);
|
||||
setIsSuccess(false);
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<AuthHead title={t("auth.register")} />
|
||||
<AuthOtpStatus
|
||||
mobile={mobile}
|
||||
resendLoading={resendLoading}
|
||||
onExpiredChange={setOtpExpired}
|
||||
onResend={async () => {
|
||||
if (!mobile) return;
|
||||
setResendLoading(true);
|
||||
try {
|
||||
await request("POST", "/register", { mobile });
|
||||
formik.setFieldValue("otp", "");
|
||||
setOtpError(false);
|
||||
setIsSuccess(false);
|
||||
} finally {
|
||||
setResendLoading(false);
|
||||
}
|
||||
}}
|
||||
onComplete={() => setTimeout(() => formik.submitForm(), 400)}
|
||||
error={otpError || Boolean(formik.touched.otp && formik.errors.otp)}
|
||||
success={isSuccess}
|
||||
disabled={loading || otpExpired || isSuccess}
|
||||
/>
|
||||
{formik.touched.otp && formik.errors.otp && (
|
||||
<small className="text-red-500 mt-2">{formik.errors.otp}</small>
|
||||
)}
|
||||
<AuthButton
|
||||
type="submit"
|
||||
className="mt-5"
|
||||
loading={loading} disabled={loading || otpExpired || isSuccess || formik.values.otp.length !== 6}
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
تایید کد
|
||||
</AuthButton>
|
||||
</form>
|
||||
<Link href={"/forget-password"}>
|
||||
<small className="text-[#667AC1] font-bold text-xs mt-8 block">
|
||||
اصلاح شماره موبایل
|
||||
</small>
|
||||
</Link>
|
||||
<AuthRules isModalOpen={isModalOpen} setModalOpen={setModalOpen} />
|
||||
</div>
|
||||
</Container>
|
||||
<AuthOtpInput
|
||||
className="mt-3"
|
||||
value={formik.values.otp}
|
||||
onChange={(otp) => {
|
||||
formik.setFieldValue("otp", otp);
|
||||
setOtpError(false);
|
||||
setIsSuccess(false);
|
||||
}}
|
||||
onComplete={() => setTimeout(() => formik.submitForm(), 400)}
|
||||
error={otpError || Boolean(formik.touched.otp && formik.errors.otp)}
|
||||
success={isSuccess}
|
||||
disabled={loading || otpExpired || isSuccess}
|
||||
/>
|
||||
{formik.touched.otp && formik.errors.otp && (
|
||||
<small className="text-red-500 mt-2">{formik.errors.otp}</small>
|
||||
)}
|
||||
<AuthButton
|
||||
type="submit"
|
||||
className="mt-5"
|
||||
loading={loading}
|
||||
disabled={
|
||||
loading ||
|
||||
otpExpired ||
|
||||
isSuccess ||
|
||||
formik.values.otp.length !== 6
|
||||
}
|
||||
>
|
||||
{t("auth.verifyCode")}
|
||||
</AuthButton>
|
||||
</form>
|
||||
<Link href={"/forget-password"}>
|
||||
<small className="text-[#667AC1] font-bold text-xs mt-8 block">
|
||||
{t("auth.editMobile")}
|
||||
</small>
|
||||
</Link>
|
||||
<AuthRules isModalOpen={isModalOpen} setModalOpen={setModalOpen} />
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,8 +12,11 @@ import { useRouter } from "next/navigation";
|
||||
import AuthNextButton from "@/components/auth/AuthNextButton";
|
||||
import toast from "react-hot-toast";
|
||||
import { getSafeRedirectPath } from "@/lib/auth/postLogin";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
|
||||
function RegisterCompletePage() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const { request, loading } = useAxios();
|
||||
const [action, setAction] = useState<"app" | "continue" | null>(null);
|
||||
@@ -32,47 +35,50 @@ function RegisterCompletePage() {
|
||||
router.push(getSafeRedirectPath());
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.message || "خطا در تکمیل ثبتنام");
|
||||
toast.error(
|
||||
err?.response?.data?.message || t("auth.registrationFinishFailed")
|
||||
);
|
||||
} finally {
|
||||
setAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthPageLayout>
|
||||
<div className="flex w-full flex-1 flex-col items-center">
|
||||
<AuthPageContent>
|
||||
<AuthHead title="ثبتنام اولیه تکمیل شد" />
|
||||
<p className="text-sm text-gray-500 text-center max-w-sm mt-2">
|
||||
میتوانید وارد برنامه شوید یا ثبتنام کامل (تایید هویت و پروفایل)
|
||||
را ادامه دهید.
|
||||
</p>
|
||||
</AuthPageContent>
|
||||
<LocalePageShell>
|
||||
<AuthPageLayout>
|
||||
<div className="flex w-full flex-1 flex-col items-center">
|
||||
<AuthPageContent>
|
||||
<AuthHead title={t("auth.registerCompleteTitle")} />
|
||||
<p className="text-sm text-gray-500 text-center max-w-sm mt-2">
|
||||
{t("auth.registerCompleteDesc")}
|
||||
</p>
|
||||
</AuthPageContent>
|
||||
|
||||
<AuthFormFooter>
|
||||
<div className="flex flex-col sm:flex-row gap-3 w-full max-w-sm justify-center items-center">
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={() => finishRegistration("continue")}
|
||||
className=" w-full sm:w-auto"
|
||||
disabled={loading}
|
||||
loading={loading && action === "continue"}
|
||||
>
|
||||
ادامه ثبت نام
|
||||
</AuthNextButton>
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={() => finishRegistration("app")}
|
||||
className="!border-[#FF0000] !text-[#FF0000] w-full sm:w-auto"
|
||||
disabled={loading}
|
||||
loading={loading && action === "app"}
|
||||
>
|
||||
ورود به برنامه
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
</AuthFormFooter>
|
||||
</div>
|
||||
</AuthPageLayout>
|
||||
<AuthFormFooter>
|
||||
<div className="flex flex-col sm:flex-row gap-3 w-full max-w-sm justify-center items-center">
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={() => finishRegistration("continue")}
|
||||
className=" w-full sm:w-auto"
|
||||
disabled={loading}
|
||||
loading={loading && action === "continue"}
|
||||
>
|
||||
{t("auth.continueRegistration")}
|
||||
</AuthNextButton>
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={() => finishRegistration("app")}
|
||||
className="!border-[#FF0000] !text-[#FF0000] w-full sm:w-auto"
|
||||
disabled={loading}
|
||||
loading={loading && action === "app"}
|
||||
>
|
||||
{t("auth.enterApp")}
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
</AuthFormFooter>
|
||||
</div>
|
||||
</AuthPageLayout>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import AuthPageLayout, {
|
||||
AuthFormFooter,
|
||||
AuthPageContent,
|
||||
} from "@/components/auth/AuthPageLayout";
|
||||
import React, { useState } from "react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useFormik } from "formik";
|
||||
import * as yup from "yup";
|
||||
@@ -15,13 +15,11 @@ import { useRouter } from "next/navigation";
|
||||
import AuthNextButton from "@/components/auth/AuthNextButton";
|
||||
import toast from "react-hot-toast";
|
||||
import { getSafeRedirectPath } from "@/lib/auth/postLogin";
|
||||
|
||||
const schema = yup.object().shape({
|
||||
name: yup.string().trim().required("نام الزامی است").min(2, "نام کوتاه است"),
|
||||
family: yup.string().trim().optional(),
|
||||
});
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
|
||||
function FullNamePage() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const { request, loading } = useAxios();
|
||||
const [nameSaved, setNameSaved] = useState(false);
|
||||
@@ -29,6 +27,19 @@ function FullNamePage() {
|
||||
null
|
||||
);
|
||||
|
||||
const schema = useMemo(
|
||||
() =>
|
||||
yup.object().shape({
|
||||
name: yup
|
||||
.string()
|
||||
.trim()
|
||||
.required(t("auth.nameRequired"))
|
||||
.min(2, t("auth.nameTooShort")),
|
||||
family: yup.string().trim().optional(),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
family: "",
|
||||
@@ -50,9 +61,9 @@ function FullNamePage() {
|
||||
}
|
||||
|
||||
setNameSaved(true);
|
||||
toast.success("نام با موفقیت ثبت شد");
|
||||
toast.success(t("auth.nameSavedSuccess"));
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.message || "خطا در ذخیره نام");
|
||||
toast.error(err?.response?.data?.message || t("auth.saveNameFailed"));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -73,105 +84,109 @@ function FullNamePage() {
|
||||
router.push(getSafeRedirectPath());
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.message || "خطا در تکمیل ثبتنام");
|
||||
toast.error(
|
||||
err?.response?.data?.message || t("auth.registrationFinishFailed")
|
||||
);
|
||||
} finally {
|
||||
setChoiceLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthPageLayout>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="flex w-full flex-1 flex-col items-center"
|
||||
>
|
||||
<AuthPageContent>
|
||||
<AuthHead title="نام و نام خانوادگی" />
|
||||
<div className="w-full max-w-sm flex flex-col items-center">
|
||||
<AuthInput
|
||||
name="name"
|
||||
type="text"
|
||||
placeholder="نام *"
|
||||
dir="rtl"
|
||||
disabled={nameSaved || loading}
|
||||
className={`border mt-4 ${
|
||||
formik.touched.name && formik.errors.name
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.name}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.name && formik.errors.name && (
|
||||
<small className="text-red-500 mt-2 block text-center">
|
||||
{formik.errors.name}
|
||||
</small>
|
||||
)}
|
||||
<LocalePageShell>
|
||||
<AuthPageLayout>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="flex w-full flex-1 flex-col items-center"
|
||||
>
|
||||
<AuthPageContent>
|
||||
<AuthHead title={t("auth.fullNameTitle")} />
|
||||
<div className="w-full max-w-sm flex flex-col items-center">
|
||||
<AuthInput
|
||||
name="name"
|
||||
type="text"
|
||||
placeholder={t("auth.firstNamePlaceholder")}
|
||||
dir="rtl"
|
||||
disabled={nameSaved || loading}
|
||||
className={`border mt-4 ${
|
||||
formik.touched.name && formik.errors.name
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.name}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.name && formik.errors.name && (
|
||||
<small className="text-red-500 mt-2 block text-center">
|
||||
{formik.errors.name}
|
||||
</small>
|
||||
)}
|
||||
|
||||
<AuthInput
|
||||
name="family"
|
||||
type="text"
|
||||
placeholder="نام خانوادگی (اختیاری)"
|
||||
dir="rtl"
|
||||
disabled={nameSaved || loading}
|
||||
className={`border mt-4 ${
|
||||
formik.touched.family && formik.errors.family
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.family}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.family && formik.errors.family && (
|
||||
<small className="text-red-500 mt-2 block text-center">
|
||||
{formik.errors.family}
|
||||
</small>
|
||||
)}
|
||||
<AuthInput
|
||||
name="family"
|
||||
type="text"
|
||||
placeholder={t("auth.lastNamePlaceholder")}
|
||||
dir="rtl"
|
||||
disabled={nameSaved || loading}
|
||||
className={`border mt-4 ${
|
||||
formik.touched.family && formik.errors.family
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.family}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.family && formik.errors.family && (
|
||||
<small className="text-red-500 mt-2 block text-center">
|
||||
{formik.errors.family}
|
||||
</small>
|
||||
)}
|
||||
|
||||
{nameSaved && (
|
||||
<p className="text-sm text-green-600 text-center mt-4">
|
||||
نام شما ثبت شد. یکی از گزینههای زیر را انتخاب کنید.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</AuthPageContent>
|
||||
|
||||
<AuthFormFooter>
|
||||
{!nameSaved ? (
|
||||
<AuthNextButton
|
||||
type="submit"
|
||||
disabled={loading || !formik.values.name.trim()}
|
||||
loading={loading}
|
||||
>
|
||||
ثبت و ادامه
|
||||
</AuthNextButton>
|
||||
) : (
|
||||
<div className="flex flex-col sm:flex-row gap-3 w-full max-w-sm justify-center items-center">
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={() => finishRegistration("continue")}
|
||||
className=" w-full sm:w-auto"
|
||||
disabled={Boolean(choiceLoading)}
|
||||
loading={choiceLoading === "continue"}
|
||||
>
|
||||
ادامه ثبت نام
|
||||
</AuthNextButton>
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={() => finishRegistration("app")}
|
||||
className="!border-[#FF0000] !text-[#FF0000] w-full sm:w-auto"
|
||||
disabled={Boolean(choiceLoading)}
|
||||
loading={choiceLoading === "app"}
|
||||
>
|
||||
ورود به برنامه
|
||||
</AuthNextButton>
|
||||
{nameSaved && (
|
||||
<p className="text-sm text-green-600 text-center mt-4">
|
||||
{t("auth.nameSavedChoose")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</AuthFormFooter>
|
||||
</form>
|
||||
</AuthPageLayout>
|
||||
</AuthPageContent>
|
||||
|
||||
<AuthFormFooter>
|
||||
{!nameSaved ? (
|
||||
<AuthNextButton
|
||||
type="submit"
|
||||
disabled={loading || !formik.values.name.trim()}
|
||||
loading={loading}
|
||||
>
|
||||
{t("auth.saveAndContinue")}
|
||||
</AuthNextButton>
|
||||
) : (
|
||||
<div className="flex flex-col sm:flex-row gap-3 w-full max-w-sm justify-center items-center">
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={() => finishRegistration("continue")}
|
||||
className=" w-full sm:w-auto"
|
||||
disabled={Boolean(choiceLoading)}
|
||||
loading={choiceLoading === "continue"}
|
||||
>
|
||||
{t("auth.continueRegistration")}
|
||||
</AuthNextButton>
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={() => finishRegistration("app")}
|
||||
className="!border-[#FF0000] !text-[#FF0000] w-full sm:w-auto"
|
||||
disabled={Boolean(choiceLoading)}
|
||||
loading={choiceLoading === "app"}
|
||||
>
|
||||
{t("auth.enterApp")}
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
)}
|
||||
</AuthFormFooter>
|
||||
</form>
|
||||
</AuthPageLayout>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
15
src/app/(auth)/(register)/register/loading.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import IOSSpinner from "@/components/ui/IOSSpinner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function RegisterLoading() {
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[50vh] w-full max-w-xl flex-col items-center justify-center gap-3 px-4">
|
||||
<IOSSpinner size={28} color="#ff107d" />
|
||||
<span className="text-sm text-neutral-500">{t("auth.pageLoading")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,28 +5,34 @@ import AuthHead from "@/components/auth/AuthHead";
|
||||
import AuthInput from "@/components/auth/AuthInput";
|
||||
import Container from "@/components/elements/Container";
|
||||
import AuthRules from "@/components/auth/AuthRules";
|
||||
import React, { useState } from "react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useFormik } from "formik";
|
||||
import * as yup from "yup";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { markOtpSent } from "@/lib/auth/otpTimer";
|
||||
import GoogleSignInButton from "@/components/auth/GoogleSignInButton";
|
||||
import { GoogleSignInButton } from "@/app/(auth)/AuthProviders";
|
||||
import { MOBILE_NUMERIC_INPUT_PROPS } from "@/lib/auth/inputProps";
|
||||
|
||||
// Validation schema using Yup
|
||||
const schema = yup.object({
|
||||
mobile: yup
|
||||
.string()
|
||||
.matches(/^(09\d{9})$/, "شماره موبایل معتبر نیست")
|
||||
.required("شماره موبایل الزامی است"),
|
||||
});
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
|
||||
function Register() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const [isModalOpen, setModalOpen] = useState(false);
|
||||
const { request, loading } = useAxios();
|
||||
|
||||
const schema = useMemo(
|
||||
() =>
|
||||
yup.object({
|
||||
mobile: yup
|
||||
.string()
|
||||
.matches(/^(09\d{9})$/, t("auth.mobileInvalid"))
|
||||
.required(t("auth.mobileRequired")),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
mobile: "",
|
||||
@@ -45,46 +51,53 @@ function Register() {
|
||||
});
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<AuthHead title="ثبت نام" />
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
<AuthInput
|
||||
name="mobile"
|
||||
maxLength={11}
|
||||
{...MOBILE_NUMERIC_INPUT_PROPS}
|
||||
dir="ltr"
|
||||
placeholder="09 _ _ _ _ _ _ _ _ _"
|
||||
className={`border ${
|
||||
formik.touched.mobile && formik.errors.mobile
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.mobile}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.mobile && formik.errors.mobile && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.mobile}
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<AuthHead title={t("auth.register")} />
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
<AuthInput
|
||||
name="mobile"
|
||||
maxLength={11}
|
||||
{...MOBILE_NUMERIC_INPUT_PROPS}
|
||||
dir="ltr"
|
||||
placeholder="09 _ _ _ _ _ _ _ _ _"
|
||||
className={`border ${
|
||||
formik.touched.mobile && formik.errors.mobile
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.mobile}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.mobile && formik.errors.mobile && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.mobile}
|
||||
</small>
|
||||
)}
|
||||
<AuthButton
|
||||
type="submit"
|
||||
className="mt-5"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("auth.sendCode")}
|
||||
</AuthButton>
|
||||
</form>
|
||||
<GoogleSignInButton mode="register" />
|
||||
<button onClick={() => setModalOpen(true)} className="mb-2">
|
||||
<small className="text-[#FF5C00] font-bold text-xs mt-8 block">
|
||||
{t("auth.rules")}
|
||||
</small>
|
||||
)}
|
||||
<AuthButton type="submit" className="mt-5" loading={loading} disabled={loading}>
|
||||
ارسال کد
|
||||
</AuthButton>
|
||||
</form>
|
||||
<GoogleSignInButton mode="register" />
|
||||
<button onClick={() => setModalOpen(true)} className="mb-2">
|
||||
<small className="text-[#FF5C00] font-bold text-xs mt-8 block">
|
||||
قوانین و مقررات
|
||||
</small>
|
||||
</button>
|
||||
<AuthRules isModalOpen={isModalOpen} setModalOpen={setModalOpen} />
|
||||
</div>
|
||||
</Container>
|
||||
</button>
|
||||
<AuthRules isModalOpen={isModalOpen} setModalOpen={setModalOpen} />
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import AuthPageLayout, {
|
||||
AuthFormFooter,
|
||||
AuthPageContent,
|
||||
} from "@/components/auth/AuthPageLayout";
|
||||
import React from "react";
|
||||
import React, { useMemo } from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useFormik } from "formik";
|
||||
import * as yup from "yup";
|
||||
@@ -15,29 +15,33 @@ import { useRouter } from "next/navigation";
|
||||
import { IVerifyOtp } from "@/types/types";
|
||||
import AuthNextButton from "@/components/auth/AuthNextButton";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
const schema = yup.object().shape({
|
||||
password: yup
|
||||
.string()
|
||||
.required("کلمه عبور نمی\u200Cتواند خالی باشد")
|
||||
.min(8, "کلمه عبور نباید کوتاه تر از 8 کاراکتر باشد")
|
||||
.matches(
|
||||
/^(?=.*[a-zA-Zآ-ی])(?=.*\d).{8,}$/,
|
||||
"کلمه عبور باید شامل حروف و اعداد باشد"
|
||||
),
|
||||
confirmPassword: yup
|
||||
.string()
|
||||
.required("تکرار کلمه عبور نمی\u200Cتواند خالی باشد")
|
||||
.oneOf(
|
||||
[yup.ref("password")],
|
||||
"تکرار کلمه عبور باید با کلمه عبور مطابقت داشته باشد"
|
||||
),
|
||||
});
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
|
||||
function PasswordPage() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const { request, loading } = useAxios();
|
||||
|
||||
const schema = useMemo(
|
||||
() =>
|
||||
yup.object().shape({
|
||||
password: yup
|
||||
.string()
|
||||
.required(t("auth.passwordRequired"))
|
||||
.min(8, t("auth.passwordMin"))
|
||||
.matches(
|
||||
/^(?=.*[a-zA-Zآ-ی])(?=.*\d).{8,}$/,
|
||||
t("auth.passwordLettersNumbers")
|
||||
),
|
||||
confirmPassword: yup
|
||||
.string()
|
||||
.required(t("auth.confirmPasswordRequired"))
|
||||
.oneOf([yup.ref("password")], t("auth.confirmPasswordMismatch")),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
password: "",
|
||||
@@ -52,68 +56,71 @@ function PasswordPage() {
|
||||
router.push("/register/fullname");
|
||||
} catch (err: any) {
|
||||
const message =
|
||||
err?.response?.data?.message || "ذخیره کلمه عبور ناموفق بود";
|
||||
err?.response?.data?.message || t("auth.savePasswordFailed");
|
||||
toast.error(message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<AuthPageLayout>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="flex w-full flex-1 flex-col items-center"
|
||||
>
|
||||
<AuthPageContent>
|
||||
<AuthHead title="کلمه عبور" />
|
||||
<div className="w-full max-w-sm flex flex-col items-center">
|
||||
<AuthPasswordInput
|
||||
name="password"
|
||||
placeholder="کلمه عبور"
|
||||
wrapperClassName="mt-4"
|
||||
className={`border ${
|
||||
formik.touched.password && formik.errors.password
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.password}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.password && formik.errors.password && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.password}
|
||||
</small>
|
||||
)}
|
||||
<LocalePageShell>
|
||||
<AuthPageLayout>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="flex w-full flex-1 flex-col items-center"
|
||||
>
|
||||
<AuthPageContent>
|
||||
<AuthHead title={t("auth.passwordTitle")} />
|
||||
<div className="w-full max-w-sm flex flex-col items-center">
|
||||
<AuthPasswordInput
|
||||
name="password"
|
||||
placeholder={t("auth.passwordPlaceholder")}
|
||||
wrapperClassName="mt-4"
|
||||
className={`border ${
|
||||
formik.touched.password && formik.errors.password
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.password}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.password && formik.errors.password && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.password}
|
||||
</small>
|
||||
)}
|
||||
|
||||
<AuthPasswordInput
|
||||
name="confirmPassword"
|
||||
placeholder="تکرار کلمه عبور"
|
||||
wrapperClassName="mt-4"
|
||||
className={`border ${
|
||||
formik.touched.confirmPassword && formik.errors.confirmPassword
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.confirmPassword}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.confirmPassword && formik.errors.confirmPassword && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.confirmPassword}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
</AuthPageContent>
|
||||
<AuthPasswordInput
|
||||
name="confirmPassword"
|
||||
placeholder={t("auth.confirmPasswordPlaceholder")}
|
||||
wrapperClassName="mt-4"
|
||||
className={`border ${
|
||||
formik.touched.confirmPassword && formik.errors.confirmPassword
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.confirmPassword}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.confirmPassword &&
|
||||
formik.errors.confirmPassword && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.confirmPassword}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
</AuthPageContent>
|
||||
|
||||
<AuthFormFooter>
|
||||
<AuthNextButton type="submit" loading={loading} disabled={loading}>
|
||||
ثبت و ادامه
|
||||
</AuthNextButton>
|
||||
</AuthFormFooter>
|
||||
</form>
|
||||
</AuthPageLayout>
|
||||
<AuthFormFooter>
|
||||
<AuthNextButton type="submit" loading={loading} disabled={loading}>
|
||||
{t("auth.saveAndContinue")}
|
||||
</AuthNextButton>
|
||||
</AuthFormFooter>
|
||||
</form>
|
||||
</AuthPageLayout>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,21 +7,25 @@ import AuthPageLayout, {
|
||||
AuthFormFooter,
|
||||
AuthPageContent,
|
||||
} from "@/components/auth/AuthPageLayout";
|
||||
import React, { useState } from "react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useFormik } from "formik";
|
||||
import * as yup from "yup";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { IVerifyOtp } from "@/types/types";
|
||||
import AuthNextButton from "@/components/auth/AuthNextButton";
|
||||
import UsernameSuggestions from "@/components/auth/UsernameSuggestions";
|
||||
import { usernameFormSchema } from "@/lib/validation/usernameSchema";
|
||||
import {
|
||||
sanitizeUsernameInput,
|
||||
USERNAME_MIN_LENGTH,
|
||||
USERNAME_VALIDATION_MESSAGE,
|
||||
USERNAME_MAX_LENGTH,
|
||||
USERNAME_REGEX,
|
||||
} from "@/lib/validation/username";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
|
||||
function UsernamePage() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const { request, loading } = useAxios();
|
||||
const [isDuplicate, setIsDuplicate] = useState(false);
|
||||
@@ -29,11 +33,31 @@ function UsernamePage() {
|
||||
const mobile =
|
||||
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
|
||||
|
||||
const schema = useMemo(
|
||||
() =>
|
||||
yup.object({
|
||||
username: yup
|
||||
.string()
|
||||
.required(t("auth.usernameRequired"))
|
||||
.min(
|
||||
USERNAME_MIN_LENGTH,
|
||||
t("auth.usernameMinLength", { min: USERNAME_MIN_LENGTH })
|
||||
)
|
||||
.max(
|
||||
USERNAME_MAX_LENGTH,
|
||||
t("auth.usernameMaxLength", { max: USERNAME_MAX_LENGTH })
|
||||
)
|
||||
.matches(/^[a-zA-Z0-9]/, t("auth.usernameStartRule"))
|
||||
.matches(USERNAME_REGEX, t("auth.usernameCharsRule")),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
username: "",
|
||||
},
|
||||
validationSchema: usernameFormSchema,
|
||||
validationSchema: schema,
|
||||
onSubmit: async (values) => {
|
||||
setIsDuplicate(false);
|
||||
setSuggestions([]);
|
||||
@@ -45,7 +69,13 @@ function UsernamePage() {
|
||||
}) as IVerifyOtp;
|
||||
|
||||
localStorage.setItem("username", values.username.trim().toLowerCase());
|
||||
router.push("/register/password");
|
||||
const authProvider =
|
||||
typeof window !== "undefined"
|
||||
? localStorage.getItem("auth_provider")
|
||||
: null;
|
||||
router.push(
|
||||
authProvider === "google" ? "/register/fullname" : "/register/password"
|
||||
);
|
||||
} catch (err: any) {
|
||||
const data = err?.response?.data;
|
||||
if (data?.message?.includes("تکراری")) {
|
||||
@@ -72,73 +102,81 @@ function UsernamePage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthPageLayout>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="flex w-full flex-1 flex-col items-center"
|
||||
>
|
||||
<AuthPageContent>
|
||||
<AuthHead title="نام کاربری" />
|
||||
<div className="w-full max-w-sm flex flex-col items-center">
|
||||
<p className="text-xs text-gray-500 text-center mb-3 max-w-[290px]">
|
||||
حداقل {USERNAME_MIN_LENGTH} کاراکتر؛ فقط حروف انگلیسی، اعداد و
|
||||
. _ -
|
||||
</p>
|
||||
<AuthInput
|
||||
name="username"
|
||||
type="text"
|
||||
placeholder="نام کاربری"
|
||||
dir="ltr"
|
||||
autoComplete="username"
|
||||
spellCheck={false}
|
||||
className={`border ${
|
||||
(formik.touched.username && formik.errors.username) || isDuplicate
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.username}
|
||||
onChange={(e) => handleUsernameChange(e.target.value)}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
<LocalePageShell>
|
||||
<AuthPageLayout>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="flex w-full flex-1 flex-col items-center"
|
||||
>
|
||||
<AuthPageContent>
|
||||
<AuthHead title={t("auth.usernameTitle")} />
|
||||
<div className="w-full max-w-sm flex flex-col items-center">
|
||||
<p className="text-xs text-gray-500 text-center mb-3 max-w-[290px]">
|
||||
{t("auth.usernameHint", {
|
||||
max: USERNAME_MAX_LENGTH,
|
||||
min: USERNAME_MIN_LENGTH,
|
||||
})}
|
||||
</p>
|
||||
<AuthInput
|
||||
name="username"
|
||||
type="text"
|
||||
placeholder={t("auth.usernamePlaceholder")}
|
||||
dir="ltr"
|
||||
autoComplete="username"
|
||||
spellCheck={false}
|
||||
maxLength={USERNAME_MAX_LENGTH}
|
||||
className={`border ${
|
||||
(formik.touched.username && formik.errors.username) ||
|
||||
isDuplicate
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.username}
|
||||
onChange={(e) => handleUsernameChange(e.target.value)}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
|
||||
{isDuplicate && (
|
||||
<small className="text-red-500 mt-2 block text-center font-medium">
|
||||
نام کاربری تکراری است
|
||||
</small>
|
||||
)}
|
||||
{isDuplicate && (
|
||||
<small className="text-red-500 mt-2 block text-center font-medium">
|
||||
{t("auth.usernameDuplicate")}
|
||||
</small>
|
||||
)}
|
||||
|
||||
{formik.touched.username && formik.errors.username && !isDuplicate && (
|
||||
<small className="text-red-500 mt-2 block text-center">
|
||||
{formik.errors.username}
|
||||
</small>
|
||||
)}
|
||||
{formik.touched.username &&
|
||||
formik.errors.username &&
|
||||
!isDuplicate && (
|
||||
<small className="text-red-500 mt-2 block text-center">
|
||||
{formik.errors.username}
|
||||
</small>
|
||||
)}
|
||||
|
||||
{!formik.errors.username && !isDuplicate && (
|
||||
<small className="text-gray-400 mt-2 block text-center text-xs">
|
||||
{USERNAME_VALIDATION_MESSAGE}
|
||||
</small>
|
||||
)}
|
||||
{!formik.errors.username && !isDuplicate && (
|
||||
<small className="text-gray-400 mt-2 block text-center text-xs">
|
||||
{t("auth.usernameCharsRule")}
|
||||
</small>
|
||||
)}
|
||||
|
||||
<UsernameSuggestions
|
||||
suggestions={suggestions}
|
||||
onSelect={handleSuggestionSelect}
|
||||
/>
|
||||
</div>
|
||||
</AuthPageContent>
|
||||
<UsernameSuggestions
|
||||
suggestions={suggestions}
|
||||
onSelect={handleSuggestionSelect}
|
||||
/>
|
||||
</div>
|
||||
</AuthPageContent>
|
||||
|
||||
<AuthFormFooter>
|
||||
<AuthNextButton
|
||||
type="submit"
|
||||
loading={loading}
|
||||
disabled={
|
||||
loading || formik.values.username.length < USERNAME_MIN_LENGTH
|
||||
}
|
||||
>
|
||||
ثبت و ادامه
|
||||
</AuthNextButton>
|
||||
</AuthFormFooter>
|
||||
</form>
|
||||
</AuthPageLayout>
|
||||
<AuthFormFooter>
|
||||
<AuthNextButton
|
||||
type="submit"
|
||||
loading={loading}
|
||||
disabled={
|
||||
loading || formik.values.username.length < USERNAME_MIN_LENGTH
|
||||
}
|
||||
>
|
||||
{t("auth.saveAndContinue")}
|
||||
</AuthNextButton>
|
||||
</AuthFormFooter>
|
||||
</form>
|
||||
</AuthPageLayout>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { GoogleAuthRootProvider } from "@/components/auth/GoogleSignInButton";
|
||||
import GoogleSignInButtonBase, {
|
||||
GoogleAuthRootProvider,
|
||||
} from "@/components/auth/GoogleSignInButton";
|
||||
import RegisterRouteGuard from "@/components/auth/RegisterRouteGuard";
|
||||
import { GOOGLE_OAUTH_CLIENT_ID } from "@/config/googleAuth";
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
const GoogleClientIdContext = createContext("");
|
||||
|
||||
export function useGoogleClientId(): string {
|
||||
return useContext(GoogleClientIdContext);
|
||||
}
|
||||
|
||||
export function GoogleSignInButton(
|
||||
props: Omit<React.ComponentProps<typeof GoogleSignInButtonBase>, "clientId">
|
||||
) {
|
||||
const clientId = useGoogleClientId();
|
||||
return (
|
||||
<GoogleSignInButtonBase
|
||||
{...props}
|
||||
clientId={clientId || GOOGLE_OAUTH_CLIENT_ID}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AuthProviders({
|
||||
children,
|
||||
googleClientId = "",
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
googleClientId?: string;
|
||||
}) {
|
||||
const [resolvedClientId, setResolvedClientId] = useState(
|
||||
googleClientId || GOOGLE_OAUTH_CLIENT_ID
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function loadClientId() {
|
||||
const fallback = googleClientId || GOOGLE_OAUTH_CLIENT_ID;
|
||||
setResolvedClientId(fallback);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/auth/config", { cache: "no-store" });
|
||||
if (!res.ok) return;
|
||||
const data = (await res.json()) as { googleClientId?: string };
|
||||
if (!cancelled) {
|
||||
setResolvedClientId(data?.googleClientId || fallback);
|
||||
}
|
||||
} catch {
|
||||
// keep fallback
|
||||
}
|
||||
}
|
||||
|
||||
void loadClientId();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [googleClientId]);
|
||||
|
||||
return (
|
||||
<GoogleAuthRootProvider>
|
||||
<RegisterRouteGuard>{children}</RegisterRouteGuard>
|
||||
</GoogleAuthRootProvider>
|
||||
<GoogleClientIdContext.Provider value={resolvedClientId}>
|
||||
<GoogleAuthRootProvider clientId={resolvedClientId}>
|
||||
<RegisterRouteGuard>{children}</RegisterRouteGuard>
|
||||
</GoogleAuthRootProvider>
|
||||
</GoogleClientIdContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import type { Metadata } from "next";
|
||||
import AuthProviders from "./AuthProviders";
|
||||
import { getGoogleClientId } from "@/lib/auth/googleClientId";
|
||||
import { generateSeoPageMetadata } from "@/lib/i18n/server";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: "ورود و ثبتنام | مدستاگرام",
|
||||
},
|
||||
description: "ورود یا ثبتنام در مدستاگرام",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
return generateSeoPageMetadata("auth", { index: false });
|
||||
}
|
||||
|
||||
export default function AuthLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <AuthProviders>{children}</AuthProviders>;
|
||||
const googleClientId = getGoogleClientId();
|
||||
|
||||
return (
|
||||
<AuthProviders googleClientId={googleClientId}>{children}</AuthProviders>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import AuthPageLayout, {
|
||||
AuthFormFooter,
|
||||
AuthPageContent,
|
||||
} from "@/components/auth/AuthPageLayout";
|
||||
import React from "react";
|
||||
import React, { useMemo } from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useFormik } from "formik";
|
||||
import * as yup from "yup";
|
||||
@@ -19,30 +19,38 @@ import persian from "react-date-object/calendars/persian";
|
||||
import persian_fa from "react-date-object/locales/persian_fa";
|
||||
import Image from "next/image";
|
||||
import moment from "jalali-moment";
|
||||
|
||||
// Validation schema using Yup
|
||||
const schema = yup.object().shape({
|
||||
birthDate: yup.string().required(" انتخاب تاریخ تولد الزامی است"),
|
||||
shaba: yup
|
||||
.string()
|
||||
.matches(/^(?=.{24}$)[0-9]*$/, "شماره شبا باید ۲۴ رقم باشد")
|
||||
.required("شماره شبا الزامی است"),
|
||||
nationalCode: yup
|
||||
.string()
|
||||
.matches(/^\d{10}$/, "کد ملی باید ۱۰ رقم باشد")
|
||||
.required("کد ملی الزامی است"),
|
||||
});
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
|
||||
function AuthPage() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const { request, loading } = useAxios();
|
||||
|
||||
|
||||
const p2e = (es: string): string =>
|
||||
es.replace(/[۰-۹]/g, (d) => String.fromCharCode("۰۱۲۳۴۵۶۷۸۹".indexOf(d) + 48));
|
||||
|
||||
es.replace(/[۰-۹]/g, (d) =>
|
||||
String.fromCharCode("۰۱۲۳۴۵۶۷۸۹".indexOf(d) + 48)
|
||||
);
|
||||
|
||||
const mobile =
|
||||
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
|
||||
|
||||
const schema = useMemo(
|
||||
() =>
|
||||
yup.object().shape({
|
||||
birthDate: yup.string().required(t("auth.birthDateRequired")),
|
||||
shaba: yup
|
||||
.string()
|
||||
.matches(/^(?=.{24}$)[0-9]*$/, t("auth.shabaInvalid"))
|
||||
.required(t("auth.shabaRequired")),
|
||||
nationalCode: yup
|
||||
.string()
|
||||
.matches(/^\d{10}$/, t("auth.nationalCodeInvalid"))
|
||||
.required(t("auth.nationalCodeRequired")),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
nationalCode: "",
|
||||
@@ -56,129 +64,128 @@ function AuthPage() {
|
||||
mobile: mobile,
|
||||
birthday: moment(p2e(values.birthDate), "jYYYY/jMM/jDD").format(
|
||||
"YYYY/MM/DD"
|
||||
), // تبدیل به میلادی
|
||||
),
|
||||
national_code: values.nationalCode,
|
||||
shaba: values.shaba,
|
||||
});
|
||||
router.push("/verify/location");
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || "خطای نامشخصی رخ داد.");
|
||||
toast.error(err?.response?.data?.message || t("auth.unknownError"));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
console.log(formik.values);
|
||||
|
||||
return (
|
||||
<AuthPageLayout className="auth-page">
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="flex w-full flex-1 flex-col items-center"
|
||||
>
|
||||
<AuthPageContent>
|
||||
<span className="text-xl font-bold">احراز هویت</span>
|
||||
<div className="mt-5">
|
||||
<AuthUserDetails />
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-sm flex flex-col items-center">
|
||||
<small className="font-bold mt-10 mb-4"> جهت احراز هویت</small>
|
||||
<div className="flex w-full gap-2 max-w-[300px]">
|
||||
<div>
|
||||
{/* کد ملی */}
|
||||
<AuthInput
|
||||
name="nationalCode"
|
||||
type="text"
|
||||
placeholder="کد ملی"
|
||||
className={`border mt-2 !p-1 h-[36px] ${
|
||||
formik.touched.nationalCode && formik.errors.nationalCode
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.nationalCode}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
<LocalePageShell>
|
||||
<AuthPageLayout className="auth-page">
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="flex w-full flex-1 flex-col items-center"
|
||||
>
|
||||
<AuthPageContent>
|
||||
<span className="text-xl font-bold">
|
||||
{t("auth.identityVerification")}
|
||||
</span>
|
||||
<div className="mt-5">
|
||||
<AuthUserDetails />
|
||||
</div>
|
||||
{/* تاریخ تولد */}
|
||||
<div className="relative">
|
||||
<DatePicker
|
||||
id="ss"
|
||||
calendar={persian}
|
||||
locale={persian_fa}
|
||||
value={formik.values.birthDate}
|
||||
onChange={(value) => {
|
||||
const formattedDate = value?.format("YYYY/MM/DD") || ""; // اگر مقدار `null` بود، رشته خالی
|
||||
formik.setFieldValue("birthDate", formattedDate); // مقدار فرم را تنظیم کنید
|
||||
}}
|
||||
calendarPosition="bottom-center"
|
||||
placeholder="تاریخ تولد"
|
||||
inputClass={`w-full dir-ltr max-w-[290px] p-3 !pr-6 text-center rounded-2xl border border-border-primary-light dark:border-border-primary-dark bg-secondary-light dark:bg-secondary-dark font-medium border mt-2 !p-1 h-[36px] ${
|
||||
formik.touched.birthDate && formik.errors.birthDate
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
/>
|
||||
<label htmlFor="ss" className="absolute right-4 top-3.5">
|
||||
<Image
|
||||
width={24}
|
||||
height={24}
|
||||
alt="calendar icon"
|
||||
src={"/images/icons/calendar.svg"}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{formik.touched.nationalCode && formik.errors.nationalCode && (
|
||||
<small className="text-red-500 mt-2 block text-center">
|
||||
{formik.errors.nationalCode}
|
||||
</small>
|
||||
)}
|
||||
{formik.touched.birthDate && formik.errors.birthDate && (
|
||||
<small className="text-red-500 mt-2 block text-center">
|
||||
{formik.errors.birthDate}
|
||||
</small>
|
||||
)}
|
||||
<small className="font-bold mt-10"> جهت واریز حق الزحمه</small>
|
||||
{/* شماره شبا */}
|
||||
<div className="flex flex-col w-full relative mt-4 ">
|
||||
<span className="absolute left-4 top-1.5">IR</span>
|
||||
<AuthInput
|
||||
name="shaba"
|
||||
type="text"
|
||||
placeholder="شماره شبا"
|
||||
className={`border w-full mx-auto h-[36px] p-2 max-w-full pl-8 text-sm ${
|
||||
formik.touched.shaba && formik.errors.shaba
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.shaba}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.shaba && formik.errors.shaba && (
|
||||
<small className="text-red-500 mt-2 block text-center">
|
||||
{formik.errors.shaba}
|
||||
<div className="w-full max-w-sm flex flex-col items-center">
|
||||
<small className="font-bold mt-10 mb-4">
|
||||
{t("auth.forIdentityVerification")}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-full gap-2 max-w-[300px]">
|
||||
<div>
|
||||
<AuthInput
|
||||
name="nationalCode"
|
||||
type="text"
|
||||
placeholder={t("auth.nationalCodePlaceholder")}
|
||||
className={`border mt-2 !p-1 h-[36px] ${
|
||||
formik.touched.nationalCode && formik.errors.nationalCode
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.nationalCode}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<DatePicker
|
||||
id="ss"
|
||||
calendar={persian}
|
||||
locale={persian_fa}
|
||||
value={formik.values.birthDate}
|
||||
onChange={(value) => {
|
||||
const formattedDate = value?.format("YYYY/MM/DD") || "";
|
||||
formik.setFieldValue("birthDate", formattedDate);
|
||||
}}
|
||||
calendarPosition="bottom-center"
|
||||
placeholder={t("auth.birthDatePlaceholder")}
|
||||
inputClass={`w-full dir-ltr max-w-[290px] p-3 !pr-6 text-center rounded-2xl border border-border-primary-light dark:border-border-primary-dark bg-secondary-light dark:bg-secondary-dark font-medium border mt-2 !p-1 h-[36px] ${
|
||||
formik.touched.birthDate && formik.errors.birthDate
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
/>
|
||||
<label htmlFor="ss" className="absolute right-4 top-3.5">
|
||||
<Image
|
||||
width={24}
|
||||
height={24}
|
||||
alt="calendar icon"
|
||||
src={"/images/icons/calendar.svg"}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<small className="font-bold mt-10">
|
||||
شماره شبا باید به نام خود شخص باشد
|
||||
</small>
|
||||
</div>
|
||||
</AuthPageContent>
|
||||
{formik.touched.nationalCode && formik.errors.nationalCode && (
|
||||
<small className="text-red-500 mt-2 block text-center">
|
||||
{formik.errors.nationalCode}
|
||||
</small>
|
||||
)}
|
||||
{formik.touched.birthDate && formik.errors.birthDate && (
|
||||
<small className="text-red-500 mt-2 block text-center">
|
||||
{formik.errors.birthDate}
|
||||
</small>
|
||||
)}
|
||||
<small className="font-bold mt-10">{t("auth.forPayment")}</small>
|
||||
<div className="flex flex-col w-full relative mt-4 ">
|
||||
<span className="absolute left-4 top-1.5">IR</span>
|
||||
<AuthInput
|
||||
name="shaba"
|
||||
type="text"
|
||||
placeholder={t("auth.shabaPlaceholder")}
|
||||
className={`border w-full mx-auto h-[36px] p-2 max-w-full pl-8 text-sm ${
|
||||
formik.touched.shaba && formik.errors.shaba
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.shaba}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.shaba && formik.errors.shaba && (
|
||||
<small className="text-red-500 mt-2 block text-center">
|
||||
{formik.errors.shaba}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AuthFormFooter onSkip={() => router.push("/verify/location")}>
|
||||
<AuthNextButton type="submit" loading={loading} disabled={loading}>
|
||||
ثبت و ادامه
|
||||
</AuthNextButton>
|
||||
</AuthFormFooter>
|
||||
</form>
|
||||
</AuthPageLayout>
|
||||
<small className="font-bold mt-10">{t("auth.shabaOwnerHint")}</small>
|
||||
</div>
|
||||
</AuthPageContent>
|
||||
|
||||
<AuthFormFooter onSkip={() => router.push("/verify/location")}>
|
||||
<AuthNextButton type="submit" loading={loading} disabled={loading}>
|
||||
{t("auth.saveAndContinue")}
|
||||
</AuthNextButton>
|
||||
</AuthFormFooter>
|
||||
</form>
|
||||
</AuthPageLayout>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,19 +6,20 @@ import AuthPageLayout, {
|
||||
AuthPageContent,
|
||||
} from "@/components/auth/AuthPageLayout";
|
||||
import AuthNextButton from "@/components/auth/AuthNextButton";
|
||||
import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { IProfileData } from "@/types/types";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
// کتابخانه برای crop
|
||||
import Cropper from "react-easy-crop";
|
||||
import getCroppedImg from "@/helpers/cropImage";
|
||||
import { Area } from "react-easy-crop";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
|
||||
function AvatarPage() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const { request, loading } = useAxios();
|
||||
const [userDetail, setUserDetail] = useState<IProfileData | null>(null);
|
||||
@@ -45,7 +46,6 @@ function AvatarPage() {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
// انتخاب تصویر
|
||||
const selectImage = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (event.target.files && event.target.files[0]) {
|
||||
const file = event.target.files[0];
|
||||
@@ -58,37 +58,28 @@ function AvatarPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// حذف تصویر
|
||||
// const removeImage = () => {
|
||||
// setAvatar(null);
|
||||
// setOriginalImage(null);
|
||||
// };
|
||||
|
||||
// دریافت مختصات crop شده
|
||||
const onCropComplete = useCallback(
|
||||
(croppedArea: Area, croppedPixels: Area) => {
|
||||
(_croppedArea: Area, croppedPixels: Area) => {
|
||||
setCroppedAreaPixels(croppedPixels);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// تبدیل crop شده به blob
|
||||
const createCroppedImage = async () => {
|
||||
if (!avatar || !croppedAreaPixels) return null;
|
||||
const croppedImage = await getCroppedImg(avatar, croppedAreaPixels);
|
||||
return croppedImage;
|
||||
};
|
||||
|
||||
// آپلود تصویر
|
||||
const uploadImage = async () => {
|
||||
if (!avatar) {
|
||||
toast.error("لطفا تصویر پروفایل را انتخاب کنید");
|
||||
toast.error(t("auth.selectProfileImage"));
|
||||
return;
|
||||
}
|
||||
setLoadingUpload(true);
|
||||
const croppedBlob = await createCroppedImage();
|
||||
if (!croppedBlob) {
|
||||
toast.error("خطا در برش تصویر");
|
||||
toast.error(t("auth.cropError"));
|
||||
setLoadingUpload(false);
|
||||
return;
|
||||
}
|
||||
@@ -101,16 +92,15 @@ function AvatarPage() {
|
||||
await request("POST", "/verify/profile_image", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
toast.success("تصویر با موفقیت ثبت شد!");
|
||||
toast.success(t("auth.imageSavedSuccess"));
|
||||
navigateHandler();
|
||||
} catch (err) {
|
||||
console.log("Upload error:", err);
|
||||
toast.error("خطا در ارسال تصویر!");
|
||||
toast.error(t("auth.imageUploadError"));
|
||||
setLoadingUpload(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ناوبری بعد از آپلود
|
||||
const navigateHandler = () => {
|
||||
if (userDetail?.user_type === "user") {
|
||||
router.push("/verify/expertise");
|
||||
@@ -120,68 +110,71 @@ function AvatarPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthPageLayout>
|
||||
<AuthPageContent>
|
||||
<span className="text-xl font-bold mb-4">تصویر پروفایل</span>
|
||||
<LocalePageShell>
|
||||
<AuthPageLayout>
|
||||
<AuthPageContent>
|
||||
<span className="text-xl font-bold mb-4">
|
||||
{t("auth.profileImageTitle")}
|
||||
</span>
|
||||
|
||||
<div className="relative aspect-square w-[250px] bg-gray-200 rounded-3xl overflow-hidden mb-4">
|
||||
{avatar ? (
|
||||
<Cropper
|
||||
image={avatar.startsWith("data:") ? avatar : IMAGE_BASE_URL + avatar}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
aspect={1} // مربع 1:1
|
||||
onCropChange={setCrop}
|
||||
onZoomChange={setZoom}
|
||||
onCropComplete={onCropComplete}
|
||||
cropShape="rect"
|
||||
showGrid={false}
|
||||
objectFit="contain" // نمایش کامل تصویر
|
||||
<div className="relative aspect-square w-[250px] bg-gray-200 rounded-3xl overflow-hidden mb-4">
|
||||
{avatar ? (
|
||||
<Cropper
|
||||
image={
|
||||
avatar.startsWith("data:") ? avatar : IMAGE_BASE_URL + avatar
|
||||
}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
aspect={1}
|
||||
onCropChange={setCrop}
|
||||
onZoomChange={setZoom}
|
||||
onCropComplete={onCropComplete}
|
||||
cropShape="rect"
|
||||
showGrid={false}
|
||||
objectFit="contain"
|
||||
/>
|
||||
) : (
|
||||
<label
|
||||
htmlFor="fileInput"
|
||||
className="cursor-pointer w-full h-full flex items-center justify-center"
|
||||
>
|
||||
<Image
|
||||
src="/images/icons/gallery-add.svg"
|
||||
width={120}
|
||||
height={120}
|
||||
alt="add profile icon"
|
||||
/>
|
||||
) : (
|
||||
<label
|
||||
htmlFor="fileInput"
|
||||
className="cursor-pointer w-full h-full flex items-center justify-center"
|
||||
>
|
||||
<Image
|
||||
src="/images/icons/gallery-add.svg"
|
||||
width={120}
|
||||
height={120}
|
||||
alt="add profile icon"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
id="fileInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={selectImage}
|
||||
/>
|
||||
<input
|
||||
id="fileInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={selectImage}
|
||||
/>
|
||||
|
||||
<AuthNextButton className="mt-4">
|
||||
<label className="cursor-pointer w-full h-full" htmlFor="fileInput">
|
||||
انتخاب/ویرایش تصویر
|
||||
</label>
|
||||
</AuthNextButton>
|
||||
</AuthPageContent>
|
||||
<AuthNextButton className="mt-4">
|
||||
<label className="cursor-pointer w-full h-full" htmlFor="fileInput">
|
||||
{t("auth.selectEditImage")}
|
||||
</label>
|
||||
</AuthNextButton>
|
||||
</AuthPageContent>
|
||||
|
||||
<AuthFormFooter
|
||||
onSkip={navigateHandler}
|
||||
skipDisabled={loadingUpload}
|
||||
>
|
||||
<AuthNextButton
|
||||
onClick={uploadImage}
|
||||
loading={loadingUpload}
|
||||
disabled={loadingUpload}
|
||||
>
|
||||
ثبت و ادامه
|
||||
</AuthNextButton>
|
||||
</AuthFormFooter>
|
||||
</AuthPageLayout>
|
||||
<AuthFormFooter onSkip={navigateHandler} skipDisabled={loadingUpload}>
|
||||
<AuthNextButton
|
||||
onClick={uploadImage}
|
||||
loading={loadingUpload}
|
||||
disabled={loadingUpload}
|
||||
>
|
||||
{t("auth.saveAndContinue")}
|
||||
</AuthNextButton>
|
||||
</AuthFormFooter>
|
||||
</AuthPageLayout>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default AvatarPage;
|
||||
export default AvatarPage;
|
||||
|
||||
@@ -5,7 +5,7 @@ import AuthPageLayout, {
|
||||
AuthFormFooter,
|
||||
AuthPageContent,
|
||||
} from "@/components/auth/AuthPageLayout";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import * as yup from "yup";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -13,15 +13,12 @@ import AuthNextButton from "@/components/auth/AuthNextButton";
|
||||
import toast from "react-hot-toast";
|
||||
import AuthUserDetails from "@/components/auth/AuthUserDetails";
|
||||
import Image from "next/image";
|
||||
|
||||
// Validation schema using Yup
|
||||
const schema = yup.object().shape({
|
||||
selectedHairImage: yup.string().required("رنگ مو الزامی است"),
|
||||
selectedEyeImage: yup.string().required(" رنگ چشم الزامی است"),
|
||||
});
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
|
||||
function Colors() {
|
||||
const [selectedButton, setSelectedButton] = useState<number>(1); // Default: 1 for "قد"
|
||||
const { t } = useTranslation("common");
|
||||
const [selectedButton, setSelectedButton] = useState<number>(1);
|
||||
const [selectedHairImage, setSelectedHairImage] = useState<string>("");
|
||||
const [selectedEyeImage, setSelectedEyeImage] = useState<string>("");
|
||||
const router = useRouter();
|
||||
@@ -32,6 +29,15 @@ function Colors() {
|
||||
typeof window !== "undefined" ? localStorage.getItem("expertise") || "" : ""
|
||||
);
|
||||
|
||||
const schema = useMemo(
|
||||
() =>
|
||||
yup.object().shape({
|
||||
selectedHairImage: yup.string().required(t("auth.hairColorRequired")),
|
||||
selectedEyeImage: yup.string().required(t("auth.eyeColorRequired")),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (expertise && expertise !== "مدل") {
|
||||
router.replace("/verify/public-relations");
|
||||
@@ -49,8 +55,6 @@ function Colors() {
|
||||
}
|
||||
};
|
||||
|
||||
console.log(selectedHairImage, selectedEyeImage);
|
||||
|
||||
const sendSizesHandler = async () => {
|
||||
try {
|
||||
const data = {
|
||||
@@ -69,7 +73,6 @@ function Colors() {
|
||||
setSelectedButton(buttonIndex);
|
||||
};
|
||||
|
||||
// تصاویر رنگ چشم و رنگ مو
|
||||
const eyeImages = Array.from(
|
||||
{ length: 26 },
|
||||
(_, i) => `/images/eyes/${String(i + 1).padStart(2, "0")}.png`
|
||||
@@ -79,9 +82,8 @@ function Colors() {
|
||||
(_, i) => `/images/hairs/${String(i + 1).padStart(2, "0")}.png`
|
||||
);
|
||||
|
||||
// تابع برای ذخیره نام فایل انتخاب شده
|
||||
const handleImageSelect = (image: string, type: "eye" | "hair") => {
|
||||
const fileName = image.split("/").pop(); // استخراج فقط نام فایل
|
||||
const fileName = image.split("/").pop();
|
||||
if (type === "eye") {
|
||||
setSelectedEyeImage(fileName || "");
|
||||
} else if (type === "hair") {
|
||||
@@ -89,98 +91,97 @@ function Colors() {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<AuthPageLayout>
|
||||
<AuthPageContent>
|
||||
<span className="text-xl font-bold">سایز</span>
|
||||
<div className="mt-5">
|
||||
<AuthUserDetails />
|
||||
</div>
|
||||
<LocalePageShell>
|
||||
<AuthPageLayout>
|
||||
<AuthPageContent>
|
||||
<span className="text-xl font-bold">{t("auth.sizesTitle")}</span>
|
||||
<div className="mt-5">
|
||||
<AuthUserDetails />
|
||||
</div>
|
||||
|
||||
<p className="mt-8 text-center text-sm font-bold">مشخصات ظاهری</p>
|
||||
<p className="mt-8 text-center text-sm font-bold">
|
||||
{t("auth.appearanceDetails")}
|
||||
</p>
|
||||
|
||||
{/* Buttons for categories */}
|
||||
<div className="flex justify-center gap-2 mt-4 w-full max-w-[320px]">
|
||||
<button
|
||||
className={`p-1 rounded-full w-full border text-sm flex items-center justify-center gap-1 ${
|
||||
selectedButton === 1
|
||||
? "btn-modern--selected text-white"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
|
||||
}`}
|
||||
onClick={() => handleButtonPress(1)}
|
||||
<div className="flex justify-center gap-2 mt-4 w-full max-w-[320px]">
|
||||
<button
|
||||
className={`p-1 rounded-full w-full border text-sm flex items-center justify-center gap-1 ${
|
||||
selectedButton === 1
|
||||
? "btn-modern--selected text-white"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
|
||||
}`}
|
||||
onClick={() => handleButtonPress(1)}
|
||||
>
|
||||
{t("auth.eyeColor")}
|
||||
</button>
|
||||
<button
|
||||
className={`p-1 rounded-full w-full border text-sm flex items-center justify-center gap-1 ${
|
||||
selectedButton === 2
|
||||
? "btn-modern--selected text-white"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
|
||||
}`}
|
||||
onClick={() => handleButtonPress(2)}
|
||||
>
|
||||
{t("auth.hairColor")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 w-full overflow-y-auto flex flex-col items-center">
|
||||
{selectedButton === 1 && (
|
||||
<div className="grid grid-cols-4 gap-2 dir-ltr">
|
||||
{eyeImages.map((image) => (
|
||||
<Image
|
||||
width={75}
|
||||
height={75}
|
||||
key={image}
|
||||
src={image}
|
||||
alt={image}
|
||||
className={`cursor-pointer w-full h-auto rounded-lg ${
|
||||
selectedEyeImage === image?.split("/").pop()
|
||||
? "border-4 border-[#FC8EAC]"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => handleImageSelect(image, "eye")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedButton === 2 && (
|
||||
<div className="grid grid-cols-4 gap-2 dir-ltr">
|
||||
{hairImages.map((image) => (
|
||||
<Image
|
||||
width={75}
|
||||
height={75}
|
||||
key={image}
|
||||
src={image}
|
||||
alt={image}
|
||||
className={`cursor-pointer w-full h-auto rounded-lg ${
|
||||
selectedHairImage === image?.split("/").pop()
|
||||
? "border-4 border-[#FC8EAC]"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => handleImageSelect(image, "hair")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AuthPageContent>
|
||||
|
||||
<AuthFormFooter onSkip={() => router.push("/verify/public-relations")}>
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={handleCheck}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
رنگ چشم
|
||||
</button>
|
||||
<button
|
||||
className={`p-1 rounded-full w-full border text-sm flex items-center justify-center gap-1 ${
|
||||
selectedButton === 2
|
||||
? "btn-modern--selected text-white"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
|
||||
}`}
|
||||
onClick={() => handleButtonPress(2)}
|
||||
>
|
||||
رنگ مو
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Display images based on selected button */}
|
||||
<div className="mt-6 w-full overflow-y-auto flex flex-col items-center">
|
||||
{selectedButton === 1 && (
|
||||
<div className="grid grid-cols-4 gap-2 dir-ltr">
|
||||
{eyeImages.map((image) => (
|
||||
<Image
|
||||
width={75}
|
||||
height={75}
|
||||
key={image}
|
||||
src={image}
|
||||
alt={image}
|
||||
className={`cursor-pointer w-full h-auto rounded-lg ${
|
||||
selectedEyeImage === image?.split("/").pop()
|
||||
? "border-4 border-[#FC8EAC]"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => handleImageSelect(image, "eye")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedButton === 2 && (
|
||||
<div className="grid grid-cols-4 gap-2 dir-ltr">
|
||||
{hairImages.map((image) => (
|
||||
<Image
|
||||
width={75}
|
||||
height={75}
|
||||
key={image}
|
||||
src={image}
|
||||
alt={image}
|
||||
className={`cursor-pointer w-full h-auto rounded-lg ${
|
||||
selectedHairImage === image?.split("/").pop()
|
||||
? "border-4 border-[#FC8EAC]"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => handleImageSelect(image, "hair")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AuthPageContent>
|
||||
|
||||
<AuthFormFooter onSkip={() => router.push("/verify/public-relations")}>
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={handleCheck}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
ثبت و ادامه
|
||||
</AuthNextButton>
|
||||
</AuthFormFooter>
|
||||
</AuthPageLayout>
|
||||
{t("auth.saveAndContinue")}
|
||||
</AuthNextButton>
|
||||
</AuthFormFooter>
|
||||
</AuthPageLayout>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,14 +12,16 @@ import VerificationBadge from "@/components/main/VerificationBadge";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
import { formatFullName } from "@/lib/formatFullName";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
|
||||
function Confirm() {
|
||||
const { t } = useTranslation("common");
|
||||
const user = useUser();
|
||||
const { request } = useAxios();
|
||||
const router = useRouter();
|
||||
const [showModal, setShowModal] = useState<boolean>(false);
|
||||
const [navigating, setNavigating] = useState(false);
|
||||
|
||||
const [verifiedStatus, setVerifiedStatus] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -43,114 +45,122 @@ function Confirm() {
|
||||
setShowModal(true);
|
||||
setNavigating(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center justify-center address-page ">
|
||||
<span className="text-xl font-bold text-[#238800]">
|
||||
ثبت نام شما تکمیل شد
|
||||
</span>
|
||||
<small className="font-bold mt-4 mb-4">
|
||||
ورود شما به خانواده مدستاگرام را تبریک می گوییم{" "}
|
||||
</small>
|
||||
<div className="flex flex-col justify-center items-center">
|
||||
<ProfileAvatar
|
||||
src={user?.profile_image}
|
||||
alt={user?.user_name || "user profile"}
|
||||
size="xl"
|
||||
rounded="2xl"
|
||||
/>
|
||||
<div className="flex flex-col justify-center items-center gap-1 mt-1">
|
||||
<span>{formatFullName(user?.first_name, user?.last_name)}</span>
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{user?.user_name}
|
||||
<VerificationBadge
|
||||
isVerified={verifiedStatus ?? user?.is_verified ?? "pending"}
|
||||
/>
|
||||
</span>
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center justify-center address-page ">
|
||||
<span className="text-xl font-bold text-[#238800]">
|
||||
{t("auth.registrationComplete")}
|
||||
</span>
|
||||
<small className="font-bold mt-4 mb-4">
|
||||
{t("auth.welcomeMessage")}
|
||||
</small>
|
||||
<div className="flex flex-col justify-center items-center">
|
||||
<ProfileAvatar
|
||||
src={user?.profile_image}
|
||||
alt={user?.user_name || "user profile"}
|
||||
size="xl"
|
||||
rounded="2xl"
|
||||
/>
|
||||
<div className="flex flex-col justify-center items-center gap-1 mt-1">
|
||||
<span>{formatFullName(user?.first_name, user?.last_name)}</span>
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{user?.user_name}
|
||||
<VerificationBadge
|
||||
isVerified={
|
||||
verifiedStatus ?? user?.is_verified ?? "pending"
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<small className="font-bold mt-10 mb-4 text-center">
|
||||
پس از تایید مدارک و احراز هویت، تیک آبی یا طلایی کنار نام کاربری شما
|
||||
قرار می گیرد
|
||||
</small>
|
||||
<small className="font-bold mt-10 mb-4 text-center">
|
||||
{t("auth.verificationBadgeHint")}
|
||||
</small>
|
||||
|
||||
<div className=" flex">
|
||||
<div className="">
|
||||
<svg
|
||||
width="80"
|
||||
height="80"
|
||||
viewBox="0 0 80 80"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
opacity="0.4"
|
||||
d="M35.8331 8.16689C38.1331 6.20023 41.8998 6.20023 44.2331 8.16689L49.4998 12.7002C50.4998 13.5669 52.3664 14.2669 53.6998 14.2669H59.3664C62.8998 14.2669 65.7998 17.1669 65.7998 20.7002V26.3669C65.7998 27.6669 66.4998 29.5669 67.3664 30.5669L71.8998 35.8336C73.8664 38.1336 73.8664 41.9002 71.8998 44.2336L67.3664 49.5002C66.4998 50.5002 65.7998 52.3669 65.7998 53.7002V59.3669C65.7998 62.9002 62.8998 65.8002 59.3664 65.8002H53.6998C52.3998 65.8002 50.4998 66.5002 49.4998 67.3669L44.2331 71.9002C41.9331 73.8669 38.1664 73.8669 35.8331 71.9002L30.5664 67.3669C29.5664 66.5002 27.6998 65.8002 26.3664 65.8002H20.5998C17.0664 65.8002 14.1664 62.9002 14.1664 59.3669V53.6669C14.1664 52.3669 13.4664 50.5002 12.6331 49.5002L8.13311 44.2002C6.19977 41.9002 6.19977 38.1669 8.13311 35.8669L12.6331 30.5669C13.4664 29.5669 14.1664 27.7002 14.1664 26.4002V20.6669C14.1664 17.1336 17.0664 14.2336 20.5998 14.2336H26.3664C27.6664 14.2336 29.5664 13.5336 30.5664 12.6669L35.8331 8.16689Z"
|
||||
fill="#0066FF"
|
||||
/>
|
||||
<path
|
||||
d="M35.9665 50.5668C35.2999 50.5668 34.6665 50.3001 34.1999 49.8334L26.1332 41.7668C25.1665 40.8001 25.1665 39.2001 26.1332 38.2334C27.0999 37.2668 28.6999 37.2668 29.6665 38.2334L35.9665 44.5334L50.2999 30.2001C51.2665 29.2334 52.8665 29.2334 53.8332 30.2001C54.7999 31.1668 54.7999 32.7668 53.8332 33.7334L37.7332 49.8334C37.2665 50.3001 36.6332 50.5668 35.9665 50.5668Z"
|
||||
fill="#0066FF"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="">
|
||||
<svg
|
||||
width="80"
|
||||
height="80"
|
||||
viewBox="0 0 80 80"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
opacity="0.4"
|
||||
d="M35.8331 8.16689C38.1331 6.20023 41.8998 6.20023 44.2331 8.16689L49.4998 12.7002C50.4998 13.5669 52.3664 14.2669 53.6998 14.2669H59.3664C62.8998 14.2669 65.7998 17.1669 65.7998 20.7002V26.3669C65.7998 27.6669 66.4998 29.5669 67.3664 30.5669L71.8998 35.8336C73.8664 38.1336 73.8664 41.9002 71.8998 44.2336L67.3664 49.5002C66.4998 50.5002 65.7998 52.3669 65.7998 53.7002V59.3669C65.7998 62.9002 62.8998 65.8002 59.3664 65.8002H53.6998C52.3998 65.8002 50.4998 66.5002 49.4998 67.3669L44.2331 71.9002C41.9331 73.8669 38.1664 73.8669 35.8331 71.9002L30.5664 67.3669C29.5664 66.5002 27.6998 65.8002 26.3664 65.8002H20.5998C17.0664 65.8002 14.1664 62.9002 14.1664 59.3669V53.6669C14.1664 52.3669 13.4664 50.5002 12.6331 49.5002L8.13311 44.2002C6.19977 41.9002 6.19977 38.1669 8.13311 35.8669L12.6331 30.5669C13.4664 29.5669 14.1664 27.7002 14.1664 26.4002V20.6669C14.1664 17.1336 17.0664 14.2336 20.5998 14.2336H26.3664C27.6664 14.2336 29.5664 13.5336 30.5664 12.6669L35.8331 8.16689Z"
|
||||
fill="#dbd40b"
|
||||
/>
|
||||
<path
|
||||
d="M35.9665 50.5668C35.2999 50.5668 34.6665 50.3001 34.1999 49.8334L26.1332 41.7668C25.1665 40.8001 25.1665 39.2001 26.1332 38.2334C27.0999 37.2668 28.6999 37.2668 29.6665 38.2334L35.9665 44.5334L50.2999 30.2001C51.2665 29.2334 52.8665 29.2334 53.8332 30.2001C54.7999 31.1668 54.7999 32.7668 53.8332 33.7334L37.7332 49.8334C37.2665 50.3001 36.6332 50.5668 35.9665 50.5668Z"
|
||||
fill="#f5f507"
|
||||
/>
|
||||
</svg>
|
||||
<div className=" flex">
|
||||
<div className="">
|
||||
<svg
|
||||
width="80"
|
||||
height="80"
|
||||
viewBox="0 0 80 80"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
opacity="0.4"
|
||||
d="M35.8331 8.16689C38.1331 6.20023 41.8998 6.20023 44.2331 8.16689L49.4998 12.7002C50.4998 13.5669 52.3664 14.2669 53.6998 14.2669H59.3664C62.8998 14.2669 65.7998 17.1669 65.7998 20.7002V26.3669C65.7998 27.6669 66.4998 29.5669 67.3664 30.5669L71.8998 35.8336C73.8664 38.1336 73.8664 41.9002 71.8998 44.2336L67.3664 49.5002C66.4998 50.5002 65.7998 52.3669 65.7998 53.7002V59.3669C65.7998 62.9002 62.8998 65.8002 59.3664 65.8002H53.6998C52.3998 65.8002 50.4998 66.5002 49.4998 67.3669L44.2331 71.9002C41.9331 73.8669 38.1664 73.8669 35.8331 71.9002L30.5664 67.3669C29.5664 66.5002 27.6998 65.8002 26.3664 65.8002H20.5998C17.0664 65.8002 14.1664 62.9002 14.1664 59.3669V53.6669C14.1664 52.3669 13.4664 50.5002 12.6331 49.5002L8.13311 44.2002C6.19977 41.9002 6.19977 38.1669 8.13311 35.8669L12.6331 30.5669C13.4664 29.5669 14.1664 27.7002 14.1664 26.4002V20.6669C14.1664 17.1336 17.0664 14.2336 20.5998 14.2336H26.3664C27.6664 14.2336 29.5664 13.5336 30.5664 12.6669L35.8331 8.16689Z"
|
||||
fill="#0066FF"
|
||||
/>
|
||||
<path
|
||||
d="M35.9665 50.5668C35.2999 50.5668 34.6665 50.3001 34.1999 49.8334L26.1332 41.7668C25.1665 40.8001 25.1665 39.2001 26.1332 38.2334C27.0999 37.2668 28.6999 37.2668 29.6665 38.2334L35.9665 44.5334L50.2999 30.2001C51.2665 29.2334 52.8665 29.2334 53.8332 30.2001C54.7999 31.1668 54.7999 32.7668 53.8332 33.7334L37.7332 49.8334C37.2665 50.3001 36.6332 50.5668 35.9665 50.5668Z"
|
||||
fill="#0066FF"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="">
|
||||
<svg
|
||||
width="80"
|
||||
height="80"
|
||||
viewBox="0 0 80 80"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
opacity="0.4"
|
||||
d="M35.8331 8.16689C38.1331 6.20023 41.8998 6.20023 44.2331 8.16689L49.4998 12.7002C50.4998 13.5669 52.3664 14.2669 53.6998 14.2669H59.3664C62.8998 14.2669 65.7998 17.1669 65.7998 20.7002V26.3669C65.7998 27.6669 66.4998 29.5669 67.3664 30.5669L71.8998 35.8336C73.8664 38.1336 73.8664 41.9002 71.8998 44.2336L67.3664 49.5002C66.4998 50.5002 65.7998 52.3669 65.7998 53.7002V59.3669C65.7998 62.9002 62.8998 65.8002 59.3664 65.8002H53.6998C52.3998 65.8002 50.4998 66.5002 49.4998 67.3669L44.2331 71.9002C41.9331 73.8669 38.1664 73.8669 35.8331 71.9002L30.5664 67.3669C29.5664 66.5002 27.6998 65.8002 26.3664 65.8002H20.5998C17.0664 65.8002 14.1664 62.9002 14.1664 59.3669V53.6669C14.1664 52.3669 13.4664 50.5002 12.6331 49.5002L8.13311 44.2002C6.19977 41.9002 6.19977 38.1669 8.13311 35.8669L12.6331 30.5669C13.4664 29.5669 14.1664 27.7002 14.1664 26.4002V20.6669C14.1664 17.1336 17.0664 14.2336 20.5998 14.2336H26.3664C27.6664 14.2336 29.5664 13.5336 30.5664 12.6669L35.8331 8.16689Z"
|
||||
fill="#dbd40b"
|
||||
/>
|
||||
<path
|
||||
d="M35.9665 50.5668C35.2999 50.5668 34.6665 50.3001 34.1999 49.8334L26.1332 41.7668C25.1665 40.8001 25.1665 39.2001 26.1332 38.2334C27.0999 37.2668 28.6999 37.2668 29.6665 38.2334L35.9665 44.5334L50.2999 30.2001C51.2665 29.2334 52.8665 29.2334 53.8332 30.2001C54.7999 31.1668 54.7999 32.7668 53.8332 33.7334L37.7332 49.8334C37.2665 50.3001 36.6332 50.5668 35.9665 50.5668Z"
|
||||
fill="#f5f507"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<AuthNextButton
|
||||
onClick={confirmHandler}
|
||||
className="mt-20"
|
||||
loading={navigating}
|
||||
disabled={navigating}
|
||||
>
|
||||
{t("auth.saveAndContinue")}
|
||||
</AuthNextButton>
|
||||
<small className="font-bold mt-10 mb-1 text-center">
|
||||
{t("auth.verificationTimeOffice")}
|
||||
</small>
|
||||
<small className="font-bold text-center">
|
||||
{t("auth.verificationTimeOffHours")}
|
||||
</small>
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
onClose={() => setShowModal(false)}
|
||||
height="200px"
|
||||
>
|
||||
<p className="font-bold text-center mt-4">
|
||||
{t("auth.shareWorkHint")}
|
||||
</p>
|
||||
<div className="flex w-full justify-center items-center mt-10 gap-5">
|
||||
<Link
|
||||
href={"/new-post"}
|
||||
className="bg-sky-200 py-2 px-5 rounded-xl min-w-[120px] text-sky-600 text-center"
|
||||
>
|
||||
{t("auth.createPost")}
|
||||
</Link>
|
||||
<Link
|
||||
href={"/"}
|
||||
className="bg-sky-200 py-2 px-5 rounded-xl min-w-[120px] text-sky-600 text-center"
|
||||
>
|
||||
{t("auth.later")}
|
||||
</Link>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
{/* */}
|
||||
<AuthNextButton onClick={confirmHandler} className="mt-20" loading={navigating} disabled={navigating}>
|
||||
ثبت و ادامه
|
||||
</AuthNextButton>
|
||||
<small className="font-bold mt-10 mb-1 text-center">
|
||||
زمان تایید مدارک 5 دقیقه تا 1 ساعت در ساعات اداری و
|
||||
</small>
|
||||
<small className="font-bold text-center">
|
||||
3 تا 8 ساعت در ساعات غیر اداری
|
||||
</small>
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
onClose={() => setShowModal(false)}
|
||||
height="200px"
|
||||
>
|
||||
<p className="font-bold text-center mt-4">
|
||||
.برای بهتر دیده شدن نمونه کارهای خود را به اشتراگ بگذراید
|
||||
</p>
|
||||
<div className="flex w-full justify-center items-center mt-10 gap-5">
|
||||
<Link
|
||||
href={"/new-post"}
|
||||
className="bg-sky-200 py-2 px-5 rounded-xl min-w-[120px] text-sky-600 text-center"
|
||||
>
|
||||
ثبت پست
|
||||
</Link>
|
||||
<Link
|
||||
href={"/"}
|
||||
className="bg-sky-200 py-2 px-5 rounded-xl min-w-[120px] text-sky-600 text-center"
|
||||
>
|
||||
بعدا
|
||||
</Link>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
</Container>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import AuthPageLayout, {
|
||||
AuthFormFooter,
|
||||
AuthPageContent,
|
||||
} from "@/components/auth/AuthPageLayout";
|
||||
import React from "react";
|
||||
import React, { useMemo } from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useFormik } from "formik";
|
||||
import * as yup from "yup";
|
||||
@@ -13,18 +13,24 @@ import { useRouter } from "next/navigation";
|
||||
import AuthNextButton from "@/components/auth/AuthNextButton";
|
||||
import toast from "react-hot-toast";
|
||||
import AuthUserDetails from "@/components/auth/AuthUserDetails";
|
||||
|
||||
// Validation schema using Yup
|
||||
const schema = yup.object().shape({
|
||||
selectedButton: yup.boolean().required("انتخاب نوع همکاری الزامی است"),
|
||||
});
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
|
||||
function CooperationType() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const { request, loading } = useAxios();
|
||||
const mobile =
|
||||
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
|
||||
|
||||
const schema = useMemo(
|
||||
() =>
|
||||
yup.object().shape({
|
||||
selectedButton: yup.boolean().required(t("auth.cooperationRequired")),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
selectedButton: null,
|
||||
@@ -39,7 +45,7 @@ function CooperationType() {
|
||||
});
|
||||
router.push("/verify/public-relations");
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || "خطای نامشخصی رخ داد.");
|
||||
toast.error(err?.response?.data?.message || t("auth.unknownError"));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -47,62 +53,66 @@ function CooperationType() {
|
||||
});
|
||||
|
||||
return (
|
||||
<AuthPageLayout>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="flex w-full flex-1 flex-col items-center"
|
||||
>
|
||||
<AuthPageContent>
|
||||
<span className="text-xl font-bold">نوع همکاری</span>
|
||||
<div className="mt-5">
|
||||
<AuthUserDetails />
|
||||
</div>
|
||||
|
||||
<p className="my-10 text-sm font-bold">
|
||||
آیا مایل به همکاری خارج از محل سکونت خود هستید؟
|
||||
</p>
|
||||
|
||||
<div className="w-full max-w-sm flex flex-col items-center">
|
||||
<div className="flex w-full gap-2">
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
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
|
||||
? "btn-modern--selected text-white"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
بله خوشحال هم می شم
|
||||
</AuthNextButton>
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
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
|
||||
? "btn-modern--selected text-white"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
نه شهر خودم رو ترجیح میدم
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
|
||||
{formik.errors.selectedButton && formik.touched.selectedButton && (
|
||||
<div className="text-red-500 mt-2 text-sm">
|
||||
{formik.errors.selectedButton}
|
||||
<LocalePageShell>
|
||||
<AuthPageLayout>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="flex w-full flex-1 flex-col items-center"
|
||||
>
|
||||
<AuthPageContent>
|
||||
<span className="text-xl font-bold">
|
||||
{t("auth.cooperationTypeTitle")}
|
||||
</span>
|
||||
<div className="mt-5">
|
||||
<AuthUserDetails />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AuthPageContent>
|
||||
|
||||
<AuthFormFooter onSkip={() => router.push("/verify/public-relations")}>
|
||||
<AuthNextButton type="submit" loading={loading} disabled={loading}>
|
||||
ثبت و ادامه
|
||||
</AuthNextButton>
|
||||
</AuthFormFooter>
|
||||
</form>
|
||||
</AuthPageLayout>
|
||||
<p className="my-10 text-sm font-bold">
|
||||
{t("auth.cooperationQuestion")}
|
||||
</p>
|
||||
|
||||
<div className="w-full max-w-sm flex flex-col items-center">
|
||||
<div className="flex w-full gap-2">
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
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
|
||||
? "btn-modern--selected text-white"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{t("auth.cooperationYes")}
|
||||
</AuthNextButton>
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
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
|
||||
? "btn-modern--selected text-white"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{t("auth.cooperationNo")}
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
|
||||
{formik.errors.selectedButton && formik.touched.selectedButton && (
|
||||
<div className="text-red-500 mt-2 text-sm">
|
||||
{formik.errors.selectedButton}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AuthPageContent>
|
||||
|
||||
<AuthFormFooter onSkip={() => router.push("/verify/public-relations")}>
|
||||
<AuthNextButton type="submit" loading={loading} disabled={loading}>
|
||||
{t("auth.saveAndContinue")}
|
||||
</AuthNextButton>
|
||||
</AuthFormFooter>
|
||||
</form>
|
||||
</AuthPageLayout>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import AuthPageLayout, {
|
||||
AuthFormFooter,
|
||||
AuthPageContent,
|
||||
} from "@/components/auth/AuthPageLayout";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import * as yup from "yup";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -14,24 +14,42 @@ import toast from "react-hot-toast";
|
||||
import AuthUserDetails from "@/components/auth/AuthUserDetails";
|
||||
import ExpertisePicker from "@/components/auth/ExpertisePicker";
|
||||
import { IExpertise } from "@/types/types";
|
||||
|
||||
const schema = yup.object().shape({
|
||||
subExpertise: yup
|
||||
.array()
|
||||
.of(yup.string().required("حداقل یک زیرمهارت را انتخاب کنید"))
|
||||
.min(1, "حداقل یک زیرمهارت را انتخاب کنید"),
|
||||
expertise: yup.string().required("انتخاب نوع تخصص الزامی است"),
|
||||
});
|
||||
import {
|
||||
resolveDisplaySubExpertise,
|
||||
subExpertiseListIncludes,
|
||||
} from "@/lib/subExpertiseDisplay";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
|
||||
function Expertise() {
|
||||
const { t } = useTranslation("common");
|
||||
const [expertise, setExpertise] = useState<string>("");
|
||||
const [expertiseList, setExpertiseList] = useState<IExpertise[] | null>(null);
|
||||
const [subExpertise, setSubExpertise] = useState<string[]>([]);
|
||||
const [displaySubExpertise, setDisplaySubExpertise] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const router = useRouter();
|
||||
const { request, loading } = useAxios();
|
||||
const mobile =
|
||||
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
|
||||
|
||||
const schema = useMemo(
|
||||
() =>
|
||||
yup.object().shape({
|
||||
subExpertise: yup
|
||||
.array()
|
||||
.of(yup.string().required(t("auth.subExpertiseMin")))
|
||||
.min(1, t("auth.subExpertiseMin")),
|
||||
displaySubExpertise: yup
|
||||
.string()
|
||||
.nullable()
|
||||
.required(t("auth.displaySubExpertiseRequired")),
|
||||
expertise: yup.string().required(t("auth.expertiseRequired")),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const response = await request<{ expertises: IExpertise[] }>(
|
||||
@@ -48,10 +66,44 @@ function Expertise() {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const handleSubExpertiseToggle = (value: string) => {
|
||||
setSubExpertise((prev) => {
|
||||
const next = prev.includes(value)
|
||||
? prev.filter((item) => item !== value)
|
||||
: [...prev, value];
|
||||
|
||||
setDisplaySubExpertise((current) => {
|
||||
if (current === value && !next.includes(value)) {
|
||||
return next[0] ?? null;
|
||||
}
|
||||
if (current && !next.includes(current)) {
|
||||
return next[0] ?? null;
|
||||
}
|
||||
if (!current && next.length === 1) {
|
||||
return next[0];
|
||||
}
|
||||
return current;
|
||||
});
|
||||
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleCheck = async () => {
|
||||
const resolvedDisplay = subExpertiseListIncludes(
|
||||
subExpertise,
|
||||
displaySubExpertise
|
||||
)
|
||||
? displaySubExpertise
|
||||
: resolveDisplaySubExpertise(subExpertise, displaySubExpertise);
|
||||
|
||||
try {
|
||||
await schema.validate({ expertise, subExpertise });
|
||||
await sendExpertiseTypeHandler();
|
||||
await schema.validate({
|
||||
expertise,
|
||||
subExpertise,
|
||||
displaySubExpertise: resolvedDisplay,
|
||||
});
|
||||
await sendExpertiseTypeHandler(resolvedDisplay);
|
||||
} catch (validationError: any) {
|
||||
toast.error(validationError.message, {
|
||||
duration: 4000,
|
||||
@@ -59,59 +111,62 @@ function Expertise() {
|
||||
}
|
||||
};
|
||||
|
||||
const sendExpertiseTypeHandler = async () => {
|
||||
const sendExpertiseTypeHandler = async (resolvedDisplay: string | null) => {
|
||||
try {
|
||||
const data = {
|
||||
mobile,
|
||||
expertise,
|
||||
sub_expertise: subExpertise,
|
||||
display_sub_expertise: resolvedDisplay,
|
||||
};
|
||||
await request("POST", "/verify/expertise", data);
|
||||
localStorage.setItem("expertise", expertise);
|
||||
if (resolvedDisplay) {
|
||||
localStorage.setItem("display_sub_expertise", resolvedDisplay);
|
||||
}
|
||||
router.push("/verify/services");
|
||||
} catch (error: any) {
|
||||
toast.error(error?.message || "خطای نامشخصی رخ داد.");
|
||||
toast.error(error?.response?.data?.message || t("auth.unknownError"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthPageLayout>
|
||||
<AuthPageContent>
|
||||
<span className="text-xl font-bold">تخصص</span>
|
||||
<LocalePageShell>
|
||||
<AuthPageLayout>
|
||||
<AuthPageContent>
|
||||
<span className="text-xl font-bold">{t("auth.expertiseTitle")}</span>
|
||||
|
||||
<AuthUserDetails />
|
||||
<AuthUserDetails />
|
||||
|
||||
<p className="mt-8 text-center">در چه زمینه ای تخصص دارید؟</p>
|
||||
<p className="mt-8 text-center">{t("auth.expertiseQuestion")}</p>
|
||||
|
||||
<ExpertisePicker
|
||||
expertiseList={expertiseList}
|
||||
expertise={expertise}
|
||||
subExpertise={subExpertise}
|
||||
onExpertiseSelect={(value) => {
|
||||
setExpertise(value);
|
||||
setSubExpertise([]);
|
||||
}}
|
||||
onSubExpertiseToggle={(value) => {
|
||||
setSubExpertise((prev) =>
|
||||
prev.includes(value)
|
||||
? prev.filter((item) => item !== value)
|
||||
: [...prev, value]
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</AuthPageContent>
|
||||
<ExpertisePicker
|
||||
expertiseList={expertiseList}
|
||||
expertise={expertise}
|
||||
subExpertise={subExpertise}
|
||||
displaySubExpertise={displaySubExpertise}
|
||||
onExpertiseSelect={(value) => {
|
||||
setExpertise(value);
|
||||
setSubExpertise([]);
|
||||
setDisplaySubExpertise(null);
|
||||
}}
|
||||
onSubExpertiseToggle={handleSubExpertiseToggle}
|
||||
onDisplaySubExpertiseChange={setDisplaySubExpertise}
|
||||
/>
|
||||
</AuthPageContent>
|
||||
|
||||
<AuthFormFooter onSkip={() => router.push("/verify/services")}>
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={handleCheck}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
ثبت و ادامه
|
||||
</AuthNextButton>
|
||||
</AuthFormFooter>
|
||||
</AuthPageLayout>
|
||||
<AuthFormFooter onSkip={() => router.push("/verify/services")}>
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={handleCheck}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("auth.saveAndContinue")}
|
||||
</AuthNextButton>
|
||||
</AuthFormFooter>
|
||||
</AuthPageLayout>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import AuthPageLayout, {
|
||||
AuthFormFooter,
|
||||
AuthPageContent,
|
||||
} from "@/components/auth/AuthPageLayout";
|
||||
import React from "react";
|
||||
import React, { useMemo } from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useFormik } from "formik";
|
||||
import * as yup from "yup";
|
||||
@@ -14,13 +14,11 @@ import AuthNextButton from "@/components/auth/AuthNextButton";
|
||||
import toast from "react-hot-toast";
|
||||
import AuthUserDetails from "@/components/auth/AuthUserDetails";
|
||||
import Image from "next/image";
|
||||
|
||||
// Validation schema using Yup
|
||||
const schema = yup.object().shape({
|
||||
selectedButton: yup.string().required("جنسیت الزامی است"),
|
||||
});
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
|
||||
function Gender() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const { request, loading } = useAxios();
|
||||
const mobile =
|
||||
@@ -28,6 +26,14 @@ function Gender() {
|
||||
const expertise =
|
||||
typeof window !== "undefined" ? localStorage.getItem("expertise") : null;
|
||||
|
||||
const schema = useMemo(
|
||||
() =>
|
||||
yup.object().shape({
|
||||
selectedButton: yup.string().required(t("auth.genderRequired")),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
selectedButton: "",
|
||||
@@ -45,7 +51,7 @@ function Gender() {
|
||||
router.push("/verify/services");
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || "خطای نامشخصی رخ داد.");
|
||||
toast.error(err?.response?.data?.message || t("auth.unknownError"));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -53,70 +59,72 @@ function Gender() {
|
||||
});
|
||||
|
||||
return (
|
||||
<AuthPageLayout>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="flex w-full flex-1 flex-col items-center"
|
||||
>
|
||||
<AuthPageContent>
|
||||
<span className="text-xl font-bold">جنسیت</span>
|
||||
<div className="mt-5">
|
||||
<AuthUserDetails />
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-sm flex flex-col items-center">
|
||||
<div className="flex w-full gap-4">
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
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"
|
||||
? "btn-modern--selected text-white"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<Image
|
||||
src={"/images/icons/woman.svg"}
|
||||
width={25}
|
||||
height={25}
|
||||
alt="gender icon"
|
||||
/>
|
||||
خانم
|
||||
</AuthNextButton>
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={() => formik.setFieldValue("selectedButton", "male")}
|
||||
className={`mt-5 w-full max-w-[250px] !border-[#3E8BFF] flex items-center justify-center flex-row-reverse gap-2 ${
|
||||
formik.values.selectedButton === "male"
|
||||
? "!bg-[#3E8BFF] !text-white"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<Image
|
||||
src={"/images/icons/man.svg"}
|
||||
width={25}
|
||||
height={25}
|
||||
alt="gender icon"
|
||||
/>
|
||||
آقا
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
|
||||
{formik.errors.selectedButton && formik.touched.selectedButton && (
|
||||
<div className="text-red-500 mt-2 text-sm">
|
||||
{formik.errors.selectedButton}
|
||||
<LocalePageShell>
|
||||
<AuthPageLayout>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="flex w-full flex-1 flex-col items-center"
|
||||
>
|
||||
<AuthPageContent>
|
||||
<span className="text-xl font-bold">{t("auth.genderTitle")}</span>
|
||||
<div className="mt-5">
|
||||
<AuthUserDetails />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AuthPageContent>
|
||||
|
||||
<AuthFormFooter onSkip={() => router.push("/verify/sizes")}>
|
||||
<AuthNextButton type="submit" loading={loading} disabled={loading}>
|
||||
ثبت و ادامه
|
||||
</AuthNextButton>
|
||||
</AuthFormFooter>
|
||||
</form>
|
||||
</AuthPageLayout>
|
||||
<div className="w-full max-w-sm flex flex-col items-center">
|
||||
<div className="flex w-full gap-4">
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
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"
|
||||
? "btn-modern--selected text-white"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<Image
|
||||
src={"/images/icons/woman.svg"}
|
||||
width={25}
|
||||
height={25}
|
||||
alt="gender icon"
|
||||
/>
|
||||
{t("auth.female")}
|
||||
</AuthNextButton>
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={() => formik.setFieldValue("selectedButton", "male")}
|
||||
className={`mt-5 w-full max-w-[250px] !border-[#3E8BFF] flex items-center justify-center flex-row-reverse gap-2 ${
|
||||
formik.values.selectedButton === "male"
|
||||
? "!bg-[#3E8BFF] !text-white"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<Image
|
||||
src={"/images/icons/man.svg"}
|
||||
width={25}
|
||||
height={25}
|
||||
alt="gender icon"
|
||||
/>
|
||||
{t("auth.male")}
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
|
||||
{formik.errors.selectedButton && formik.touched.selectedButton && (
|
||||
<div className="text-red-500 mt-2 text-sm">
|
||||
{formik.errors.selectedButton}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AuthPageContent>
|
||||
|
||||
<AuthFormFooter onSkip={() => router.push("/verify/sizes")}>
|
||||
<AuthNextButton type="submit" loading={loading} disabled={loading}>
|
||||
{t("auth.saveAndContinue")}
|
||||
</AuthNextButton>
|
||||
</AuthFormFooter>
|
||||
</form>
|
||||
</AuthPageLayout>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import AuthPageLayout, {
|
||||
AuthFormFooter,
|
||||
AuthPageContent,
|
||||
} from "@/components/auth/AuthPageLayout";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useFormik } from "formik";
|
||||
import * as yup from "yup";
|
||||
@@ -16,19 +16,13 @@ import AuthUserDetails from "@/components/auth/AuthUserDetails";
|
||||
import SelectBox from "@/components/elements/SelectBox";
|
||||
import { ICity, IProvince } from "@/types/types";
|
||||
import Map, { GeolocateControl, Marker } from "react-map-gl";
|
||||
|
||||
import "mapbox-gl/dist/mapbox-gl.css";
|
||||
import Image from "next/image";
|
||||
|
||||
// Validation schema using Yup
|
||||
const schema = yup.object().shape({
|
||||
markerCoordinate: yup.mixed().required(" انتخاب لوکیشن الزامی است"),
|
||||
address: yup.string().required(" آدرس الزامی است"),
|
||||
cityId: yup.string().required(" انتخاب شهر الزامی است"),
|
||||
stateId: yup.string().required(" انتخاب استان الزامی است"),
|
||||
});
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
|
||||
function LocationPage() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const { request, loading } = useAxios();
|
||||
|
||||
@@ -40,6 +34,17 @@ function LocationPage() {
|
||||
lng: number;
|
||||
} | null>(null);
|
||||
|
||||
const schema = useMemo(
|
||||
() =>
|
||||
yup.object().shape({
|
||||
markerCoordinate: yup.mixed().required(t("auth.locationRequired")),
|
||||
address: yup.string().required(t("auth.addressRequired")),
|
||||
cityId: yup.string().required(t("auth.cityRequired")),
|
||||
stateId: yup.string().required(t("auth.provinceRequired")),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
const fetchStates = async () => {
|
||||
try {
|
||||
const response = await request<{ provinces: IProvince[] }>(
|
||||
@@ -52,7 +57,6 @@ function LocationPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch cities when a province is selected
|
||||
const fetchCities = async (provinceId: string) => {
|
||||
try {
|
||||
const response = await request<{ cities: ICity[] }>(
|
||||
@@ -97,18 +101,17 @@ function LocationPage() {
|
||||
});
|
||||
router.push("/verify/national-cart");
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || "خطای نامشخصی رخ داد.");
|
||||
toast.error(err?.response?.data?.message || t("auth.unknownError"));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Handle province change
|
||||
const handleProvinceChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const selectedProvinceId = e.target.value;
|
||||
formik.setFieldValue("stateId", selectedProvinceId);
|
||||
fetchCities(selectedProvinceId); // Fetch cities for the selected province
|
||||
fetchCities(selectedProvinceId);
|
||||
};
|
||||
|
||||
const handleMapClick = (event: any) => {
|
||||
@@ -121,135 +124,137 @@ function LocationPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthPageLayout className="address-page">
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="flex w-full flex-1 flex-col items-center"
|
||||
>
|
||||
<AuthPageContent>
|
||||
<span className="text-xl font-bold">لوکیشن</span>
|
||||
<div className="mt-5">
|
||||
<AuthUserDetails />
|
||||
</div>
|
||||
<LocalePageShell>
|
||||
<AuthPageLayout className="address-page">
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="flex w-full flex-1 flex-col items-center"
|
||||
>
|
||||
<AuthPageContent>
|
||||
<span className="text-xl font-bold">{t("auth.locationTitle")}</span>
|
||||
<div className="mt-5">
|
||||
<AuthUserDetails />
|
||||
</div>
|
||||
|
||||
<small className="font-bold mt-10 mb-4">
|
||||
مشخص کنید در کدام شهر قادر به انجام فعالیت هستید
|
||||
</small>
|
||||
|
||||
<div className="w-full max-w-sm flex flex-col items-center">
|
||||
<SelectBox
|
||||
className={`mt-4 ${
|
||||
formik.touched.stateId && formik.errors.stateId
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.stateId}
|
||||
onChange={handleProvinceChange}
|
||||
>
|
||||
<option disabled value="">
|
||||
استان
|
||||
</option>
|
||||
{allStates?.map((item: IProvince) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item?.name}
|
||||
</option>
|
||||
))}
|
||||
</SelectBox>
|
||||
{formik.touched.stateId && formik.errors.stateId && (
|
||||
<small className="text-red-500 mt-2 block text-center">
|
||||
{formik.errors.stateId}
|
||||
<small className="font-bold mt-10 mb-4">
|
||||
{t("auth.locationActivityHint")}
|
||||
</small>
|
||||
)}
|
||||
|
||||
<SelectBox
|
||||
className={`mt-4 ${
|
||||
formik.touched.cityId && formik.errors.cityId
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.cityId}
|
||||
onChange={(e) => formik.setFieldValue("cityId", e.target.value)}
|
||||
>
|
||||
<option disabled value="">
|
||||
شهر
|
||||
</option>
|
||||
{cities?.map((city: ICity) => (
|
||||
<option key={city.id} value={city.id}>
|
||||
{city.name}
|
||||
</option>
|
||||
))}
|
||||
</SelectBox>
|
||||
{formik.touched.cityId && formik.errors.cityId && (
|
||||
<small className="text-red-500 mt-2 block text-center">
|
||||
{formik.errors.cityId}
|
||||
</small>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
name="address"
|
||||
placeholder="آدرس"
|
||||
className={`border mt-4 w-full max-w-[300px] p-3 rounded-2xl border-border-primary-light dark:border-border-primary-dark bg-secondary-light dark:bg-secondary-dark font-medium ${
|
||||
formik.touched.address && formik.errors.address
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.address}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.address && formik.errors.address && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.address}
|
||||
</small>
|
||||
)}
|
||||
<Map
|
||||
style={{ height: "calc(100vh - 260px)" }}
|
||||
initialViewState={{
|
||||
longitude: 51.375433528216654,
|
||||
latitude: 35.73356434056531,
|
||||
zoom: 11,
|
||||
}}
|
||||
mapboxAccessToken="pk.eyJ1IjoiYWxkZjkyIiwiYSI6ImNscHh5ZG56dTByMXAycnJ2cDNmdDQ5M3YifQ.a_sUt1rLFMfA0jHrETcM7A"
|
||||
mapStyle="mapbox://styles/mapbox/streets-v11"
|
||||
onClick={handleMapClick}
|
||||
>
|
||||
<GeolocateControl />
|
||||
{selectedLocation && (
|
||||
<Marker
|
||||
latitude={selectedLocation.lat}
|
||||
longitude={selectedLocation.lng}
|
||||
<div className="w-full max-w-sm flex flex-col items-center">
|
||||
<SelectBox
|
||||
className={`mt-4 ${
|
||||
formik.touched.stateId && formik.errors.stateId
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.stateId}
|
||||
onChange={handleProvinceChange}
|
||||
>
|
||||
<Image
|
||||
alt="location icon"
|
||||
className="-mt-5"
|
||||
width={25}
|
||||
height={25}
|
||||
src={"/images/icons/location.svg"}
|
||||
/>
|
||||
</Marker>
|
||||
)}
|
||||
</Map>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
className="scale-125"
|
||||
type="checkbox"
|
||||
checked={isCheckedOne}
|
||||
onChange={toggleCheckBox}
|
||||
/>
|
||||
<span className="text-xs font-bold">
|
||||
اطلاعات لوکیشن شما برای همه قابل نمایش باشد
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</AuthPageContent>
|
||||
<option disabled value="">
|
||||
{t("auth.province")}
|
||||
</option>
|
||||
{allStates?.map((item: IProvince) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item?.name}
|
||||
</option>
|
||||
))}
|
||||
</SelectBox>
|
||||
{formik.touched.stateId && formik.errors.stateId && (
|
||||
<small className="text-red-500 mt-2 block text-center">
|
||||
{formik.errors.stateId}
|
||||
</small>
|
||||
)}
|
||||
|
||||
<AuthFormFooter onSkip={() => router.push("/verify/national-cart")}>
|
||||
<AuthNextButton type="submit" loading={loading} disabled={loading}>
|
||||
ثبت و ادامه
|
||||
</AuthNextButton>
|
||||
</AuthFormFooter>
|
||||
</form>
|
||||
</AuthPageLayout>
|
||||
<SelectBox
|
||||
className={`mt-4 ${
|
||||
formik.touched.cityId && formik.errors.cityId
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.cityId}
|
||||
onChange={(e) => formik.setFieldValue("cityId", e.target.value)}
|
||||
>
|
||||
<option disabled value="">
|
||||
{t("auth.city")}
|
||||
</option>
|
||||
{cities?.map((city: ICity) => (
|
||||
<option key={city.id} value={city.id}>
|
||||
{city.name}
|
||||
</option>
|
||||
))}
|
||||
</SelectBox>
|
||||
{formik.touched.cityId && formik.errors.cityId && (
|
||||
<small className="text-red-500 mt-2 block text-center">
|
||||
{formik.errors.cityId}
|
||||
</small>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
name="address"
|
||||
placeholder={t("auth.addressPlaceholder")}
|
||||
className={`border mt-4 w-full max-w-[300px] p-3 rounded-2xl border-border-primary-light dark:border-border-primary-dark bg-secondary-light dark:bg-secondary-dark font-medium ${
|
||||
formik.touched.address && formik.errors.address
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.address}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.address && formik.errors.address && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.address}
|
||||
</small>
|
||||
)}
|
||||
<Map
|
||||
style={{ height: "calc(100vh - 260px)" }}
|
||||
initialViewState={{
|
||||
longitude: 51.375433528216654,
|
||||
latitude: 35.73356434056531,
|
||||
zoom: 11,
|
||||
}}
|
||||
mapboxAccessToken="pk.eyJ1IjoiYWxkZjkyIiwiYSI6ImNscHh5ZG56dTByMXAycnJ2cDNmdDQ5M3YifQ.a_sUt1rLFMfA0jHrETcM7A"
|
||||
mapStyle="mapbox://styles/mapbox/streets-v11"
|
||||
onClick={handleMapClick}
|
||||
>
|
||||
<GeolocateControl />
|
||||
{selectedLocation && (
|
||||
<Marker
|
||||
latitude={selectedLocation.lat}
|
||||
longitude={selectedLocation.lng}
|
||||
>
|
||||
<Image
|
||||
alt="location icon"
|
||||
className="-mt-5"
|
||||
width={25}
|
||||
height={25}
|
||||
src={"/images/icons/location.svg"}
|
||||
/>
|
||||
</Marker>
|
||||
)}
|
||||
</Map>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
className="scale-125"
|
||||
type="checkbox"
|
||||
checked={isCheckedOne}
|
||||
onChange={toggleCheckBox}
|
||||
/>
|
||||
<span className="text-xs font-bold">
|
||||
{t("auth.showLocationPublic")}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</AuthPageContent>
|
||||
|
||||
<AuthFormFooter onSkip={() => router.push("/verify/national-cart")}>
|
||||
<AuthNextButton type="submit" loading={loading} disabled={loading}>
|
||||
{t("auth.saveAndContinue")}
|
||||
</AuthNextButton>
|
||||
</AuthFormFooter>
|
||||
</form>
|
||||
</AuthPageLayout>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,10 +12,13 @@ import AuthNextButton from "@/components/auth/AuthNextButton";
|
||||
import toast from "react-hot-toast";
|
||||
import AuthUserDetails from "@/components/auth/AuthUserDetails";
|
||||
import Image from "next/image";
|
||||
import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
|
||||
import AuthRules from "@/components/auth/AuthRules";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
|
||||
function NationalCart() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const { request, loading } = useAxios();
|
||||
|
||||
@@ -28,7 +31,6 @@ function NationalCart() {
|
||||
const mobile =
|
||||
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
|
||||
|
||||
// انتخاب عکس از دوربین یا فایل
|
||||
const selectImage = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (event.target.files && event.target.files[0]) {
|
||||
const file = event.target.files[0];
|
||||
@@ -38,17 +40,15 @@ function NationalCart() {
|
||||
}
|
||||
};
|
||||
|
||||
// حذف تصویر
|
||||
const removeImage = () => setNationalCartImg(null);
|
||||
|
||||
// ارسال فرم
|
||||
const uploadImage = async () => {
|
||||
if (!nationalCartImg) {
|
||||
toast.error("لطفا تصویر کارت ملی را انتخاب کنید");
|
||||
toast.error(t("auth.selectNationalCard"));
|
||||
return;
|
||||
}
|
||||
if (!isCheckedOne) {
|
||||
toast.error("لطفا قوانین را مطالعه و تایید کنید");
|
||||
toast.error(t("auth.acceptRulesRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -63,105 +63,109 @@ function NationalCart() {
|
||||
await request("POST", "/verify/national_card_image", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
toast.success("تصویر با موفقیت ارسال شد!");
|
||||
toast.success(t("auth.nationalCardSentSuccess"));
|
||||
router.push("/verify/confirm");
|
||||
} catch (err) {
|
||||
console.log("Upload error:", err);
|
||||
toast.error("خطا در ارسال تصویر!");
|
||||
toast.error(t("auth.imageUploadError"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthPageLayout className="address-page">
|
||||
<AuthPageContent>
|
||||
<span className="text-xl font-bold">احراز هویت</span>
|
||||
<div className="mt-5">
|
||||
<AuthUserDetails />
|
||||
</div>
|
||||
<LocalePageShell>
|
||||
<AuthPageLayout className="address-page">
|
||||
<AuthPageContent>
|
||||
<span className="text-xl font-bold">
|
||||
{t("auth.identityVerification")}
|
||||
</span>
|
||||
<div className="mt-5">
|
||||
<AuthUserDetails />
|
||||
</div>
|
||||
|
||||
<small className="font-bold mt-10 mb-4">
|
||||
برای ثبت درخواست، نیازمند احراز هویت شما هستیم
|
||||
</small>
|
||||
<small className="font-bold mt-10 mb-4">
|
||||
{t("auth.nationalCardVerificationDesc")}
|
||||
</small>
|
||||
|
||||
<div className="relative w-[150px] h-[150px] flex items-center justify-center text-white rounded-3xl border border-[#676767] overflow-hidden mt-4">
|
||||
{nationalCartImg ? (
|
||||
<>
|
||||
<img
|
||||
src={
|
||||
nationalCartImg.startsWith("data:")
|
||||
? nationalCartImg
|
||||
: IMAGE_BASE_URL + nationalCartImg
|
||||
}
|
||||
alt="nationalCartImg"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<button
|
||||
onClick={removeImage}
|
||||
className="p-4 absolute top-0 right-0"
|
||||
<div className="relative w-[150px] h-[150px] flex items-center justify-center text-white rounded-3xl border border-[#676767] overflow-hidden mt-4">
|
||||
{nationalCartImg ? (
|
||||
<>
|
||||
<img
|
||||
src={
|
||||
nationalCartImg.startsWith("data:")
|
||||
? nationalCartImg
|
||||
: IMAGE_BASE_URL + nationalCartImg
|
||||
}
|
||||
alt="nationalCartImg"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<button
|
||||
onClick={removeImage}
|
||||
className="p-4 absolute top-0 right-0"
|
||||
>
|
||||
<Image
|
||||
src={"/images/icons/close-circle.svg"}
|
||||
width={25}
|
||||
height={25}
|
||||
alt="remove profile icon"
|
||||
/>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<label
|
||||
htmlFor="fileInput"
|
||||
className="cursor-pointer w-[150px] h-[150px] flex items-center justify-center text-white rounded-3xl border border-[#676767]"
|
||||
>
|
||||
<Image
|
||||
src={"/images/icons/close-circle.svg"}
|
||||
width={25}
|
||||
height={25}
|
||||
alt="remove profile icon"
|
||||
src={"/images/icons/gallery-add.svg"}
|
||||
width={76}
|
||||
height={76}
|
||||
alt="add profile icon"
|
||||
/>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<label
|
||||
htmlFor="fileInput"
|
||||
className="cursor-pointer w-[150px] h-[150px] flex items-center justify-center text-white rounded-3xl border border-[#676767]"
|
||||
>
|
||||
<Image
|
||||
src={"/images/icons/gallery-add.svg"}
|
||||
width={76}
|
||||
height={76}
|
||||
alt="add profile icon"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
id="fileInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment" // 🔥 مستقیم دوربین پشت
|
||||
className="hidden"
|
||||
onChange={selectImage}
|
||||
/>
|
||||
|
||||
<small className="font-bold mt-10 mb-4">
|
||||
اصل کارت ملی را بر روی سینه در دست گرفته و از خود عکس بگیرید
|
||||
</small>
|
||||
|
||||
<div className="flex items-center gap-2 mt-10">
|
||||
<input
|
||||
className="scale-125"
|
||||
type="checkbox"
|
||||
onChange={toggleCheckBox}
|
||||
id="fileInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
className="hidden"
|
||||
onChange={selectImage}
|
||||
/>
|
||||
<span
|
||||
onClick={() => setModalOpen(true)}
|
||||
className="text-xs font-bold cursor-pointer"
|
||||
|
||||
<small className="font-bold mt-10 mb-4">
|
||||
{t("auth.nationalCardPhotoHint")}
|
||||
</small>
|
||||
|
||||
<div className="flex items-center gap-2 mt-10">
|
||||
<input
|
||||
className="scale-125"
|
||||
type="checkbox"
|
||||
onChange={toggleCheckBox}
|
||||
/>
|
||||
<span
|
||||
onClick={() => setModalOpen(true)}
|
||||
className="text-xs font-bold cursor-pointer"
|
||||
>
|
||||
{t("auth.acceptRulesLabel")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<AuthRules isModalOpen={isModalOpen} setModalOpen={setModalOpen} />
|
||||
</AuthPageContent>
|
||||
|
||||
<AuthFormFooter onSkip={() => router.push("/verify/confirm")}>
|
||||
<AuthNextButton
|
||||
onClick={uploadImage}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
تایید قوانین و مقررات
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<AuthRules isModalOpen={isModalOpen} setModalOpen={setModalOpen} />
|
||||
</AuthPageContent>
|
||||
|
||||
<AuthFormFooter onSkip={() => router.push("/verify/confirm")}>
|
||||
<AuthNextButton
|
||||
onClick={uploadImage}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
ثبت و ادامه
|
||||
</AuthNextButton>
|
||||
</AuthFormFooter>
|
||||
</AuthPageLayout>
|
||||
{t("auth.saveAndContinue")}
|
||||
</AuthNextButton>
|
||||
</AuthFormFooter>
|
||||
</AuthPageLayout>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import AuthPageLayout, {
|
||||
AuthFormFooter,
|
||||
AuthPageContent,
|
||||
} from "@/components/auth/AuthPageLayout";
|
||||
import React from "react";
|
||||
import React, { useMemo } from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useFormik } from "formik";
|
||||
import * as yup from "yup";
|
||||
@@ -13,19 +13,24 @@ import { useRouter } from "next/navigation";
|
||||
import AuthNextButton from "@/components/auth/AuthNextButton";
|
||||
import toast from "react-hot-toast";
|
||||
import AuthUserDetails from "@/components/auth/AuthUserDetails";
|
||||
|
||||
// Validation schema using Yup
|
||||
const schema = yup.object().shape({
|
||||
bio: yup.string().required("نوشتن bio الزامی است"),
|
||||
});
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
|
||||
function PublicRelations() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const { request, loading } = useAxios();
|
||||
const mobile =
|
||||
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
|
||||
|
||||
const schema = useMemo(
|
||||
() =>
|
||||
yup.object().shape({
|
||||
bio: yup.string().required(t("auth.bioRequired")),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
selectedButton: null,
|
||||
@@ -41,7 +46,7 @@ function PublicRelations() {
|
||||
});
|
||||
router.push("/verify/location");
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || "خطای نامشخصی رخ داد.");
|
||||
toast.error(err?.response?.data?.message || t("auth.unknownError"));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -49,55 +54,61 @@ function PublicRelations() {
|
||||
});
|
||||
|
||||
return (
|
||||
<AuthPageLayout>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="flex w-full flex-1 flex-col items-center"
|
||||
>
|
||||
<AuthPageContent>
|
||||
<span className="text-xl font-bold">روابط عمومی</span>
|
||||
<div className="mt-5">
|
||||
<AuthUserDetails />
|
||||
</div>
|
||||
<LocalePageShell>
|
||||
<AuthPageLayout>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="flex w-full flex-1 flex-col items-center"
|
||||
>
|
||||
<AuthPageContent>
|
||||
<span className="text-xl font-bold">
|
||||
{t("auth.publicRelationsTitle")}
|
||||
</span>
|
||||
<div className="mt-5">
|
||||
<AuthUserDetails />
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-sm flex flex-col items-center">
|
||||
<small className="font-bold mt-10"> در مورد خودتان چیزی بگویید</small>
|
||||
<textarea
|
||||
name="bio"
|
||||
placeholder="بیو"
|
||||
className={`border mt-4 w-full max-w-[290px] p-3 rounded-2xl border-border-primary-light dark:border-border-primary-dark bg-secondary-light dark:bg-secondary-dark font-medium ${
|
||||
formik.touched.bio && formik.errors.bio
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.bio}
|
||||
onChange={(e) => {
|
||||
if (e.target.value.length <= 500) {
|
||||
formik.handleChange(e);
|
||||
}
|
||||
}}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
<div className="w-full max-w-sm flex flex-col items-center">
|
||||
<small className="font-bold mt-10">
|
||||
{t("auth.bioAboutYou")}
|
||||
</small>
|
||||
<textarea
|
||||
name="bio"
|
||||
placeholder={t("auth.bioPlaceholder")}
|
||||
className={`border mt-4 w-full max-w-[290px] p-3 rounded-2xl border-border-primary-light dark:border-border-primary-dark bg-secondary-light dark:bg-secondary-dark font-medium ${
|
||||
formik.touched.bio && formik.errors.bio
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.bio}
|
||||
onChange={(e) => {
|
||||
if (e.target.value.length <= 500) {
|
||||
formik.handleChange(e);
|
||||
}
|
||||
}}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
|
||||
<small className="mt-1 text-gray-500 block text-right">
|
||||
{formik.values.bio.length}/500
|
||||
</small>
|
||||
<small className="mt-1 text-gray-500 block text-right">
|
||||
{formik.values.bio.length}/500
|
||||
</small>
|
||||
|
||||
{formik.touched.bio && formik.errors.bio && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.bio}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
</AuthPageContent>
|
||||
{formik.touched.bio && formik.errors.bio && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.bio}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
</AuthPageContent>
|
||||
|
||||
<AuthFormFooter onSkip={() => router.push("/verify/location")}>
|
||||
<AuthNextButton type="submit" loading={loading} disabled={loading}>
|
||||
ثبت و ادامه
|
||||
</AuthNextButton>
|
||||
</AuthFormFooter>
|
||||
</form>
|
||||
</AuthPageLayout>
|
||||
<AuthFormFooter onSkip={() => router.push("/verify/location")}>
|
||||
<AuthNextButton type="submit" loading={loading} disabled={loading}>
|
||||
{t("auth.saveAndContinue")}
|
||||
</AuthNextButton>
|
||||
</AuthFormFooter>
|
||||
</form>
|
||||
</AuthPageLayout>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,8 +14,11 @@ import useAxios from "@/hooks/useAxios";
|
||||
import toast from "react-hot-toast";
|
||||
import { dataURLtoBlob } from "@/helpers/helpers";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
|
||||
const ServicesPage: React.FC = () => {
|
||||
const { t } = useTranslation("common");
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [isModalOpen, setModalOpen] = useState(false);
|
||||
const { request, loading } = useAxios();
|
||||
@@ -24,12 +27,10 @@ const ServicesPage: React.FC = () => {
|
||||
typeof window !== "undefined" ? localStorage.getItem("expertise") || "" : ""
|
||||
);
|
||||
|
||||
// افزودن خدمت جدید
|
||||
const handleAddService = (newService: Service) => {
|
||||
setServices((prev) => [...prev, newService]);
|
||||
};
|
||||
|
||||
// حذف خدمت
|
||||
const handleDeleteService = (id: string) => {
|
||||
setServices((prev) => prev.filter((service) => service.id !== id));
|
||||
};
|
||||
@@ -38,14 +39,13 @@ const ServicesPage: React.FC = () => {
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (services.length === 0) {
|
||||
toast.error("لطفاً حداقل یک خدمت وارد کنید.");
|
||||
toast.error(t("auth.minOneService"));
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("services", JSON.stringify(services));
|
||||
|
||||
// اضافه کردن تصاویر خدمات
|
||||
services.forEach(({ id, image }) => {
|
||||
if (image) {
|
||||
formData.append(
|
||||
@@ -60,8 +60,8 @@ const ServicesPage: React.FC = () => {
|
||||
await request("post", "/verify/services", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
toast.success("خدمات با موفقیت ثبت شدند!");
|
||||
setServices([]); // ریست لیست پس از ثبت
|
||||
toast.success(t("auth.servicesSavedSuccess"));
|
||||
setServices([]);
|
||||
|
||||
if (expertise === "مدل") {
|
||||
router.push("/verify/sizes");
|
||||
@@ -70,7 +70,7 @@ const ServicesPage: React.FC = () => {
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
toast.error("خطا در ثبت خدمات!");
|
||||
toast.error(t("auth.servicesSaveError"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -83,53 +83,55 @@ const ServicesPage: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthPageLayout>
|
||||
<AuthPageContent className="w-full">
|
||||
<span className="text-xl font-bold">خدمات</span>
|
||||
<LocalePageShell>
|
||||
<AuthPageLayout>
|
||||
<AuthPageContent className="w-full">
|
||||
<span className="text-xl font-bold">{t("auth.servicesTitle")}</span>
|
||||
|
||||
<div className="mt-5 w-full max-w-md">
|
||||
<AuthUserDetails />
|
||||
</div>
|
||||
<div className="mt-5 w-full max-w-md">
|
||||
<AuthUserDetails />
|
||||
</div>
|
||||
|
||||
<div className="gap-4 min-h-[384px] max-h-96 overflow-y-auto w-full mt-4">
|
||||
{services.map((service) => (
|
||||
<ServiceItem
|
||||
onDelete={handleDeleteService}
|
||||
key={service.id}
|
||||
service={service}
|
||||
<div className="gap-4 min-h-[384px] max-h-96 overflow-y-auto w-full mt-4">
|
||||
{services.map((service) => (
|
||||
<ServiceItem
|
||||
onDelete={handleDeleteService}
|
||||
key={service.id}
|
||||
service={service}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isModalOpen && (
|
||||
<AddServiceModal
|
||||
onClose={() => setModalOpen(false)}
|
||||
onAdd={handleAddService}
|
||||
isModalOpen={isModalOpen}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</AuthPageContent>
|
||||
|
||||
{isModalOpen && (
|
||||
<AddServiceModal
|
||||
onClose={() => setModalOpen(false)}
|
||||
onAdd={handleAddService}
|
||||
isModalOpen={isModalOpen}
|
||||
/>
|
||||
)}
|
||||
</AuthPageContent>
|
||||
<AuthFormFooter onSkip={Reject}>
|
||||
<div className="flex gap-4">
|
||||
<AuthNextButton
|
||||
onClick={handleSubmit}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
className="w-32"
|
||||
>
|
||||
{t("auth.saveAndContinue")}
|
||||
</AuthNextButton>
|
||||
|
||||
<AuthFormFooter onSkip={Reject}>
|
||||
<div className="flex gap-4">
|
||||
<AuthNextButton
|
||||
onClick={handleSubmit}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
className="w-32"
|
||||
>
|
||||
ثبت و ادامه
|
||||
</AuthNextButton>
|
||||
|
||||
<AuthNextButton
|
||||
onClick={() => setModalOpen(true)}
|
||||
className="!text-[#0066FF] !border-[#0066FF] w-32"
|
||||
>
|
||||
افزودن
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
</AuthFormFooter>
|
||||
</AuthPageLayout>
|
||||
<AuthNextButton
|
||||
onClick={() => setModalOpen(true)}
|
||||
className="!text-[#0066FF] !border-[#0066FF] w-32"
|
||||
>
|
||||
{t("auth.addService")}
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
</AuthFormFooter>
|
||||
</AuthPageLayout>
|
||||
</LocalePageShell>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import AuthPageLayout, {
|
||||
AuthFormFooter,
|
||||
AuthPageContent,
|
||||
} from "@/components/auth/AuthPageLayout";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import * as yup from "yup";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -13,21 +13,17 @@ import AuthNextButton from "@/components/auth/AuthNextButton";
|
||||
import toast from "react-hot-toast";
|
||||
import AuthUserDetails from "@/components/auth/AuthUserDetails";
|
||||
import Image from "next/image";
|
||||
|
||||
// Validation schema using Yup
|
||||
const schema = yup.object().shape({
|
||||
size: yup.string().required("انتخاب سایز الزامی است"),
|
||||
weight: yup.string().required("وارد کردن وزن الزامی است"),
|
||||
height: yup.string().required("وارد کردن قد الزامی است"),
|
||||
});
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
|
||||
const sizes = ["34", "36", "38", "40", "42", "44", "46", "48", "50"];
|
||||
|
||||
function Sizes() {
|
||||
const { t } = useTranslation("common");
|
||||
const [height, setHeight] = useState<string>("");
|
||||
const [weight, setWeight] = useState<string>("");
|
||||
const [size, setSize] = useState<string | null>(null);
|
||||
const [selectedButton, setSelectedButton] = useState<number>(1); // Default: 1 for "قد"
|
||||
const [selectedButton, setSelectedButton] = useState<number>(1);
|
||||
const router = useRouter();
|
||||
const { request, loading } = useAxios();
|
||||
const mobile =
|
||||
@@ -37,6 +33,16 @@ function Sizes() {
|
||||
typeof window !== "undefined" ? localStorage.getItem("expertise") || "" : ""
|
||||
);
|
||||
|
||||
const schema = useMemo(
|
||||
() =>
|
||||
yup.object().shape({
|
||||
size: yup.string().required(t("auth.sizeRequired")),
|
||||
weight: yup.string().required(t("auth.weightRequired")),
|
||||
height: yup.string().required(t("auth.heightRequired")),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (expertise && expertise !== "مدل") {
|
||||
router.replace("/verify/public-relations");
|
||||
@@ -65,7 +71,7 @@ function Sizes() {
|
||||
await request("POST", "/verify/sizes", data);
|
||||
router.push("/verify/colors");
|
||||
} catch (error: any) {
|
||||
toast.error(error?.message || "خطای نامشخصی رخ داد.");
|
||||
toast.error(error?.response?.data?.message || t("auth.unknownError"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -78,121 +84,123 @@ function Sizes() {
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthPageLayout>
|
||||
<AuthPageContent>
|
||||
<span className="text-xl font-bold">سایز</span>
|
||||
<div className="mt-5">
|
||||
<AuthUserDetails />
|
||||
</div>
|
||||
<LocalePageShell>
|
||||
<AuthPageLayout>
|
||||
<AuthPageContent>
|
||||
<span className="text-xl font-bold">{t("auth.sizesTitle")}</span>
|
||||
<div className="mt-5">
|
||||
<AuthUserDetails />
|
||||
</div>
|
||||
|
||||
<p className="mt-8 text-center text-sm font-bold">مشخصات ظاهری</p>
|
||||
<p className="mt-8 text-center text-sm font-bold">
|
||||
{t("auth.appearanceDetails")}
|
||||
</p>
|
||||
|
||||
{/* Buttons for categories */}
|
||||
<div className="flex gap-2 mt-4 w-full max-w-[320px]">
|
||||
<button
|
||||
className={`p-1 rounded-full w-1/3 border text-sm flex items-center justify-center gap-1 ${
|
||||
selectedButton === 1
|
||||
? "btn-modern--selected text-white"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark text-black border-gray-400"
|
||||
}`}
|
||||
onClick={() => handleButtonPress(1)}
|
||||
<div className="flex gap-2 mt-4 w-full max-w-[320px]">
|
||||
<button
|
||||
className={`p-1 rounded-full w-1/3 border text-sm flex items-center justify-center gap-1 ${
|
||||
selectedButton === 1
|
||||
? "btn-modern--selected text-white"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark text-black border-gray-400"
|
||||
}`}
|
||||
onClick={() => handleButtonPress(1)}
|
||||
>
|
||||
<Image
|
||||
src={"/images/icons/ruler&pen.svg"}
|
||||
width={25}
|
||||
height={25}
|
||||
className="dark:invert"
|
||||
alt={t("auth.height")}
|
||||
/>
|
||||
{t("auth.height")}
|
||||
</button>
|
||||
<button
|
||||
className={`p-1 rounded-full w-1/3 border text-sm flex items-center justify-center gap-1 ${
|
||||
selectedButton === 2
|
||||
? "btn-modern--selected text-white"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
|
||||
}`}
|
||||
onClick={() => handleButtonPress(2)}
|
||||
>
|
||||
<Image
|
||||
src={"/images/icons/weight.svg"}
|
||||
width={25}
|
||||
height={25}
|
||||
className="dark:invert"
|
||||
alt={t("auth.weight")}
|
||||
/>
|
||||
{t("auth.weight")}
|
||||
</button>
|
||||
<button
|
||||
className={`p-1 rounded-full w-1/3 border text-sm flex items-center justify-center gap-1 ${
|
||||
selectedButton === 3
|
||||
? "btn-modern--selected text-white"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
|
||||
}`}
|
||||
onClick={() => handleButtonPress(3)}
|
||||
>
|
||||
<Image
|
||||
src={"/images/icons/timer.svg"}
|
||||
width={25}
|
||||
height={25}
|
||||
className="dark:invert"
|
||||
alt={t("auth.size")}
|
||||
/>
|
||||
{t("auth.size")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 w-full max-w-md flex flex-col items-center">
|
||||
{selectedButton === 1 && (
|
||||
<input
|
||||
type="number"
|
||||
placeholder={t("auth.height")}
|
||||
className="w-20 mx-auto p-2 mb-4 border rounded-full text-center bg-tertiary-light dark:bg-tertiary-dark"
|
||||
value={height}
|
||||
onChange={(e) => setHeight(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
{selectedButton === 2 && (
|
||||
<input
|
||||
type="number"
|
||||
placeholder={t("auth.weight")}
|
||||
className="w-20 mx-auto p-2 mb-4 border rounded-full text-center bg-tertiary-light dark:bg-tertiary-dark"
|
||||
value={weight}
|
||||
onChange={(e) => setWeight(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
{selectedButton === 3 && (
|
||||
<div className="grid grid-cols-3 gap-2 w-full max-w-[320px]">
|
||||
{sizes.map((sizeOption) => (
|
||||
<button
|
||||
key={sizeOption}
|
||||
className={`p-2 py-1 rounded-full border text-sm full ${
|
||||
size === sizeOption
|
||||
? "btn-modern--selected text-white"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
|
||||
}`}
|
||||
onClick={() => handleSizeSelection(sizeOption)}
|
||||
>
|
||||
{sizeOption}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AuthPageContent>
|
||||
|
||||
<AuthFormFooter onSkip={() => router.push("/verify/colors")}>
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={handleCheck}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
<Image
|
||||
src={"/images/icons/ruler&pen.svg"}
|
||||
width={25}
|
||||
height={25}
|
||||
className="dark:invert"
|
||||
alt="قد"
|
||||
/>
|
||||
قد
|
||||
</button>
|
||||
<button
|
||||
className={`p-1 rounded-full w-1/3 border text-sm flex items-center justify-center gap-1 ${
|
||||
selectedButton === 2
|
||||
? "btn-modern--selected text-white"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
|
||||
}`}
|
||||
onClick={() => handleButtonPress(2)}
|
||||
>
|
||||
<Image
|
||||
src={"/images/icons/weight.svg"}
|
||||
width={25}
|
||||
height={25}
|
||||
className="dark:invert"
|
||||
alt="وزن"
|
||||
/>
|
||||
وزن
|
||||
</button>
|
||||
<button
|
||||
className={`p-1 rounded-full w-1/3 border text-sm flex items-center justify-center gap-1 ${
|
||||
selectedButton === 3
|
||||
? "btn-modern--selected text-white"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
|
||||
}`}
|
||||
onClick={() => handleButtonPress(3)}
|
||||
>
|
||||
<Image
|
||||
src={"/images/icons/timer.svg"}
|
||||
width={25}
|
||||
height={25}
|
||||
className="dark:invert"
|
||||
alt="سایز"
|
||||
/>
|
||||
سایز
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Inputs based on selected button */}
|
||||
<div className="mt-6 w-full max-w-md flex flex-col items-center">
|
||||
{selectedButton === 1 && (
|
||||
<input
|
||||
type="number"
|
||||
placeholder="قد"
|
||||
className="w-20 mx-auto p-2 mb-4 border rounded-full text-center bg-tertiary-light dark:bg-tertiary-dark"
|
||||
value={height}
|
||||
onChange={(e) => setHeight(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
{selectedButton === 2 && (
|
||||
<input
|
||||
type="number"
|
||||
placeholder="وزن"
|
||||
className="w-20 mx-auto p-2 mb-4 border rounded-full text-center bg-tertiary-light dark:bg-tertiary-dark"
|
||||
value={weight}
|
||||
onChange={(e) => setWeight(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
{selectedButton === 3 && (
|
||||
<div className="grid grid-cols-3 gap-2 w-full max-w-[320px]">
|
||||
{sizes.map((sizeOption) => (
|
||||
<button
|
||||
key={sizeOption}
|
||||
className={`p-2 py-1 rounded-full border text-sm full ${
|
||||
size === sizeOption
|
||||
? "btn-modern--selected text-white"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
|
||||
}`}
|
||||
onClick={() => handleSizeSelection(sizeOption)}
|
||||
>
|
||||
{sizeOption}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AuthPageContent>
|
||||
|
||||
<AuthFormFooter onSkip={() => router.push("/verify/colors")}>
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={handleCheck}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
ثبت و ادامه
|
||||
</AuthNextButton>
|
||||
</AuthFormFooter>
|
||||
</AuthPageLayout>
|
||||
{t("auth.saveAndContinue")}
|
||||
</AuthNextButton>
|
||||
</AuthFormFooter>
|
||||
</AuthPageLayout>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,9 @@ import { cookies } from "next/headers";
|
||||
import InfinitePosts from "@/components/academy/InfinitePakage";
|
||||
import { Metadata } from "next";
|
||||
import AcademyFilter from "@/components/academy/AcademyFilter";
|
||||
import { pageSeo } from "@/config/pageSeo";
|
||||
import { generatePageMetadata } from "@/utils/generatePageMetadata";
|
||||
import { getSeoPage } from "@/lib/i18n/seo";
|
||||
import { generateSeoPageMetadata, getServerLanguage } from "@/lib/i18n/server";
|
||||
import {
|
||||
buildAcademyListSeo,
|
||||
AcademyListFilters,
|
||||
@@ -50,6 +51,7 @@ function normalizeAcademyFilters(
|
||||
export async function generateMetadata({
|
||||
searchParams,
|
||||
}: IAcademyListProps): Promise<Metadata> {
|
||||
const lang = await getServerLanguage();
|
||||
const filters = normalizeAcademyFilters(await searchParams);
|
||||
const hasFilters = Boolean(
|
||||
filters.category ||
|
||||
@@ -60,21 +62,24 @@ export async function generateMetadata({
|
||||
);
|
||||
|
||||
if (!hasFilters) {
|
||||
return generatePageMetadata({
|
||||
title: pageSeo.academy.title,
|
||||
description: pageSeo.academy.description,
|
||||
path: pageSeo.academy.path,
|
||||
keywords: [...pageSeo.academy.keywords],
|
||||
});
|
||||
return generateSeoPageMetadata("academy", { lang });
|
||||
}
|
||||
|
||||
const seo = buildAcademyListSeo(filters);
|
||||
return generatePageMetadata({
|
||||
const seo = buildAcademyListSeo(filters, lang);
|
||||
const academyPage = getSeoPage("academy", lang);
|
||||
const meta = generatePageMetadata({
|
||||
title: seo.title,
|
||||
ogTitle: seo.title,
|
||||
description: seo.description,
|
||||
path: seo.path,
|
||||
keywords: [...pageSeo.academy.keywords],
|
||||
keywords: academyPage.keywords,
|
||||
lang,
|
||||
});
|
||||
|
||||
return {
|
||||
...meta,
|
||||
title: { absolute: seo.title },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function AcademyListPage({
|
||||
|
||||
@@ -30,18 +30,19 @@ import { usePathname } from "next/navigation";
|
||||
import Cookies from "js-cookie";
|
||||
import BillboardContactInfoModal from "@/components/billboards/BillboardPage/BillboardContactInfoModal";
|
||||
import { AcademyCourseDetailSkeleton } from "@/components/academy/AcademySkeletons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
// ==================== دیتای فیک ====================
|
||||
const MOCK_COURSE = {
|
||||
_id: "course_123",
|
||||
cuorse_name: "در حال بارگذاری...",
|
||||
cuorse_name: "",
|
||||
price: 0,
|
||||
category: "در حال بارگذاری...",
|
||||
category: "",
|
||||
offer: 0,
|
||||
caption: "در حال بارگذاری...",
|
||||
course_time: "در حال بارگذاری...",
|
||||
teacher_name: "در حال بارگذاری...",
|
||||
course_image: "در حال بارگذاری...",
|
||||
caption: "",
|
||||
course_time: "",
|
||||
teacher_name: "",
|
||||
course_image: "",
|
||||
number_of_course_content: "0",
|
||||
averageRate: 0,
|
||||
};
|
||||
@@ -49,11 +50,11 @@ const MOCK_COURSE = {
|
||||
const MOCK_VIDEOS = [
|
||||
{
|
||||
_id: "video_1",
|
||||
file_name: "در حال بارگذاری...",
|
||||
file_name: "",
|
||||
course_video: "/videos/sample1.mp4",
|
||||
is_free: false,
|
||||
type: "video",
|
||||
duration: "در حال بارگذاری...",
|
||||
duration: "",
|
||||
}
|
||||
|
||||
];
|
||||
@@ -97,6 +98,7 @@ export default function CourseDetail({
|
||||
courseId,
|
||||
initialIsPurchased = false,
|
||||
}: CourseDetailProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const [selectedVideo, setSelectedVideo] = useState(MOCK_VIDEOS[0]);
|
||||
const [isPurchased, setIsPurchased] = useState(initialIsPurchased);
|
||||
const [purchasing, setPurchasing] = useState(false);
|
||||
@@ -147,7 +149,7 @@ export default function CourseDetail({
|
||||
setSelectedVideo(getAcademyCourses.data.courses[0])
|
||||
} catch (err) {
|
||||
console.log("get error:", err);
|
||||
toast.error("خطا در دریافت اطلاعات");
|
||||
toast.error(t("academy.course.loadError"));
|
||||
} finally {
|
||||
}
|
||||
};
|
||||
@@ -176,7 +178,7 @@ export default function CourseDetail({
|
||||
|
||||
} catch (err) {
|
||||
console.log("get error:", err);
|
||||
toast.error("خطا در دریافت اطلاعات");
|
||||
toast.error(t("academy.course.loadError"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -271,7 +273,7 @@ export default function CourseDetail({
|
||||
uniqueUserIds.forEach((userId, index) => {
|
||||
userMap[userId] = usersData[index] || {
|
||||
_id: userId,
|
||||
user_name: "کاربر ناشناس",
|
||||
user_name: t("common.user"),
|
||||
profile_image: null,
|
||||
is_verified: "unverified"
|
||||
};
|
||||
@@ -303,7 +305,7 @@ export default function CourseDetail({
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching comments:", error);
|
||||
toast.error("خطا در دریافت نظرات");
|
||||
toast.error(t("academy.commentsError"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -362,7 +364,7 @@ export default function CourseDetail({
|
||||
}
|
||||
} catch (err) {
|
||||
console.log("get error:", err);
|
||||
toast.error("خطا در دریافت اطلاعات");
|
||||
toast.error(t("academy.course.loadError"));
|
||||
setCourses([]);
|
||||
} finally {
|
||||
setCourseLoading(false);
|
||||
@@ -410,15 +412,15 @@ export default function CourseDetail({
|
||||
const token = Cookies.get("token");
|
||||
|
||||
if (!token) {
|
||||
toast.error("لطفاً ابتدا وارد حساب کاربری خود شوید");
|
||||
toast.error(t("academy.course.loginToBuy"));
|
||||
return;
|
||||
}
|
||||
|
||||
setPurchasing(true);
|
||||
const loadingToastId = toast.loading(
|
||||
courses[0]?.is_free
|
||||
? "در حال ثبت پکیج رایگان..."
|
||||
: "در حال اتصال به درگاه پرداخت..."
|
||||
? t("academy.course.registeringFree")
|
||||
: t("academy.course.connectingPayment")
|
||||
);
|
||||
|
||||
try {
|
||||
@@ -430,7 +432,7 @@ export default function CourseDetail({
|
||||
|
||||
if (response?.free) {
|
||||
setIsPurchased(true);
|
||||
toast.success(response?.message || "پکیج رایگان با موفقیت ثبت شد");
|
||||
toast.success(response?.message || t("academy.course.freeRegistered"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -449,12 +451,14 @@ export default function CourseDetail({
|
||||
}
|
||||
|
||||
toast.error(
|
||||
response?.message || response?.data?.message || "خطا در اتصال به درگاه پرداخت"
|
||||
response?.message ||
|
||||
response?.data?.message ||
|
||||
t("academy.course.paymentError")
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
toast.dismiss(loadingToastId);
|
||||
console.error("خطا در خرید:", err);
|
||||
toast.error("خطا در شروع پرداخت");
|
||||
toast.error(t("academy.course.paymentStartError"));
|
||||
} finally {
|
||||
setPurchasing(false);
|
||||
}
|
||||
@@ -474,12 +478,12 @@ export default function CourseDetail({
|
||||
const token = Cookies.get("token");
|
||||
|
||||
if (!token) {
|
||||
toast.error("لطفاً ابتدا وارد حساب کاربری خود شوید");
|
||||
toast.error(t("academy.course.loginToBuy"));
|
||||
return
|
||||
}
|
||||
|
||||
// نمایش لودینگ
|
||||
loadingToastId = toast.loading("در حال اتصال به درگاه پرداخت...");
|
||||
loadingToastId = toast.loading(t("academy.course.connectingPayment"));
|
||||
|
||||
console.log("ارسال درخواست پرداخت:", {
|
||||
planDuration: plan.duration,
|
||||
@@ -523,17 +527,21 @@ export default function CourseDetail({
|
||||
const newWindow = window.open(paymentUrl, "_blank");
|
||||
|
||||
if (!newWindow) {
|
||||
toast.error("پاپآپ مسدود شده است. لطفاً اجازه باز کردن پنجره جدید را بدهید.");
|
||||
toast.error(t("academy.course.popupBlocked"));
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success("به درگاه پرداخت هدایت شدید");
|
||||
toast.success(t("academy.course.redirectedToPayment"));
|
||||
|
||||
|
||||
|
||||
} else {
|
||||
console.error("ساختار پاسخ نامعتبر:", response);
|
||||
toast.error(response?.data?.message || response?.message || "خطا در دریافت اطلاعات پرداخت");
|
||||
toast.error(
|
||||
response?.data?.message ||
|
||||
response?.message ||
|
||||
t("academy.course.paymentStartError")
|
||||
);
|
||||
}
|
||||
|
||||
} catch (err: any) {
|
||||
@@ -545,7 +553,7 @@ export default function CourseDetail({
|
||||
console.error("خطا در شروع پرداخت:", err);
|
||||
|
||||
// استخراج پیام خطا
|
||||
let errorMessage = "خطا در شروع پرداخت";
|
||||
let errorMessage = t("academy.course.paymentStartError");
|
||||
|
||||
if (err?.response?.data?.message) {
|
||||
errorMessage = err.response.data.message;
|
||||
@@ -555,11 +563,10 @@ export default function CourseDetail({
|
||||
errorMessage = err.message;
|
||||
}
|
||||
|
||||
// نمایش خطاهای خاص زرینپال
|
||||
if (errorMessage.includes("amount") || errorMessage.includes("مبلغ")) {
|
||||
errorMessage = "مبلغ پرداختی نامعتبر است";
|
||||
errorMessage = t("academy.course.invalidAmount");
|
||||
} else if (errorMessage.includes("merchant")) {
|
||||
errorMessage = "خطا در تنظیمات درگاه پرداخت";
|
||||
errorMessage = t("academy.course.gatewayConfigError");
|
||||
}
|
||||
|
||||
toast.error(errorMessage);
|
||||
@@ -570,11 +577,11 @@ export default function CourseDetail({
|
||||
// پخش ویدیو
|
||||
const handlePlayVideo = (video: (typeof MOCK_VIDEOS)[0]) => {
|
||||
if (!video.is_free && !isPurchased) {
|
||||
toast.error("برای مشاهده این ویدیو باید دوره را خریداری کنید");
|
||||
toast.error(t("academy.course.mustPurchaseVideo"));
|
||||
return;
|
||||
}
|
||||
setSelectedVideo(video);
|
||||
toast.success(`در حال پخش: ${video.file_name}`);
|
||||
toast.success(t("academy.course.playing", { name: video.file_name }));
|
||||
};
|
||||
|
||||
// جلوگیری از دانلود ویدیو
|
||||
@@ -584,7 +591,7 @@ export default function CourseDetail({
|
||||
| React.KeyboardEvent<HTMLVideoElement>
|
||||
) => {
|
||||
e.preventDefault();
|
||||
toast.error("دانلود این ویدیو امکان پذیر نیست");
|
||||
toast.error(t("academy.course.downloadBlocked"));
|
||||
};
|
||||
|
||||
const finalPrice = courses[0]?.is_free
|
||||
@@ -626,7 +633,7 @@ useEffect(() => {
|
||||
const paymentStatus = params.get('payment');
|
||||
|
||||
if (paymentStatus === 'success') {
|
||||
toast.success("پرداخت با موفقیت انجام شد");
|
||||
toast.success(t("academy.course.paymentSuccess"));
|
||||
setIsPurchased(true);
|
||||
}
|
||||
}, []);
|
||||
@@ -654,17 +661,17 @@ useEffect(() => {
|
||||
disablePictureInPicture
|
||||
>
|
||||
<source src={"https://app.modstagram.ir" + selectedVideo?.course_video} type="video/mp4" />
|
||||
مرورگر شما از پخش ویدیو پشتیبانی نمیکند
|
||||
{t("academy.course.videoUnsupported")}
|
||||
</video>
|
||||
|
||||
{!selectedVideo?.is_free && !isPurchased && (
|
||||
<div className="absolute inset-0 bg-black/80 flex flex-col items-center justify-center backdrop-blur-sm">
|
||||
<Lock className="w-20 h-20 text-white mb-4" />
|
||||
<p className="text-white text-xl font-bold mb-2">
|
||||
این ویدیو رایگان نیست
|
||||
{t("academy.course.videoNotFree")}
|
||||
</p>
|
||||
<p className="text-gray-300 text-sm mb-6">
|
||||
برای مشاهده تمام ویدیوها دوره را تهیه کنید
|
||||
{t("academy.course.purchaseToWatch")}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
@@ -673,8 +680,10 @@ useEffect(() => {
|
||||
>
|
||||
<ShoppingCart className="w-5 h-5" />
|
||||
{courses[0]?.is_free
|
||||
? "دریافت رایگان پکیج"
|
||||
: `خرید دوره با ${Math.round(finalPrice).toLocaleString()} تومان`}
|
||||
? t("academy.course.getFreePackage")
|
||||
: t("academy.course.buyForPrice", {
|
||||
price: Math.round(finalPrice).toLocaleString(),
|
||||
})}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -685,7 +694,9 @@ useEffect(() => {
|
||||
<div className="flex items-center gap-4 mt-2 text-sm text-gray-400">
|
||||
<span> {selectedVideo?.duration || ""}</span>
|
||||
{selectedVideo?.is_free && (
|
||||
<span className="text-green-500">✓ ویدیوی رایگان</span>
|
||||
<span className="text-green-500">
|
||||
✓ {t("academy.course.freeVideo")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -695,7 +706,7 @@ useEffect(() => {
|
||||
<div className="bg-neutral-200 dark:bg-neutral-800 text-black dark:text-white rounded-xl p-6 shadow-sm">
|
||||
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
|
||||
<BookOpen className="w-5 h-5 text-[#FF107D]" />
|
||||
درباره دوره
|
||||
{t("academy.course.about")}
|
||||
</h2>
|
||||
<div className="prose dark:prose-invert max-w-none">
|
||||
{courses[0]?.caption || course.caption}
|
||||
@@ -706,9 +717,11 @@ useEffect(() => {
|
||||
<div className="bg-neutral-200 dark:bg-neutral-800 text-black dark:text-white rounded-xl p-6 shadow-sm">
|
||||
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
|
||||
<Star className="w-5 h-5 text-yellow-500" />
|
||||
نظرات دانشجویان
|
||||
{t("academy.course.studentComments")}
|
||||
{totalComments > 0 && (
|
||||
<span className="text-sm text-gray-500">({totalComments} نظر)</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
{t("academy.course.commentCount", { count: totalComments })}
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
|
||||
@@ -716,7 +729,9 @@ useEffect(() => {
|
||||
<div className="mb-6 p-4 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">میانگین امتیاز</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t("academy.course.averageRating")}
|
||||
</p>
|
||||
<RatingStars rate={rating || course.averageRate} />
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-yellow-600">
|
||||
@@ -731,7 +746,7 @@ useEffect(() => {
|
||||
>
|
||||
{!loading && comments.length === 0 ? (
|
||||
<p className="text-center text-gray-500 py-10">
|
||||
هنوز نظری ثبت نشده است. اولین نفری باشید که نظر میدهید!
|
||||
{t("academy.course.noReviewsYet")}
|
||||
</p>
|
||||
) : (
|
||||
comments.map((item, index) => (
|
||||
@@ -747,7 +762,7 @@ useEffect(() => {
|
||||
<div className="shrink-0">
|
||||
<ProfileAvatar
|
||||
src={item?.user_id?.profile_image}
|
||||
alt={item?.user_id?.user_name || "کاربر"}
|
||||
alt={item?.user_id?.user_name || t("common.user")}
|
||||
size="chat"
|
||||
rounded="full"
|
||||
fallback="/images/default-avatar.png"
|
||||
@@ -758,7 +773,7 @@ useEffect(() => {
|
||||
{/* اطلاعات کاربر و امتیاز */}
|
||||
<div className="flex items-center gap-2 flex-wrap mb-2">
|
||||
<span className="font-semibold text-sm dark:text-white inline-flex items-center gap-1">
|
||||
{item?.user_id?.user_name || "کاربر ناشناس"}
|
||||
{item?.user_id?.user_name || t("common.user")}
|
||||
<VerificationBadge isVerified={item?.user_id?.is_verified} />
|
||||
</span>
|
||||
|
||||
@@ -782,7 +797,7 @@ useEffect(() => {
|
||||
<span>
|
||||
{item.createdAt
|
||||
? new Date(item.createdAt).toLocaleDateString("fa-IR")
|
||||
: "تاریخ نامشخص"}
|
||||
: t("academy.course.unknownDate")}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
❤️ {item.likes || 0}
|
||||
@@ -798,7 +813,7 @@ useEffect(() => {
|
||||
<div className="text-center py-4">
|
||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 dark:border-white"></div>
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
در حال بارگذاری نظرات...
|
||||
{t("academy.course.loadingComments")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -835,27 +850,34 @@ useEffect(() => {
|
||||
{/* قیمت */}
|
||||
<div className="mb-4">
|
||||
{courses[0]?.is_free ? (
|
||||
<span className="text-3xl font-bold text-green-600">رایگان</span>
|
||||
<span className="text-3xl font-bold text-green-600">
|
||||
{t("academy.course.free")}
|
||||
</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]">
|
||||
{Math.round(
|
||||
courses[0]?.price * (1 - courses[0]?.offerNumber / 100)
|
||||
).toLocaleString()} تومان
|
||||
).toLocaleString()}{" "}
|
||||
{t("settings.toman")}
|
||||
</span>
|
||||
<span className="text-sm line-through text-gray-400">
|
||||
{Math.round(courses[0]?.price).toLocaleString()} تومان
|
||||
{Math.round(courses[0]?.price).toLocaleString()}{" "}
|
||||
{t("settings.toman")}
|
||||
</span>
|
||||
<span className="bg-red-500 text-white text-xs px-2 py-1 rounded-full flex items-center gap-1">
|
||||
<Percent className="w-3 h-3" />
|
||||
{courses[0]?.offerNumber}% تخفیف
|
||||
{t("academy.course.discount", {
|
||||
percent: courses[0]?.offerNumber,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-3xl font-bold text-[#FF107D]">
|
||||
{Math.round(courses[0]?.price || course.price).toLocaleString()} تومان
|
||||
{Math.round(courses[0]?.price || course.price).toLocaleString()}{" "}
|
||||
{t("settings.toman")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -864,7 +886,7 @@ useEffect(() => {
|
||||
{isPurchased ? (
|
||||
<div className="bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 p-3 rounded-lg flex items-center justify-center gap-2">
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
<span className="font-medium">دوره خریداری شده است</span>
|
||||
<span className="font-medium">{t("academy.course.purchased")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
@@ -875,11 +897,11 @@ useEffect(() => {
|
||||
<ShoppingCart className="w-5 h-5" />
|
||||
{purchasing
|
||||
? courses[0]?.is_free
|
||||
? "در حال ثبت..."
|
||||
: "در حال اتصال به درگاه..."
|
||||
? t("academy.course.registering")
|
||||
: t("academy.course.connecting")
|
||||
: courses[0]?.is_free
|
||||
? "دریافت رایگان پکیج"
|
||||
: "خرید دوره"}
|
||||
? t("academy.course.getFreePackage")
|
||||
: t("academy.course.buyCourse")}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -890,7 +912,7 @@ useEffect(() => {
|
||||
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" />
|
||||
اطلاعات تماس
|
||||
{t("academy.course.contactInfo")}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -898,24 +920,31 @@ useEffect(() => {
|
||||
<div className="mt-6 space-y-3 border-t pt-4">
|
||||
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-300">
|
||||
<User className="w-4 h-4 text-[#FF107D]" />
|
||||
<span className="text-sm">مدرس: {courses[0]?.teacher_name || course.teacher_name}</span>
|
||||
<span className="text-sm">
|
||||
{t("academy.course.teacher")}{" "}
|
||||
{courses[0]?.teacher_name || course.teacher_name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-300">
|
||||
<Clock className="w-4 h-4 text-[#FF107D]" />
|
||||
<span className="text-sm">
|
||||
مدت زمان: {courses[0]?.course_time || course.course_time}
|
||||
{t("academy.course.duration")}{" "}
|
||||
{courses[0]?.course_time || course.course_time}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-300">
|
||||
<FileVideo className="w-4 h-4 text-[#FF107D]" />
|
||||
<span className="text-sm">
|
||||
تعداد ویدیوها: {courses[0]?.number_of_course_content || course.number_of_course_content}
|
||||
{t("academy.course.videoCountLabel")}{" "}
|
||||
{courses[0]?.number_of_course_content ||
|
||||
course.number_of_course_content}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-300">
|
||||
<Tag className="w-4 h-4 text-[#FF107D]" />
|
||||
<span className="text-sm">
|
||||
دسته بندی: {courses[0]?.category || course.category}
|
||||
{t("academy.course.category")}{" "}
|
||||
{courses[0]?.category || course.category}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -928,9 +957,11 @@ useEffect(() => {
|
||||
<div className="bg-neutral-200 dark:bg-neutral-800 text-black dark:text-white rounded-xl p-6 shadow-sm">
|
||||
<h3 className="font-bold text-lg mb-4 flex items-center gap-2">
|
||||
<Play className="w-5 h-5 text-[#FF107D]" />
|
||||
سرفصلهای دوره
|
||||
{t("academy.course.syllabus")}
|
||||
<span className="text-sm text-gray-400 mr-2">
|
||||
({courses[0]?.number_of_course_content} ویدیو)
|
||||
{t("academy.course.videoCountShort", {
|
||||
count: courses[0]?.number_of_course_content,
|
||||
})}
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
@@ -989,7 +1020,7 @@ useEffect(() => {
|
||||
<div className="flex items-center gap-2">
|
||||
{video.is_free && !isPurchased && (
|
||||
<span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded">
|
||||
رایگان
|
||||
{t("academy.course.free")}
|
||||
</span>
|
||||
)}
|
||||
{isLocked && <Lock className="w-4 h-4 text-gray-400" />}
|
||||
|
||||
@@ -3,6 +3,8 @@ import CourseDetail from "./CourseClient";
|
||||
import { generatePageMetadata } from "@/utils/generatePageMetadata";
|
||||
import { getApiBaseUrl } from "@/components/main/BaseUrl";
|
||||
import { buildCourseDetailSeo } from "@/lib/buildCourseDetailSeo";
|
||||
import { getServerLanguage } from "@/lib/i18n/server";
|
||||
import { translateCommon } from "@/lib/i18n/translate";
|
||||
|
||||
type Props = {
|
||||
params: Promise<{ id: string; title: string }>;
|
||||
@@ -24,23 +26,28 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
response?.[0];
|
||||
|
||||
if (course) {
|
||||
const seo = buildCourseDetailSeo(course, id);
|
||||
const lang = await getServerLanguage();
|
||||
const seo = buildCourseDetailSeo(course, id, lang);
|
||||
|
||||
return generatePageMetadata({
|
||||
title: seo.title,
|
||||
description: seo.description,
|
||||
path: seo.path,
|
||||
type: "article",
|
||||
lang,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("خطا در دریافت اطلاعات سئو:", error);
|
||||
}
|
||||
|
||||
const lang = await getServerLanguage();
|
||||
|
||||
return generatePageMetadata({
|
||||
title: "جزئیات پکیج | مدستاگرام",
|
||||
description: "مشاهده جزئیات پکیج آموزشی در مدستاگرام.",
|
||||
title: translateCommon(lang, "academy.course.metaTitle"),
|
||||
description: translateCommon(lang, "academy.course.metaDescription"),
|
||||
path: `/academy/${id}/${encodeURIComponent(slugTitle || "course")}`,
|
||||
lang,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import Header from "@/components/main/Header";
|
||||
import TabNavigation from "@/components/TabNavigation";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="pb-24">
|
||||
<Header />
|
||||
{children}
|
||||
<LocalePageShell>{children}</LocalePageShell>
|
||||
<TabNavigation currentPage="/academy" />
|
||||
</section>
|
||||
);
|
||||
|
||||
15
src/app/(projects)/academy/loading.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import IOSSpinner from "@/components/ui/IOSSpinner";
|
||||
import { getServerLanguage } from "@/lib/i18n/server";
|
||||
import { translateCommon } from "@/lib/i18n/translate";
|
||||
|
||||
export default async function AcademyLoading() {
|
||||
const lang = await getServerLanguage();
|
||||
const label = translateCommon(lang, "academy.loading");
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[50vh] w-full max-w-xl flex-col items-center justify-center gap-3 px-4">
|
||||
<IOSSpinner size={28} color="#ff107d" />
|
||||
<span className="text-sm text-neutral-500">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,8 +9,10 @@ import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import MainProjectCard from "@/components/projects/MainProjectCard";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function FailedProject() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const { request } = useAxios();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -19,6 +21,17 @@ function FailedProject() {
|
||||
const [project, setProject] = useState<Project>();
|
||||
const [typeList, setTypeList] = useState<IProjectType[] | null>(null);
|
||||
const [price, setPrice] = useState("");
|
||||
|
||||
const getDisplayTypeLabel = (projectType?: string) => {
|
||||
if (projectType === "normal") {
|
||||
return t("projects.newProject.displaySimple");
|
||||
}
|
||||
if (projectType === "force") {
|
||||
return t("projects.newProject.displayUrgent");
|
||||
}
|
||||
return t("projects.newProject.displayHighlight");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAd = async () => {
|
||||
const response = await request<{ project: Project }>(
|
||||
@@ -47,7 +60,7 @@ function FailedProject() {
|
||||
(item) => item.name === project.project_type
|
||||
);
|
||||
if (foundType) {
|
||||
setPrice(String(foundType.price)); // فرض بر اینکه price عددی است و نیاز به تبدیل به رشته دارد
|
||||
setPrice(String(foundType.price));
|
||||
}
|
||||
}
|
||||
}, [project, typeList]);
|
||||
@@ -58,58 +71,58 @@ function FailedProject() {
|
||||
"POST",
|
||||
"/projects/initiate-payment-web",
|
||||
{
|
||||
item_name: project?.project_type, // Pass advertisingId to initiate payment
|
||||
item_name: project?.project_type,
|
||||
projectId: projectId,
|
||||
}
|
||||
);
|
||||
if (response.type === "free") {
|
||||
router.push("/settings/workroom");
|
||||
} else {
|
||||
const authority = response.authority; // Get the payment authority
|
||||
const paymentUrl = `https://www.zarinpal.com/pg/StartPay/${authority}`; // Construct payment URL
|
||||
const authority = response.authority;
|
||||
const paymentUrl = `https://www.zarinpal.com/pg/StartPay/${authority}`;
|
||||
router.push(paymentUrl);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<h6 className="font-bold md:text-xl text-lg text-center mt-2 line-clamp-2 mb-4 text-[#FF0000] ">
|
||||
پرداخت ناموفق
|
||||
{t("academy.payment.failed")}
|
||||
</h6>
|
||||
<div className="flex flex-col items-center text-sm">
|
||||
<Image
|
||||
width={50}
|
||||
height={50}
|
||||
alt={"verify icon"}
|
||||
alt={t("academy.payment.verifyIconAlt")}
|
||||
src={`/images/icons/failed.svg`}
|
||||
className="pb-1 mb-5"
|
||||
/>
|
||||
<span> پرداخت شما با خطا مواجه شد. </span>
|
||||
<span> برای تایید درخواست پرداخت خود را کامل کنید</span>
|
||||
<span>{t("academy.payment.failedHint")}</span>
|
||||
<span>{t("academy.payment.completePayment")}</span>
|
||||
</div>
|
||||
<div className="px-4 w-full text-sm md:text-md font-semibold mt-10">
|
||||
{project && <MainProjectCard project={project} />}
|
||||
<div className="flex flex-col items-center gap-4 mt-4">
|
||||
<RoundedDiv className="p-2 w-full">
|
||||
{project?.project_type === "normal"
|
||||
? "نمایش ساده"
|
||||
: project?.project_type === "force"
|
||||
? "نمایش با برچسب فوری"
|
||||
: "نمایش با رنگ پس زمینه متفاوت"}
|
||||
: {Number(price).toLocaleString()} تومان
|
||||
{getDisplayTypeLabel(project?.project_type)}:{" "}
|
||||
{Number(price).toLocaleString()} {t("settings.toman")}
|
||||
</RoundedDiv>
|
||||
<RoundedDiv className="p-2 w-full">
|
||||
مبلغ قابل پرداخت: {Number(price).toLocaleString()} تومان
|
||||
{t("academy.payment.payableAmount", {
|
||||
price: Number(price).toLocaleString(),
|
||||
})}
|
||||
</RoundedDiv>
|
||||
<RoundedButton
|
||||
onClick={() => {
|
||||
payHandler();
|
||||
}}
|
||||
variant="primary" className="w-32 h-9"
|
||||
variant="primary"
|
||||
className="w-32 h-9"
|
||||
>
|
||||
پرداخت مجدد
|
||||
{t("academy.payment.retry")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,8 +10,10 @@ import { useSearchParams } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import MainProjectCard from "@/components/projects/MainProjectCard";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function SuccessProject() {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
const searchParams = useSearchParams();
|
||||
const projectId = searchParams.get("projectId");
|
||||
@@ -19,6 +21,17 @@ function SuccessProject() {
|
||||
const [project, setProject] = useState<Project>();
|
||||
const [typeList, setTypeList] = useState<IProjectType[] | null>(null);
|
||||
const [price, setPrice] = useState("");
|
||||
|
||||
const getDisplayTypeLabel = (projectType?: string) => {
|
||||
if (projectType === "normal") {
|
||||
return t("projects.newProject.displaySimple");
|
||||
}
|
||||
if (projectType === "force") {
|
||||
return t("projects.newProject.displayUrgent");
|
||||
}
|
||||
return t("projects.newProject.displayHighlight");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAd = async () => {
|
||||
const response = await request<{ project: Project }>(
|
||||
@@ -47,20 +60,21 @@ function SuccessProject() {
|
||||
(item) => item.name === project.project_type
|
||||
);
|
||||
if (foundType) {
|
||||
setPrice(String(foundType.price)); // فرض بر اینکه price عددی است و نیاز به تبدیل به رشته دارد
|
||||
setPrice(String(foundType.price));
|
||||
}
|
||||
}
|
||||
}, [project, typeList]);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<h6 className="font-bold md:text-xl text-lg text-center mt-2 line-clamp-2 mb-4 text-[#17A600] ">
|
||||
پرداخت موفق
|
||||
{t("academy.payment.success")}
|
||||
</h6>
|
||||
<div className="flex flex-col items-center text-sm">
|
||||
<Image
|
||||
width={50}
|
||||
height={50}
|
||||
alt={"verify icon"}
|
||||
alt={t("academy.payment.verifyIconAlt")}
|
||||
src={`/images/icons/success.svg`}
|
||||
className="pb-1 mb-5"
|
||||
/>
|
||||
@@ -69,19 +83,13 @@ function SuccessProject() {
|
||||
{project && <MainProjectCard project={project} />}
|
||||
<div className="flex flex-col items-center gap-4 mt-4">
|
||||
<RoundedDiv className="p-2 w-full">
|
||||
{project?.project_type === "normal"
|
||||
? "نمایش ساده"
|
||||
: project?.project_type === "force"
|
||||
? "نمایش با برچسب فوری"
|
||||
: "نمایش با رنگ پس زمینه متفاوت"}
|
||||
: {Number(price).toLocaleString()} تومان
|
||||
{getDisplayTypeLabel(project?.project_type)}:{" "}
|
||||
{Number(price).toLocaleString()} {t("settings.toman")}
|
||||
</RoundedDiv>
|
||||
<p className="my-5">
|
||||
پروژه شما پس از بررسی توسط کارشناسان ما منتشر خواهد شد
|
||||
</p>
|
||||
<p className="my-5">{t("academy.payment.reviewNotice")}</p>
|
||||
<Link href={"/settings/workroom"}>
|
||||
<RoundedButton variant="primary" className="w-32 h-9">
|
||||
اتاق کار
|
||||
{t("academy.payment.workroom")}
|
||||
</RoundedButton>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -8,16 +8,18 @@ import {
|
||||
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 { profileActionBtnClass } from "@/lib/ui/buttonStyles";
|
||||
import { cn } from "@/lib/utils";
|
||||
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";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface AcademyStats {
|
||||
packagesCount: number;
|
||||
@@ -51,17 +53,18 @@ interface AcademyData {
|
||||
|
||||
function openContact(
|
||||
type: "mobile" | "whatsapp" | "telegram" | "instagram",
|
||||
contact?: IContactInfo
|
||||
contact: IContactInfo | undefined,
|
||||
t: (key: string) => string
|
||||
) {
|
||||
if (!contact) {
|
||||
toast.error("اطلاعات تماس ثبت نشده است.");
|
||||
toast.error(t("academy.profile.contact.none"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "mobile") {
|
||||
const phone = contact.mobile || contact.phone;
|
||||
if (!phone) {
|
||||
toast.error("شماره موبایل ثبت نشده است.");
|
||||
toast.error(t("academy.profile.contact.mobileMissing"));
|
||||
return;
|
||||
}
|
||||
window.open(`tel:${phone}`, "_self");
|
||||
@@ -70,7 +73,7 @@ function openContact(
|
||||
|
||||
if (type === "whatsapp") {
|
||||
if (!contact.whatsappNumber) {
|
||||
toast.error("شماره واتساپ ثبت نشده است.");
|
||||
toast.error(t("academy.profile.contact.whatsappMissing"));
|
||||
return;
|
||||
}
|
||||
window.open(
|
||||
@@ -82,7 +85,7 @@ function openContact(
|
||||
|
||||
if (type === "telegram") {
|
||||
if (!contact.telegramLink) {
|
||||
toast.error("آیدی تلگرام ثبت نشده است.");
|
||||
toast.error(t("academy.profile.contact.telegramMissing"));
|
||||
return;
|
||||
}
|
||||
window.open(`https://t.me/${contact.telegramLink.replace("@", "")}`, "_blank");
|
||||
@@ -90,7 +93,7 @@ function openContact(
|
||||
}
|
||||
|
||||
if (!contact.instagramLink) {
|
||||
toast.error("آیدی اینستاگرام ثبت نشده است.");
|
||||
toast.error(t("academy.profile.contact.instagramMissing"));
|
||||
return;
|
||||
}
|
||||
window.open(
|
||||
@@ -99,7 +102,19 @@ function openContact(
|
||||
);
|
||||
}
|
||||
|
||||
function isContactAvailable(
|
||||
type: "mobile" | "whatsapp" | "telegram" | "instagram",
|
||||
contact?: IContactInfo
|
||||
): boolean {
|
||||
if (!contact) return false;
|
||||
if (type === "mobile") return Boolean(contact.mobile || contact.phone);
|
||||
if (type === "whatsapp") return Boolean(contact.whatsappNumber);
|
||||
if (type === "telegram") return Boolean(contact.telegramLink);
|
||||
return Boolean(contact.instagramLink);
|
||||
}
|
||||
|
||||
export default function AcademyProfileClient() {
|
||||
const { t } = useTranslation("common");
|
||||
const params = useParams();
|
||||
const academyId = params?.academyId as string;
|
||||
const { request } = useAxios();
|
||||
@@ -128,7 +143,7 @@ export default function AcademyProfileClient() {
|
||||
response?.academy || response?.data?.academy || response;
|
||||
setAcademy(academyData);
|
||||
} catch {
|
||||
toast.error("خطا در دریافت اطلاعات آموزشگاه");
|
||||
toast.error(t("academy.profile.loadError"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -150,7 +165,7 @@ export default function AcademyProfileClient() {
|
||||
response?.data?.courses || response?.courses || [];
|
||||
setCourses(list);
|
||||
} catch {
|
||||
toast.error("خطا در دریافت پکیجها");
|
||||
toast.error(t("academy.profile.packagesError"));
|
||||
setCourses([]);
|
||||
} finally {
|
||||
setIsLoadingCourses(false);
|
||||
@@ -172,12 +187,14 @@ export default function AcademyProfileClient() {
|
||||
if (!academy) {
|
||||
return (
|
||||
<Container>
|
||||
<div className="py-20 text-center text-neutral-500">آموزشگاه یافت نشد.</div>
|
||||
<div className="py-20 text-center text-neutral-500">
|
||||
{t("academy.profile.notFound")}
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
const academyName = academy.academy_name || "آموزشگاه";
|
||||
const academyName = academy.academy_name || t("academy.profile.defaultName");
|
||||
const owner = academy.owner;
|
||||
const displayUserName = owner?.user_name || ownerFromApi?.user_name;
|
||||
const displayUserScore =
|
||||
@@ -190,37 +207,38 @@ export default function AcademyProfileClient() {
|
||||
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"
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-end gap-1.5 px-4 py-2 text-xs font-semibold md:text-sm"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(shareUrl);
|
||||
toast.success("لینک آموزشگاه کپی شد");
|
||||
toast.success(t("academy.profile.addressCopied"));
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
width={22}
|
||||
height={22}
|
||||
alt="copy icon"
|
||||
alt=""
|
||||
src="/images/icons/copy.svg"
|
||||
className="dark:invert"
|
||||
/>
|
||||
<span>{shareUrl}</span>
|
||||
</div>
|
||||
<span>{t("academy.profile.copyAddress")}</span>
|
||||
</button>
|
||||
|
||||
<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>
|
||||
<span>{t("academy.profile.totalVideos")}</span>
|
||||
</div>
|
||||
<div className="font-semibold flex flex-col items-center text-[#0C8002] max-sm:text-[10px]">
|
||||
<span>{stats?.packagesCount ?? 0}</span>
|
||||
<span>تعداد پکیجها</span>
|
||||
<span>{t("academy.profile.packageCount")}</span>
|
||||
</div>
|
||||
<div className="font-semibold flex flex-col items-center text-[#3A59A9] max-sm:text-[10px]">
|
||||
<span>{stats?.soldCount ?? 0}</span>
|
||||
<span>تعداد فروخته شده</span>
|
||||
<span>{t("academy.profile.soldCount")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -239,22 +257,22 @@ export default function AcademyProfileClient() {
|
||||
|
||||
<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">
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<span>{Number(academy.rate || 0).toFixed(1)}</span>
|
||||
<Image
|
||||
width={22}
|
||||
height={22}
|
||||
alt="امتیاز"
|
||||
src="/images/icons/star1.svg"
|
||||
alt={t("academy.profile.ratingAlt")}
|
||||
src="/images/icons/star1.png"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<span>{displayUserScore || 0}</span>
|
||||
<Image
|
||||
width={22}
|
||||
height={22}
|
||||
alt="امتیاز کاربر"
|
||||
src="/images/icons/medal-star.svg"
|
||||
alt={t("academy.profile.userRatingAlt")}
|
||||
src="/images/icons/medal-star.png"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -277,50 +295,73 @@ export default function AcademyProfileClient() {
|
||||
|
||||
<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 || "توضیحاتی برای این آموزشگاه ثبت نشده است."}
|
||||
{academy.bio || t("academy.profile.noBio")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-2 w-full text-xs md:text-sm font-semibold">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-1 md:gap-4">
|
||||
<div className="mt-2 grid grid-cols-2 gap-1 md:grid-cols-4 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"
|
||||
onClick={() => openContact("mobile", academy.contactInfo, t)}
|
||||
disabled={!isContactAvailable("mobile", academy.contactInfo)}
|
||||
className={cn(
|
||||
profileActionBtnClass,
|
||||
!isContactAvailable("mobile", academy.contactInfo) &&
|
||||
"opacity-50"
|
||||
)}
|
||||
>
|
||||
موبایل
|
||||
{t("academy.profile.contact.mobile")}
|
||||
</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"
|
||||
onClick={() => openContact("whatsapp", academy.contactInfo, t)}
|
||||
disabled={!isContactAvailable("whatsapp", academy.contactInfo)}
|
||||
className={cn(
|
||||
profileActionBtnClass,
|
||||
!isContactAvailable("whatsapp", academy.contactInfo) &&
|
||||
"opacity-50"
|
||||
)}
|
||||
>
|
||||
واتساپ
|
||||
{t("academy.profile.contact.whatsapp")}
|
||||
</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"
|
||||
onClick={() => openContact("telegram", academy.contactInfo, t)}
|
||||
disabled={!isContactAvailable("telegram", academy.contactInfo)}
|
||||
className={cn(
|
||||
profileActionBtnClass,
|
||||
!isContactAvailable("telegram", academy.contactInfo) &&
|
||||
"opacity-50"
|
||||
)}
|
||||
>
|
||||
تلگرام
|
||||
{t("academy.profile.contact.telegram")}
|
||||
</button>
|
||||
<RoundedButton
|
||||
onClick={() => openContact("instagram", academy.contactInfo)}
|
||||
className="text-[9px] md:text-sm h-7 md:h-8 max-sm:text-[8px]"
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openContact("instagram", academy.contactInfo, t)}
|
||||
disabled={!isContactAvailable("instagram", academy.contactInfo)}
|
||||
className={cn(
|
||||
profileActionBtnClass,
|
||||
!isContactAvailable("instagram", academy.contactInfo) &&
|
||||
"opacity-50"
|
||||
)}
|
||||
>
|
||||
اینستاگرام
|
||||
</RoundedButton>
|
||||
{t("academy.profile.contact.instagram")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-4 mt-6">
|
||||
<h2 className="font-bold text-base mb-4">پکیجهای آموزشی</h2>
|
||||
<h2 className="font-bold text-base mb-4">
|
||||
{t("academy.profile.trainingPackages")}
|
||||
</h2>
|
||||
{isLoadingCourses ? (
|
||||
<AcademyPackageListSkeleton count={3} />
|
||||
) : courses.length === 0 ? (
|
||||
<p className="text-center text-gray-500 py-10">
|
||||
هنوز پکیجی برای این آموزشگاه ثبت نشده است.
|
||||
{t("academy.profile.noPackages")}
|
||||
</p>
|
||||
) : (
|
||||
courses.map((course) => (
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Metadata } from "next";
|
||||
import AcademyProfileClient from "./AcademyProfileClient";
|
||||
import { generatePageMetadata } from "@/utils/generatePageMetadata";
|
||||
import { getApiBaseUrl } from "@/components/main/BaseUrl";
|
||||
import { getServerLanguage } from "@/lib/i18n/server";
|
||||
import { translateCommon } from "@/lib/i18n/translate";
|
||||
|
||||
type Props = {
|
||||
params: Promise<{ academyId: string }>;
|
||||
@@ -21,20 +23,26 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const academy = data?.academy;
|
||||
|
||||
if (academy?.academy_name) {
|
||||
const lang = await getServerLanguage();
|
||||
return generatePageMetadata({
|
||||
title: `${academy.academy_name} | مدستاگرام`,
|
||||
title: academy.academy_name,
|
||||
description: academy.bio || academy.academy_name,
|
||||
path: `/academy/profile/${academyId}`,
|
||||
appendSiteName: true,
|
||||
lang,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// fallback below
|
||||
}
|
||||
|
||||
const lang = await getServerLanguage();
|
||||
|
||||
return generatePageMetadata({
|
||||
title: "آموزشگاه | مدستاگرام",
|
||||
description: "صفحه آموزشگاه در مدستاگرام",
|
||||
title: translateCommon(lang, "academy.course.profileMetaTitle"),
|
||||
description: translateCommon(lang, "academy.course.profileMetaDescription"),
|
||||
path: `/academy/profile/${academyId}`,
|
||||
lang,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import Header from "@/components/main/Header";
|
||||
import TabNavigation from "@/components/TabNavigation";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "ثبت پروژه جدید | مدستاگرام",
|
||||
description: "ثبت درخواست همکاری و پروژه جدید در مدستاگرام",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="pb-24">
|
||||
<Header />
|
||||
{children}
|
||||
<TabNavigation currentPage="/projects" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
import Header from "@/components/main/Header";
|
||||
|
||||
import TabNavigation from "@/components/TabNavigation";
|
||||
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
|
||||
import { generateSeoPageMetadata } from "@/lib/i18n/server";
|
||||
|
||||
|
||||
|
||||
export async function generateMetadata() {
|
||||
|
||||
return generateSeoPageMetadata("newProject", {
|
||||
|
||||
index: false,
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -3,14 +3,18 @@
|
||||
import Container from "@/components/elements/Container";
|
||||
import MainProjectCard from "@/components/projects/MainProjectCard";
|
||||
import ProjectRequestForm from "@/components/projects/ProjectPage/ProjectRequestForm";
|
||||
import ProjectRequests from "@/components/projects/ProjectPage/ProjectRequests";
|
||||
import PageLoader from "@/components/ui/PageLoader";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { Project } from "@/types/types";
|
||||
import { Project, IProjectRequest } from "@/types/types";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function ProjectDetailClient({ id }: { id: string }) {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [projectRequests, setProjectRequests] = useState<IProjectRequest[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
@@ -21,13 +25,19 @@ export default function ProjectDetailClient({ id }: { id: string }) {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
try {
|
||||
const response = await request<{ project: Project }>(
|
||||
const response = await request<{
|
||||
project: Project;
|
||||
projectRequests?: IProjectRequest[];
|
||||
}>(
|
||||
"GET",
|
||||
`/projects/get/web/${id}`,
|
||||
null,
|
||||
{ noToast: true }
|
||||
);
|
||||
if (!cancelled) setProject(response?.project ?? null);
|
||||
if (!cancelled) {
|
||||
setProject(response?.project ?? null);
|
||||
setProjectRequests(response?.projectRequests ?? []);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setError(true);
|
||||
} finally {
|
||||
@@ -46,21 +56,31 @@ export default function ProjectDetailClient({ id }: { id: string }) {
|
||||
if (error || !project) {
|
||||
return (
|
||||
<Container>
|
||||
<p className="py-20 text-center text-neutral-500">پروژه یافت نشد.</p>
|
||||
<p className="py-20 text-center text-neutral-500">
|
||||
{t("projects.notFound")}
|
||||
</p>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className="pb-28">
|
||||
<MainProjectCard project={project} full />
|
||||
<ProjectRequestForm
|
||||
projectId={id}
|
||||
creatorId={
|
||||
(project as Project & { creator_id?: string }).creator_id ||
|
||||
project.creator?._id
|
||||
}
|
||||
/>
|
||||
<MainProjectCard project={project} full />
|
||||
{projectRequests.length > 0 ? (
|
||||
<div className="mt-4">
|
||||
<p className="mb-2 text-center text-xs font-semibold md:text-sm">
|
||||
{t("projects.requestUsersTitle")}
|
||||
</p>
|
||||
<ProjectRequests projectRequests={projectRequests} />
|
||||
</div>
|
||||
) : null}
|
||||
<ProjectRequestForm
|
||||
projectId={id}
|
||||
creatorId={
|
||||
(project as Project & { creator_id?: string }).creator_id ||
|
||||
project.creator?._id
|
||||
}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,11 @@ 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";
|
||||
import {
|
||||
buildProjectDetailFallbackSeo,
|
||||
buildProjectDetailSeo,
|
||||
} from "@/lib/buildProjectDetailSeo";
|
||||
import { getServerLanguage } from "@/lib/i18n/server";
|
||||
|
||||
type ProjectDetailPageProps = {
|
||||
params: Promise<{ id: string; title: string }>;
|
||||
@@ -16,23 +20,30 @@ export async function generateMetadata({
|
||||
}: ProjectDetailPageProps): Promise<Metadata> {
|
||||
const { id, title } = await params;
|
||||
const token = (await cookies()).get("token")?.value || "";
|
||||
const lang = await getServerLanguage();
|
||||
const project = await fetchProject(id, token);
|
||||
|
||||
if (project) {
|
||||
const seo = buildProjectDetailSeo(project);
|
||||
const seo = buildProjectDetailSeo(project, lang);
|
||||
return generatePageMetadata({
|
||||
title: seo.title,
|
||||
description: seo.description,
|
||||
path: seo.path,
|
||||
type: "article",
|
||||
lang,
|
||||
});
|
||||
}
|
||||
|
||||
const decodedTitle = decodeURIComponent(title || "پروژه");
|
||||
const seo = buildProjectDetailFallbackSeo(
|
||||
decodeURIComponent(title || ""),
|
||||
id,
|
||||
lang
|
||||
);
|
||||
return generatePageMetadata({
|
||||
title: `${decodedTitle} - مدستاگرام`,
|
||||
description: `جزئیات پروژه «${decodedTitle}» در مدستاگرام.`,
|
||||
path: `/projects/${id}/${encodeURIComponent(decodedTitle)}`,
|
||||
title: seo.title,
|
||||
description: seo.description,
|
||||
path: seo.path,
|
||||
lang,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Header from "@/components/main/Header";
|
||||
import TabNavigation from "@/components/TabNavigation";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
|
||||
export default function ProjectsLayout({
|
||||
children,
|
||||
@@ -9,7 +10,7 @@ export default function ProjectsLayout({
|
||||
return (
|
||||
<section className="pb-24">
|
||||
<Header />
|
||||
{children}
|
||||
<LocalePageShell>{children}</LocalePageShell>
|
||||
<TabNavigation currentPage="/projects" />
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -4,8 +4,9 @@ import ProjectsFilter from "@/components/projects/ProjectsFilter";
|
||||
import { fetchProjects } from "@/api/fetchProjects";
|
||||
import { cookies } from "next/headers";
|
||||
import { Metadata } from "next";
|
||||
import { pageSeo } from "@/config/pageSeo";
|
||||
import { generatePageMetadata } from "@/utils/generatePageMetadata";
|
||||
import { getSeoPage } from "@/lib/i18n/seo";
|
||||
import { generateSeoPageMetadata, getServerLanguage } from "@/lib/i18n/server";
|
||||
import {
|
||||
buildProjectsListSeo,
|
||||
ProjectListFilters,
|
||||
@@ -36,26 +37,30 @@ function normalizeProjectFilters(
|
||||
export async function generateMetadata({
|
||||
searchParams,
|
||||
}: ProjectsPageProps): Promise<Metadata> {
|
||||
const lang = await getServerLanguage();
|
||||
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],
|
||||
});
|
||||
return generateSeoPageMetadata("projects", { lang });
|
||||
}
|
||||
|
||||
const seo = buildProjectsListSeo(filters);
|
||||
const seo = buildProjectsListSeo(filters, lang);
|
||||
const projectsPage = getSeoPage("projects", lang);
|
||||
|
||||
return generatePageMetadata({
|
||||
const meta = generatePageMetadata({
|
||||
title: seo.title,
|
||||
ogTitle: seo.title,
|
||||
description: seo.description,
|
||||
path: seo.path,
|
||||
keywords: [...pageSeo.projects.keywords],
|
||||
keywords: projectsPage.keywords,
|
||||
lang,
|
||||
});
|
||||
|
||||
return {
|
||||
...meta,
|
||||
title: { absolute: seo.title },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ProjectsPage({ searchParams }: ProjectsPageProps) {
|
||||
|
||||
@@ -5,15 +5,16 @@ import InfinitePosts from "@/components/models/InfinitePosts";
|
||||
import StoriesBar from "@/components/stories/StoriesBar";
|
||||
import Header from "@/components/main/Header";
|
||||
import TabNavigation from "@/components/TabNavigation";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import { Metadata } from "next";
|
||||
import { pageSeo } from "@/config/pageSeo";
|
||||
import { getSeoPage } from "@/lib/i18n/seo";
|
||||
import { getServerLanguage } from "@/lib/i18n/server";
|
||||
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";
|
||||
import {
|
||||
buildHomeListSeo,
|
||||
resolveHomeListFilters,
|
||||
} from "@/lib/buildHomeListSeo";
|
||||
import { normalizeUserLevel } from "@/lib/userLevel";
|
||||
|
||||
interface IModelsProps {
|
||||
params: Promise<{ slug?: string[] }>;
|
||||
@@ -35,82 +36,31 @@ interface IModelsProps {
|
||||
}>;
|
||||
}
|
||||
|
||||
// تابع هوشمند تولید متادیتا
|
||||
export async function generateMetadata({ params, searchParams }: IModelsProps): Promise<Metadata> {
|
||||
const lang = await getServerLanguage();
|
||||
const [urlParams, filters] = await Promise.all([params, searchParams]);
|
||||
|
||||
const isDefaultHome =
|
||||
(!urlParams.slug || urlParams.slug.length === 0) &&
|
||||
!filters.expertise &&
|
||||
!filters.province &&
|
||||
!filters.city &&
|
||||
!filters.userLevel &&
|
||||
!filters.rateFilter &&
|
||||
!filters.hashtag;
|
||||
|
||||
if (isDefaultHome) {
|
||||
return generatePageMetadata({
|
||||
title: pageSeo.home.title,
|
||||
description: pageSeo.home.description,
|
||||
path: pageSeo.home.path,
|
||||
keywords: [...pageSeo.home.keywords],
|
||||
});
|
||||
}
|
||||
|
||||
let locationLabel = "";
|
||||
let levelLabel = "";
|
||||
|
||||
try {
|
||||
if (urlParams.slug && urlParams.slug.length > 0) {
|
||||
const decodedText = decodeURIComponent(urlParams.slug[0]).replace(/-/g, ' ');
|
||||
|
||||
// اولویت با متن فارسی ساخته شده در URL
|
||||
if (decodedText.startsWith("استخدام")) {
|
||||
const title = `${decodedText} | مدستاگرام`;
|
||||
const description = `لیست برترین متخصصین مد و زیبایی: ${decodedText}. پیدا کردن مدل، عکاس و آرایشگر حرفهای در مدستاگرام.`;
|
||||
return generatePageMetadata({
|
||||
title,
|
||||
description,
|
||||
path: `/${urlParams.slug.join("/")}`,
|
||||
});
|
||||
}
|
||||
|
||||
const pSlug = decodeURIComponent(urlParams.slug[0]);
|
||||
const province = provinces.find((p) => p.slug === pSlug);
|
||||
if (province) {
|
||||
locationLabel = ` در استان ${province.name}`;
|
||||
if (urlParams.slug.length > 1) {
|
||||
const cSlug = decodeURIComponent(urlParams.slug[1]);
|
||||
const city = cities.find((c) => c.slug === cSlug);
|
||||
if (city) locationLabel = ` در ${city.name}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!locationLabel) {
|
||||
if (filters.city) {
|
||||
const city = cities.find(c => c.id.toString() === filters.city);
|
||||
if (city) locationLabel = ` در شهر ${city.name}`;
|
||||
} else if (filters.province) {
|
||||
const province = provinces.find(p => p.id.toString() === filters.province);
|
||||
if (province) locationLabel = ` در استان ${province.name}`;
|
||||
}
|
||||
}
|
||||
if (filters.userLevel) levelLabel = ` سطح ${filters.userLevel}`;
|
||||
} catch (error) {
|
||||
console.error("Error generating metadata", error);
|
||||
}
|
||||
|
||||
const expertiseLabel = filters.expertise || "مدل، عکاس و آرایشگر";
|
||||
const title = `استخدام ${expertiseLabel}${levelLabel}${locationLabel} | مدستاگرام`;
|
||||
const description = `پلتفرم تخصصی استخدام ${expertiseLabel}${locationLabel}. بهترین متخصصین حوزه زیبایی و مد را در مدستاگرام پیدا کنید.`;
|
||||
|
||||
const path =
|
||||
const slugPath =
|
||||
urlParams.slug && urlParams.slug.length > 0
|
||||
? `/${urlParams.slug.join("/")}`
|
||||
: pageSeo.home.path;
|
||||
: undefined;
|
||||
|
||||
return generatePageMetadata({ title, description, path });
|
||||
const seoFilters = resolveHomeListFilters(urlParams.slug || [], filters);
|
||||
const seo = buildHomeListSeo(seoFilters, slugPath, lang);
|
||||
const homePage = getSeoPage("home", lang);
|
||||
|
||||
const meta = generatePageMetadata({
|
||||
title: seo.title,
|
||||
ogTitle: seo.title,
|
||||
description: seo.description,
|
||||
path: seo.path,
|
||||
keywords: homePage.keywords,
|
||||
lang,
|
||||
});
|
||||
|
||||
return {
|
||||
...meta,
|
||||
title: { absolute: seo.title },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Models({ params, searchParams }: IModelsProps) {
|
||||
@@ -121,74 +71,28 @@ export default async function Models({ params, searchParams }: IModelsProps) {
|
||||
]);
|
||||
|
||||
const token = cookieStore.get("token")?.value || "";
|
||||
|
||||
// ۱. مقادیر پایه (اولویت با کوئری پارامتر برای دقت در فیلتر)
|
||||
let provinceId = filters.province || "";
|
||||
let cityId = filters.city || "";
|
||||
let userLevel = normalizeUserLevel(filters.userLevel || "");
|
||||
let expertise = filters.expertise || "";
|
||||
const seoFilters = resolveHomeListFilters(urlParams.slug || [], filters);
|
||||
const seo = buildHomeListSeo(
|
||||
seoFilters,
|
||||
urlParams.slug?.length ? `/${urlParams.slug.join("/")}` : undefined
|
||||
);
|
||||
|
||||
// ۲. استخراج و همگامسازی اطلاعات از URL فارسی (Slug)
|
||||
if (urlParams.slug && urlParams.slug.length > 0) {
|
||||
const fullText = decodeURIComponent(urlParams.slug[0]).replace(/-/g, ' ');
|
||||
|
||||
// استخراج تخصص از slug (مدل، عکاس، پوشاک و هر تخصص جدید)
|
||||
const parsedExpertise = parseExpertiseFromSlug(fullText);
|
||||
if (parsedExpertise) expertise = parsedExpertise;
|
||||
else if (fullText.includes("متخصصین")) expertise = "";
|
||||
|
||||
const parsedLevel = parseUserLevelFromSlug(fullText);
|
||||
if (parsedLevel) userLevel = parsedLevel;
|
||||
|
||||
// استخراج مکان (IDها)
|
||||
if (fullText.includes("در شهر")) {
|
||||
const cityName = fullText.split("در شهر")[1]?.trim();
|
||||
const foundCity = cities.find(c => cityName.includes(c.name));
|
||||
if (foundCity) cityId = foundCity.id.toString();
|
||||
} else if (fullText.includes("در استان")) {
|
||||
const provinceName = fullText.split("در استان")[1]?.trim();
|
||||
const foundProvince = provinces.find(p => provinceName.includes(p.name));
|
||||
if (foundProvince) provinceId = foundProvince.id.toString();
|
||||
}
|
||||
|
||||
// هندل کردن اسلاگهای انگلیسی (مثلاً برای سئو قدیمی یا دستی)
|
||||
if (!provinceId && !cityId) {
|
||||
const p = provinces.find(x => x.slug === urlParams.slug![0]);
|
||||
if (p) {
|
||||
provinceId = p.id.toString();
|
||||
if (urlParams.slug![1]) {
|
||||
const c = cities.find(x => x.slug === urlParams.slug![1]);
|
||||
if (c) cityId = c.id.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// آمادهسازی لیبلها برای نمایش در صفحه (H1)
|
||||
const displayExpertise = expertise || filters.expertise || "متخصصین مد و زیبایی";
|
||||
const levelText = userLevel ? ` سطح ${userLevel}` : "";
|
||||
let provinceId = seoFilters.province || "";
|
||||
let cityId = seoFilters.city || "";
|
||||
let userLevel = normalizeUserLevel(seoFilters.userLevel || "");
|
||||
let expertise = seoFilters.expertise || "";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<Container shell>
|
||||
{/* H1 مخفی برای بهبود سئو بر اساس آدرس صفحه */}
|
||||
<h1 className="sr-only">
|
||||
{(!urlParams.slug || urlParams.slug.length === 0) &&
|
||||
!filters.expertise &&
|
||||
!filters.province &&
|
||||
!filters.city &&
|
||||
!filters.userLevel
|
||||
? pageSeo.home.title.replace(" | مدستاگرام", "")
|
||||
: `استخدام ${displayExpertise}${levelText} در مدستاگرام`}
|
||||
</h1>
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<h1 className="sr-only">{seo.h1}</h1>
|
||||
|
||||
{/* کامپوننت فیلتر با مقدار تخصص فعلی */}
|
||||
<ModelsFilter expertise={expertise || filters.expertise || ""} />
|
||||
|
||||
<StoriesBar />
|
||||
<StoriesBar token={token} />
|
||||
|
||||
{/* نمایش پستها با تمام فیلترهای استخراج شده */}
|
||||
<InfinitePosts
|
||||
filters={{
|
||||
...filters,
|
||||
@@ -209,7 +113,8 @@ export default async function Models({ params, searchParams }: IModelsProps) {
|
||||
token={token}
|
||||
/>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
<TabNavigation currentPage="/" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import Footer from "@/components/main/Footer";
|
||||
import Header from "@/components/main/Header";
|
||||
import type { Metadata } from "next";
|
||||
import { pageSeo } from "@/config/pageSeo";
|
||||
import { generatePageMetadata } from "@/utils/generatePageMetadata";
|
||||
import { generateSeoPageMetadata } from "@/lib/i18n/server";
|
||||
|
||||
export const metadata: Metadata = generatePageMetadata({
|
||||
title: pageSeo.about.title,
|
||||
description: pageSeo.about.description,
|
||||
path: pageSeo.about.path,
|
||||
keywords: [...pageSeo.about.keywords],
|
||||
});
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
return generateSeoPageMetadata("about");
|
||||
}
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
|
||||
@@ -1,36 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import RoundedDiv from "@/components/elements/RoundedDiv";
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function AboutUs() {
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="min-h-[500px] flex flex-col justify-around max-w-md mx-auto px-4">
|
||||
<div className="">
|
||||
|
||||
<h6 className="text-center mt-10 font-bold text-xl">
|
||||
راه های ارتباطی با مجموعه مدستاگرام
|
||||
</h6>
|
||||
<div className="flex flex-col w-full mt-20 gap-1 ">
|
||||
<span>ایمیل مجموعه : </span>
|
||||
<span> modstagram.com@gmail.com</span>
|
||||
</div>
|
||||
<div className="flex flex-col w-full mt-6 gap-1">
|
||||
<span>شماره تماس : </span>
|
||||
<span>09128893712</span>
|
||||
</div>
|
||||
<h6 className="text-center mt-10 font-bold text-xl">{t("aboutPage.title")}</h6>
|
||||
<div className="flex flex-col w-full mt-20 gap-1 ">
|
||||
<span>{t("aboutPage.emailLabel")}</span>
|
||||
<span> modstagram.com@gmail.com</span>
|
||||
</div>
|
||||
<div className="flex flex-col w-full mt-6 gap-1">
|
||||
<span>{t("aboutPage.phoneLabel")}</span>
|
||||
<span>09128893712</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex justify-center items-center">
|
||||
|
||||
<Link
|
||||
href={"/settings/tickets/new"}
|
||||
className="flex flex-col mt-6 gap-1"
|
||||
>
|
||||
<RoundedDiv className="w-28 py-1">
|
||||
<span>ارسال تیکت</span>
|
||||
</RoundedDiv>
|
||||
</Link>
|
||||
<Link href={"/settings/tickets/new"} className="flex flex-col mt-6 gap-1">
|
||||
<RoundedDiv className="w-28 py-1">
|
||||
<span>{t("aboutPage.sendTicket")}</span>
|
||||
</RoundedDiv>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
10
src/app/api/auth/config/route.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { getGoogleClientId } from "@/lib/auth/googleClientId";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET() {
|
||||
return Response.json({
|
||||
googleClientId: getGoogleClientId(),
|
||||
});
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
import Container from "@/components/elements/Container";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import { cookies } from "next/headers";
|
||||
import InfiniteBillboard from "@/components/billboards/InfiniteBillboard";
|
||||
import BillboardsFilter from "@/components/billboards/BillboardPage/BillboardsFilter";
|
||||
import { fetchBillboards } from "@/api/fetchBillboards";
|
||||
import BillboardsListSection from "@/components/billboards/BillboardsListSection";
|
||||
import { Metadata } from "next";
|
||||
import { pageSeo } from "@/config/pageSeo";
|
||||
import { generatePageMetadata } from "@/utils/generatePageMetadata";
|
||||
|
||||
// درونریزی دیتاها برای تبدیل متن به ID
|
||||
import provinces from "@/data/provinces.json";
|
||||
import cities from "@/data/cities.json";
|
||||
import { getSeoPage } from "@/lib/i18n/seo";
|
||||
import { getServerLanguage } from "@/lib/i18n/server";
|
||||
import { Suspense } from "react";
|
||||
import { AcademyListSkeleton } from "@/components/academy/AcademySkeletons";
|
||||
import { buildBillboardsListSeo } from "@/lib/buildBillboardsListSeo";
|
||||
|
||||
interface IBillboardsProps {
|
||||
params: Promise<{ slug?: string[] }>;
|
||||
@@ -22,93 +22,27 @@ interface IBillboardsProps {
|
||||
}>;
|
||||
}
|
||||
|
||||
// تابع استخراج داده که هم در Metadata و هم در Page استفاده میشود
|
||||
const getSEOData = (slugSegments: string[] = [], filters: any) => {
|
||||
let provinceId = filters.province || "";
|
||||
let cityId = filters.city || "";
|
||||
let category = filters.category || "";
|
||||
let locationLabel = "";
|
||||
|
||||
if (slugSegments && slugSegments.length > 0) {
|
||||
const fullText = decodeURIComponent(slugSegments[0]).replace(/-/g, ' ');
|
||||
|
||||
if (fullText.startsWith("تبلیغات")) {
|
||||
// استخراج دستهبندی
|
||||
const afterAds = fullText.split("تبلیغات")[1]?.trim();
|
||||
if (afterAds) {
|
||||
category = afterAds.split("در")[0]?.trim();
|
||||
if (category.includes("خدمات مد و زیبایی")) category = "";
|
||||
}
|
||||
|
||||
// استخراج مکان و تبدیل به ID برای فیلتر API
|
||||
if (fullText.includes("در شهر")) {
|
||||
const cityName = fullText.split("در شهر")[1]?.trim();
|
||||
const foundCity = cities.find(c => cityName.includes(c.name));
|
||||
if (foundCity) {
|
||||
cityId = foundCity.id.toString();
|
||||
locationLabel = ` در شهر ${foundCity.name}`;
|
||||
}
|
||||
} else if (fullText.includes("در استان")) {
|
||||
const provinceName = fullText.split("در استان")[1]?.trim();
|
||||
const foundProvince = provinces.find(p => provinceName.includes(p.name));
|
||||
if (foundProvince) {
|
||||
provinceId = foundProvince.id.toString();
|
||||
locationLabel = ` در استان ${foundProvince.name}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// اگر لیبل از URL ساخته نشد، از کوئری پارامتر استفاده کن
|
||||
if (!locationLabel) {
|
||||
if (filters.city) {
|
||||
const city = cities.find(c => c.id.toString() === filters.city);
|
||||
if (city) locationLabel = ` در شهر ${city.name}`;
|
||||
} else if (filters.province) {
|
||||
const province = provinces.find(p => p.id.toString() === filters.province);
|
||||
if (province) locationLabel = ` در استان ${province.name}`;
|
||||
}
|
||||
}
|
||||
|
||||
const categoryLabel = category || filters.category || "خدمات حوزه زیبایی و مد";
|
||||
return { provinceId, cityId, category, categoryLabel, locationLabel };
|
||||
};
|
||||
|
||||
// --- متادیتای سئو ---
|
||||
export async function generateMetadata({ params, searchParams }: IBillboardsProps): Promise<Metadata> {
|
||||
const lang = await getServerLanguage();
|
||||
const [urlParams, filters] = await Promise.all([params, searchParams]);
|
||||
const seo = buildBillboardsListSeo(urlParams.slug || [], filters, lang);
|
||||
const billboardsPage = getSeoPage("billboards", lang);
|
||||
|
||||
const isDefaultBillboards =
|
||||
(!urlParams.slug || urlParams.slug.length === 0) &&
|
||||
!filters.province &&
|
||||
!filters.city &&
|
||||
!filters.category &&
|
||||
!filters.search &&
|
||||
!filters.sort;
|
||||
const meta = generatePageMetadata({
|
||||
title: seo.title,
|
||||
ogTitle: seo.title,
|
||||
description: seo.description,
|
||||
path: seo.path,
|
||||
keywords: billboardsPage.keywords,
|
||||
lang,
|
||||
});
|
||||
|
||||
if (isDefaultBillboards) {
|
||||
return generatePageMetadata({
|
||||
title: pageSeo.billboards.title,
|
||||
description: pageSeo.billboards.description,
|
||||
path: pageSeo.billboards.path,
|
||||
keywords: [...pageSeo.billboards.keywords],
|
||||
});
|
||||
}
|
||||
|
||||
const { locationLabel, categoryLabel } = getSEOData(urlParams.slug, filters);
|
||||
|
||||
const title = `تبلیغات ${categoryLabel}${locationLabel} | بیلبورد مدستاگرام`;
|
||||
const description = `بیلبورد و تبلیغات ${categoryLabel}${locationLabel} در مدستاگرام. فضای ویژه تبلیغات کسبوکارهای حوزه زیبایی برای دیدهشدن بیشتر و جذب مشتری.`;
|
||||
|
||||
const path =
|
||||
urlParams.slug && urlParams.slug.length > 0
|
||||
? `/billboards/${urlParams.slug.join("/")}`
|
||||
: pageSeo.billboards.path;
|
||||
|
||||
return generatePageMetadata({ title, description, path });
|
||||
return {
|
||||
...meta,
|
||||
title: { absolute: seo.title },
|
||||
};
|
||||
}
|
||||
|
||||
// --- اصلاح فیلتر و محتوا ---
|
||||
export default async function Billboards({ params, searchParams }: IBillboardsProps) {
|
||||
const [urlParams, filters, cookieStore] = await Promise.all([
|
||||
params,
|
||||
@@ -117,41 +51,26 @@ export default async function Billboards({ params, searchParams }: IBillboardsPr
|
||||
]);
|
||||
|
||||
const token = cookieStore.get("token")?.value || "";
|
||||
const seo = buildBillboardsListSeo(urlParams.slug || [], filters);
|
||||
|
||||
// ۱. استخراج دادههای واقعی از اسلاگ فارسی
|
||||
const { provinceId, cityId, category, categoryLabel, locationLabel } = getSEOData(urlParams.slug, filters);
|
||||
|
||||
// ۲. ترکیب فیلترها (بسیار مهم: مقادیر استخراج شده از URL جایگزین فیلترهای خالی میشوند)
|
||||
const combinedFilters = {
|
||||
...filters,
|
||||
province: provinceId || filters.province,
|
||||
city: cityId || filters.city,
|
||||
category: category || filters.category
|
||||
province: seo.provinceId || filters.province,
|
||||
city: seo.cityId || filters.city,
|
||||
category: seo.category || filters.category
|
||||
};
|
||||
|
||||
// ۳. ارسال فیلترهای صحیح به API
|
||||
const initialData = await fetchBillboards(1, 10, combinedFilters, token);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<h1 className="sr-only">
|
||||
{(!urlParams.slug || urlParams.slug.length === 0) &&
|
||||
!filters.province &&
|
||||
!filters.city &&
|
||||
!filters.category &&
|
||||
!filters.search
|
||||
? pageSeo.billboards.title.replace(" | مدستاگرام", "")
|
||||
: `تبلیغات ${categoryLabel}${locationLabel} در مدستاگرام`}
|
||||
</h1>
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<h1 className="sr-only">{seo.h1}</h1>
|
||||
|
||||
<BillboardsFilter />
|
||||
|
||||
{/* ۴. پاس دادن فیلترهای ترکیبی به کامپوننت اینفینیت */}
|
||||
<InfiniteBillboard
|
||||
initialData={initialData}
|
||||
filters={combinedFilters}
|
||||
token={token}
|
||||
/>
|
||||
</Container>
|
||||
<BillboardsFilter />
|
||||
|
||||
<Suspense fallback={<AcademyListSkeleton count={3} />}>
|
||||
<BillboardsListSection filters={combinedFilters} token={token} />
|
||||
</Suspense>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,20 @@
|
||||
import { BASE_URL } from "@/components/main/BaseUrl";
|
||||
import React from "react";
|
||||
import { cookies } from "next/headers";
|
||||
import Container from "@/components/elements/Container";
|
||||
import BillboardDetailContent from "@/components/billboards/BillboardDetailContent";
|
||||
import BillboardNotFound from "@/components/billboards/BillboardNotFound";
|
||||
import { IAdvertising, IRate } from "@/types/types";
|
||||
import Image from "next/image";
|
||||
import BillboardImageSlider from "@/components/billboards/BillboardPage/BillboardImageSlider";
|
||||
import MainBillboardCardActions from "@/components/billboards/MainBillboardCard/MainBillboardCardActions";
|
||||
import BillboardDetails from "@/components/billboards/BillboardPage/BillboardDetails";
|
||||
import { cookies } from "next/headers";
|
||||
import { Metadata } from "next";
|
||||
|
||||
import { generatePageMetadata } from "@/utils/generatePageMetadata";
|
||||
import { getLocaleBundle } from "@/lib/i18n/resources";
|
||||
import { getServerLanguage } from "@/lib/i18n/server";
|
||||
|
||||
interface IBillboardProps {
|
||||
params: Promise<{ id: string; title: string }>;
|
||||
}
|
||||
|
||||
// تابع کمکی برای دریافت اطلاعات بیلبورد جهت جلوگیری از تکرار کد در Metadata و Page
|
||||
async function getBillboardData(id: string, token: string) {
|
||||
const response = await fetch(`${BASE_URL}/advertising/get/web/${id}`, {
|
||||
cache: "no-store",
|
||||
next: { revalidate: 60 },
|
||||
headers: {
|
||||
Authorization: token ? `Bearer ${token}` : "",
|
||||
},
|
||||
@@ -31,23 +27,25 @@ export async function generateMetadata({
|
||||
params,
|
||||
}: IBillboardProps): Promise<Metadata> {
|
||||
const { id } = await params;
|
||||
const lang = await getServerLanguage();
|
||||
const token = (await cookies()).get("token")?.value || "";
|
||||
const common = getLocaleBundle(lang).common as {
|
||||
billboards: { notFoundMeta: string };
|
||||
};
|
||||
|
||||
const data = await getBillboardData(id, token);
|
||||
const billboard = data?.advertising as IAdvertising;
|
||||
|
||||
if (!billboard) {
|
||||
return { title: "بیلبورد یافت نشد | مدستاگرام" };
|
||||
return { title: common.billboards.notFoundMeta };
|
||||
}
|
||||
|
||||
// ۱. استخراج متغیرها
|
||||
const title = billboard.title || "";
|
||||
const category = billboard.category || "";
|
||||
const province = billboard.province?.name || "";
|
||||
const city = billboard.city?.name || "";
|
||||
const neighborhood = billboard.neighbourhood || "";
|
||||
|
||||
// ۲. منطق حذف هوشمند استان: اگر مجموع حروف تایتل و شهر زیاد باشد، استان حذف میشود
|
||||
const shouldHideProvince = (title.length + category.length + city.length) > 40;
|
||||
|
||||
const titleParts = [
|
||||
@@ -55,21 +53,17 @@ export async function generateMetadata({
|
||||
category,
|
||||
!shouldHideProvince ? province : null,
|
||||
city,
|
||||
// جلوگیری از تکرار نام شهر در بخش محله (مثلاً تهران - تهران)
|
||||
neighborhood !== city ? neighborhood : null,
|
||||
].filter(Boolean);
|
||||
|
||||
// تایتل نمایشی در مرورگر (جدا شده با خط تیره و فاصله)
|
||||
const dynamicTitle = titleParts.join(" - ");
|
||||
|
||||
// ۳. هماهنگسازی URL با تایتل (تبدیل تمام اجزا به اسلاگ با خط تیره)
|
||||
const urlSlug = titleParts
|
||||
.join(" ") // ترکیب تمام بخشها با فاصله
|
||||
.trim() // حذف فاصلههای اضافی
|
||||
.replace(/\s+/g, '-') // تبدیل تمام فاصلهها به خط تیره (-)
|
||||
.replace(/-+/g, '-'); // جلوگیری از تکرار خط تیره
|
||||
.join(" ")
|
||||
.trim()
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-");
|
||||
|
||||
// ۴. تنظیم دیسکریپشن طبق فرمت: آدرس - توضیحات
|
||||
const address = billboard.address || `${province} ${city} ${neighborhood}`;
|
||||
const dynamicDescription = `${address} - ${billboard.description || ""}`.substring(0, 160);
|
||||
|
||||
@@ -78,6 +72,7 @@ export async function generateMetadata({
|
||||
description: dynamicDescription,
|
||||
path: `/billboards/${id}/${encodeURIComponent(urlSlug)}`,
|
||||
appendSiteName: true,
|
||||
lang,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -86,113 +81,15 @@ async function BillboardPage({ params }: IBillboardProps) {
|
||||
const token = (await cookies()).get("token")?.value || "";
|
||||
|
||||
const data = await getBillboardData(id, token);
|
||||
|
||||
|
||||
if (!data || !data.advertising) {
|
||||
return <Container className="py-20 text-center font-bold">بیلبورد یافت نشد!</Container>;
|
||||
return <BillboardNotFound />;
|
||||
}
|
||||
|
||||
const billboard = data.advertising as IAdvertising;
|
||||
const rate = data.rate as IRate;
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<a
|
||||
href={`/billboards/profile/${billboard?.creatorId}/${encodeURIComponent(billboard?.title || "")}`}
|
||||
>
|
||||
<h1 className="font-bold md:text-xl text-lg text-center mt-2 line-clamp-2 mb-4 text-text-blue-light dark:text-text-blue-dark">
|
||||
{billboard?.title}
|
||||
</h1>
|
||||
</a>
|
||||
<div className="px-4 w-full text-sm md:text-md font-semibold">
|
||||
<div className="flex justify-between">
|
||||
<div className="flex gap-4">
|
||||
<a
|
||||
href={`/billboards/profile/${billboard?.creatorId}/${encodeURIComponent(billboard?.title || "")}`}
|
||||
>
|
||||
<h2 className="text-text-green-light dark:text-text-green-dark font-semibold line-clamp-2 ">
|
||||
{billboard?.category}
|
||||
</h2>
|
||||
</a>
|
||||
|
||||
<div className="flex items-center justify-center">
|
||||
<>
|
||||
<span>{rate?.adTotalRatings ? rate?.adTotalRatings : "0"}</span>
|
||||
<Image
|
||||
width={22}
|
||||
height={22}
|
||||
alt={"medal-star icon"}
|
||||
src={`/images/icons/medal-star.svg`}
|
||||
className="pb-1"
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<>
|
||||
<span>
|
||||
{rate?.adAverageRating ? rate?.adAverageRating : "0"}
|
||||
</span>
|
||||
<Image
|
||||
width={22}
|
||||
height={22}
|
||||
alt={"star icon"}
|
||||
src={`/images/icons/star1.svg`}
|
||||
className="pb-1"
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
{billboard?.showDiscount && billboard?.mostDiscountPercentage && (
|
||||
<div className="text-md bg-red-600 text-white w-16 h-8 flex items-center justify-center rounded-full pt-1">
|
||||
{billboard?.mostDiscountPercentage}%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{billboard?.images && (
|
||||
<BillboardImageSlider images={billboard?.images} />
|
||||
)}
|
||||
<hr />
|
||||
<MainBillboardCardActions
|
||||
_id={billboard?._id}
|
||||
likedByUser={billboard?.likedByUser}
|
||||
likesCount={billboard?.likesCount}
|
||||
commentsCount={billboard?.commentsCount}
|
||||
viewCount={billboard?.viewCount}
|
||||
isDetail={true}
|
||||
/>
|
||||
|
||||
{/* بخش نمایش مکان بهبود یافته برای سئو و خوانایی */}
|
||||
<div className="flex flex-wrap gap-4 mt-6 p-4 bg-gray-50 dark:bg-zinc-900 rounded-xl">
|
||||
<div className="flex gap-1 text-gray-500">
|
||||
<span>استان:</span>
|
||||
<span className="text-black dark:text-white">{billboard?.province?.name}</span>
|
||||
</div>
|
||||
<div className="flex gap-1 text-gray-500">
|
||||
<h3>شهر:</h3>
|
||||
<span className="text-black dark:text-white">{billboard?.city?.name}</span>
|
||||
</div>
|
||||
<div className="flex gap-1 text-gray-500">
|
||||
<h4>محله:</h4>
|
||||
<span className="text-black dark:text-white">{billboard?.neighbourhood}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mt-4 mb-8">
|
||||
<span className="text-gray-500">آدرس:</span>
|
||||
<h5 className="font-bold">{billboard?.address || "ثبت نشده"}</h5>
|
||||
</div>
|
||||
|
||||
<BillboardDetails
|
||||
services={billboard?.services}
|
||||
features={billboard?.features}
|
||||
contactInfo={billboard?.contactInfo}
|
||||
description={billboard?.description}
|
||||
lat={billboard?.lat}
|
||||
lng={billboard?.lng}
|
||||
_id={billboard?._id}
|
||||
/>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
return <BillboardDetailContent billboard={billboard} rate={rate} />;
|
||||
}
|
||||
|
||||
export default BillboardPage;
|
||||
export default BillboardPage;
|
||||
|
||||
5
src/app/billboards/loading.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import BillboardsLoadingContent from "@/components/billboards/BillboardsLoadingContent";
|
||||
|
||||
export default function BillboardsLoading() {
|
||||
return <BillboardsLoadingContent />;
|
||||
}
|
||||
@@ -7,13 +7,17 @@ import React, { useEffect, useState } from "react";
|
||||
import MainBillboardCard from "@/components/billboards/MainBillboardCard/MainBillboardCard";
|
||||
import RoundedDiv from "@/components/elements/RoundedDiv";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import { getBillboardDisplayTypeLabel } from "@/lib/billboards/displayTypeLabel";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface IBillboardProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
function BillboardPayment({ params }: IBillboardProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const resolvedParams = React.use(params);
|
||||
const router = useRouter();
|
||||
const { request } = useAxios();
|
||||
@@ -21,6 +25,7 @@ function BillboardPayment({ params }: IBillboardProps) {
|
||||
const [advertising, setAdvertising] = useState<IAdvertising>();
|
||||
const [typeList, setTypeList] = useState<IAdvertisingType[] | null>(null);
|
||||
const [price, setPrice] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAd = async () => {
|
||||
const response = await request<{ advertising: IAdvertising }>(
|
||||
@@ -41,7 +46,7 @@ function BillboardPayment({ params }: IBillboardProps) {
|
||||
};
|
||||
fetchAd();
|
||||
fetchStates();
|
||||
}, []);
|
||||
}, [id, request]);
|
||||
|
||||
const payHandler = async () => {
|
||||
try {
|
||||
@@ -49,20 +54,17 @@ function BillboardPayment({ params }: IBillboardProps) {
|
||||
"POST",
|
||||
"/advertising/initiate-payment-web",
|
||||
{
|
||||
item_name: advertising?.type, // Pass advertisingId to initiate payment
|
||||
item_name: advertising?.type,
|
||||
advertisingId: id,
|
||||
showDiscount: advertising?.showDiscount,
|
||||
}
|
||||
);
|
||||
|
||||
console.log(advertising?.type);
|
||||
|
||||
|
||||
if (advertising?.type === "free") {
|
||||
router.push("/settings/my-billboards");
|
||||
} else {
|
||||
const authority = response.authority; // Get the payment authority
|
||||
const paymentUrl = `https://www.zarinpal.com/pg/StartPay/${authority}`; // Construct payment URL
|
||||
const authority = response.authority;
|
||||
const paymentUrl = `https://www.zarinpal.com/pg/StartPay/${authority}`;
|
||||
router.push(paymentUrl);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
@@ -74,42 +76,40 @@ function BillboardPayment({ params }: IBillboardProps) {
|
||||
if (advertising?.type && typeList) {
|
||||
const foundType = typeList.find((item) => item.name === advertising.type);
|
||||
if (foundType) {
|
||||
setPrice(String(foundType.price)); // فرض بر اینکه price عددی است و نیاز به تبدیل به رشته دارد
|
||||
setPrice(String(foundType.price));
|
||||
}
|
||||
}
|
||||
}, [advertising, typeList]);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<h6 className="font-bold md:text-xl text-lg text-center mt-2 line-clamp-2 mb-4 text-text-blue-light dark:text-text-blue-dark">
|
||||
پرداخت
|
||||
</h6>
|
||||
<div className="px-4 w-full text-sm md:text-md font-semibold mt-10">
|
||||
{advertising && <MainBillboardCard billboard={advertising} />}
|
||||
<div className="flex flex-col items-center gap-4 mt-4">
|
||||
<RoundedDiv className="p-2 w-full">
|
||||
{advertising?.type === "special"
|
||||
? "ویژه"
|
||||
: advertising?.type === "normal"
|
||||
? "نمایش ساده"
|
||||
: advertising?.type === "free"
|
||||
? "رایگان"
|
||||
: "برجسته"}
|
||||
: {Number(price).toLocaleString()} تومان
|
||||
</RoundedDiv>
|
||||
<RoundedDiv className="p-2 w-full">
|
||||
مبلغ قابل پرداخت: {Number(price).toLocaleString()} تومان
|
||||
</RoundedDiv>
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
onClick={payHandler}
|
||||
className="h-9 w-32"
|
||||
>
|
||||
پرداخت
|
||||
</RoundedButton>
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<h6 className="font-bold md:text-xl text-lg text-center mt-2 line-clamp-2 mb-4 text-text-blue-light dark:text-text-blue-dark">
|
||||
{t("billboards.payment.title")}
|
||||
</h6>
|
||||
<div className="px-4 w-full text-sm md:text-md font-semibold mt-10">
|
||||
{advertising && <MainBillboardCard billboard={advertising} />}
|
||||
<div className="flex flex-col items-center gap-4 mt-4">
|
||||
<RoundedDiv className="p-2 w-full">
|
||||
{getBillboardDisplayTypeLabel(t, advertising?.type)}:{" "}
|
||||
{Number(price).toLocaleString()} {t("settings.toman")}
|
||||
</RoundedDiv>
|
||||
<RoundedDiv className="p-2 w-full">
|
||||
{t("billboards.payment.payableAmount", {
|
||||
amount: Number(price).toLocaleString(),
|
||||
})}
|
||||
</RoundedDiv>
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
onClick={payHandler}
|
||||
className="h-9 w-32"
|
||||
>
|
||||
{t("billboards.payment.title")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import MultiStepForm from "@/components/billboards/NewBillboard/MultiStepForm";
|
||||
import Container from "@/components/elements/Container";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import { BillboardFormProvider } from "@/contexts/BillboardFormContext";
|
||||
import React from "react";
|
||||
|
||||
function NewBillboard() {
|
||||
return (
|
||||
<BillboardFormProvider>
|
||||
<Container>
|
||||
<MultiStepForm />
|
||||
</Container>
|
||||
</BillboardFormProvider>
|
||||
<LocalePageShell>
|
||||
<BillboardFormProvider>
|
||||
<Container>
|
||||
<MultiStepForm />
|
||||
</Container>
|
||||
</BillboardFormProvider>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,20 +7,24 @@ import React, { useEffect, useState } from "react";
|
||||
import MainBillboardCard from "@/components/billboards/MainBillboardCard/MainBillboardCard";
|
||||
import RoundedDiv from "@/components/elements/RoundedDiv";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import { getBillboardDisplayTypeLabel } from "@/lib/billboards/displayTypeLabel";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function FailedBillboard() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const { request } = useAxios();
|
||||
const searchParams = useSearchParams();
|
||||
const projectId = searchParams.get("projectId");
|
||||
const type = searchParams.get("type");
|
||||
|
||||
console.log({ projectId });
|
||||
const [advertising, setAdvertising] = useState<IAdvertising>();
|
||||
const [typeList, setTypeList] = useState<IAdvertisingType[] | null>(null);
|
||||
const [price, setPrice] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAd = async () => {
|
||||
const response = await request<{ advertising: IAdvertising }>(
|
||||
@@ -41,7 +45,7 @@ function FailedBillboard() {
|
||||
};
|
||||
fetchAd();
|
||||
fetchStates();
|
||||
}, []);
|
||||
}, [projectId, request]);
|
||||
|
||||
const payHandler = async () => {
|
||||
try {
|
||||
@@ -49,7 +53,7 @@ function FailedBillboard() {
|
||||
"POST",
|
||||
"/advertising/initiate-payment-web",
|
||||
{
|
||||
item_name: advertising?.type, // Pass advertisingId to initiate payment
|
||||
item_name: advertising?.type,
|
||||
advertisingId: projectId,
|
||||
showDiscount: advertising?.showDiscount,
|
||||
}
|
||||
@@ -58,21 +62,22 @@ function FailedBillboard() {
|
||||
if (advertising?.type === "free") {
|
||||
router.push("/settings/my-billboards");
|
||||
} else {
|
||||
const authority = response.authority; // Get the payment authority
|
||||
const paymentUrl = `https://www.zarinpal.com/pg/StartPay/${authority}`; // Construct payment URL
|
||||
const authority = response.authority;
|
||||
const paymentUrl = `https://www.zarinpal.com/pg/StartPay/${authority}`;
|
||||
router.push(paymentUrl);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
const republishHandler = async () => {
|
||||
try {
|
||||
const response = await request<{ authority?: string }>(
|
||||
"POST",
|
||||
"/advertising/republish-payment/web",
|
||||
{
|
||||
item_name: advertising?.type, // Pass advertisingId to initiate payment
|
||||
item_name: advertising?.type,
|
||||
advertisingId: projectId,
|
||||
showDiscount: advertising?.showDiscount,
|
||||
}
|
||||
@@ -81,69 +86,70 @@ function FailedBillboard() {
|
||||
if (type === "free") {
|
||||
router.push("/settings/my-billboards");
|
||||
} else {
|
||||
const authority = response.authority; // Get the payment authority
|
||||
const paymentUrl = `https://www.zarinpal.com/pg/StartPay/${authority}`; // Construct payment URL
|
||||
const authority = response.authority;
|
||||
const paymentUrl = `https://www.zarinpal.com/pg/StartPay/${authority}`;
|
||||
router.push(paymentUrl);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (advertising?.type && typeList) {
|
||||
const foundType = typeList.find((item) => item.name === advertising.type);
|
||||
if (foundType) {
|
||||
setPrice(String(foundType.price)); // فرض بر اینکه price عددی است و نیاز به تبدیل به رشته دارد
|
||||
setPrice(String(foundType.price));
|
||||
}
|
||||
}
|
||||
}, [advertising, typeList]);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<h6 className="font-bold md:text-xl text-lg text-center mt-2 line-clamp-2 mb-4 text-[#FF0000] ">
|
||||
پرداخت ناموفق
|
||||
</h6>
|
||||
<div className="flex flex-col items-center text-sm">
|
||||
<Image
|
||||
width={50}
|
||||
height={50}
|
||||
alt={"verify icon"}
|
||||
src={`/images/icons/failed.svg`}
|
||||
className="pb-1 mb-5"
|
||||
/>
|
||||
<span> پرداخت شما با خطا مواجه شد. </span>
|
||||
<span> برای تایید درخواست پرداخت خود را کامل کنید</span>
|
||||
</div>
|
||||
<div className="px-4 w-full text-sm md:text-md font-semibold mt-10">
|
||||
{advertising && <MainBillboardCard billboard={advertising} />}
|
||||
<div className="flex flex-col items-center gap-4 mt-4">
|
||||
<RoundedDiv className="p-2 w-full">
|
||||
{advertising?.type === "special"
|
||||
? "ویژه"
|
||||
: advertising?.type === "normal"
|
||||
? "نمایش ساده"
|
||||
: advertising?.type === "free"
|
||||
? "رایگان"
|
||||
: "برجسته"}
|
||||
: {Number(price).toLocaleString()} تومان
|
||||
</RoundedDiv>
|
||||
<RoundedDiv className="p-2 w-full">
|
||||
مبلغ قابل پرداخت: {Number(price).toLocaleString()} تومان
|
||||
</RoundedDiv>
|
||||
<RoundedButton
|
||||
onClick={() => {
|
||||
if (type == "advertising-republish") {
|
||||
republishHandler();
|
||||
} else {
|
||||
payHandler();
|
||||
}
|
||||
}}
|
||||
variant="primary" className="w-32 h-9"
|
||||
>
|
||||
پرداخت مجدد
|
||||
</RoundedButton>
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<h6 className="font-bold md:text-xl text-lg text-center mt-2 line-clamp-2 mb-4 text-[#FF0000] ">
|
||||
{t("billboards.payment.failed")}
|
||||
</h6>
|
||||
<div className="flex flex-col items-center text-sm">
|
||||
<Image
|
||||
width={50}
|
||||
height={50}
|
||||
alt={"verify icon"}
|
||||
src={`/images/icons/failed.svg`}
|
||||
className="pb-1 mb-5"
|
||||
/>
|
||||
<span>{t("billboards.payment.failedHint1")}</span>
|
||||
<span>{t("billboards.payment.failedHint2")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
<div className="px-4 w-full text-sm md:text-md font-semibold mt-10">
|
||||
{advertising && <MainBillboardCard billboard={advertising} />}
|
||||
<div className="flex flex-col items-center gap-4 mt-4">
|
||||
<RoundedDiv className="p-2 w-full">
|
||||
{getBillboardDisplayTypeLabel(t, advertising?.type)}:{" "}
|
||||
{Number(price).toLocaleString()} {t("settings.toman")}
|
||||
</RoundedDiv>
|
||||
<RoundedDiv className="p-2 w-full">
|
||||
{t("billboards.payment.payableAmount", {
|
||||
amount: Number(price).toLocaleString(),
|
||||
})}
|
||||
</RoundedDiv>
|
||||
<RoundedButton
|
||||
onClick={() => {
|
||||
if (type == "advertising-republish") {
|
||||
republishHandler();
|
||||
} else {
|
||||
payHandler();
|
||||
}
|
||||
}}
|
||||
variant="primary"
|
||||
className="w-32 h-9"
|
||||
>
|
||||
{t("billboards.payment.retry")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,11 +7,15 @@ import React, { useEffect, useState } from "react";
|
||||
import MainBillboardCard from "@/components/billboards/MainBillboardCard/MainBillboardCard";
|
||||
import RoundedDiv from "@/components/elements/RoundedDiv";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import { getBillboardDisplayTypeLabel } from "@/lib/billboards/displayTypeLabel";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function SuccessBillboard() {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
const searchParams = useSearchParams();
|
||||
const projectId = searchParams.get("projectId");
|
||||
@@ -19,6 +23,7 @@ function SuccessBillboard() {
|
||||
const [advertising, setAdvertising] = useState<IAdvertising>();
|
||||
const [typeList, setTypeList] = useState<IAdvertisingType[] | null>(null);
|
||||
const [price, setPrice] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAd = async () => {
|
||||
const response = await request<{ advertising: IAdvertising }>(
|
||||
@@ -39,55 +44,49 @@ function SuccessBillboard() {
|
||||
};
|
||||
fetchAd();
|
||||
fetchStates();
|
||||
}, []);
|
||||
}, [projectId, request]);
|
||||
|
||||
useEffect(() => {
|
||||
if (advertising?.type && typeList) {
|
||||
const foundType = typeList.find((item) => item.name === advertising.type);
|
||||
if (foundType) {
|
||||
setPrice(String(foundType.price)); // فرض بر اینکه price عددی است و نیاز به تبدیل به رشته دارد
|
||||
setPrice(String(foundType.price));
|
||||
}
|
||||
}
|
||||
}, [advertising, typeList]);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<h6 className="font-bold md:text-xl text-lg text-center mt-2 line-clamp-2 mb-4 text-[#17A600] ">
|
||||
پرداخت موفق
|
||||
</h6>
|
||||
<div className="flex flex-col items-center text-sm">
|
||||
<Image
|
||||
width={50}
|
||||
height={50}
|
||||
alt={"verify icon"}
|
||||
src={`/images/icons/success.svg`}
|
||||
className="pb-1 mb-5"
|
||||
/>
|
||||
</div>
|
||||
<div className="px-4 w-full text-sm md:text-md font-semibold mt-10">
|
||||
{advertising && <MainBillboardCard billboard={advertising} />}
|
||||
<div className="flex flex-col items-center gap-4 mt-4">
|
||||
<RoundedDiv className="p-2 w-full">
|
||||
{advertising?.type === "special"
|
||||
? "ویژه"
|
||||
: advertising?.type === "normal"
|
||||
? "نمایش ساده"
|
||||
: advertising?.type === "free"
|
||||
? "رایگان"
|
||||
: "برجسته"}
|
||||
: {Number(price).toLocaleString()} تومان
|
||||
</RoundedDiv>
|
||||
<p className="my-5">
|
||||
آگهی شما در بیلبورد ثبت شد. پس از بررسی آگهی شما در بیلبورد منتشر
|
||||
خواهد شد.
|
||||
</p>
|
||||
<Link href={"/settings/my-billboards"}>
|
||||
<RoundedButton variant="primary" className="w-32 h-9">
|
||||
بیلورد من
|
||||
</RoundedButton>
|
||||
</Link>
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<h6 className="font-bold md:text-xl text-lg text-center mt-2 line-clamp-2 mb-4 text-[#17A600] ">
|
||||
{t("billboards.payment.success")}
|
||||
</h6>
|
||||
<div className="flex flex-col items-center text-sm">
|
||||
<Image
|
||||
width={50}
|
||||
height={50}
|
||||
alt={"verify icon"}
|
||||
src={`/images/icons/success.svg`}
|
||||
className="pb-1 mb-5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
<div className="px-4 w-full text-sm md:text-md font-semibold mt-10">
|
||||
{advertising && <MainBillboardCard billboard={advertising} />}
|
||||
<div className="flex flex-col items-center gap-4 mt-4">
|
||||
<RoundedDiv className="p-2 w-full">
|
||||
{getBillboardDisplayTypeLabel(t, advertising?.type)}:{" "}
|
||||
{Number(price).toLocaleString()} {t("settings.toman")}
|
||||
</RoundedDiv>
|
||||
<p className="my-5">{t("billboards.payment.registeredMessage")}</p>
|
||||
<Link href={"/settings/my-billboards"}>
|
||||
<RoundedButton variant="primary" className="w-32 h-9">
|
||||
{t("billboards.payment.myBillboards")}
|
||||
</RoundedButton>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,89 +1,80 @@
|
||||
import { BASE_URL, IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import React, { cache } from "react";
|
||||
import { cookies } from "next/headers";
|
||||
import { BASE_URL, IMAGE_BASE_URL } from "@/components/main/BaseUrl";
|
||||
import AdvertisingProfileContent from "@/components/billboards/AdvertisingProfileContent";
|
||||
import BillboardAdNotFound from "@/components/billboards/BillboardAdNotFound";
|
||||
import { IAdvertisingProfile } from "@/types/types";
|
||||
import Container from "@/components/elements/Container";
|
||||
import AdProfileHead from "@/components/billboards/Profile/AdProfileHead";
|
||||
import AdProfileContent from "@/components/billboards/Profile/AdProfileContent";
|
||||
import { Metadata } from "next";
|
||||
import { cache } from "react";
|
||||
import { getLocaleBundle } from "@/lib/i18n/resources";
|
||||
import { getServerLanguage } from "@/lib/i18n/server";
|
||||
|
||||
interface IUserProps {
|
||||
params: Promise<{ id: string }>;
|
||||
params: Promise<{ id: string[] }>;
|
||||
}
|
||||
|
||||
// بهینهسازی واکشی دادهها برای جلوگیری از تکرار درخواست (Shared Cache)
|
||||
const getProfile = cache(async (id: string) => {
|
||||
try {
|
||||
const res = await fetch(`${BASE_URL}/advertising/profile-web?vitrineId=${id}`, {
|
||||
cache: 'no-store'
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
return data.profileDetails as IAdvertisingProfile;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
export async function generateMetadata({ params }: IUserProps): Promise<Metadata> {
|
||||
const { id } = await params;
|
||||
const { id: idSegments } = await params;
|
||||
const id = idSegments?.[0] || "";
|
||||
const lang = await getServerLanguage();
|
||||
const common = getLocaleBundle(lang).common as {
|
||||
billboards: { adNotFoundMeta: string };
|
||||
};
|
||||
const profile = await getProfile(id);
|
||||
|
||||
if (!profile) {
|
||||
return { title: "آگهی یافت نشد | مدستاگرام" };
|
||||
return { title: common.billboards.adNotFoundMeta };
|
||||
}
|
||||
|
||||
// --- تنظیم تایتل طبق فرمت درخواستی شما ---
|
||||
// فرمت: عنوان آگهی - دسته بندی - محله - شهر - مدستاگرام
|
||||
const titleParts = [
|
||||
profile.title,
|
||||
profile.category?.name,
|
||||
profile.neighborhood?.name,
|
||||
profile.vitrine_name,
|
||||
profile.category,
|
||||
profile.neighbourhood,
|
||||
profile.city?.name,
|
||||
"مدستاگرام"
|
||||
].filter(Boolean); // حذف مقادیر خالی
|
||||
"Modstagram",
|
||||
].filter(Boolean);
|
||||
|
||||
const finalTitle = titleParts.join(" - ");
|
||||
|
||||
// --- تنظیم دیسکریپشن طبق فرمت درخواستی شما ---
|
||||
// فرمت: دسته بندی - شهر - توضیحات آگهی
|
||||
const description = `${profile.category?.name || ""} - ${profile.city?.name || ""} - ${profile.description || ""}`.slice(0, 160);
|
||||
|
||||
const image = profile.images?.[0] ? `${IMAGE_BASE_URL}${profile.images[0]}` : "/images/logo.png";
|
||||
const description = `${profile.category || ""} - ${profile.city?.name || ""} - ${profile.about_us || ""}`.slice(0, 160);
|
||||
const image = profile.allImages?.[0]
|
||||
? `${IMAGE_BASE_URL}${profile.allImages[0]}`
|
||||
: "/images/logo.png";
|
||||
|
||||
return {
|
||||
title: finalTitle,
|
||||
description: description,
|
||||
description,
|
||||
openGraph: {
|
||||
title: finalTitle,
|
||||
description: description,
|
||||
description,
|
||||
url: `https://modstagram.com/billboards/${id}`,
|
||||
siteName: "مدستاگرام",
|
||||
siteName: "Modstagram",
|
||||
images: [{ url: image }],
|
||||
locale: "fa_IR",
|
||||
locale: lang === "fa" ? "fa_IR" : "en_US",
|
||||
type: "article",
|
||||
},
|
||||
alternates: {
|
||||
canonical: `https://modstagram.com/billboards/${id}`,
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function AdvertisingProfilePage({ params }: IUserProps) {
|
||||
const { id } = await params;
|
||||
const { id: idSegments } = await params;
|
||||
const id = idSegments?.[0] || "";
|
||||
const profile = await getProfile(id);
|
||||
|
||||
if (!profile) return <Container className="py-20 text-center">آگهی یافت نشد</Container>;
|
||||
if (!profile) return <BillboardAdNotFound />;
|
||||
|
||||
return (
|
||||
<Container>
|
||||
{/* اضافه کردن H1 برای سبز شدن نمره سئو - مخفی از کاربر بصری، مرئی برای گوگل */}
|
||||
<h1 className="sr-only">
|
||||
{profile.title} - {profile.category?.name} در {profile.city?.name}، {profile.neighborhood?.name}
|
||||
</h1>
|
||||
|
||||
<AdProfileHead profile={profile} id={id} />
|
||||
<AdProfileContent profile={profile} />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
return <AdvertisingProfileContent profile={profile} id={id} />;
|
||||
}
|
||||
|
||||
@@ -1,71 +1,70 @@
|
||||
import { BASE_URL, IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import React, { cache } from "react";
|
||||
import { cookies } from "next/headers";
|
||||
import { BASE_URL, IMAGE_BASE_URL } from "@/components/main/BaseUrl";
|
||||
import AdvertisingProfileContent from "@/components/billboards/AdvertisingProfileContent";
|
||||
import BillboardAdNotFound from "@/components/billboards/BillboardAdNotFound";
|
||||
import { IAdvertisingProfile } from "@/types/types";
|
||||
import Container from "@/components/elements/Container";
|
||||
import AdProfileHead from "@/components/billboards/Profile/AdProfileHead";
|
||||
import AdProfileContent from "@/components/billboards/Profile/AdProfileContent";
|
||||
import { Metadata } from "next";
|
||||
import { cache } from "react";
|
||||
import { getLocaleBundle } from "@/lib/i18n/resources";
|
||||
import { getServerLanguage } from "@/lib/i18n/server";
|
||||
|
||||
interface IUserProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
// بهینهسازی واکشی دادهها برای جلوگیری از تکرار درخواست (Shared Cache)
|
||||
const getProfile = cache(async (id: string) => {
|
||||
try {
|
||||
const res = await fetch(`${BASE_URL}/advertising/profile-web?vitrineId=${id}`, {
|
||||
cache: 'no-store'
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
return data.profileDetails as IAdvertisingProfile;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
export async function generateMetadata({ params }: IUserProps): Promise<Metadata> {
|
||||
const { id } = await params;
|
||||
const lang = await getServerLanguage();
|
||||
const common = getLocaleBundle(lang).common as {
|
||||
billboards: { adNotFoundMeta: string };
|
||||
};
|
||||
const profile = await getProfile(id);
|
||||
|
||||
if (!profile) {
|
||||
return { title: "آگهی یافت نشد | مدستاگرام" };
|
||||
return { title: common.billboards.adNotFoundMeta };
|
||||
}
|
||||
|
||||
// --- تنظیم تایتل طبق فرمت درخواستی شما ---
|
||||
// فرمت: عنوان آگهی - دسته بندی - محله - شهر - مدستاگرام
|
||||
const titleParts = [
|
||||
profile.title,
|
||||
profile.category?.name,
|
||||
profile.neighborhood?.name,
|
||||
profile.vitrine_name,
|
||||
profile.category,
|
||||
profile.neighbourhood,
|
||||
profile.city?.name,
|
||||
"مدستاگرام"
|
||||
].filter(Boolean); // حذف مقادیر خالی
|
||||
"Modstagram",
|
||||
].filter(Boolean);
|
||||
|
||||
const finalTitle = titleParts.join(" - ");
|
||||
|
||||
// --- تنظیم دیسکریپشن طبق فرمت درخواستی شما ---
|
||||
// فرمت: دسته بندی - شهر - توضیحات آگهی
|
||||
const description = `${profile.category?.name || ""} - ${profile.city?.name || ""} - ${profile.description || ""}`.slice(0, 160);
|
||||
|
||||
const image = profile.images?.[0] ? `${IMAGE_BASE_URL}${profile.images[0]}` : "/images/logo.png";
|
||||
const description = `${profile.category || ""} - ${profile.city?.name || ""} - ${profile.about_us || ""}`.slice(0, 160);
|
||||
const image = profile.allImages?.[0]
|
||||
? `${IMAGE_BASE_URL}${profile.allImages[0]}`
|
||||
: "/images/logo.png";
|
||||
|
||||
return {
|
||||
title: finalTitle,
|
||||
description: description,
|
||||
description,
|
||||
openGraph: {
|
||||
title: finalTitle,
|
||||
description: description,
|
||||
description,
|
||||
url: `https://modstagram.com/billboards/${id}`,
|
||||
siteName: "مدستاگرام",
|
||||
siteName: "Modstagram",
|
||||
images: [{ url: image }],
|
||||
locale: "fa_IR",
|
||||
locale: lang === "fa" ? "fa_IR" : "en_US",
|
||||
type: "article",
|
||||
},
|
||||
alternates: {
|
||||
canonical: `https://modstagram.com/billboards/${id}`,
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -73,17 +72,7 @@ export default async function AdvertisingProfilePage({ params }: IUserProps) {
|
||||
const { id } = await params;
|
||||
const profile = await getProfile(id);
|
||||
|
||||
if (!profile) return <Container className="py-20 text-center">آگهی یافت نشد</Container>;
|
||||
if (!profile) return <BillboardAdNotFound />;
|
||||
|
||||
return (
|
||||
<Container>
|
||||
{/* اضافه کردن H1 برای سبز شدن نمره سئو - مخفی از کاربر بصری، مرئی برای گوگل */}
|
||||
<h1 className="sr-only">
|
||||
{profile.title} - {profile.category?.name} در {profile.city?.name}، {profile.neighborhood?.name}
|
||||
</h1>
|
||||
|
||||
<AdProfileHead profile={profile} id={id} />
|
||||
<AdProfileContent profile={profile} />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
return <AdvertisingProfileContent profile={profile} id={id} />;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
buildPostSeoTitle,
|
||||
} from "@/lib/postSlug";
|
||||
import ExploreReelClient from "./ExploreReelClient";
|
||||
import { getServerLanguage } from "@/lib/i18n/server";
|
||||
import { translateCommon } from "@/lib/i18n/translate";
|
||||
|
||||
type Props = {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -15,11 +17,14 @@ type Props = {
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { id } = await params;
|
||||
const lang = await getServerLanguage();
|
||||
const post = await fetchPostById(id);
|
||||
const title = post ? buildPostSeoTitle(post) : "پست | مدستاگرام";
|
||||
const title = post
|
||||
? buildPostSeoTitle(post, lang)
|
||||
: translateCommon(lang, "exploreMeta.defaultTitle");
|
||||
const description = post
|
||||
? buildPostSeoDescription(post)
|
||||
: "مشاهده پست در اکسپلور مدستاگرام";
|
||||
? buildPostSeoDescription(post, lang)
|
||||
: translateCommon(lang, "exploreMeta.defaultDescription");
|
||||
const ogImage = post?.files?.[0]?.path
|
||||
? buildStorageUrl(post.files[0].path)
|
||||
: undefined;
|
||||
@@ -30,9 +35,10 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
path: buildPostPath(id, post ?? undefined),
|
||||
type: "article",
|
||||
imageUrl: ogImage,
|
||||
imageAlt: post?.caption?.slice(0, 80) || "پست مدستاگرام",
|
||||
imageAlt: post?.caption?.slice(0, 80) || translateCommon(lang, "exploreMeta.defaultAlt"),
|
||||
publishedTime: post?.createdAt,
|
||||
modifiedTime: post?.updatedAt,
|
||||
lang,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import type { Metadata } from "next";
|
||||
import { pageSeo } from "@/config/pageSeo";
|
||||
import { generatePageMetadata } from "@/utils/generatePageMetadata";
|
||||
import { generateSeoPageMetadata } from "@/lib/i18n/server";
|
||||
|
||||
export const metadata: Metadata = generatePageMetadata({
|
||||
title: pageSeo.explore.title,
|
||||
description: pageSeo.explore.description,
|
||||
path: pageSeo.explore.path,
|
||||
keywords: [...pageSeo.explore.keywords],
|
||||
});
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
return generateSeoPageMetadata("explore");
|
||||
}
|
||||
|
||||
export default function ExploreLayout({
|
||||
children,
|
||||
|
||||
@@ -5,10 +5,13 @@ import TabNavigation from "@/components/TabNavigation";
|
||||
import ExploreFilterBar from "@/components/explore/ExploreFilterBar";
|
||||
import ExploreGrid from "@/components/explore/ExploreGrid";
|
||||
import Header from "@/components/main/Header";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import { ExploreFilterId } from "@/constants/exploreFilters";
|
||||
import { pageSeo } from "@/config/pageSeo";
|
||||
import { getSeoPage } from "@/lib/i18n/seo";
|
||||
import { useAppLanguage } from "@/contexts/LanguageProvider";
|
||||
import { useCallback, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function parseSearchQuery(input: string): { q?: string; hashtag?: string } {
|
||||
const trimmed = input.trim();
|
||||
@@ -21,6 +24,8 @@ function parseSearchQuery(input: string): { q?: string; hashtag?: string } {
|
||||
}
|
||||
|
||||
export default function ExplorePage() {
|
||||
const { t } = useTranslation("common");
|
||||
const { language } = useAppLanguage();
|
||||
const [activeFilter, setActiveFilter] = useState<ExploreFilterId>("all");
|
||||
const [coords, setCoords] = useState<{ lat: number; lng: number } | null>(
|
||||
null
|
||||
@@ -41,7 +46,7 @@ export default function ExplorePage() {
|
||||
const handleFilterChange = useCallback((filter: ExploreFilterId) => {
|
||||
if (filter === "near_me") {
|
||||
if (!navigator.geolocation) {
|
||||
toast.error("مرورگر شما از موقعیت مکانی پشتیبانی نمیکند.");
|
||||
toast.error(t("explore.geoNotSupported"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -57,9 +62,7 @@ export default function ExplorePage() {
|
||||
},
|
||||
() => {
|
||||
setLoadingLocation(false);
|
||||
toast.error(
|
||||
"برای نمایش پستهای نزدیک، دسترسی به موقعیت مکانی لازم است."
|
||||
);
|
||||
toast.error(t("explore.geoPermissionRequired"));
|
||||
},
|
||||
{ enableHighAccuracy: true, timeout: 15000 }
|
||||
);
|
||||
@@ -70,29 +73,33 @@ export default function ExplorePage() {
|
||||
if (filter !== "near_me") {
|
||||
setCoords(null);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
const exploreSeo = getSeoPage("explore", language);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<Container className="pb-28">
|
||||
<h1 className="sr-only">
|
||||
{pageSeo.explore.title.replace(" | مدستاگرام", "")}
|
||||
</h1>
|
||||
<ExploreFilterBar
|
||||
activeFilter={activeFilter}
|
||||
onFilterChange={handleFilterChange}
|
||||
loadingLocation={loadingLocation}
|
||||
searchValue={searchInput}
|
||||
onSearchChange={setSearchInput}
|
||||
onSearchSubmit={handleSearchSubmit}
|
||||
/>
|
||||
<ExploreGrid
|
||||
activeFilter={activeFilter}
|
||||
coords={coords}
|
||||
searchQuery={searchQuery}
|
||||
/>
|
||||
</Container>
|
||||
<LocalePageShell>
|
||||
<Container className="pb-28">
|
||||
<h1 className="sr-only">{exploreSeo.title.split("|")[0].trim()}</h1>
|
||||
<div className="pb-2 pt-1">
|
||||
<ExploreFilterBar
|
||||
activeFilter={activeFilter}
|
||||
onFilterChange={handleFilterChange}
|
||||
loadingLocation={loadingLocation}
|
||||
searchValue={searchInput}
|
||||
onSearchChange={setSearchInput}
|
||||
onSearchSubmit={handleSearchSubmit}
|
||||
/>
|
||||
</div>
|
||||
<ExploreGrid
|
||||
activeFilter={activeFilter}
|
||||
coords={coords}
|
||||
searchQuery={searchQuery}
|
||||
/>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
<TabNavigation currentPage="/explore" />
|
||||
</>
|
||||
);
|
||||
|
||||
39
src/app/global-error.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import i18n from "@/lib/i18n";
|
||||
import { readStoredLanguagePreference, resolveBrowserLanguage } from "@/lib/i18n/clientLanguage";
|
||||
|
||||
export default function GlobalError({
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
const [lang, setLang] = useState(readStoredLanguagePreference() ?? resolveBrowserLanguage());
|
||||
|
||||
useEffect(() => {
|
||||
const stored = readStoredLanguagePreference() ?? resolveBrowserLanguage();
|
||||
setLang(stored);
|
||||
void i18n.changeLanguage(stored);
|
||||
}, []);
|
||||
|
||||
const t = (key: string) => i18n.t(key, { lng: lang, ns: "common" });
|
||||
const dir = lang === "fa" ? "rtl" : "ltr";
|
||||
|
||||
return (
|
||||
<html lang={lang} dir={dir}>
|
||||
<body className="flex min-h-screen flex-col items-center justify-center gap-4 bg-white px-6 text-center text-neutral-900">
|
||||
<p className="text-lg font-bold">{t("errors.appLoad")}</p>
|
||||
<p className="max-w-md text-sm text-neutral-600">{t("errors.safariIosHint")}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => reset()}
|
||||
className="rounded-xl bg-[#0095f6] px-5 py-2.5 text-sm font-bold text-white"
|
||||
>
|
||||
{t("errors.retry")}
|
||||
</button>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -661,6 +661,15 @@ select {
|
||||
animation: check-pop 0.35s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||
}
|
||||
|
||||
@keyframes story-ring-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.story-ring-live {
|
||||
animation: story-ring-spin 3s linear infinite;
|
||||
}
|
||||
|
||||
/* Message enter — handled by framer-motion in ChatMessageCard */
|
||||
|
||||
/* ─── Shrink header ─── */
|
||||
@@ -771,4 +780,18 @@ select {
|
||||
}
|
||||
.glass-modal-panel--center {
|
||||
border-radius: 1.5rem;
|
||||
}
|
||||
|
||||
/* English LTR page shells (applied page-by-page later) */
|
||||
.locale-page-shell--ltr {
|
||||
direction: ltr;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
html[dir="ltr"] body {
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
html[dir="ltr"] .rtl\:flex-row-reverse {
|
||||
flex-direction: row;
|
||||
}
|
||||
@@ -1,76 +1,104 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import localFont from "next/font/local";
|
||||
import { defaultSEOConfig } from "@/config/seoConfig";
|
||||
import { pageSeo, siteKeywords } from "@/config/pageSeo";
|
||||
import { getSiteSeoMeta, getSeoPage } from "@/lib/i18n/seo";
|
||||
import { getLanguageDefinition } from "@/lib/i18n/registry";
|
||||
import { getServerLanguage } from "@/lib/i18n/server";
|
||||
import SiteJsonLd from "@/components/seo/SiteJsonLd";
|
||||
|
||||
import "./globals.css";
|
||||
import Layout from "@/components/Layout";
|
||||
import RegisterSW from "@/components/RegisterSW";
|
||||
import ClientErrorBoundary from "@/components/ClientErrorBoundary";
|
||||
|
||||
const iranSansFont = localFont({
|
||||
src: "./../../public/fonts/IRANSansX-Regular.woff",
|
||||
});
|
||||
|
||||
const defaultTitle = pageSeo.home.title;
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const lang = await getServerLanguage();
|
||||
const site = getSiteSeoMeta(lang);
|
||||
const home = getSeoPage("home", lang);
|
||||
|
||||
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",
|
||||
manifest: "/manifest.json",
|
||||
icons: {
|
||||
icon: [{ url: "/favicon.ico", type: "image/x-icon" }],
|
||||
shortcut: ["/favicon.ico"],
|
||||
apple: [
|
||||
{ url: "/images/icons/apple-touch-icon.png" },
|
||||
{ url: "/images/icons/192x192.png" },
|
||||
],
|
||||
},
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: "black",
|
||||
},
|
||||
openGraph: {
|
||||
siteName: defaultSEOConfig.openGraph?.site_name || "مدستاگرام",
|
||||
locale: "fa_IR",
|
||||
type: "website",
|
||||
images: defaultSEOConfig.openGraph?.images || [],
|
||||
},
|
||||
robots: "index, follow",
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
creator: "@modstagram",
|
||||
site: "@modstagram",
|
||||
images: defaultSEOConfig.openGraph?.images?.map((img) => img.url) || [],
|
||||
},
|
||||
verification: {
|
||||
google: "yVjB5yKPchPUtxl33GWVyMvjH6wCEInqEvaH7ZsXXMo",
|
||||
},
|
||||
other: {
|
||||
"theme-color": "#ffffff",
|
||||
"msapplication-TileColor": "#0072BC",
|
||||
samandehi: "522190795",
|
||||
},
|
||||
return {
|
||||
title: {
|
||||
default: site.name,
|
||||
template: `%s | ${site.titleSuffix}`,
|
||||
},
|
||||
description: home.description,
|
||||
metadataBase: new URL("https://modstagram.com"),
|
||||
keywords: site.keywords,
|
||||
authors: [{ name: "Modstagram", url: "https://modstagram.com" }],
|
||||
creator: "Modstagram",
|
||||
manifest: "/manifest.webmanifest",
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: "/favicon.png", type: "image/png", sizes: "32x32" },
|
||||
{ url: "/images/icons/192x192.png", type: "image/png", sizes: "192x192" },
|
||||
],
|
||||
shortcut: ["/favicon.png"],
|
||||
apple: [
|
||||
{ url: "/images/icons/apple-touch-icon.png", sizes: "180x180" },
|
||||
{ url: "/images/icons/192x192.png", sizes: "192x192" },
|
||||
],
|
||||
},
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: "default",
|
||||
title: site.name,
|
||||
},
|
||||
openGraph: {
|
||||
siteName: site.name,
|
||||
locale: lang === "fa" ? "fa_IR" : "en_US",
|
||||
type: "website",
|
||||
images: defaultSEOConfig.openGraph?.images || [],
|
||||
},
|
||||
robots: "index, follow",
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
creator: "@modstagram",
|
||||
site: "@modstagram",
|
||||
images: defaultSEOConfig.openGraph?.images?.map((img) => img.url) || [],
|
||||
},
|
||||
verification: {
|
||||
google: "yVjB5yKPchPUtxl33GWVyMvjH6wCEInqEvaH7ZsXXMo",
|
||||
},
|
||||
other: {
|
||||
"theme-color": "#FF107D",
|
||||
"mobile-web-app-capable": "yes",
|
||||
"msapplication-TileColor": "#FF107D",
|
||||
samandehi: "522190795",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
viewportFit: "cover",
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "#0a0a0a" },
|
||||
],
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const lang = await getServerLanguage();
|
||||
const def = getLanguageDefinition(lang);
|
||||
|
||||
return (
|
||||
<html lang="fa" dir="rtl" suppressHydrationWarning>
|
||||
<html lang={def.htmlLang} dir={def.direction} suppressHydrationWarning>
|
||||
<body className={`${iranSansFont.className} antialiased`}>
|
||||
<SiteJsonLd />
|
||||
<RegisterSW />
|
||||
<Layout>{children}</Layout>
|
||||
<ClientErrorBoundary>
|
||||
<Layout>{children}</Layout>
|
||||
</ClientErrorBoundary>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import IOSSpinner from "@/components/ui/IOSSpinner";
|
||||
import { getServerLanguage } from "@/lib/i18n/server";
|
||||
import { translateCommon } from "@/lib/i18n/translate";
|
||||
|
||||
export default async function Loading() {
|
||||
const lang = await getServerLanguage();
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="flex h-screen w-full flex-col items-center justify-center gap-3 bg-background">
|
||||
<IOSSpinner size={32} color="#007aff" />
|
||||
<span className="text-sm text-neutral-500">در حال بارگذاری…</span>
|
||||
<span className="text-sm text-neutral-500">{translateCommon(lang, "common.loading")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
42
src/app/manifest.webmanifest/route.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
buildWebManifest,
|
||||
PWA_VERSION,
|
||||
toPwaLang,
|
||||
} from "@/lib/pwaConfig";
|
||||
import {
|
||||
DEFAULT_LANGUAGE,
|
||||
isAppLanguage,
|
||||
LANGUAGE_COOKIE,
|
||||
} from "@/lib/i18n/registry";
|
||||
import { resolveLanguageFromAcceptHeader } from "@/lib/i18n/resolveLanguage";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function resolveLangFromRequest(request: NextRequest) {
|
||||
const queryLang = request.nextUrl.searchParams.get("lang");
|
||||
if (isAppLanguage(queryLang)) return queryLang;
|
||||
|
||||
const cookieLang = request.cookies.get(LANGUAGE_COOKIE)?.value;
|
||||
if (isAppLanguage(cookieLang)) return cookieLang;
|
||||
|
||||
const fromAccept = resolveLanguageFromAcceptHeader(
|
||||
request.headers.get("accept-language")
|
||||
);
|
||||
if (fromAccept) return fromAccept;
|
||||
|
||||
return DEFAULT_LANGUAGE;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const lang = toPwaLang(resolveLangFromRequest(request));
|
||||
const manifest = buildWebManifest(lang);
|
||||
|
||||
return NextResponse.json(manifest, {
|
||||
headers: {
|
||||
"Content-Type": "application/manifest+json; charset=utf-8",
|
||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||
"X-PWA-Version": PWA_VERSION,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -3,12 +3,11 @@ import TabNavigation from "@/components/TabNavigation";
|
||||
import { Suspense } from "react";
|
||||
import PageLoader from "@/components/ui/PageLoader";
|
||||
import type { Metadata } from "next";
|
||||
import { generateSeoPageMetadata } from "@/lib/i18n/server";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "انتشار پست | مدستاگرام",
|
||||
description: "انتشار پست، ویدئو یا استوری در مدستاگرام",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
return generateSeoPageMetadata("newPost", { index: false });
|
||||
}
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
|
||||
@@ -7,20 +7,37 @@ import { optimizeImageToWebP, optimizeVideoToMp4 } from "@/lib/media";
|
||||
import { createStoryBase64, StoryTextOverlay } from "@/api/fetchStories";
|
||||
import StoryEditor from "@/components/stories/StoryEditor";
|
||||
import TagUsersPicker, { TaggedUser } from "@/components/posts/TagUsersPicker";
|
||||
import PostLinkPicker from "@/components/posts/PostLinkPicker";
|
||||
import {
|
||||
SelectedPostLink,
|
||||
} from "@/lib/postLinkCaption";
|
||||
import {
|
||||
buildFullPostCaption,
|
||||
countPostCaptionLength,
|
||||
POST_CAPTION_MAX_LENGTH,
|
||||
} from "@/lib/captionLength";
|
||||
import PostLocationNoticeModal from "@/components/posts/PostLocationNoticeModal";
|
||||
import PostExpertiseRequiredModal from "@/components/posts/PostExpertiseRequiredModal";
|
||||
import VideoCoverPicker from "@/components/posts/VideoCoverPicker";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
import {
|
||||
userHasExpertise,
|
||||
} from "@/lib/postExpertise";
|
||||
import {
|
||||
hasSeenPostLocationNotice,
|
||||
markPostLocationNoticeSeen,
|
||||
} from "@/lib/postLocationNotice";
|
||||
import Cookies from "js-cookie";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { User } from "@/types/types";
|
||||
|
||||
type CreateMode = "image" | "video" | "story";
|
||||
|
||||
const MODE_LABELS: Record<CreateMode, string> = {
|
||||
image: "عکس",
|
||||
video: "ویدئو",
|
||||
story: "استوری",
|
||||
};
|
||||
|
||||
function fileToBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
@@ -31,9 +48,12 @@ function fileToBase64(file: File): Promise<string> {
|
||||
}
|
||||
|
||||
export default function NewPostPage() {
|
||||
const { t, i18n } = useTranslation("common");
|
||||
const captionLang = i18n.language === "en" ? "en" : "fa";
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { request, loading } = useAxios();
|
||||
const user = useUser();
|
||||
|
||||
const initialMode = (searchParams.get("type") as CreateMode) || "image";
|
||||
const [mode, setMode] = useState<CreateMode>(
|
||||
@@ -45,7 +65,28 @@ export default function NewPostPage() {
|
||||
const [extraPreviews, setExtraPreviews] = useState<string[]>([""]);
|
||||
const [description, setDescription] = useState("");
|
||||
const [taggedUsers, setTaggedUsers] = useState<TaggedUser[]>([]);
|
||||
const [selectedPackages, setSelectedPackages] = useState<SelectedPostLink[]>(
|
||||
[]
|
||||
);
|
||||
const [selectedProjects, setSelectedProjects] = useState<SelectedPostLink[]>(
|
||||
[]
|
||||
);
|
||||
const [selectedBillboards, setSelectedBillboards] = useState<
|
||||
SelectedPostLink[]
|
||||
>([]);
|
||||
const [storyOverlays, setStoryOverlays] = useState<StoryTextOverlay[]>([]);
|
||||
const [videoCover, setVideoCover] = useState<File | null>(null);
|
||||
const [locationNoticeOpen, setLocationNoticeOpen] = useState(false);
|
||||
const [expertiseModalOpen, setExpertiseModalOpen] = useState(false);
|
||||
|
||||
const modeLabels: Record<CreateMode, string> = useMemo(
|
||||
() => ({
|
||||
image: t("posts.modeImage"),
|
||||
video: t("posts.modeVideo"),
|
||||
story: t("posts.modeStory"),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const t = searchParams.get("type") as CreateMode;
|
||||
@@ -60,6 +101,7 @@ export default function NewPostPage() {
|
||||
setExtraImages([null]);
|
||||
setExtraPreviews([""]);
|
||||
setStoryOverlays([]);
|
||||
setVideoCover(null);
|
||||
};
|
||||
|
||||
const switchMode = (next: CreateMode) => {
|
||||
@@ -77,11 +119,11 @@ export default function NewPostPage() {
|
||||
if (!picked) return;
|
||||
|
||||
if (mode === "video" && !picked.type.startsWith("video/")) {
|
||||
toast.error("فقط ویدئو مجاز است");
|
||||
toast.error(t("posts.videoOnly"));
|
||||
return;
|
||||
}
|
||||
if (mode === "image" && !picked.type.startsWith("image/")) {
|
||||
toast.error("فقط تصویر مجاز است");
|
||||
toast.error(t("posts.imageOnly"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -93,7 +135,7 @@ export default function NewPostPage() {
|
||||
const onPickExtra = (index: number) => (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const picked = e.target.files?.[0];
|
||||
if (!picked || !picked.type.startsWith("image/")) {
|
||||
toast.error("فقط تصویر مجاز است");
|
||||
toast.error(t("posts.imageOnly"));
|
||||
return;
|
||||
}
|
||||
const files = [...extraImages];
|
||||
@@ -108,20 +150,50 @@ export default function NewPostPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const returnPath = useMemo(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (mode !== "image") params.set("type", mode);
|
||||
const qs = params.toString();
|
||||
return qs ? `/new-post?${qs}` : "/new-post";
|
||||
}, [mode]);
|
||||
|
||||
const resolveProfileExpertise = async (): Promise<User | undefined> => {
|
||||
if (userHasExpertise(user)) return user;
|
||||
try {
|
||||
const response = await request<{ user: User }>(
|
||||
"GET",
|
||||
"/profile",
|
||||
null,
|
||||
{ noToast: true }
|
||||
);
|
||||
return response?.user;
|
||||
} catch {
|
||||
return user;
|
||||
}
|
||||
};
|
||||
|
||||
const ensurePostExpertise = async (): Promise<boolean> => {
|
||||
const profile = await resolveProfileExpertise();
|
||||
if (userHasExpertise(profile)) return true;
|
||||
toast.error(t("posts.expertiseRequired"));
|
||||
setExpertiseModalOpen(true);
|
||||
return false;
|
||||
};
|
||||
|
||||
const publish = async () => {
|
||||
const token = Cookies.get("token");
|
||||
if (!token) {
|
||||
toast.error("لطفاً وارد حساب کاربری شوید");
|
||||
toast.error(t("posts.loginToPost"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === "story") {
|
||||
if (!file) {
|
||||
toast.error("انتخاب عکس یا ویدئو برای استوری الزامی است");
|
||||
toast.error(t("posts.storyMediaRequired"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
toast.loading("در حال انتشار استوری…", { id: "story-upload" });
|
||||
toast.loading(t("posts.publishingStory"), { id: "story-upload" });
|
||||
let optimized: File;
|
||||
if (file.type.startsWith("video/")) {
|
||||
// اگر تبدیل ویدیو (ffmpeg) در دسترس نبود، فایل اصلی ارسال میشود
|
||||
@@ -146,34 +218,35 @@ export default function NewPostPage() {
|
||||
token,
|
||||
storyOverlays
|
||||
);
|
||||
toast.success("استوری منتشر شد", { id: "story-upload" });
|
||||
toast.success(t("posts.storyPublished"), { id: "story-upload" });
|
||||
router.push("/");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "خطا در انتشار استوری", {
|
||||
toast.error(err instanceof Error ? err.message : t("posts.storyPublishError"), {
|
||||
id: "story-upload",
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await ensurePostExpertise())) {
|
||||
return;
|
||||
}
|
||||
|
||||
const imageFiles =
|
||||
mode === "image"
|
||||
? [...(file ? [file] : []), ...extraImages.filter(Boolean)] as File[]
|
||||
: file
|
||||
? [file]
|
||||
: [];
|
||||
? ([...(file ? [file] : []), ...extraImages.filter(Boolean)] as File[])
|
||||
: mode === "video" && file
|
||||
? ([...(videoCover ? [videoCover] : []), file] as File[])
|
||||
: file
|
||||
? [file]
|
||||
: [];
|
||||
|
||||
if (!imageFiles.length) {
|
||||
toast.error("انتخاب مدیا الزامی است");
|
||||
toast.error(t("posts.mediaRequired"));
|
||||
return;
|
||||
}
|
||||
if (!description.trim()) {
|
||||
toast.error("نوشتن کپشن الزامی است");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
toast.loading("در حال آپلود…", { id: "post-upload" });
|
||||
toast.loading(t("posts.uploading"), { id: "post-upload" });
|
||||
const optimizedFiles = await Promise.all(
|
||||
imageFiles.map(async (f) =>
|
||||
f.type.startsWith("video/")
|
||||
@@ -188,26 +261,89 @@ export default function NewPostPage() {
|
||||
data: await fileToBase64(f),
|
||||
}))
|
||||
);
|
||||
const tagLine = taggedUsers.map((u) => `@${u.user_name}`).join(" ");
|
||||
const caption = [description, tagLine].filter(Boolean).join("\n");
|
||||
const caption = buildFullPostCaption(
|
||||
description,
|
||||
taggedUsers,
|
||||
selectedPackages,
|
||||
selectedProjects,
|
||||
selectedBillboards,
|
||||
captionLang
|
||||
);
|
||||
|
||||
if (caption.length > POST_CAPTION_MAX_LENGTH) {
|
||||
toast.error(
|
||||
t("posts.captionTooLong", { max: POST_CAPTION_MAX_LENGTH }),
|
||||
{
|
||||
id: "post-upload",
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await request("POST", "/posts/create-base64", {
|
||||
files: filesBase64,
|
||||
caption,
|
||||
tagged_user_ids: taggedUsers.map((u) => u._id),
|
||||
});
|
||||
toast.success("پست منتشر شد", { id: "post-upload" });
|
||||
linked_course_id: selectedPackages[0]?._id ?? null,
|
||||
linked_project_id: selectedProjects[0]?._id ?? null,
|
||||
linked_billboard_id: selectedBillboards[0]?._id ?? null,
|
||||
}, { noToast: true });
|
||||
toast.success(t("posts.postPublished"), { id: "post-upload" });
|
||||
router.push("/");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "خطا در آپلود", {
|
||||
} catch (err: unknown) {
|
||||
const axiosErr = err as {
|
||||
response?: { data?: { type?: string; message?: string } };
|
||||
};
|
||||
if (axiosErr.response?.data?.type === "expertise_required") {
|
||||
toast.error(
|
||||
axiosErr.response.data.message || t("posts.expertiseRequired"),
|
||||
{ id: "post-upload" }
|
||||
);
|
||||
setExpertiseModalOpen(true);
|
||||
return;
|
||||
}
|
||||
toast.error(err instanceof Error ? err.message : t("posts.uploadError"), {
|
||||
id: "post-upload",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const locationSharingEnabled = user?.post_location_enabled === true;
|
||||
|
||||
const handleShareClick = async () => {
|
||||
if (!isStory && !(await ensurePostExpertise())) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!isStory &&
|
||||
locationSharingEnabled &&
|
||||
!hasSeenPostLocationNotice()
|
||||
) {
|
||||
setLocationNoticeOpen(true);
|
||||
return;
|
||||
}
|
||||
void publish();
|
||||
};
|
||||
|
||||
const dismissLocationNotice = () => {
|
||||
markPostLocationNoticeSeen();
|
||||
setLocationNoticeOpen(false);
|
||||
};
|
||||
|
||||
const captionLength = countPostCaptionLength(
|
||||
description,
|
||||
taggedUsers,
|
||||
selectedPackages,
|
||||
selectedProjects,
|
||||
selectedBillboards,
|
||||
captionLang
|
||||
);
|
||||
|
||||
const isStory = mode === "story";
|
||||
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<div className="mx-auto flex min-h-[calc(100dvh-8rem)] max-w-lg flex-col bg-background">
|
||||
{/* Header — Instagram style */}
|
||||
<header className="sticky top-0 z-10 flex items-center justify-between border-b border-neutral-200 px-4 py-3 dark:border-neutral-800">
|
||||
@@ -215,20 +351,20 @@ export default function NewPostPage() {
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
className="flex h-9 w-9 items-center justify-center"
|
||||
aria-label="بازگشت"
|
||||
aria-label={t("common.back")}
|
||||
>
|
||||
<BoldIcon name="arrow-right-3" size={22} className="block dark:invert" />
|
||||
</button>
|
||||
<span className="text-base font-semibold">
|
||||
{isStory ? "استوری جدید" : "پست جدید"}
|
||||
{isStory ? t("posts.newStory") : t("posts.newPost")}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={publish}
|
||||
onClick={handleShareClick}
|
||||
disabled={loading}
|
||||
className="text-sm font-semibold text-[#0095f6] disabled:opacity-40"
|
||||
>
|
||||
{loading ? "…" : "اشتراک"}
|
||||
{loading ? "…" : t("posts.shareButton")}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
@@ -245,7 +381,7 @@ export default function NewPostPage() {
|
||||
: "text-neutral-400"
|
||||
}`}
|
||||
>
|
||||
{MODE_LABELS[m]}
|
||||
{modeLabels[m]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -260,7 +396,7 @@ export default function NewPostPage() {
|
||||
onChange={setStoryOverlays}
|
||||
/>
|
||||
<label className="mx-auto rounded-lg bg-neutral-100 px-4 py-2 text-center text-xs text-neutral-600 dark:bg-neutral-800 dark:text-neutral-300">
|
||||
تغییر فایل
|
||||
{t("posts.changeFile")}
|
||||
<input
|
||||
type="file"
|
||||
accept={acceptAttr}
|
||||
@@ -305,13 +441,13 @@ export default function NewPostPage() {
|
||||
<BoldIcon name="gallery-add" size={48} className="block opacity-60 dark:invert" />
|
||||
<span className="text-sm">
|
||||
{isStory
|
||||
? "عکس یا ویدئو استوری را انتخاب کنید"
|
||||
? t("posts.pickStoryMedia")
|
||||
: mode === "video"
|
||||
? "ویدئو را انتخاب کنید"
|
||||
: "عکس را انتخاب کنید"}
|
||||
? t("posts.pickVideo")
|
||||
: t("posts.pickImage")}
|
||||
</span>
|
||||
<span className="rounded-lg bg-[#0095f6] px-4 py-2 text-sm font-semibold text-white">
|
||||
انتخاب از گالری
|
||||
{t("posts.pickFromGallery")}
|
||||
</span>
|
||||
<input
|
||||
type="file"
|
||||
@@ -323,7 +459,7 @@ export default function NewPostPage() {
|
||||
)}
|
||||
{preview && (
|
||||
<label className="absolute bottom-4 rounded-lg bg-black/60 px-4 py-2 text-xs text-white">
|
||||
تغییر فایل
|
||||
{t("posts.changeFile")}
|
||||
<input type="file" accept={acceptAttr} className="hidden" onChange={onPickMain} />
|
||||
</label>
|
||||
)}
|
||||
@@ -359,30 +495,58 @@ export default function NewPostPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === "video" && preview && file?.type.startsWith("video/") && (
|
||||
<VideoCoverPicker videoUrl={preview} onCoverChange={setVideoCover} />
|
||||
)}
|
||||
|
||||
{/* Caption — not for story */}
|
||||
{!isStory && (
|
||||
<div className="space-y-3 border-t border-neutral-200 p-4 dark:border-neutral-800">
|
||||
<textarea
|
||||
placeholder="کپشن بنویسید…"
|
||||
placeholder={t("posts.captionPlaceholder")}
|
||||
value={description}
|
||||
onChange={(e) => {
|
||||
if (e.target.value.length <= 1000) setDescription(e.target.value);
|
||||
}}
|
||||
rows={3}
|
||||
className="w-full resize-none bg-transparent text-sm outline-none placeholder:text-neutral-400"
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={5}
|
||||
className="min-h-[7.5rem] w-full resize-none whitespace-pre-wrap bg-transparent text-sm outline-none placeholder:text-neutral-400"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-neutral-400">
|
||||
<span>{description.length}/1000</span>
|
||||
<span>
|
||||
{captionLength}/{POST_CAPTION_MAX_LENGTH}
|
||||
</span>
|
||||
</div>
|
||||
<TagUsersPicker selected={taggedUsers} onChange={setTaggedUsers} max={50} />
|
||||
<PostLinkPicker
|
||||
selectedPackages={selectedPackages}
|
||||
selectedProjects={selectedProjects}
|
||||
selectedBillboards={selectedBillboards}
|
||||
onPackagesChange={setSelectedPackages}
|
||||
onProjectsChange={setSelectedProjects}
|
||||
onBillboardsChange={setSelectedBillboards}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isStory && (
|
||||
<p className="px-4 pb-6 text-center text-xs text-neutral-500">
|
||||
استوری شما ۲۴ ساعت نمایش داده میشود و سپس بهطور خودکار حذف میگردد.
|
||||
{t("posts.storyExpiryHint")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<PostLocationNoticeModal
|
||||
isOpen={locationNoticeOpen}
|
||||
onNo={() => {
|
||||
dismissLocationNotice();
|
||||
void publish();
|
||||
}}
|
||||
onYes={dismissLocationNotice}
|
||||
/>
|
||||
|
||||
<PostExpertiseRequiredModal
|
||||
isOpen={expertiseModalOpen}
|
||||
onClose={() => setExpertiseModalOpen(false)}
|
||||
returnPath={returnPath}
|
||||
/>
|
||||
</div>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,23 +18,23 @@ import React, { useState, useEffect } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import Cookies from "js-cookie";
|
||||
import * as Yup from "yup";
|
||||
|
||||
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
selectedType: Yup.string().required(" انتخاب کردن نوع درخواست الزامی است"),
|
||||
});
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface ITicketChatProps {
|
||||
params: Promise<{ id: string; title: string }>;
|
||||
}
|
||||
|
||||
function TicketChat({ params }: ITicketChatProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const resolvedParams = React.use(params);
|
||||
const { request } = useAxios();
|
||||
const { id } = resolvedParams;
|
||||
const router = useRouter();
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
selectedType: Yup.string().required(t("offerPage.typeRequired")),
|
||||
});
|
||||
|
||||
const [userDetail, setUserDetail] = useState<User>();
|
||||
const [selectedType, setSelectedType] = useState("normal");
|
||||
const [typeList, setTypeList] = useState<IOfferType[] | null>(null);
|
||||
@@ -74,7 +74,7 @@ function TicketChat({ params }: ITicketChatProps) {
|
||||
validationSchema,
|
||||
onSubmit: async (values, { setSubmitting }) => {
|
||||
if (values.selectedType === "free" && hasUsedFreeOffer) {
|
||||
toast.error("شما قبلاً از درخواست رایگان استفاده کردهاید!");
|
||||
toast.error(t("offerPage.freeAlreadyUsed"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ function TicketChat({ params }: ITicketChatProps) {
|
||||
console.log("Authority:", data.authority);
|
||||
console.log("Response:", response);
|
||||
if (values.selectedType === "free") {
|
||||
toast.success("درخواست رایگان شما ثبت شد!");
|
||||
toast.success(t("offerPage.freeSuccess"));
|
||||
setHasUsedFreeOffer(true);
|
||||
router.push(`/offer/payment/success?userId=${id}`);
|
||||
}
|
||||
@@ -115,7 +115,7 @@ function TicketChat({ params }: ITicketChatProps) {
|
||||
console.log("Redirecting to:", paymentUrl);
|
||||
window.open(paymentUrl, "_blank");
|
||||
} else {
|
||||
toast.error("خطا در دریافت اطلاعات پرداخت");
|
||||
toast.error(t("offerPage.paymentInfoError"));
|
||||
}
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ function TicketChat({ params }: ITicketChatProps) {
|
||||
|
||||
} catch (err: any) {
|
||||
console.error("Payment initiation error:", err);
|
||||
toast.error(err?.response?.data?.error || "خطا در شروع پرداخت");
|
||||
toast.error(err?.response?.data?.error || t("offerPage.paymentStartError"));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -135,9 +135,9 @@ function TicketChat({ params }: ITicketChatProps) {
|
||||
<Container>
|
||||
<div className="mx-3 mb-10">
|
||||
|
||||
<PageTitle>پرداخت</PageTitle>
|
||||
<PageTitle>{t("offerPage.paymentTitle")}</PageTitle>
|
||||
<p className="text-center">
|
||||
شما فقط 1 بار میتوانید درخواست رایگان ثبت کنید
|
||||
{t("offerPage.freeOnceHint")}
|
||||
</p>
|
||||
<form
|
||||
className="flex flex-col gap-2 w-full items-center text-sm"
|
||||
@@ -174,7 +174,7 @@ function TicketChat({ params }: ITicketChatProps) {
|
||||
width={21}
|
||||
height={21}
|
||||
alt={"verify icon"}
|
||||
src={`/images/icons/medal-star.svg`}
|
||||
src={`/images/icons/medal-star.png`}
|
||||
className="pb-1"
|
||||
/>
|
||||
</div>
|
||||
@@ -184,7 +184,7 @@ function TicketChat({ params }: ITicketChatProps) {
|
||||
width={21}
|
||||
height={21}
|
||||
alt={"verify icon"}
|
||||
src={`/images/icons/star1.svg`}
|
||||
src={`/images/icons/star1.png`}
|
||||
className="pb-1"
|
||||
/>
|
||||
</div>
|
||||
@@ -195,14 +195,14 @@ function TicketChat({ params }: ITicketChatProps) {
|
||||
}`}
|
||||
>
|
||||
{item.name === "normal"
|
||||
? "درخواست همکاری"
|
||||
? t("offerPage.types.normal")
|
||||
: item.name === "free"
|
||||
? "ثبت درخواست رایگان"
|
||||
? t("offerPage.types.free")
|
||||
: item.name === "special"
|
||||
? "نمایش با آیکون ویژه"
|
||||
: "نمایش با رنگ پس زمینه متفاوت"}
|
||||
? t("offerPage.types.special")
|
||||
: t("offerPage.types.highlight")}
|
||||
{item.price !== 0
|
||||
? ": " + Number(item.price).toLocaleString() + "تومان"
|
||||
? ": " + Number(item.price).toLocaleString() + t("offerPage.currencySuffix")
|
||||
: ""}
|
||||
</RoundedDiv>
|
||||
</div>
|
||||
@@ -210,7 +210,7 @@ function TicketChat({ params }: ITicketChatProps) {
|
||||
})}
|
||||
|
||||
<RoundedButton type="submit" variant="primary" className="mt-4 px-8 py-2">
|
||||
ثبت درخواست
|
||||
{t("offerPage.submitRequest")}
|
||||
</RoundedButton>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -6,32 +6,34 @@ import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function FailedBillboard() {
|
||||
const { t } = useTranslation("common");
|
||||
const searchParams = useSearchParams();
|
||||
const userId = searchParams.get("userId");
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<h6 className="font-bold md:text-xl text-lg text-center mt-20 line-clamp-2 mb-10 text-[#FF0000] ">
|
||||
پرداخت ناموفق
|
||||
{t("offerPage.paymentFailed.title")}
|
||||
</h6>
|
||||
<div className="flex flex-col items-center text-sm">
|
||||
<Image
|
||||
width={50}
|
||||
height={50}
|
||||
alt={"verify icon"}
|
||||
alt="failed icon"
|
||||
src={`/images/icons/failed.svg`}
|
||||
className="pb-1 mb-8"
|
||||
/>
|
||||
<span> پرداخت شما با خطا مواجه شد. </span>
|
||||
<span>{t("offerPage.paymentFailed.message")}</span>
|
||||
</div>
|
||||
<Link
|
||||
className="flex items-center flex-col mt-8"
|
||||
href={`/offer/${userId}`}
|
||||
>
|
||||
<RoundedButton variant="primary" className="w-32 h-9">
|
||||
پرداخت مجدد
|
||||
{t("offerPage.paymentFailed.retry")}
|
||||
</RoundedButton>
|
||||
</Link>
|
||||
</Container>
|
||||
|
||||
@@ -8,8 +8,10 @@ import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function SuccessBillboard() {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
const searchParams = useSearchParams();
|
||||
const userId = searchParams.get("userId");
|
||||
@@ -29,13 +31,13 @@ function SuccessBillboard() {
|
||||
return (
|
||||
<Container>
|
||||
<h6 className="font-bold md:text-xl text-lg text-center mt-10 line-clamp-2 mb-4 text-[#17A600] ">
|
||||
پرداخت موفق
|
||||
{t("offerPage.paymentSuccess.title")}
|
||||
</h6>
|
||||
<div className="flex flex-col items-center text-sm">
|
||||
<Image
|
||||
width={50}
|
||||
height={50}
|
||||
alt={"verify icon"}
|
||||
alt="success icon"
|
||||
src={`/images/icons/success.svg`}
|
||||
className="pb-1 mb-5"
|
||||
/>
|
||||
@@ -43,17 +45,19 @@ function SuccessBillboard() {
|
||||
<div className="px-4 w-full text-sm md:text-md font-semibold mt-10">
|
||||
<div className="flex flex-col items-center gap-4 mt-4">
|
||||
<p className="my-5">
|
||||
یک درخواست همکاری برای کاربر {userDetail?.user_name} ثبت شد
|
||||
{t("offerPage.paymentSuccess.message", {
|
||||
username: userDetail?.user_name ?? "",
|
||||
})}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Link href={`/settings/chats/${userDetail?.user_name}/${userId}`}>
|
||||
<RoundedButton variant="primary" className="w-32 h-9">
|
||||
ارسال پیام
|
||||
{t("offerPage.paymentSuccess.sendMessage")}
|
||||
</RoundedButton>
|
||||
</Link>
|
||||
<Link href={"/"}>
|
||||
<RoundedButton variant="primary" className="w-32 h-9">
|
||||
بعدا
|
||||
{t("offerPage.paymentSuccess.later")}
|
||||
</RoundedButton>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
buildPostSeoDescription,
|
||||
buildPostSeoTitle,
|
||||
} from "@/lib/postSlug";
|
||||
import { getSiteSeoMeta } from "@/lib/i18n/seo";
|
||||
import { getServerLanguage } from "@/lib/i18n/server";
|
||||
import { translateCommon } from "@/lib/i18n/translate";
|
||||
|
||||
type Props = {
|
||||
params: Promise<{ id: string; slug?: string[] }>;
|
||||
@@ -47,11 +50,14 @@ function toFeedSearchParams(
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { id } = await params;
|
||||
const lang = await getServerLanguage();
|
||||
const post = await fetchPostById(id);
|
||||
const title = post ? buildPostSeoTitle(post) : "پست | مدستاگرام";
|
||||
const title = post
|
||||
? buildPostSeoTitle(post, lang)
|
||||
: translateCommon(lang, "postsMeta.defaultTitle");
|
||||
const description = post
|
||||
? buildPostSeoDescription(post)
|
||||
: "مشاهده پست در مدستاگرام — پلتفرم تخصصی حوزه زیبایی، مدلینگ و عکاسی";
|
||||
? buildPostSeoDescription(post, lang)
|
||||
: translateCommon(lang, "postsMeta.defaultDescription");
|
||||
const ogImage = post?.files?.[0]?.path
|
||||
? buildStorageUrl(post.files[0].path)
|
||||
: undefined;
|
||||
@@ -60,23 +66,26 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
([post.first_name, post.last_name].filter(Boolean).join(" ") ||
|
||||
post.user_name);
|
||||
|
||||
const site = getSiteSeoMeta(lang);
|
||||
|
||||
return generatePageMetadata({
|
||||
title,
|
||||
description,
|
||||
path: buildPostPath(id, post ?? undefined),
|
||||
type: "article",
|
||||
imageUrl: ogImage,
|
||||
imageAlt: post?.caption?.slice(0, 80) || "پست مدستاگرام",
|
||||
imageAlt: post?.caption?.slice(0, 80) || translateCommon(lang, "postsMeta.defaultAlt"),
|
||||
keywords: [
|
||||
"مدستاگرام",
|
||||
site.name,
|
||||
post?.expertise || "",
|
||||
authorName || "",
|
||||
"پست",
|
||||
"مدلینگ",
|
||||
"زیبایی",
|
||||
translateCommon(lang, "postsMeta.keywordPost"),
|
||||
translateCommon(lang, "postsMeta.keywordModeling"),
|
||||
translateCommon(lang, "postsMeta.keywordBeauty"),
|
||||
].filter(Boolean),
|
||||
publishedTime: post?.createdAt,
|
||||
modifiedTime: post?.updatedAt,
|
||||
lang,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -4,15 +4,17 @@ export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: {
|
||||
userAgent: '*',
|
||||
// اجازه دسترسی به صفحات اصلی و آرشیوها
|
||||
allow: [
|
||||
'/',
|
||||
'/videos',
|
||||
'/billboards',
|
||||
'/users',
|
||||
'/posts'
|
||||
'/',
|
||||
'/videos',
|
||||
'/billboards',
|
||||
'/users/',
|
||||
'/posts',
|
||||
'/projects',
|
||||
'/academy',
|
||||
'/explore',
|
||||
'/about-us',
|
||||
],
|
||||
// جلوگیری از ایندکس صفحات سیستمی و شخصی
|
||||
disallow: [
|
||||
'/api/',
|
||||
'/_next/',
|
||||
@@ -22,14 +24,13 @@ export default function robots(): MetadataRoute.Robots {
|
||||
'/new-post',
|
||||
'/new-project',
|
||||
'/search',
|
||||
'/*/payment/', // صفحات موفقیت یا شکست پرداخت
|
||||
'/*/payment/',
|
||||
'/verify/',
|
||||
'/register',
|
||||
'/login',
|
||||
'/*?*', // جلوگیری از ایندکس شدن لینکهای دارای فیلتر و کوئری استرینگ (جلوگیری از محتوای تکراری)
|
||||
'/*?*',
|
||||
],
|
||||
},
|
||||
// معرفی نقشه سایت به گوگل
|
||||
sitemap: 'https://modstagram.com/sitemap.xml',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,13 @@ import SearchFilter from "@/components/search/SearchFilter";
|
||||
import { fetchSearch } from "@/api/fetchSearch";
|
||||
import InfiniteSearch from "@/components/search/InfiniteSearch";
|
||||
import { Metadata } from "next";
|
||||
import { pageSeo } from "@/config/pageSeo";
|
||||
import { generatePageMetadata } from "@/utils/generatePageMetadata";
|
||||
import {
|
||||
getListSeoTemplates,
|
||||
getSeoPage,
|
||||
interpolateTemplate,
|
||||
} from "@/lib/i18n/seo";
|
||||
import { generateSeoPageMetadata, getServerLanguage } from "@/lib/i18n/server";
|
||||
|
||||
interface ISearchProps {
|
||||
searchParams: Promise<{
|
||||
@@ -14,26 +19,25 @@ interface ISearchProps {
|
||||
}>;
|
||||
}
|
||||
|
||||
// تبدیل متادیتای ثابت به داینامیک
|
||||
export async function generateMetadata({ searchParams }: ISearchProps): Promise<Metadata> {
|
||||
const lang = await getServerLanguage();
|
||||
const { search } = await searchParams;
|
||||
|
||||
if (!search) {
|
||||
return generatePageMetadata({
|
||||
title: pageSeo.search.title,
|
||||
description: pageSeo.search.description,
|
||||
path: pageSeo.search.path,
|
||||
keywords: [...pageSeo.search.keywords],
|
||||
});
|
||||
return generateSeoPageMetadata("search", { lang });
|
||||
}
|
||||
|
||||
const title = `نتایج جستجو برای «${search}» | مدستاگرام`;
|
||||
const description = `نتایج جستجوی تخصصی برای «${search}» در حوزه مد، زیبایی و مدلینگ در مدستاگرام.`;
|
||||
const templates = getListSeoTemplates(lang).search as Record<string, string>;
|
||||
const searchPage = getSeoPage("search", lang);
|
||||
|
||||
return generatePageMetadata({
|
||||
title,
|
||||
description,
|
||||
path: `${pageSeo.search.path}?search=${encodeURIComponent(search)}`,
|
||||
title: interpolateTemplate(templates.resultsTitle, { query: search }),
|
||||
description: interpolateTemplate(templates.resultsDescription, {
|
||||
query: search,
|
||||
}),
|
||||
path: `${searchPage.path}?search=${encodeURIComponent(search)}`,
|
||||
keywords: searchPage.keywords,
|
||||
lang,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -44,23 +48,12 @@ export default async function Search({ searchParams }: ISearchProps) {
|
||||
const initialData =
|
||||
filters.search && filters.type
|
||||
? await fetchSearch(1, 10, filters, token)
|
||||
: undefined;
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Container>
|
||||
{/* اضافه کردن H1 مخفی برای بهبود سئو */}
|
||||
<h1 className="sr-only">
|
||||
{filters.search
|
||||
? `نتایج جستجوی مدستاگرام برای: ${filters.search}`
|
||||
: "جستجوی تخصصی در پلتفرم مد و زیبایی مدستاگرام"}
|
||||
</h1>
|
||||
|
||||
<Container className="pb-28">
|
||||
<SearchFilter />
|
||||
<InfiniteSearch
|
||||
initialData={initialData}
|
||||
filters={filters}
|
||||
token={token}
|
||||
/>
|
||||
<InfiniteSearch token={token} initialData={initialData} filters={filters} />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||