first commit
This commit is contained in:
12
.dockerignore
Normal file
12
.dockerignore
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.next
|
||||||
|
node_modules
|
||||||
|
output
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
README.md
|
||||||
3
.eslintrc.json
Normal file
3
.eslintrc.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": ["next/core-web-vitals"]
|
||||||
|
}
|
||||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
.next
|
||||||
|
node_modules
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
coverage
|
||||||
|
dist
|
||||||
|
uploads
|
||||||
|
*.log
|
||||||
32
Dockerfile
Normal file
32
Dockerfile
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
FROM node:20-slim AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
FROM node:20-slim AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
RUN npm prune --omit=dev
|
||||||
|
|
||||||
|
FROM node:20-slim AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME=0.0.0.0
|
||||||
|
|
||||||
|
COPY --from=builder /app/package.json ./package.json
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/.next ./.next
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder /app/prisma ./prisma
|
||||||
|
COPY --from=builder /app/next.config.mjs ./next.config.mjs
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["npm", "run", "start"]
|
||||||
631
home.html
Normal file
631
home.html
Normal file
@@ -0,0 +1,631 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fa" dir="rtl">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>گروه بازرگانی قاسمپور | Ghasempour Trading Group</title>
|
||||||
|
|
||||||
|
<!-- External CDN references removed. This file is kept only as a local visual reference. -->
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Modam';
|
||||||
|
src: url('modam.woff2') format('woff2'), url('modam.woff') format('woff');
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--brand-red: #A51C24;
|
||||||
|
--dark-bg: #0a0a0a;
|
||||||
|
--card-bg: #111111;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Modam', sans-serif;
|
||||||
|
background-color: var(--dark-bg);
|
||||||
|
color: #fff;
|
||||||
|
line-height: 1.6;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-italic { font-style: normal !important; }
|
||||||
|
|
||||||
|
/* Mega Menu Styles */
|
||||||
|
.nav-item-dropdown { position: relative; padding: 20px 0; }
|
||||||
|
.nav-scrolled {
|
||||||
|
background: rgba(5, 5, 5, 0.98) !important;
|
||||||
|
backdrop-filter: blur(30px) !important;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1) !important;
|
||||||
|
padding-top: 10px !important;
|
||||||
|
padding-bottom: 10px !important;
|
||||||
|
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
.mega-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
right: -50px;
|
||||||
|
width: 650px;
|
||||||
|
background: #0d0d0d !important; /* پسزمینه کاملاً تیره */
|
||||||
|
backdrop-filter: blur(40px) !important;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15) !important;
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 35px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 15px;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transform: translateY(20px);
|
||||||
|
transition: all 0.4s cubic-bezier(0.23, 1, 0.32, 1);
|
||||||
|
z-index: 1000;
|
||||||
|
box-shadow: 0 40px 100px rgba(0, 0, 0, 0.8);
|
||||||
|
}
|
||||||
|
.nav-item-dropdown:hover .mega-menu {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
.mega-menu-item {
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
transition: 0.3s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.mega-menu-item:hover {
|
||||||
|
background: rgba(165, 28, 36, 0.15);
|
||||||
|
color: var(--brand-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-content {
|
||||||
|
padding: 28px !important; /* افزایش فاصله از لبهها */
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-end;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-card {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
transition: all 0.4s cubic-bezier(0.25, 1, 0.5, 1);
|
||||||
|
flex: 0 0 calc(25% - 15px);
|
||||||
|
margin: 0 7.5px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column; /* اطمینان از چیدمان عمودی */
|
||||||
|
}
|
||||||
|
@media (max-width: 1024px) { .brand-card { flex: 0 0 calc(50% - 15px); } }
|
||||||
|
@media (max-width: 640px) { .brand-card { flex: 0 0 calc(100% - 15px); } }
|
||||||
|
|
||||||
|
.slider-container { position: relative; width: 100%; overflow: hidden; padding: 20px 0; }
|
||||||
|
.slider-track { display: flex; transition: transform 0.5s ease-out; }
|
||||||
|
.nav-btn {
|
||||||
|
position: absolute; top: 50%; transform: translateY(-50%);
|
||||||
|
width: 50px; height: 50px; background: white; color: black;
|
||||||
|
border-radius: 50%; display: flex; align-items: center; justify-content: center;
|
||||||
|
z-index: 50; cursor: pointer; transition: 0.3s;
|
||||||
|
}
|
||||||
|
.nav-btn:hover { background: var(--brand-red); color: white; }
|
||||||
|
.prev-btn { right: 10px; } .next-btn { left: 10px; }
|
||||||
|
|
||||||
|
/* Service Section Improvements */
|
||||||
|
.service-image-container {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 40px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 30px 60px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
.service-image-container::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(to right, rgba(10,10,10,0.8), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.manager-card {
|
||||||
|
background: rgba(255,255,255,0.02);
|
||||||
|
border: 1px solid rgba(255,255,255,0.05);
|
||||||
|
padding: 40px; text-align: center; border-radius: 30px; transition: 0.3s;
|
||||||
|
}
|
||||||
|
.manager-card:hover { border-color: var(--brand-red); }
|
||||||
|
|
||||||
|
.scroll-progress { position: fixed; top: 0; right: 0; height: 3px; background: var(--brand-red); z-index: 200; width: 0%; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="scroll-progress"></div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<nav id="main-nav" class="fixed w-full z-[100] px-6 py-2 flex justify-between items-center transition-all duration-300">
|
||||||
|
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<img src="logo.png" alt="Logo" class="h-16 md:h-20 object-contain">
|
||||||
|
<div class="hidden lg:flex gap-8 text-xs font-bold uppercase tracking-widest opacity-70">
|
||||||
|
<div class="nav-item-dropdown">
|
||||||
|
<a href="#brands" class="hover:text-red-600 transition flex items-center gap-1">
|
||||||
|
برندها
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M6 9l6 6 6-6"/></svg>
|
||||||
|
</a>
|
||||||
|
<!-- Mega Menu -->
|
||||||
|
<div class="mega-menu">
|
||||||
|
<a href="#" class="mega-menu-item"><span>•</span> XTRIM</a>
|
||||||
|
<a href="#" class="mega-menu-item"><span>•</span> FOWNIX</a>
|
||||||
|
<a href="#" class="mega-menu-item"><span>•</span> MVM</a>
|
||||||
|
<a href="#" class="mega-menu-item"><span>•</span> LAMARI</a>
|
||||||
|
<a href="#" class="mega-menu-item"><span>•</span> NISSAN</a>
|
||||||
|
<a href="#" class="mega-menu-item"><span>•</span> HYUNDAI</a>
|
||||||
|
<a href="#" class="mega-menu-item"><span>•</span> AUDI</a>
|
||||||
|
<a href="#" class="mega-menu-item"><span>•</span> KAVIR MOTOR</a>
|
||||||
|
<a href="#" class="mega-menu-item"><span>•</span> ARINA DRIVE</a>
|
||||||
|
<a href="#" class="mega-menu-item"><span>•</span> RUBBER</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="#services" class="mt-[20px] hover:text-red-600 transition">خدمات</a>
|
||||||
|
<a href="#managers" class="mt-[20px] hover:text-red-600 transition">مدیران</a>
|
||||||
|
<a href="#branches" class="mt-[20px] hover:text-red-600 transition">شعب</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-6">
|
||||||
|
<div class="text-left hidden md:block">
|
||||||
|
<div class="text-[10px] opacity-50 uppercase">CENTRAL OFFICE</div>
|
||||||
|
<div class="text-sm font-black">۰۷۱-۳۸۸۹</div>
|
||||||
|
</div>
|
||||||
|
<a href="#" class="bg-white text-black px-6 py-2 text-xs font-bold hover:bg-red-600 hover:text-white transition">باشگاه مشتریان</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Hero Section -->
|
||||||
|
<section class="h-screen relative flex items-center px-10 md:px-24 overflow-hidden border-b border-white/5">
|
||||||
|
<div class="absolute inset-0 z-0">
|
||||||
|
<img src="/uploads/car-placeholder.svg" class="w-full h-full object-cover opacity-30" alt="Hero">
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-l from-black via-transparent to-black"></div>
|
||||||
|
</div>
|
||||||
|
<div class="relative z-10 max-w-4xl">
|
||||||
|
<h2 class="text-red-600 font-bold tracking-[0.4em] mb-4 uppercase no-italic">Ghasempour Commerce Group</h2>
|
||||||
|
<h1 class="text-6xl md:text-8xl font-black mb-8 leading-tight no-italic">تجربه قدرت و اصالت <br>در رانندگی</h1>
|
||||||
|
<p class="text-lg md:text-xl text-gray-400 max-w-2xl leading-loose mb-10 no-italic">
|
||||||
|
نمایندگی رسمی برترین برندهای خودرویی با بیش از ۱۵ سال سابقه درخشان در تامین و خدمات خودرو در جنوب کشور.
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<button class="bg-red-700 text-white px-12 py-4 font-black hover:bg-white hover:text-black transition uppercase text-sm tracking-widest">موجودی انبار</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Brands Slider Section -->
|
||||||
|
<section id="brands" class="py-32 px-6 md:px-20 bg-[#080808]">
|
||||||
|
<div class="max-w-7xl mx-auto mb-20 text-right">
|
||||||
|
<h2 class="text-5xl font-black mb-4 no-italic">برندهای <span class="text-red-600">بازرگانی</span></h2>
|
||||||
|
<div class="w-20 h-1 bg-red-600"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="max-w-7xl mx-auto slider-container">
|
||||||
|
<div class="nav-btn prev-btn"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M9 18l6-6-6-6"/></svg></div>
|
||||||
|
<div class="nav-btn next-btn"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M15 18l-6-6 6-6"/></svg></div>
|
||||||
|
<div class="slider-track" id="brand-track">
|
||||||
|
<!-- 1. XTRIM -->
|
||||||
|
<div class="brand-card">
|
||||||
|
<div class="brand-image-box"><img src="/uploads/car-placeholder.svg" alt="XTRIM"></div>
|
||||||
|
<div class="brand-content"><div class="text-2xl font-black uppercase">XTRIM</div><div class="text-left"><div class="text-sm font-bold text-gray-400">ایکس تریم</div><div class="text-[10px] text-red-600 font-black">کد ۵۰۵</div></div></div>
|
||||||
|
</div>
|
||||||
|
<!-- 2. Fownix -->
|
||||||
|
<div class="brand-card">
|
||||||
|
<div class="brand-image-box"><img src="/uploads/car-placeholder.svg" alt="Fownix"></div>
|
||||||
|
<div class="brand-content"><div class="text-2xl font-black uppercase">Fownix</div><div class="text-left"><div class="text-sm font-bold text-gray-400">فونیکس</div><div class="text-[10px] text-red-600 font-black">کد ۵۰۵</div></div></div>
|
||||||
|
</div>
|
||||||
|
<!-- 3. MVM -->
|
||||||
|
<div class="brand-card">
|
||||||
|
<div class="brand-image-box"><img src="/uploads/car-placeholder.svg" alt="MVM"></div>
|
||||||
|
<div class="brand-content"><div class="text-2xl font-black uppercase">MVM</div><div class="text-left"><div class="text-sm font-bold text-gray-400">ام وی ام</div><div class="text-[10px] text-red-600 font-black">کد ۵۰۵</div></div></div>
|
||||||
|
</div>
|
||||||
|
<!-- 4. Arina Drive -->
|
||||||
|
<div class="brand-card">
|
||||||
|
<div class="brand-image-box"><img src="/uploads/car-placeholder.svg" alt="Arina Drive"></div>
|
||||||
|
<div class="brand-content"><div class="text-2xl font-black uppercase">Arina Drive</div><div class="text-left"><div class="text-sm font-bold text-gray-400">آرینا درایو</div><div class="text-[10px] text-red-600 font-black">کد ۵۲۵</div></div></div>
|
||||||
|
</div>
|
||||||
|
<!-- 5. LAMARI -->
|
||||||
|
<div class="brand-card">
|
||||||
|
<div class="brand-image-box"><img src="/uploads/car-placeholder.svg" alt="LAMARI"></div>
|
||||||
|
<div class="brand-content"><div class="text-2xl font-black uppercase">LAMARI</div><div class="text-left"><div class="text-sm font-bold text-gray-400">لاماری</div><div class="text-[10px] text-red-600 font-black">کد ۱۷۰۱</div></div></div>
|
||||||
|
</div>
|
||||||
|
<!-- 6. Rubber -->
|
||||||
|
<div class="brand-card">
|
||||||
|
<div class="brand-image-box"><img src="/uploads/car-placeholder.svg" alt="Rubber"></div>
|
||||||
|
<div class="brand-content"><div class="text-2xl font-black uppercase">Rubber</div><div class="text-left"><div class="text-sm font-bold text-gray-400">لاستیک قاسمپور</div><div class="text-[10px] text-red-600 font-black">کد ۵۲۵</div></div></div>
|
||||||
|
</div>
|
||||||
|
<!-- 7. Nissan -->
|
||||||
|
<div class="brand-card">
|
||||||
|
<div class="brand-image-box"><img src="/uploads/car-placeholder.svg" alt="Nissan"></div>
|
||||||
|
<div class="brand-content"><div class="text-2xl font-black uppercase">Nissan</div><div class="text-left"><div class="text-sm font-bold text-gray-400">نیسان</div><div class="text-[10px] text-red-600 font-black">کد ۱۲۹۱</div></div></div>
|
||||||
|
</div>
|
||||||
|
<!-- 8. Hyundai -->
|
||||||
|
<div class="brand-card">
|
||||||
|
<div class="brand-image-box"><img src="/uploads/car-placeholder.svg" alt="Hyundai"></div>
|
||||||
|
<div class="brand-content"><div class="text-2xl font-black uppercase">Hyundai</div><div class="text-left"><div class="text-sm font-bold text-gray-400">هیوندا</div><div class="text-[10px] text-red-600 font-black">کد ۷۱۰</div></div></div>
|
||||||
|
</div>
|
||||||
|
<!-- 9. Kavir Motor -->
|
||||||
|
<div class="brand-card">
|
||||||
|
<div class="brand-image-box"><img src="/uploads/car-placeholder.svg" alt="Kavir Motor"></div>
|
||||||
|
<div class="brand-content"><div class="text-2xl font-black uppercase">Kavir</div><div class="text-left"><div class="text-sm font-bold text-gray-400">کویر موتور</div><div class="text-[10px] text-red-600 font-black">کد ۵۲۵</div></div></div>
|
||||||
|
</div>
|
||||||
|
<!-- 10. Audi -->
|
||||||
|
<div class="brand-card">
|
||||||
|
<div class="brand-image-box"><img src="/uploads/car-placeholder.svg" alt="Audi"></div>
|
||||||
|
<div class="brand-content"><div class="text-2xl font-black uppercase">AUDI</div><div class="text-left"><div class="text-sm font-bold text-gray-400">آئودی</div><div class="text-[10px] text-red-600 font-black">کد ۵۲۵</div></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Services Section (Updated with High Readability) -->
|
||||||
|
<section id="services" class="py-32 px-6 md:px-20 relative bg-zinc-950">
|
||||||
|
<div class="max-w-7xl mx-auto grid md:grid-cols-2 gap-16 items-center">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-5xl font-black mb-8 no-italic">خدمات <span class="text-red-600">پس از فروش</span></h2>
|
||||||
|
<div class="bg-white/5 p-8 rounded-3xl border border-white/10 mb-8">
|
||||||
|
<p class="text-gray-300 leading-loose no-italic text-lg">
|
||||||
|
ما در بازرگانی قاسمپور معتقدیم تعهد ما با فروش آغاز میشود. مرکز تخصصی ما مجهز به مدرنترین دستگاههای عیبیابی، خدمات تضمینی و تامین قطعات اصلی تمامی برندهاست.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-4">
|
||||||
|
<button class="bg-red-700 text-white px-10 py-4 font-black hover:bg-white hover:text-black transition uppercase text-sm tracking-widest">رزرو نوبت سرویس</button>
|
||||||
|
<div class="flex items-center gap-3 px-6 py-4 bg-white/5 rounded-2xl border border-white/10">
|
||||||
|
<span class="text-red-600 font-black">۰۷۱-۳۸۸۹</span>
|
||||||
|
<span class="text-xs text-gray-500 uppercase">Call center</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="service-image-container">
|
||||||
|
<img src="/uploads/car-placeholder.svg" alt="Automotive Workshop" class="w-full h-[500px] object-cover">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Auto Arshia Section (جایگزین بخش مدیران) -->
|
||||||
|
<section id="arshia" class="py-32 px-6 md:px-20 bg-[#050505] relative overflow-hidden">
|
||||||
|
<!-- Background Decoration -->
|
||||||
|
<div class="absolute top-0 right-0 w-[500px] h-[500px] bg-red-600/10 blur-[120px] rounded-full -z-10"></div>
|
||||||
|
<div class="absolute bottom-0 left-0 w-[300px] h-[300px] bg-white/5 blur-[100px] rounded-full -z-10"></div>
|
||||||
|
|
||||||
|
<div class="max-w-7xl mx-auto">
|
||||||
|
<div class="flex flex-col md:flex-row justify-between items-end mb-20 gap-6">
|
||||||
|
<div class="reveal">
|
||||||
|
<h2 class="text-6xl md:text-8xl font-black mb-4 tracking-tighter no-italic uppercase">AUTO <span class="text-red-600">ARSHIA</span></h2>
|
||||||
|
<p class="text-xl text-gray-400 font-bold no-italic italic">هوشمندترین سامانه آنلاین خرید و فروش خودرو در ایران</p>
|
||||||
|
</div>
|
||||||
|
<div class="hidden md:block">
|
||||||
|
<div class="text-left">
|
||||||
|
<span class="text-xs text-gray-600 tracking-[5px] uppercase font-black">Powered by Ghasempour</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-10">
|
||||||
|
<!-- Seller Card -->
|
||||||
|
<div class="glass-card group p-12 rounded-[40px] border border-white/5 relative overflow-hidden transition-all duration-500 hover:border-red-600/50">
|
||||||
|
<div class="relative z-10">
|
||||||
|
<div class="w-16 h-16 bg-red-600 rounded-2xl flex items-center justify-center mb-8 shadow-[0_0_30px_rgba(165,28,36,0.5)]">
|
||||||
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-4xl font-black mb-4 no-italic">قصد فروش دارید؟</h3>
|
||||||
|
<p class="text-gray-400 text-lg mb-10 leading-loose no-italic">خودروی خود را در کمترین زمان ممکن و با قیمت واقعی بازار به شبکه خریداران اتو عرشیا معرفی کنید. فروش فوری، امن و بدون واسطه.</p>
|
||||||
|
<button class="bg-white text-black px-10 py-4 font-black rounded-2xl hover:bg-red-600 hover:text-white transition-all transform group-hover:scale-105">ثبت آگهی فروش</button>
|
||||||
|
</div>
|
||||||
|
<!-- Background Pattern -->
|
||||||
|
<div class="absolute -bottom-10 -right-10 opacity-5 group-hover:opacity-10 transition-opacity">
|
||||||
|
<svg width="300" height="300" viewBox="0 0 24 24" fill="currentColor"><path d="M18.92 6.01C18.72 5.42 18.16 5 17.5 5h-11c-.66 0-1.21.42-1.42 1.01L3 12v8c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-1h12v1c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-8l-2.08-5.99zM6.5 16c-.83 0-1.5-.67-1.5-1.5S5.67 13 6.5 13s1.5.67 1.5 1.5S7.33 16 6.5 16zm11 0c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zM5 11l1.5-4.5h11L19 11H5z"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Buyer Card -->
|
||||||
|
<div class="glass-card group p-12 rounded-[40px] border border-white/5 relative overflow-hidden bg-gradient-to-br from-white/[0.03] to-transparent transition-all duration-500 hover:border-white/20">
|
||||||
|
<div class="relative z-10">
|
||||||
|
<div class="w-16 h-16 bg-white rounded-2xl flex items-center justify-center mb-8 shadow-[0_0_30px_rgba(255,255,255,0.2)]">
|
||||||
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-4xl font-black mb-4 no-italic">دنبال خرید هستید؟</h3>
|
||||||
|
<p class="text-gray-400 text-lg mb-10 leading-loose no-italic">بودجه خود را مشخص کنید تا هوش مصنوعی اتو عرشیا، بهترین خودروهای کارشناسی شده را به شما پیشنهاد دهد. خریدی هوشمندانه و مطمئن.</p>
|
||||||
|
<button class="border-2 border-white text-white px-10 py-4 font-black rounded-2xl hover:bg-white hover:text-black transition-all transform group-hover:scale-105">جستجو بر اساس بودجه</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- =========================================================
|
||||||
|
EXCLUSIVE BRANCHES SECTION (USING iran.svg)
|
||||||
|
========================================================= -->
|
||||||
|
<style>
|
||||||
|
/* ایزوله سازی کامل برای جلوگیری از تداخل با بخش برندها */
|
||||||
|
#ghasempour-branches {
|
||||||
|
background-color: #0a0a0a;
|
||||||
|
padding: 100px 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branch-wrapper {
|
||||||
|
max-width: 1300px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 40px; /* ایجاد فاصله از کنارههای صفحه */
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 60px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.branch-wrapper { grid-template-columns: 1fr 1.2fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* استایل اختصاصی پنل اطلاعات - کاملاً مجزا از برندها */
|
||||||
|
.branch-info-card {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 40px;
|
||||||
|
padding: 50px; /* پدینگ داخلی زیاد برای جلوگیری از چسبیدن متن */
|
||||||
|
min-height: 450px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 30px 60px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.branch-tag-text {
|
||||||
|
color: #A51C24;
|
||||||
|
font-weight: 900;
|
||||||
|
font-size: 14px;
|
||||||
|
letter-spacing: 3px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branch-main-title {
|
||||||
|
font-size: clamp(28px, 4vw, 42px);
|
||||||
|
font-weight: 900;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
color: #fff;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branch-contact-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
color: #999;
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branch-contact-item svg {
|
||||||
|
width: 26px;
|
||||||
|
color: #A51C24;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* بخش نقشه */
|
||||||
|
.map-interactive-area {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-image-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iran-svg-img {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
filter: brightness(0.4) sepia(1) hue-rotate(-50deg) saturate(2); /* تغییر رنگ SVG به تم تیره و قرمز ملایم */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* نقاط تپنده روی نقشه */
|
||||||
|
.map-marker {
|
||||||
|
position: absolute;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
background-color: #ff0000;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 50;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
border: 2px solid #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-marker::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: 0; width: 100%; height: 100%;
|
||||||
|
background-color: inherit;
|
||||||
|
border-radius: inherit;
|
||||||
|
animation: map-pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes map-pulse {
|
||||||
|
0% { transform: scale(1); opacity: 1; }
|
||||||
|
100% { transform: scale(3.5); opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-marker:hover, .map-marker.active {
|
||||||
|
background-color: #fff;
|
||||||
|
box-shadow: 0 0 25px #ff0000;
|
||||||
|
scale: 1.3;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<section id="ghasempour-branches">
|
||||||
|
<div class="branch-wrapper">
|
||||||
|
|
||||||
|
<!-- سمت راست: اطلاعات شعبه (پدینگ دار و منظم) -->
|
||||||
|
<div class="branch-info-card" id="branch-info-card">
|
||||||
|
<div id="branch-anim-content">
|
||||||
|
<span class="branch-tag-text" id="b-tag">شعبه مرکزی</span>
|
||||||
|
<h3 class="branch-main-title" id="b-name">بازرگانی قاسمپور (شیراز)</h3>
|
||||||
|
|
||||||
|
<div class="branch-contact-item">
|
||||||
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||||
|
<p id="b-address" class="no-italic">شیراز، بلوار امیرکبیر، نرسیده به پلیس راه، مجتمع خودرویی قاسمپور</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="branch-contact-item">
|
||||||
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 7V5z"/></svg>
|
||||||
|
<p id="b-phone" dir="ltr" class="no-italic">۰۷۱-۳۸۸۹</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="mt-6 bg-red-700 text-white px-8 py-4 font-black rounded-xl hover:bg-white hover:text-black transition-all">مسیریابی هوشمند (Google Maps)</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- سمت چپ: نقشه فایل شما -->
|
||||||
|
<div class="map-interactive-area">
|
||||||
|
<div class="map-image-container">
|
||||||
|
<!-- لود کردن فایل SVG شما -->
|
||||||
|
<img src="iran.svg" class="iran-svg-img" alt="نقشه ایران بازرگانی قاسم پور">
|
||||||
|
|
||||||
|
<!-- نقاط دیتای شهرها (درصدها بر اساس نقشه استاندارد تنظیم شده، در صورت نیاز تغییر دهید) -->
|
||||||
|
<div class="map-marker" style="top: 30%; left: 48%;" onclick="changeBranch('tehran', this)" title="تهران"></div>
|
||||||
|
<div class="map-marker active" style="top: 66%; left: 51%;" onclick="changeBranch('shiraz', this)" title="شیراز"></div>
|
||||||
|
<div class="map-marker" style="top: 82%; left: 68%;" onclick="changeBranch('bandar', this)" title="بندرعباس"></div>
|
||||||
|
<div class="map-marker" style="top: 75%; left: 38%;" onclick="changeBranch('bushehr', this)" title="بوشهر"></div>
|
||||||
|
<div class="map-marker" style="top: 61%; left: 32%;" onclick="changeBranch('ahvaz', this)" title="اهواز"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const branchList = {
|
||||||
|
tehran: {
|
||||||
|
tag: "شعبه پایتخت",
|
||||||
|
name: "مجتمع خودرویی آرشیا (تهران)",
|
||||||
|
address: "تهران، جاده مخصوص کرج، کیلومتر ۱۲، نبش خیابان اصلی",
|
||||||
|
phone: "۰۲۱-۴۴۵۵۶۶۷۷"
|
||||||
|
},
|
||||||
|
shiraz: {
|
||||||
|
tag: "شعبه مرکزی",
|
||||||
|
name: "بازرگانی قاسمپور (شیراز)",
|
||||||
|
address: "شیراز، بلوار امیرکبیر، نرسیده به پلیس راه، مجتمع خودرویی قاسمپور",
|
||||||
|
phone: "۰۷۱-۳۸۸۹"
|
||||||
|
},
|
||||||
|
bandar: {
|
||||||
|
tag: "شعبه هرمزگان",
|
||||||
|
name: "پرشیا خودرو (بندرعباس)",
|
||||||
|
address: "بندرعباس، بلوار امام خمینی، جنب هتل هما، شعبه بازرگانی قاسمپور",
|
||||||
|
phone: "۰۷۶-۳۳۴۴۵۵۶۶"
|
||||||
|
},
|
||||||
|
bushehr: {
|
||||||
|
tag: "شعبه بوشهر",
|
||||||
|
name: "نمایشگاه ساحلی (بوشهر)",
|
||||||
|
address: "بوشهر، بلوار ساحلی، میدان خلیج فارس، مجتمع تجاری خودرو",
|
||||||
|
phone: "۰۷۷-۳۳۲۲۱۱۰۰"
|
||||||
|
},
|
||||||
|
ahvaz: {
|
||||||
|
tag: "شعبه خوزستان",
|
||||||
|
name: "مرکز فروش کارون (اهواز)",
|
||||||
|
address: "اهواز، اتوبان آیتالله بهبهانی، روبروی ترمینال آبادان",
|
||||||
|
phone: "۰۶۱-۳۳۷۷۸۸۹۹"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function changeBranch(city, element) {
|
||||||
|
// آپدیت وضعیت ظاهری نقاط
|
||||||
|
document.querySelectorAll('.map-marker').forEach(m => m.classList.remove('active'));
|
||||||
|
element.classList.add('active');
|
||||||
|
|
||||||
|
const info = branchList[city];
|
||||||
|
const contentDiv = document.getElementById('branch-anim-content');
|
||||||
|
|
||||||
|
// انیمیشن تغییر محتوا
|
||||||
|
gsap.to(contentDiv, {
|
||||||
|
opacity: 0,
|
||||||
|
y: 10,
|
||||||
|
duration: 0.3,
|
||||||
|
onComplete: () => {
|
||||||
|
document.getElementById('b-tag').innerText = info.tag;
|
||||||
|
document.getElementById('b-name').innerText = info.name;
|
||||||
|
document.getElementById('b-address').innerText = info.address;
|
||||||
|
document.getElementById('b-phone').innerText = info.phone;
|
||||||
|
|
||||||
|
gsap.to(contentDiv, {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
duration: 0.4,
|
||||||
|
ease: "power2.out"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer class="py-20 px-10 border-t border-white/5 text-center">
|
||||||
|
<img src="logo.png" alt="Logo" class="h-20 mx-auto mb-10">
|
||||||
|
<p class="text-gray-500 text-sm mb-10 max-w-xl mx-auto">گروه بازرگانی قاسمپور، نماد اعتماد و پیشرو در ارائه خدمات نوین خودرویی. تمامی حقوق برای این مجموعه محفوظ است.</p>
|
||||||
|
<div class="flex justify-center gap-10 text-[10px] font-bold tracking-[5px] text-gray-700"><span>INSTAGRAM</span><span>TELEGRAM</span><span>LINKEDIN</span></div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
gsap.registerPlugin(ScrollTrigger);
|
||||||
|
|
||||||
|
gsap.to(".scroll-progress", { width: "100%", scrollTrigger: { scrub: 0.3 } });
|
||||||
|
|
||||||
|
gsap.utils.toArray('section').forEach(section => {
|
||||||
|
gsap.from(section, { opacity: 0, y: 50, duration: 1, scrollTrigger: { trigger: section, start: "top 80%", } });
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Brand Slider Logic ---
|
||||||
|
const track = document.getElementById('brand-track');
|
||||||
|
const cards = Array.from(track.children);
|
||||||
|
const nextBtn = document.querySelector('.next-btn');
|
||||||
|
const prevBtn = document.querySelector('.prev-btn');
|
||||||
|
let currentIndex = 0;
|
||||||
|
const totalOriginalCards = cards.length;
|
||||||
|
|
||||||
|
cards.forEach(card => { track.appendChild(card.cloneNode(true)); });
|
||||||
|
|
||||||
|
function updateSlider(animate = true) {
|
||||||
|
const step = track.children[0].offsetWidth + 15;
|
||||||
|
if (animate) { gsap.to(track, { x: currentIndex * step, duration: 0.8, ease: "power2.inOut" }); }
|
||||||
|
else { gsap.set(track, { x: currentIndex * step }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextSlide() {
|
||||||
|
currentIndex++; updateSlider();
|
||||||
|
if (currentIndex >= totalOriginalCards) {
|
||||||
|
setTimeout(() => { currentIndex = 0; updateSlider(false); }, 800);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function prevSlide() {
|
||||||
|
if (currentIndex <= 0) { currentIndex = totalOriginalCards; updateSlider(false); }
|
||||||
|
setTimeout(() => { currentIndex--; updateSlider(); }, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
let autoPlay = setInterval(nextSlide, 3000);
|
||||||
|
nextBtn.addEventListener('click', () => { clearInterval(autoPlay); nextSlide(); autoPlay = setInterval(nextSlide, 3000); });
|
||||||
|
prevBtn.addEventListener('click', () => { clearInterval(autoPlay); prevSlide(); autoPlay = setInterval(nextSlide, 3000); });
|
||||||
|
window.addEventListener('resize', () => updateSlider(false));
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
window.addEventListener('scroll', () => {
|
||||||
|
const nav = document.getElementById('main-nav');
|
||||||
|
if (window.scrollY > 50) {
|
||||||
|
nav.classList.add('nav-scrolled');
|
||||||
|
} else {
|
||||||
|
nav.classList.remove('nav-scrolled');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
5
next-env.d.ts
vendored
Normal file
5
next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||||
4
next.config.mjs
Normal file
4
next.config.mjs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
6921
package-lock.json
generated
Normal file
6921
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
package.json
Normal file
39
package.json
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "ghasempour-website",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "prisma generate && next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint",
|
||||||
|
"prisma:generate": "prisma generate",
|
||||||
|
"prisma:migrate": "prisma migrate dev",
|
||||||
|
"prisma:seed": "tsx prisma/seed.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/client": "^5.22.0",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"gsap": "^3.12.5",
|
||||||
|
"lucide-react": "^0.453.0",
|
||||||
|
"next": "^14.2.30",
|
||||||
|
"next-auth": "^5.0.0-beta.25",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.9.0",
|
||||||
|
"@types/react": "^18.3.12",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"eslint": "^8.57.1",
|
||||||
|
"eslint-config-next": "^14.2.30",
|
||||||
|
"postcss": "^8.4.49",
|
||||||
|
"prisma": "^5.22.0",
|
||||||
|
"tailwindcss": "^3.4.14",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"typescript": "^5.6.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {}
|
||||||
|
}
|
||||||
|
};
|
||||||
31
prisma/schema.prisma
Normal file
31
prisma/schema.prisma
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Role {
|
||||||
|
SUPERADMIN
|
||||||
|
ADMIN
|
||||||
|
EDITOR
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
email String @unique
|
||||||
|
passwordHash String
|
||||||
|
role Role @default(EDITOR)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model SiteContent {
|
||||||
|
id String @id @default("main")
|
||||||
|
content Json
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
37
prisma/seed.ts
Normal file
37
prisma/seed.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { PrismaClient, Role } from "@prisma/client";
|
||||||
|
import { hashSync } from "bcryptjs";
|
||||||
|
import { defaultSiteContent } from "../src/lib/default-content";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await prisma.siteContent.upsert({
|
||||||
|
where: { id: "main" },
|
||||||
|
update: { content: defaultSiteContent as unknown as object },
|
||||||
|
create: {
|
||||||
|
id: "main",
|
||||||
|
content: defaultSiteContent as unknown as object
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.user.upsert({
|
||||||
|
where: { email: "superadmin@example.com" },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
name: "Super Admin",
|
||||||
|
email: "superadmin@example.com",
|
||||||
|
passwordHash: hashSync("ChangeMe123!", 10),
|
||||||
|
role: Role.SUPERADMIN
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.then(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
})
|
||||||
|
.catch(async (error) => {
|
||||||
|
console.error(error);
|
||||||
|
await prisma.$disconnect();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
BIN
public/assets/fonts/modam.woff
Normal file
BIN
public/assets/fonts/modam.woff
Normal file
Binary file not shown.
BIN
public/assets/fonts/modam.woff2
Normal file
BIN
public/assets/fonts/modam.woff2
Normal file
Binary file not shown.
3
src/app/api/auth/[...nextauth]/route.ts
Normal file
3
src/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { handlers } from "@/lib/auth";
|
||||||
|
|
||||||
|
export const { GET, POST } = handlers;
|
||||||
88
src/app/api/sales-requests/route.ts
Normal file
88
src/app/api/sales-requests/route.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { mkdir, readFile, writeFile } from "fs/promises";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
type SalesRequestRecord = {
|
||||||
|
id: string;
|
||||||
|
fullName: string;
|
||||||
|
mobile: string;
|
||||||
|
brand: string;
|
||||||
|
description: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const storagePath = path.join(process.cwd(), "data", "sales-requests.json");
|
||||||
|
|
||||||
|
function normalizeMobile(input: string) {
|
||||||
|
const digits = input.replace(/\D/g, "");
|
||||||
|
|
||||||
|
if (!digits) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (digits.startsWith("98")) {
|
||||||
|
return `0${digits.slice(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (digits.startsWith("9")) {
|
||||||
|
return `0${digits}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (digits.startsWith("0")) {
|
||||||
|
return digits;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `0${digits}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readRequests() {
|
||||||
|
try {
|
||||||
|
const payload = await readFile(storagePath, "utf8");
|
||||||
|
const parsed = JSON.parse(payload);
|
||||||
|
return Array.isArray(parsed) ? (parsed as SalesRequestRecord[]) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = (await request.json()) as Partial<SalesRequestRecord>;
|
||||||
|
const fullName = (body.fullName ?? "").trim();
|
||||||
|
const mobile = normalizeMobile(body.mobile ?? "");
|
||||||
|
const brand = (body.brand ?? "").trim();
|
||||||
|
const description = (body.description ?? "").trim();
|
||||||
|
|
||||||
|
if (fullName.length < 3) {
|
||||||
|
return NextResponse.json({ ok: false, message: "نام و نام خانوادگی معتبر نیست." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^09\d{9}$/.test(mobile)) {
|
||||||
|
return NextResponse.json({ ok: false, message: "شماره موبایل معتبر نیست." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!brand) {
|
||||||
|
return NextResponse.json({ ok: false, message: "انتخاب برند الزامی است." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const records = await readRequests();
|
||||||
|
const nextRecord: SalesRequestRecord = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
fullName,
|
||||||
|
mobile,
|
||||||
|
brand,
|
||||||
|
description,
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
await mkdir(path.dirname(storagePath), { recursive: true });
|
||||||
|
await writeFile(storagePath, JSON.stringify([nextRecord, ...records], null, 2), "utf8");
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
message: "درخواست شما ثبت شد کارشناسان ما تا ساعات اینده با شما تماس میگیرند."
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ ok: false, message: "ثبت درخواست انجام نشد." }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
658
src/app/globals.css
Normal file
658
src/app/globals.css
Normal file
@@ -0,0 +1,658 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Modam";
|
||||||
|
src:
|
||||||
|
url("/assets/fonts/modam.woff2") format("woff2"),
|
||||||
|
url("/assets/fonts/modam.woff") format("woff");
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--brand-red: #a51c24;
|
||||||
|
--dark-bg: #0a0a0a;
|
||||||
|
--card-bg: #111111;
|
||||||
|
--muted-text: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: "Modam", sans-serif;
|
||||||
|
background: var(--dark-bg);
|
||||||
|
color: #fff;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-font {
|
||||||
|
font-family: "Modam", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top right, rgba(165, 28, 36, 0.22), transparent 24%),
|
||||||
|
linear-gradient(180deg, #080808 0%, #101010 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-panel {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-nav-glass {
|
||||||
|
background: rgba(8, 8, 8, 0.32);
|
||||||
|
backdrop-filter: blur(22px);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-progress {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 3px;
|
||||||
|
width: 0;
|
||||||
|
background: var(--brand-red);
|
||||||
|
z-index: 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-scrolled {
|
||||||
|
background: rgba(5, 5, 5, 0.92) !important;
|
||||||
|
backdrop-filter: blur(30px) !important;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.14) !important;
|
||||||
|
padding-top: 10px !important;
|
||||||
|
padding-bottom: 10px !important;
|
||||||
|
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-card {
|
||||||
|
position: relative;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.01)),
|
||||||
|
radial-gradient(circle at top right, rgba(165, 28, 36, 0.22), transparent 42%),
|
||||||
|
#0d0d0d;
|
||||||
|
border-radius: 28px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
transition:
|
||||||
|
transform 0.45s cubic-bezier(0.25, 1, 0.5, 1),
|
||||||
|
border-color 0.35s ease,
|
||||||
|
box-shadow 0.35s ease;
|
||||||
|
flex: 0 0 calc(25% - 15px);
|
||||||
|
margin: 0 7.5px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100%;
|
||||||
|
box-shadow: 0 24px 50px rgba(0, 0, 0, 0.28);
|
||||||
|
isolation: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-card::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 1px;
|
||||||
|
border-radius: 27px;
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent 30%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-card::after {
|
||||||
|
content: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-card:hover {
|
||||||
|
transform: translateY(-8px) scale(1.015);
|
||||||
|
border-color: rgba(165, 28, 36, 0.55);
|
||||||
|
box-shadow:
|
||||||
|
0 30px 64px rgba(0, 0, 0, 0.4),
|
||||||
|
0 0 0 1px rgba(165, 28, 36, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.brand-card {
|
||||||
|
flex: 0 0 calc(50% - 15px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.brand-card {
|
||||||
|
flex: 0 0 calc(100% - 15px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 28px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-track {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-slider-shell {
|
||||||
|
position: relative;
|
||||||
|
padding: 0 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-card-link {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
min-height: 100%;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-image-wrap {
|
||||||
|
position: relative;
|
||||||
|
height: 15rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
transition:
|
||||||
|
transform 0.7s cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
|
filter 0.45s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-image-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(8, 8, 8, 0.02) 0%, rgba(8, 8, 8, 0.72) 100%),
|
||||||
|
linear-gradient(135deg, rgba(165, 28, 36, 0.28), transparent 48%);
|
||||||
|
transition: opacity 0.35s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-image-shine {
|
||||||
|
position: absolute;
|
||||||
|
inset: -120% -30%;
|
||||||
|
background: linear-gradient(120deg, transparent 35%, rgba(255, 255, 255, 0.22) 50%, transparent 65%);
|
||||||
|
transform: translateX(-55%) rotate(12deg);
|
||||||
|
transition: transform 0.8s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-code-chip {
|
||||||
|
position: absolute;
|
||||||
|
top: 18px;
|
||||||
|
left: 18px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
background: rgba(10, 10, 10, 0.6);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 9999px;
|
||||||
|
padding: 9px 14px 7px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-card-body {
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1.15rem 1.35rem 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-card-copy {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-name-en {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 1.45rem;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-name-fa {
|
||||||
|
margin-top: 0.45rem;
|
||||||
|
color: #b1b1b1;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-card-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.4rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-card-line {
|
||||||
|
width: 2.8rem;
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(90deg, rgba(165, 28, 36, 0.15), rgba(165, 28, 36, 0.9));
|
||||||
|
margin-top: 0.55rem;
|
||||||
|
transform-origin: left center;
|
||||||
|
transition:
|
||||||
|
width 0.35s ease,
|
||||||
|
opacity 0.35s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-card-code {
|
||||||
|
color: var(--brand-red);
|
||||||
|
font-size: 0.66rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-card:hover .brand-image {
|
||||||
|
transform: scale(1.06);
|
||||||
|
filter: saturate(1.18) contrast(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-card:hover .brand-image-overlay {
|
||||||
|
opacity: 0.88;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-card:hover .brand-image-shine {
|
||||||
|
transform: translateX(55%) rotate(12deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-card:hover .brand-card-line {
|
||||||
|
width: 4.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-nav-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(165, 28, 36, 0.95), rgba(89, 10, 15, 0.95)),
|
||||||
|
#a51c24;
|
||||||
|
color: white;
|
||||||
|
clip-path: polygon(16% 0, 100% 0, 84% 100%, 0 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 50;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
box-shadow: 0 22px 42px rgba(165, 28, 36, 0.28);
|
||||||
|
transition:
|
||||||
|
transform 0.3s ease,
|
||||||
|
box-shadow 0.3s ease,
|
||||||
|
filter 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-nav-btn:hover {
|
||||||
|
transform: translateY(-50%) scale(1.06);
|
||||||
|
box-shadow:
|
||||||
|
0 28px 55px rgba(165, 28, 36, 0.42),
|
||||||
|
0 0 0 1px rgba(255, 255, 255, 0.08);
|
||||||
|
filter: brightness(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-nav-btn-right {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-nav-btn-left {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.brand-slider-shell {
|
||||||
|
padding: 0 60px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.brand-slider-shell {
|
||||||
|
padding: 0 52px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-image-wrap {
|
||||||
|
height: 13rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-card-body {
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-nav-btn {
|
||||||
|
width: 46px;
|
||||||
|
height: 46px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-image-container {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 40px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 30px 60px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-image-container::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(to right, rgba(10, 10, 10, 0.8), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.branches-section {
|
||||||
|
background-color: #0a0a0a;
|
||||||
|
padding: 100px 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branch-wrapper {
|
||||||
|
max-width: 1300px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 40px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 60px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.branch-wrapper {
|
||||||
|
grid-template-columns: 1fr 1.2fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.branch-info-card {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 40px;
|
||||||
|
padding: 42px;
|
||||||
|
min-height: 450px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 30px 60px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.branch-info-topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branch-tag-text {
|
||||||
|
color: #a51c24;
|
||||||
|
font-weight: 900;
|
||||||
|
font-size: 14px;
|
||||||
|
letter-spacing: 3px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branch-city-badge {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: #e8e8e8;
|
||||||
|
border-radius: 9999px;
|
||||||
|
padding: 0.55rem 0.95rem 0.4rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branch-main-title {
|
||||||
|
font-size: clamp(28px, 4vw, 42px);
|
||||||
|
font-weight: 900;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
color: #fff;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branch-selector-block {
|
||||||
|
margin-bottom: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branch-selector-label {
|
||||||
|
margin-bottom: 0.9rem;
|
||||||
|
color: #8d8d8d;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.22em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branch-selector-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branch-city-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.7rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
color: #cfcfcf;
|
||||||
|
padding: 0.8rem 1rem 0.7rem;
|
||||||
|
transition:
|
||||||
|
background 0.25s ease,
|
||||||
|
color 0.25s ease,
|
||||||
|
border-color 0.25s ease,
|
||||||
|
transform 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branch-city-chip strong {
|
||||||
|
display: inline-flex;
|
||||||
|
min-width: 28px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
padding: 0.25rem 0.45rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branch-city-chip:hover,
|
||||||
|
.branch-city-chip-active {
|
||||||
|
background: rgba(165, 28, 36, 0.14);
|
||||||
|
border-color: rgba(165, 28, 36, 0.35);
|
||||||
|
color: #fff;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.branch-sublist {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branch-subitem {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
text-align: right;
|
||||||
|
border-radius: 24px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
padding: 1rem 1.1rem;
|
||||||
|
transition:
|
||||||
|
border-color 0.25s ease,
|
||||||
|
background 0.25s ease,
|
||||||
|
transform 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branch-subitem:hover,
|
||||||
|
.branch-subitem-active {
|
||||||
|
border-color: rgba(165, 28, 36, 0.34);
|
||||||
|
background: linear-gradient(180deg, rgba(165, 28, 36, 0.12), rgba(255, 255, 255, 0.03));
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.branch-subitem-tag {
|
||||||
|
color: #d8545c;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branch-subitem-name {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.98rem;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branch-contact-card {
|
||||||
|
border-radius: 28px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
background: rgba(255, 255, 255, 0.025);
|
||||||
|
padding: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branch-contact-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
color: #999;
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branch-contact-item + .branch-contact-item {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.branch-contact-item svg {
|
||||||
|
width: 26px;
|
||||||
|
color: #a51c24;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branch-map-link {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
display: inline-flex;
|
||||||
|
background: #b91c1c;
|
||||||
|
color: #fff;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
font-weight: 900;
|
||||||
|
border-radius: 14px;
|
||||||
|
transition:
|
||||||
|
background 0.25s ease,
|
||||||
|
color 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branch-map-link:hover {
|
||||||
|
background: #fff;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branch-empty-state {
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
border-radius: 24px;
|
||||||
|
border: 1px dashed rgba(255, 255, 255, 0.14);
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
padding: 1.2rem 1.25rem;
|
||||||
|
color: #a5a5a5;
|
||||||
|
line-height: 1.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-interactive-area {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-image-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iran-svg-img {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
filter: brightness(0.4) sepia(1) hue-rotate(-50deg) saturate(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-marker {
|
||||||
|
position: absolute;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
background-color: #ff0000;
|
||||||
|
border-radius: 9999px;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 50;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
border: 2px solid #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-marker::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-color: inherit;
|
||||||
|
border-radius: inherit;
|
||||||
|
animation: map-pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-marker:hover,
|
||||||
|
.map-marker.active {
|
||||||
|
background-color: #fff;
|
||||||
|
box-shadow: 0 0 25px #ff0000;
|
||||||
|
scale: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes map-pulse {
|
||||||
|
0% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: scale(3.5);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.branch-wrapper {
|
||||||
|
padding: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branch-info-card {
|
||||||
|
padding: 30px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branch-info-topbar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branch-contact-item {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
26
src/app/layout.tsx
Normal file
26
src/app/layout.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
import "@/app/globals.css";
|
||||||
|
import { getSiteContent } from "@/lib/site-content";
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const content = await getSiteContent();
|
||||||
|
return {
|
||||||
|
title: content.settings.siteTitle,
|
||||||
|
description: content.hero.description
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function RootLayout({
|
||||||
|
children
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
await getSiteContent();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html lang="fa" dir="rtl">
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
src/app/mugmanager/(protected)/layout.tsx
Normal file
9
src/app/mugmanager/(protected)/layout.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { AdminShell } from "@/components/admin/admin-shell";
|
||||||
|
|
||||||
|
export default function MugmanagerProtectedLayout({
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return <AdminShell>{children}</AdminShell>;
|
||||||
|
}
|
||||||
47
src/app/mugmanager/(protected)/page.tsx
Normal file
47
src/app/mugmanager/(protected)/page.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { Dashboard } from "@/components/admin/dashboard";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { getSiteContent, getUsers } from "@/lib/site-content";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const validSections = [
|
||||||
|
"overview",
|
||||||
|
"site-settings",
|
||||||
|
"homepage",
|
||||||
|
"brands",
|
||||||
|
"branches",
|
||||||
|
"media",
|
||||||
|
"users"
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type ValidSection = (typeof validSections)[number];
|
||||||
|
|
||||||
|
type SearchParams = {
|
||||||
|
section?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function MugmanagerPage({
|
||||||
|
searchParams
|
||||||
|
}: {
|
||||||
|
searchParams?: SearchParams;
|
||||||
|
}) {
|
||||||
|
const [session, content, users] = await Promise.all([
|
||||||
|
auth(),
|
||||||
|
getSiteContent(),
|
||||||
|
getUsers()
|
||||||
|
]);
|
||||||
|
const requestedSection = searchParams?.section;
|
||||||
|
const activeSection =
|
||||||
|
requestedSection && validSections.includes(requestedSection as ValidSection)
|
||||||
|
? (requestedSection as ValidSection)
|
||||||
|
: "overview";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dashboard
|
||||||
|
content={content}
|
||||||
|
users={users}
|
||||||
|
currentRole={session?.user.role ?? "EDITOR"}
|
||||||
|
activeSection={activeSection}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
src/app/mugmanager/layout.tsx
Normal file
7
src/app/mugmanager/layout.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export default function MugmanagerRootLayout({
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
27
src/app/mugmanager/login/page.tsx
Normal file
27
src/app/mugmanager/login/page.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { LoginForm } from "@/components/admin/login-form";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
|
||||||
|
export default async function LoginPage() {
|
||||||
|
const session = await auth();
|
||||||
|
const defaultEmail = process.env.SEED_SUPERADMIN_EMAIL || "superadmin@example.com";
|
||||||
|
const defaultPassword = process.env.SEED_SUPERADMIN_PASSWORD || "ChangeMe123!";
|
||||||
|
|
||||||
|
if (session?.user) {
|
||||||
|
redirect("/mugmanager");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="admin-shell flex min-h-screen items-center justify-center px-4" dir="rtl">
|
||||||
|
<div className="glass-panel w-full max-w-md rounded-[32px] p-8">
|
||||||
|
<h1 className="text-3xl font-black text-white">ورود به پنل /mugmanager</h1>
|
||||||
|
<p className="mt-3 text-sm text-gray-400">برای مدیریت محتوا و کاربران با حساب ادمین وارد شوید.</p>
|
||||||
|
<LoginForm />
|
||||||
|
<p className="mt-6 text-xs text-gray-500">
|
||||||
|
کاربر پیشفرض: <span dir="ltr">{defaultEmail} / {defaultPassword}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
src/app/page.tsx
Normal file
9
src/app/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { HomePage } from "@/components/home/home-page";
|
||||||
|
import { getSiteContent } from "@/lib/site-content";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function Page() {
|
||||||
|
const content = await getSiteContent();
|
||||||
|
return <HomePage content={content} />;
|
||||||
|
}
|
||||||
60
src/components/admin/admin-shell.tsx
Normal file
60
src/components/admin/admin-shell.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { logoutAction } from "@/lib/actions/admin";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
|
||||||
|
export async function AdminShell({ children }: { children: React.ReactNode }) {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
redirect("/mugmanager/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-shell min-h-screen" dir="rtl">
|
||||||
|
<div className="mx-auto grid min-h-screen max-w-[1800px] grid-cols-1 gap-8 px-4 py-6 lg:grid-cols-[320px_1fr]">
|
||||||
|
<aside className="glass-panel rounded-[32px] p-6 lg:sticky lg:top-6 lg:h-[calc(100vh-3rem)]">
|
||||||
|
<div className="mb-8 border-b border-white/10 pb-6">
|
||||||
|
<div className="inline-flex rounded-full border border-red-500/20 bg-red-500/10 px-3 py-1 text-xs font-bold text-red-200">
|
||||||
|
پنل مدیریت
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-4 text-2xl font-black text-white">mugmanager/</h1>
|
||||||
|
<p className="mt-2 text-sm leading-7 text-gray-400">
|
||||||
|
هر بخش از پنل حالا بهصورت جدا و تمیز مدیریت میشود.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 text-sm text-gray-300">
|
||||||
|
<div className="rounded-[26px] border border-white/10 bg-white/[0.04] p-4">
|
||||||
|
<div className="text-xs text-gray-500">کاربر جاری</div>
|
||||||
|
<div className="mt-2 font-bold text-white">{session.user.name}</div>
|
||||||
|
<div className="text-xs text-gray-400">{session.user.email}</div>
|
||||||
|
<div className="mt-2 inline-flex rounded-full bg-red-600/20 px-3 py-1 text-xs font-bold text-red-300">
|
||||||
|
{session.user.role}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/mugmanager"
|
||||||
|
className="block rounded-2xl border border-white/10 bg-white/[0.02] px-4 py-3 transition hover:bg-white/5"
|
||||||
|
>
|
||||||
|
داشبورد مدیریت
|
||||||
|
</Link>
|
||||||
|
<Link href="/" className="block rounded-2xl px-4 py-3 transition hover:bg-white/5">
|
||||||
|
مشاهده سایت
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form action={logoutAction} className="mt-8">
|
||||||
|
<button className="w-full rounded-2xl border border-white/10 px-4 py-3 text-sm font-bold text-white transition hover:bg-white hover:text-black">
|
||||||
|
خروج
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div className="space-y-6">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
697
src/components/admin/dashboard.tsx
Normal file
697
src/components/admin/dashboard.tsx
Normal file
@@ -0,0 +1,697 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
Blocks,
|
||||||
|
Building2,
|
||||||
|
ChevronLeft,
|
||||||
|
Globe,
|
||||||
|
Images,
|
||||||
|
LayoutDashboard,
|
||||||
|
Settings2,
|
||||||
|
ShieldCheck,
|
||||||
|
Sparkles
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { hasRole } from "@/lib/permissions";
|
||||||
|
import {
|
||||||
|
createUserAction,
|
||||||
|
deleteBranchAction,
|
||||||
|
deleteBrandAction,
|
||||||
|
deleteUserAction,
|
||||||
|
resetContentAction,
|
||||||
|
updateAutoArshiaAction,
|
||||||
|
updateBranchesSectionAction,
|
||||||
|
updateFooterAction,
|
||||||
|
updateHeroAction,
|
||||||
|
updateServicesAction,
|
||||||
|
updateSiteSettingsAction,
|
||||||
|
updateUserRoleAction,
|
||||||
|
uploadAssetAction,
|
||||||
|
upsertBranchAction,
|
||||||
|
upsertBrandAction
|
||||||
|
} from "@/lib/actions/admin";
|
||||||
|
import { SiteContent, UserRecord } from "@/types/content";
|
||||||
|
import { ActionForm } from "@/components/admin/status-message";
|
||||||
|
|
||||||
|
type AdminSection =
|
||||||
|
| "overview"
|
||||||
|
| "site-settings"
|
||||||
|
| "homepage"
|
||||||
|
| "brands"
|
||||||
|
| "branches"
|
||||||
|
| "media"
|
||||||
|
| "users";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
content: SiteContent;
|
||||||
|
users: UserRecord[];
|
||||||
|
currentRole: "SUPERADMIN" | "ADMIN" | "EDITOR";
|
||||||
|
activeSection: AdminSection;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sectionItems = [
|
||||||
|
{ id: "overview", label: "نمای کلی", description: "خلاصه وضعیت پنل", icon: LayoutDashboard },
|
||||||
|
{ id: "site-settings", label: "تنظیمات سایت", description: "هویت برند و اطلاعات کلی", icon: Settings2 },
|
||||||
|
{ id: "homepage", label: "صفحه اصلی", description: "Hero، خدمات، اتو آرشیا و فوتر", icon: Sparkles },
|
||||||
|
{ id: "brands", label: "برندها", description: "مدیریت برندها و ترتیب نمایش", icon: Blocks },
|
||||||
|
{ id: "branches", label: "شعب", description: "تنظیمات نقشه و اطلاعات شعب", icon: Building2 },
|
||||||
|
{ id: "media", label: "رسانه", description: "آپلود و مدیریت فایلها", icon: Images, minRole: "ADMIN" },
|
||||||
|
{ id: "users", label: "کاربران", description: "مدیریت دسترسی تیم", icon: ShieldCheck, superadminOnly: true }
|
||||||
|
] satisfies Array<{
|
||||||
|
id: AdminSection;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
superadminOnly?: boolean;
|
||||||
|
minRole?: Props["currentRole"];
|
||||||
|
}>;
|
||||||
|
|
||||||
|
function canAccessSection(
|
||||||
|
currentRole: Props["currentRole"],
|
||||||
|
item: { superadminOnly?: boolean; minRole?: Props["currentRole"] }
|
||||||
|
) {
|
||||||
|
if (item.superadminOnly) {
|
||||||
|
return hasRole(currentRole, "SUPERADMIN");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.minRole) {
|
||||||
|
return hasRole(currentRole, item.minRole);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
name,
|
||||||
|
defaultValue,
|
||||||
|
textarea = false,
|
||||||
|
type = "text"
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
name: string;
|
||||||
|
defaultValue?: string;
|
||||||
|
textarea?: boolean;
|
||||||
|
type?: string;
|
||||||
|
}) {
|
||||||
|
const baseClassName =
|
||||||
|
"w-full rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm text-white outline-none transition placeholder:text-gray-500 focus:border-red-500/40 focus:bg-black/50";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label className="block space-y-2">
|
||||||
|
<span className="text-sm text-gray-300">{label}</span>
|
||||||
|
{textarea ? (
|
||||||
|
<textarea
|
||||||
|
name={name}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
rows={4}
|
||||||
|
className={`${baseClassName} min-h-[110px]`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
name={name}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
className={baseClassName}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PublishToggle({ name, checked }: { name: string; checked: boolean }) {
|
||||||
|
return (
|
||||||
|
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-gray-200">
|
||||||
|
<input type="checkbox" name={name} defaultChecked={checked} className="size-4 accent-red-600" />
|
||||||
|
انتشار این بخش فعال باشد
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Card({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="glass-panel rounded-[30px] p-6">
|
||||||
|
<div className="mb-6 flex flex-col gap-2 border-b border-white/10 pb-5">
|
||||||
|
<h2 className="text-2xl font-black text-white">{title}</h2>
|
||||||
|
{description ? <p className="text-sm text-gray-400">{description}</p> : null}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SubmitButton({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<button className="rounded-2xl bg-red-700 px-6 py-3 text-sm font-bold text-white transition hover:bg-white hover:text-black">
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
helper
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
helper: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="glass-panel rounded-[28px] p-5">
|
||||||
|
<div className="text-sm text-gray-400">{label}</div>
|
||||||
|
<div className="mt-3 text-3xl font-black text-white">{value}</div>
|
||||||
|
<div className="mt-2 text-xs text-gray-500">{helper}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionNav({
|
||||||
|
activeSection,
|
||||||
|
currentRole
|
||||||
|
}: {
|
||||||
|
activeSection: AdminSection;
|
||||||
|
currentRole: Props["currentRole"];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="glass-panel rounded-[32px] p-4">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-black text-white">بخشهای مدیریت</h2>
|
||||||
|
<p className="mt-1 text-sm text-gray-400">هر قسمت را جداگانه و بدون شلوغی مدیریت کنید.</p>
|
||||||
|
</div>
|
||||||
|
<div className="hidden rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs text-gray-400 md:block">
|
||||||
|
{sectionItems.find((item) => item.id === activeSection)?.label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
{sectionItems
|
||||||
|
.filter((item) => canAccessSection(currentRole, item))
|
||||||
|
.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const isActive = item.id === activeSection;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.id}
|
||||||
|
href={`/mugmanager?section=${item.id}`}
|
||||||
|
className={`rounded-[26px] border p-4 transition ${
|
||||||
|
isActive
|
||||||
|
? "border-red-500/40 bg-red-600/10"
|
||||||
|
: "border-white/10 bg-white/[0.02] hover:border-white/20 hover:bg-white/[0.04]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-bold text-white">{item.label}</div>
|
||||||
|
<div className="mt-2 text-xs leading-6 text-gray-400">{item.description}</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`flex size-10 items-center justify-center rounded-2xl ${
|
||||||
|
isActive ? "bg-red-600 text-white" : "bg-white/5 text-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="size-4" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OverviewPanel({
|
||||||
|
content,
|
||||||
|
users,
|
||||||
|
currentRole
|
||||||
|
}: {
|
||||||
|
content: SiteContent;
|
||||||
|
users: UserRecord[];
|
||||||
|
currentRole: Props["currentRole"];
|
||||||
|
}) {
|
||||||
|
const publishedSections = [
|
||||||
|
content.hero.isPublished,
|
||||||
|
content.services.isPublished,
|
||||||
|
content.autoArshia.isPublished,
|
||||||
|
content.branchesSection.isPublished,
|
||||||
|
content.footer.isPublished
|
||||||
|
].filter(Boolean).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<section className="glass-panel rounded-[32px] p-6">
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="inline-flex items-center gap-2 rounded-full border border-red-500/20 bg-red-500/10 px-3 py-1 text-xs font-bold text-red-200">
|
||||||
|
<Globe className="size-3.5" />
|
||||||
|
پنل مدیریت محتوای سایت
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-4 text-3xl font-black text-white">داشبورد مینیمال و بخشبندیشده</h1>
|
||||||
|
<p className="mt-3 max-w-3xl text-sm leading-7 text-gray-400">
|
||||||
|
پنل از حالت یک صفحه شلوغ خارج شده و هر ناحیه بهصورت مجزا مدیریت میشود. از کارتهای بالا وارد بخش موردنظر شوید.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{hasRole(currentRole, "SUPERADMIN") ? (
|
||||||
|
<form action={resetContentAction}>
|
||||||
|
<button className="rounded-2xl border border-red-600/50 px-5 py-3 text-sm font-bold text-red-200 transition hover:bg-red-600 hover:text-white">
|
||||||
|
بازگردانی محتوای اولیه
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
id="admin-status"
|
||||||
|
className="mt-6 rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm text-gray-200"
|
||||||
|
>
|
||||||
|
آماده ویرایش
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<StatCard label="بخشهای منتشرشده" value={`${publishedSections}/5`} helper="وضعیت فعلی صفحه اصلی" />
|
||||||
|
<StatCard label="برندها" value={String(content.brandsSection.brands.length)} helper="تعداد آیتمهای اسلایدر" />
|
||||||
|
<StatCard label="شعب" value={String(content.branchesSection.branches.length)} helper="شعب ثبتشده روی نقشه" />
|
||||||
|
<StatCard label="فایلهای رسانه" value={String(content.mediaLibrary.length)} helper="آیتمهای موجود در کتابخانه" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 xl:grid-cols-[1.4fr_1fr]">
|
||||||
|
<Card title="نقشه راه مدیریت" description="برای جلوگیری از قاطیشدن فرمها، هر دسته را از مسیر خودش ویرایش کنید.">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
{sectionItems
|
||||||
|
.filter((item) => item.id !== "overview")
|
||||||
|
.filter((item) => canAccessSection(currentRole, item))
|
||||||
|
.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.id}
|
||||||
|
href={`/mugmanager?section=${item.id}`}
|
||||||
|
className="flex items-center justify-between rounded-[24px] border border-white/10 bg-white/[0.02] px-4 py-4 transition hover:border-white/20 hover:bg-white/[0.05]"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="flex size-11 items-center justify-center rounded-2xl bg-white/5 text-gray-200">
|
||||||
|
<Icon className="size-4" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<div className="font-bold text-white">{item.label}</div>
|
||||||
|
<div className="text-xs text-gray-400">{item.description}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronLeft className="size-4 text-gray-500" />
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="وضعیت دسترسی" description="سطح فعلی شما تعیین میکند کدام ماژولها را ببینید یا ویرایش کنید.">
|
||||||
|
<div className="rounded-[26px] border border-white/10 bg-white/[0.02] p-5">
|
||||||
|
<div className="text-sm text-gray-400">نقش جاری</div>
|
||||||
|
<div className="mt-3 inline-flex rounded-full bg-red-600/20 px-4 py-2 text-sm font-bold text-red-200">
|
||||||
|
{currentRole}
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 space-y-3 text-sm text-gray-300">
|
||||||
|
<div className="flex items-center justify-between rounded-2xl border border-white/10 px-4 py-3">
|
||||||
|
<span>مدیریت محتوا</span>
|
||||||
|
<span className="text-green-300">فعال</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between rounded-2xl border border-white/10 px-4 py-3">
|
||||||
|
<span>آپلود فایل</span>
|
||||||
|
<span className={hasRole(currentRole, "ADMIN") ? "text-green-300" : "text-gray-500"}>
|
||||||
|
{hasRole(currentRole, "ADMIN") ? "فعال" : "محدود"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between rounded-2xl border border-white/10 px-4 py-3">
|
||||||
|
<span>مدیریت کاربران</span>
|
||||||
|
<span className={hasRole(currentRole, "SUPERADMIN") ? "text-green-300" : "text-gray-500"}>
|
||||||
|
{hasRole(currentRole, "SUPERADMIN") ? "فعال" : "فقط سوپرادمین"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{hasRole(currentRole, "SUPERADMIN") ? (
|
||||||
|
<div className="mt-5 text-xs leading-6 text-gray-500">در حال حاضر {users.length} کاربر در پنل ثبت شدهاند.</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SiteSettingsPanel({ content }: { content: SiteContent }) {
|
||||||
|
const socialLinksJson = JSON.stringify(content.settings.socialLinks, null, 2);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
|
||||||
|
<Card title="تنظیمات سراسری سایت" description="عنوان، هویت بصری، اطلاعات تماس و لینکهای اجتماعی اینجا نگهداری میشود.">
|
||||||
|
<ActionForm action={updateSiteSettingsAction} className="space-y-4">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Field label="عنوان سایت" name="siteTitle" defaultValue={content.settings.siteTitle} />
|
||||||
|
<Field label="زیرعنوان" name="siteSubtitle" defaultValue={content.settings.siteSubtitle} />
|
||||||
|
<Field label="لوگوی هدر" name="logoUrl" defaultValue={content.settings.logoUrl} />
|
||||||
|
<Field label="لوگوی فوتر" name="footerLogoUrl" defaultValue={content.settings.footerLogoUrl} />
|
||||||
|
<Field label="فایل فونت" name="brandFontUrl" defaultValue={content.settings.brandFontUrl} />
|
||||||
|
<Field label="برچسب دفتر مرکزی" name="centralOfficeLabel" defaultValue={content.settings.centralOfficeLabel} />
|
||||||
|
<Field label="شماره دفتر مرکزی" name="centralOfficePhone" defaultValue={content.settings.centralOfficePhone} />
|
||||||
|
<Field label="متن دکمه باشگاه مشتریان" name="customerClubLabel" defaultValue={content.settings.customerClubLabel} />
|
||||||
|
<Field label="لینک باشگاه مشتریان" name="customerClubUrl" defaultValue={content.settings.customerClubUrl} />
|
||||||
|
</div>
|
||||||
|
<Field label="لینکهای اجتماعی (JSON)" name="socialLinks" defaultValue={socialLinksJson} textarea />
|
||||||
|
<SubmitButton>ذخیره تنظیمات</SubmitButton>
|
||||||
|
</ActionForm>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="پیشنمایش اطلاعات" description="مرور سریع دادههای اصلی برند قبل از ذخیره نهایی.">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-[26px] border border-white/10 bg-white/[0.02] p-4">
|
||||||
|
<div className="text-xs text-gray-500">عنوان فعلی</div>
|
||||||
|
<div className="mt-2 text-lg font-bold text-white">{content.settings.siteTitle}</div>
|
||||||
|
<div className="mt-1 text-sm text-gray-400">{content.settings.siteSubtitle}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[26px] border border-white/10 bg-white/[0.02] p-4">
|
||||||
|
<div className="text-xs text-gray-500">دفتر مرکزی</div>
|
||||||
|
<div className="mt-2 font-bold text-white">{content.settings.centralOfficeLabel}</div>
|
||||||
|
<div className="mt-1 text-sm text-gray-400">{content.settings.centralOfficePhone}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[26px] border border-white/10 bg-white/[0.02] p-4">
|
||||||
|
<div className="text-xs text-gray-500">تعداد شبکههای اجتماعی</div>
|
||||||
|
<div className="mt-2 text-2xl font-black text-white">{content.settings.socialLinks.length}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HomepagePanel({ content }: { content: SiteContent }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="grid gap-6 xl:grid-cols-2">
|
||||||
|
<Card title="Hero Section" description="بخش ورودی صفحه اصلی و مهمترین پیام برند.">
|
||||||
|
<ActionForm action={updateHeroAction} className="space-y-4">
|
||||||
|
<PublishToggle name="isPublished" checked={content.hero.isPublished} />
|
||||||
|
<Field label="Eyebrow" name="eyebrow" defaultValue={content.hero.eyebrow} />
|
||||||
|
<Field label="عنوان اصلی" name="title" defaultValue={content.hero.title} textarea />
|
||||||
|
<Field label="توضیح" name="description" defaultValue={content.hero.description} textarea />
|
||||||
|
<Field label="متن CTA" name="primaryCtaLabel" defaultValue={content.hero.primaryCtaLabel} />
|
||||||
|
<Field label="لینک CTA" name="primaryCtaHref" defaultValue={content.hero.primaryCtaHref} />
|
||||||
|
<Field label="تصویر پسزمینه" name="backgroundImageUrl" defaultValue={content.hero.backgroundImageUrl} />
|
||||||
|
<SubmitButton>ذخیره هیرو</SubmitButton>
|
||||||
|
</ActionForm>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Services Section" description="بخش معرفی خدمات و مسیر تماس سریع.">
|
||||||
|
<ActionForm action={updateServicesAction} className="space-y-4">
|
||||||
|
<PublishToggle name="isPublished" checked={content.services.isPublished} />
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Field label="عنوان" name="title" defaultValue={content.services.title} />
|
||||||
|
<Field label="بخش قرمز" name="accent" defaultValue={content.services.accent} />
|
||||||
|
<Field label="متن CTA" name="primaryCtaLabel" defaultValue={content.services.primaryCtaLabel} />
|
||||||
|
<Field label="لینک CTA" name="primaryCtaHref" defaultValue={content.services.primaryCtaHref} />
|
||||||
|
<Field label="برچسب تلفن" name="phoneLabel" defaultValue={content.services.phoneLabel} />
|
||||||
|
<Field label="شماره تماس" name="phoneValue" defaultValue={content.services.phoneValue} />
|
||||||
|
</div>
|
||||||
|
<Field label="توضیح" name="description" defaultValue={content.services.description} textarea />
|
||||||
|
<Field label="تصویر" name="imageUrl" defaultValue={content.services.imageUrl} />
|
||||||
|
<SubmitButton>ذخیره خدمات</SubmitButton>
|
||||||
|
</ActionForm>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 xl:grid-cols-2">
|
||||||
|
<Card title="Auto Arshia" description="مدیریت باکسهای فروش و خرید خودرو.">
|
||||||
|
<ActionForm action={updateAutoArshiaAction} className="space-y-4">
|
||||||
|
<PublishToggle name="isPublished" checked={content.autoArshia.isPublished} />
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Field label="عنوان اول" name="title" defaultValue={content.autoArshia.title} />
|
||||||
|
<Field label="عنوان دوم" name="accent" defaultValue={content.autoArshia.accent} />
|
||||||
|
</div>
|
||||||
|
<Field label="زیرعنوان" name="subtitle" defaultValue={content.autoArshia.subtitle} />
|
||||||
|
<Field label="Powered by" name="poweredBy" defaultValue={content.autoArshia.poweredBy} />
|
||||||
|
<div className="grid gap-4 xl:grid-cols-2">
|
||||||
|
<div className="space-y-4 rounded-[26px] border border-white/10 bg-white/[0.02] p-4">
|
||||||
|
<h3 className="font-bold text-white">کارت فروش</h3>
|
||||||
|
<Field label="عنوان" name="sellerTitle" defaultValue={content.autoArshia.sellerCard.title} />
|
||||||
|
<Field label="توضیح" name="sellerDescription" defaultValue={content.autoArshia.sellerCard.description} textarea />
|
||||||
|
<Field label="متن CTA" name="sellerCtaLabel" defaultValue={content.autoArshia.sellerCard.ctaLabel} />
|
||||||
|
<Field label="لینک CTA" name="sellerCtaHref" defaultValue={content.autoArshia.sellerCard.ctaHref} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4 rounded-[26px] border border-white/10 bg-white/[0.02] p-4">
|
||||||
|
<h3 className="font-bold text-white">کارت خرید</h3>
|
||||||
|
<Field label="عنوان" name="buyerTitle" defaultValue={content.autoArshia.buyerCard.title} />
|
||||||
|
<Field label="توضیح" name="buyerDescription" defaultValue={content.autoArshia.buyerCard.description} textarea />
|
||||||
|
<Field label="متن CTA" name="buyerCtaLabel" defaultValue={content.autoArshia.buyerCard.ctaLabel} />
|
||||||
|
<Field label="لینک CTA" name="buyerCtaHref" defaultValue={content.autoArshia.buyerCard.ctaHref} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<SubmitButton>ذخیره Auto Arshia</SubmitButton>
|
||||||
|
</ActionForm>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Footer + Branch Settings" description="بخش انتهایی سایت و تنظیمات نمایش نقشه شعب.">
|
||||||
|
<div className="space-y-8">
|
||||||
|
<ActionForm action={updateFooterAction} className="space-y-4">
|
||||||
|
<PublishToggle name="isPublished" checked={content.footer.isPublished} />
|
||||||
|
<Field label="متن فوتر" name="description" defaultValue={content.footer.description} textarea />
|
||||||
|
<SubmitButton>ذخیره فوتر</SubmitButton>
|
||||||
|
</ActionForm>
|
||||||
|
|
||||||
|
<ActionForm action={updateBranchesSectionAction} className="space-y-4 border-t border-white/10 pt-8">
|
||||||
|
<PublishToggle name="isPublished" checked={content.branchesSection.isPublished} />
|
||||||
|
<Field label="Section ID" name="sectionId" defaultValue={content.branchesSection.sectionId} />
|
||||||
|
<Field label="تصویر نقشه" name="mapImageUrl" defaultValue={content.branchesSection.mapImageUrl} />
|
||||||
|
<SubmitButton>ذخیره تنظیمات شعب</SubmitButton>
|
||||||
|
</ActionForm>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BrandsPanel({ content }: { content: SiteContent }) {
|
||||||
|
return (
|
||||||
|
<Card title="مدیریت برندها" description="افزودن، ویرایش و مرتبسازی برندها در یک فضای مستقل.">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<ActionForm action={upsertBrandAction} className="grid gap-4 rounded-[28px] border border-white/10 bg-white/[0.02] p-4 xl:grid-cols-7">
|
||||||
|
<Field label="شناسه" name="id" />
|
||||||
|
<Field label="ترتیب" name="sortOrder" defaultValue="99" />
|
||||||
|
<Field label="نام انگلیسی" name="englishName" />
|
||||||
|
<Field label="نام فارسی" name="persianName" />
|
||||||
|
<Field label="کد" name="code" />
|
||||||
|
<Field label="تصویر" name="imageUrl" />
|
||||||
|
<Field label="لینک" name="link" />
|
||||||
|
<div className="xl:col-span-7">
|
||||||
|
<SubmitButton>افزودن برند جدید</SubmitButton>
|
||||||
|
</div>
|
||||||
|
</ActionForm>
|
||||||
|
|
||||||
|
{content.brandsSection.brands.map((brand) => (
|
||||||
|
<div key={brand.id} className="rounded-[28px] border border-white/10 bg-white/[0.02] p-4">
|
||||||
|
<ActionForm action={upsertBrandAction} className="grid gap-4 xl:grid-cols-7">
|
||||||
|
<Field label="شناسه" name="id" defaultValue={brand.id} />
|
||||||
|
<Field label="ترتیب" name="sortOrder" defaultValue={String(brand.sortOrder)} />
|
||||||
|
<Field label="نام انگلیسی" name="englishName" defaultValue={brand.englishName} />
|
||||||
|
<Field label="نام فارسی" name="persianName" defaultValue={brand.persianName} />
|
||||||
|
<Field label="کد" name="code" defaultValue={brand.code} />
|
||||||
|
<Field label="تصویر" name="imageUrl" defaultValue={brand.imageUrl} />
|
||||||
|
<Field label="لینک" name="link" defaultValue={brand.link} />
|
||||||
|
<div className="xl:col-span-7">
|
||||||
|
<SubmitButton>ذخیره تغییرات برند</SubmitButton>
|
||||||
|
</div>
|
||||||
|
</ActionForm>
|
||||||
|
<form action={deleteBrandAction} className="mt-3">
|
||||||
|
<input type="hidden" name="id" value={brand.id} />
|
||||||
|
<button className="text-sm font-bold text-red-300">حذف برند</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BranchesPanel({ content }: { content: SiteContent }) {
|
||||||
|
return (
|
||||||
|
<Card title="مدیریت شعب" description="تنظیمات شعب و دادههای نقشه در یک ماژول مستقل.">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<ActionForm action={upsertBranchAction} className="grid gap-4 rounded-[28px] border border-white/10 bg-white/[0.02] p-4 xl:grid-cols-4">
|
||||||
|
<Field label="شناسه" name="id" />
|
||||||
|
<Field label="ترتیب" name="sortOrder" defaultValue="99" />
|
||||||
|
<Field label="slug" name="slug" />
|
||||||
|
<Field label="برچسب" name="tag" />
|
||||||
|
<Field label="نام شعبه" name="name" />
|
||||||
|
<Field label="تلفن" name="phone" />
|
||||||
|
<Field label="top" name="markerTop" defaultValue="50%" />
|
||||||
|
<Field label="left" name="markerLeft" defaultValue="50%" />
|
||||||
|
<div className="xl:col-span-4">
|
||||||
|
<Field label="آدرس" name="address" textarea />
|
||||||
|
</div>
|
||||||
|
<div className="xl:col-span-4">
|
||||||
|
<Field label="لینک Google Maps" name="mapUrl" />
|
||||||
|
</div>
|
||||||
|
<div className="xl:col-span-4">
|
||||||
|
<SubmitButton>افزودن شعبه جدید</SubmitButton>
|
||||||
|
</div>
|
||||||
|
</ActionForm>
|
||||||
|
|
||||||
|
{content.branchesSection.branches.map((branch) => (
|
||||||
|
<div key={branch.id} className="rounded-[28px] border border-white/10 bg-white/[0.02] p-4">
|
||||||
|
<ActionForm action={upsertBranchAction} className="grid gap-4 xl:grid-cols-4">
|
||||||
|
<Field label="شناسه" name="id" defaultValue={branch.id} />
|
||||||
|
<Field label="ترتیب" name="sortOrder" defaultValue={String(branch.sortOrder)} />
|
||||||
|
<Field label="slug" name="slug" defaultValue={branch.slug} />
|
||||||
|
<Field label="برچسب" name="tag" defaultValue={branch.tag} />
|
||||||
|
<Field label="نام شعبه" name="name" defaultValue={branch.name} />
|
||||||
|
<Field label="تلفن" name="phone" defaultValue={branch.phone} />
|
||||||
|
<Field label="top" name="markerTop" defaultValue={branch.markerTop} />
|
||||||
|
<Field label="left" name="markerLeft" defaultValue={branch.markerLeft} />
|
||||||
|
<div className="xl:col-span-4">
|
||||||
|
<Field label="آدرس" name="address" defaultValue={branch.address} textarea />
|
||||||
|
</div>
|
||||||
|
<div className="xl:col-span-4">
|
||||||
|
<Field label="لینک Google Maps" name="mapUrl" defaultValue={branch.mapUrl} />
|
||||||
|
</div>
|
||||||
|
<div className="xl:col-span-4">
|
||||||
|
<SubmitButton>ذخیره شعبه</SubmitButton>
|
||||||
|
</div>
|
||||||
|
</ActionForm>
|
||||||
|
<form action={deleteBranchAction} className="mt-3">
|
||||||
|
<input type="hidden" name="id" value={branch.id} />
|
||||||
|
<button className="text-sm font-bold text-red-300">حذف شعبه</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MediaPanel({
|
||||||
|
content,
|
||||||
|
currentRole
|
||||||
|
}: {
|
||||||
|
content: SiteContent;
|
||||||
|
currentRole: Props["currentRole"];
|
||||||
|
}) {
|
||||||
|
const canUpload = hasRole(currentRole, "ADMIN");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-6 xl:grid-cols-[0.9fr_1.1fr]">
|
||||||
|
<Card title="آپلود Asset" description="فایلهای تصویری، فونت و SVG را به کتابخانه رسانه اضافه کنید.">
|
||||||
|
{canUpload ? (
|
||||||
|
<ActionForm action={uploadAssetAction} className="space-y-4" encType="multipart/form-data">
|
||||||
|
<Field label="عنوان فایل" name="label" />
|
||||||
|
<label className="block space-y-2">
|
||||||
|
<span className="text-sm text-gray-300">فایل</span>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
name="file"
|
||||||
|
required
|
||||||
|
className="w-full rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm text-white"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<SubmitButton>آپلود فایل</SubmitButton>
|
||||||
|
</ActionForm>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-[26px] border border-amber-500/20 bg-amber-500/10 p-4 text-sm leading-7 text-amber-100">
|
||||||
|
دسترسی آپلود فایل فقط برای نقشهای `ADMIN` و `SUPERADMIN` فعال است.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="کتابخانه رسانه" description="همه فایلهای آپلودشده را یکجا ببینید.">
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
{content.mediaLibrary.map((asset) => (
|
||||||
|
<div key={asset.id} className="rounded-[24px] border border-white/10 bg-white/[0.02] p-4 text-sm">
|
||||||
|
<div className="font-bold text-white">{asset.label}</div>
|
||||||
|
<div className="mt-2 break-all text-gray-400">{asset.url}</div>
|
||||||
|
<div className="mt-3 inline-flex rounded-full bg-white/5 px-3 py-1 text-xs text-red-300">{asset.kind}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UsersPanel({ users }: { users: UserRecord[] }) {
|
||||||
|
return (
|
||||||
|
<Card title="مدیریت کاربران" description="ایجاد حساب، تغییر نقش و حذف کاربران فقط برای سوپرادمین.">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<ActionForm action={createUserAction} className="grid gap-4 rounded-[28px] border border-white/10 bg-white/[0.02] p-4 md:grid-cols-4">
|
||||||
|
<Field label="نام" name="name" />
|
||||||
|
<Field label="ایمیل" name="email" type="email" />
|
||||||
|
<Field label="رمز عبور" name="password" type="password" />
|
||||||
|
<label className="block space-y-2">
|
||||||
|
<span className="text-sm text-gray-300">نقش</span>
|
||||||
|
<select name="role" defaultValue="EDITOR" className="w-full rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm text-white">
|
||||||
|
<option value="EDITOR">EDITOR</option>
|
||||||
|
<option value="ADMIN">ADMIN</option>
|
||||||
|
<option value="SUPERADMIN">SUPERADMIN</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className="md:col-span-4">
|
||||||
|
<SubmitButton>ساخت کاربر</SubmitButton>
|
||||||
|
</div>
|
||||||
|
</ActionForm>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{users.map((user) => (
|
||||||
|
<div key={user.id} className="rounded-[28px] border border-white/10 bg-white/[0.02] p-4">
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="font-bold text-white">{user.name}</div>
|
||||||
|
<div className="text-sm text-gray-400">{user.email}</div>
|
||||||
|
</div>
|
||||||
|
<ActionForm action={updateUserRoleAction} className="flex flex-col gap-3 md:flex-row md:items-center">
|
||||||
|
<input type="hidden" name="id" value={user.id} />
|
||||||
|
<select name="role" defaultValue={user.role} className="rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm text-white">
|
||||||
|
<option value="EDITOR">EDITOR</option>
|
||||||
|
<option value="ADMIN">ADMIN</option>
|
||||||
|
<option value="SUPERADMIN">SUPERADMIN</option>
|
||||||
|
</select>
|
||||||
|
<SubmitButton>بهروزرسانی نقش</SubmitButton>
|
||||||
|
</ActionForm>
|
||||||
|
<form action={deleteUserAction} className="mt-3">
|
||||||
|
<input type="hidden" name="id" value={user.id} />
|
||||||
|
<button className="text-sm font-bold text-red-300">حذف کاربر</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Dashboard({ content, users, currentRole, activeSection }: Props) {
|
||||||
|
const requestedItem = sectionItems.find((item) => item.id === activeSection);
|
||||||
|
const visibleSection =
|
||||||
|
requestedItem && !canAccessSection(currentRole, requestedItem)
|
||||||
|
? "overview"
|
||||||
|
: activeSection;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<SectionNav activeSection={visibleSection} currentRole={currentRole} />
|
||||||
|
|
||||||
|
{visibleSection === "overview" ? <OverviewPanel content={content} users={users} currentRole={currentRole} /> : null}
|
||||||
|
{visibleSection === "site-settings" ? <SiteSettingsPanel content={content} /> : null}
|
||||||
|
{visibleSection === "homepage" ? <HomepagePanel content={content} /> : null}
|
||||||
|
{visibleSection === "brands" ? <BrandsPanel content={content} /> : null}
|
||||||
|
{visibleSection === "branches" ? <BranchesPanel content={content} /> : null}
|
||||||
|
{visibleSection === "media" ? <MediaPanel content={content} currentRole={currentRole} /> : null}
|
||||||
|
{visibleSection === "users" ? <UsersPanel users={users} /> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
src/components/admin/login-form.tsx
Normal file
62
src/components/admin/login-form.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useFormState, useFormStatus } from "react-dom";
|
||||||
|
|
||||||
|
import { loginActionWithState } from "@/lib/actions/admin";
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
ok: true,
|
||||||
|
message: "مشخصات ورود را وارد کنید"
|
||||||
|
};
|
||||||
|
|
||||||
|
function SubmitButton() {
|
||||||
|
const { pending } = useFormStatus();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={pending}
|
||||||
|
className="w-full rounded-2xl bg-red-700 px-6 py-3 font-bold text-white transition hover:bg-white hover:text-black disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{pending ? "در حال ورود..." : "ورود"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoginForm() {
|
||||||
|
const [state, formAction] = useFormState(loginActionWithState, initialState);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={`mt-6 rounded-2xl border px-4 py-3 text-sm ${
|
||||||
|
state.ok
|
||||||
|
? "border-white/10 bg-black/30 text-gray-200"
|
||||||
|
: "border-red-500/30 bg-red-950/40 text-red-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{state.message}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form action={formAction} className="mt-6 space-y-4">
|
||||||
|
<label className="block space-y-2">
|
||||||
|
<span className="text-sm text-gray-300">ایمیل</span>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
className="w-full rounded-2xl border border-white/10 bg-black/30 px-4 py-3"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block space-y-2">
|
||||||
|
<span className="text-sm text-gray-300">رمز عبور</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
className="w-full rounded-2xl border border-white/10 bg-black/30 px-4 py-3"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<SubmitButton />
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
src/components/admin/status-message.tsx
Normal file
14
src/components/admin/status-message.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
type Props = {
|
||||||
|
action: (formData: FormData) => Promise<unknown> | unknown;
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
encType?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ActionForm({ action, children, className, encType }: Props) {
|
||||||
|
return (
|
||||||
|
<form action={action} className={className} encType={encType}>
|
||||||
|
{children}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
278
src/components/home/branches-map.tsx
Normal file
278
src/components/home/branches-map.tsx
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import gsap from "gsap";
|
||||||
|
import { MapPin, Phone } from "lucide-react";
|
||||||
|
|
||||||
|
import { Branch, BranchesSection } from "@/types/content";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
section: BranchesSection;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CityGroup = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
branches: Branch[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const fallbackBranches: Branch[] = [
|
||||||
|
{
|
||||||
|
id: "tehran",
|
||||||
|
sortOrder: 1,
|
||||||
|
slug: "tehran",
|
||||||
|
tag: "شعبه پایتخت",
|
||||||
|
name: "مجتمع خودرویی آرشیا (تهران)",
|
||||||
|
address: "تهران، جاده مخصوص کرج، کیلومتر ۱۲، نبش خیابان اصلی",
|
||||||
|
phone: "021-44556677",
|
||||||
|
mapUrl: "#",
|
||||||
|
markerTop: "30%",
|
||||||
|
markerLeft: "48%"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "shiraz",
|
||||||
|
sortOrder: 2,
|
||||||
|
slug: "shiraz",
|
||||||
|
tag: "شعبه مرکزی",
|
||||||
|
name: "بازرگانی قاسم پور (شیراز)",
|
||||||
|
address: "شیراز، بلوار امیرکبیر، نرسیده به پلیس راه، مجتمع خودرویی قاسم پور",
|
||||||
|
phone: "071-3889",
|
||||||
|
mapUrl: "#",
|
||||||
|
markerTop: "66%",
|
||||||
|
markerLeft: "51%"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "bandar",
|
||||||
|
sortOrder: 3,
|
||||||
|
slug: "bandar",
|
||||||
|
tag: "شعبه هرمزگان",
|
||||||
|
name: "پرشیا خودرو (بندرعباس)",
|
||||||
|
address: "بندرعباس، بلوار امام خمینی، جنب هتل هما، شعبه بازرگانی قاسم پور",
|
||||||
|
phone: "076-33445566",
|
||||||
|
mapUrl: "#",
|
||||||
|
markerTop: "82%",
|
||||||
|
markerLeft: "68%"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "bushehr",
|
||||||
|
sortOrder: 4,
|
||||||
|
slug: "bushehr",
|
||||||
|
tag: "شعبه بوشهر",
|
||||||
|
name: "نمایشگاه ساحلی (بوشهر)",
|
||||||
|
address: "بوشهر، بلوار ساحلی، میدان خلیج فارس، مجتمع تجاری خودرو",
|
||||||
|
phone: "077-33221100",
|
||||||
|
mapUrl: "#",
|
||||||
|
markerTop: "75%",
|
||||||
|
markerLeft: "38%"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ahvaz",
|
||||||
|
sortOrder: 5,
|
||||||
|
slug: "ahvaz",
|
||||||
|
tag: "شعبه خوزستان",
|
||||||
|
name: "مرکز فروش کارون (اهواز)",
|
||||||
|
address: "اهواز، اتوبان آیتالله بهبهانی، روبروی ترمینال آبادان",
|
||||||
|
phone: "061-33778899",
|
||||||
|
mapUrl: "#",
|
||||||
|
markerTop: "61%",
|
||||||
|
markerLeft: "32%"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
function detectCity(branch: Branch) {
|
||||||
|
const haystack = `${branch.id} ${branch.slug} ${branch.name} ${branch.address}`.toLowerCase();
|
||||||
|
|
||||||
|
if (haystack.includes("tehran") || haystack.includes("تهران")) {
|
||||||
|
return { id: "tehran", label: "تهران" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (haystack.includes("shiraz") || haystack.includes("شیراز")) {
|
||||||
|
return { id: "shiraz", label: "شیراز" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (haystack.includes("bandar") || haystack.includes("بندر")) {
|
||||||
|
return { id: "bandar", label: "بندرعباس" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (haystack.includes("bushehr") || haystack.includes("بوشهر")) {
|
||||||
|
return { id: "bushehr", label: "بوشهر" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (haystack.includes("ahvaz") || haystack.includes("اهواز")) {
|
||||||
|
return { id: "ahvaz", label: "اهواز" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { id: branch.id, label: branch.name };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BranchesMap({ section }: Props) {
|
||||||
|
const branches = useMemo(() => {
|
||||||
|
const source = section.branches.length > 0 ? section.branches : fallbackBranches;
|
||||||
|
return [...source].sort((a, b) => a.sortOrder - b.sortOrder);
|
||||||
|
}, [section.branches]);
|
||||||
|
|
||||||
|
const groupedCities = useMemo<CityGroup[]>(() => {
|
||||||
|
const map = new Map<string, CityGroup>();
|
||||||
|
|
||||||
|
for (const branch of branches) {
|
||||||
|
const city = detectCity(branch);
|
||||||
|
const current = map.get(city.id);
|
||||||
|
|
||||||
|
if (current) {
|
||||||
|
current.branches.push(branch);
|
||||||
|
} else {
|
||||||
|
map.set(city.id, { id: city.id, label: city.label, branches: [branch] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(map.values()).filter((city) => city.id === "shiraz" || city.id === "bandar");
|
||||||
|
}, [branches]);
|
||||||
|
|
||||||
|
const [activeCityId, setActiveCityId] = useState(groupedCities[0]?.id ?? "");
|
||||||
|
const [activeBranchId, setActiveBranchId] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!groupedCities.some((city) => city.id === activeCityId)) {
|
||||||
|
setActiveCityId(groupedCities[0]?.id ?? "");
|
||||||
|
}
|
||||||
|
}, [activeCityId, groupedCities]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const activeCityBranches = groupedCities.find((city) => city.id === activeCityId)?.branches ?? [];
|
||||||
|
|
||||||
|
if (!activeCityBranches.some((branch) => branch.id === activeBranchId)) {
|
||||||
|
setActiveBranchId("");
|
||||||
|
}
|
||||||
|
}, [activeBranchId, activeCityId, groupedCities]);
|
||||||
|
|
||||||
|
const activeCity = groupedCities.find((city) => city.id === activeCityId) ?? groupedCities[0];
|
||||||
|
const activeBranch = activeCity?.branches.find((branch) => branch.id === activeBranchId) ?? null;
|
||||||
|
|
||||||
|
const activateCity = (city: CityGroup) => {
|
||||||
|
setActiveCityId(city.id);
|
||||||
|
setActiveBranchId("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeBranch = (branch: Branch) => {
|
||||||
|
const content = document.getElementById("branch-anim-content");
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
setActiveBranchId(branch.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
gsap.to(content, {
|
||||||
|
opacity: 0,
|
||||||
|
y: 10,
|
||||||
|
duration: 0.3,
|
||||||
|
onComplete: () => {
|
||||||
|
setActiveBranchId(branch.id);
|
||||||
|
gsap.to(content, {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
duration: 0.35,
|
||||||
|
ease: "power2.out"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!activeCity) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id={section.sectionId} data-reveal className="branches-section">
|
||||||
|
<div className="branch-wrapper">
|
||||||
|
<div className="branch-info-card" id="branch-info-card">
|
||||||
|
<div id="branch-anim-content">
|
||||||
|
<div className="branch-info-topbar">
|
||||||
|
<span className="branch-tag-text">شهر / شعبه</span>
|
||||||
|
<div className="branch-city-badge">{activeCity.label}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="branch-main-title">
|
||||||
|
{activeBranch ? activeBranch.name : "ابتدا شهر را انتخاب کنید، سپس یکی از شعب را باز کنید"}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="branch-selector-block">
|
||||||
|
<div className="branch-selector-label">شهرها</div>
|
||||||
|
<div className="branch-selector-grid">
|
||||||
|
{groupedCities.map((city) => (
|
||||||
|
<button
|
||||||
|
key={city.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => activateCity(city)}
|
||||||
|
className={`branch-city-chip ${
|
||||||
|
activeCity.id === city.id ? "branch-city-chip-active" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>{city.label}</span>
|
||||||
|
<strong>{city.branches.length}</strong>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="branch-sublist">
|
||||||
|
{activeCity.branches.map((branch) => (
|
||||||
|
<button
|
||||||
|
key={branch.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => changeBranch(branch)}
|
||||||
|
className={`branch-subitem ${activeBranch?.id === branch.id ? "branch-subitem-active" : ""}`}
|
||||||
|
>
|
||||||
|
<span className="branch-subitem-tag">{branch.tag}</span>
|
||||||
|
<span className="branch-subitem-name">{branch.name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeBranch ? (
|
||||||
|
<>
|
||||||
|
<div className="branch-contact-card">
|
||||||
|
<div className="branch-contact-item">
|
||||||
|
<MapPin strokeWidth={2} />
|
||||||
|
<p>{activeBranch.address}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="branch-contact-item">
|
||||||
|
<Phone strokeWidth={2} />
|
||||||
|
<p dir="ltr">{activeBranch.phone}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={activeBranch.mapUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="branch-map-link"
|
||||||
|
>
|
||||||
|
مسیریابی هوشمند (Google Maps)
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="map-interactive-area">
|
||||||
|
<div className="map-image-container">
|
||||||
|
<img src={section.mapImageUrl} className="iran-svg-img" alt="نقشه ایران بازرگانی قاسم پور" />
|
||||||
|
|
||||||
|
{branches.map((branch) => (
|
||||||
|
<button
|
||||||
|
key={branch.id}
|
||||||
|
type="button"
|
||||||
|
title={branch.name}
|
||||||
|
onClick={() => changeBranch(branch)}
|
||||||
|
className={`map-marker ${activeBranch?.id === branch.id ? "active" : ""}`}
|
||||||
|
style={{ top: branch.markerTop, left: branch.markerLeft }}
|
||||||
|
aria-label={branch.name}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
src/components/home/brand-slider.tsx
Normal file
128
src/components/home/brand-slider.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import gsap from "gsap";
|
||||||
|
import { ChevronRight, ChevronLeft } from "lucide-react";
|
||||||
|
|
||||||
|
import { Brand } from "@/types/content";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
brands: Brand[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BrandSlider({ brands }: Props) {
|
||||||
|
const trackRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
|
const [isPaused, setIsPaused] = useState(false);
|
||||||
|
const extendedBrands = useMemo(() => [...brands, ...brands], [brands]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const track = trackRef.current;
|
||||||
|
if (!track || brands.length === 0) return;
|
||||||
|
|
||||||
|
const update = (index: number, animate = true) => {
|
||||||
|
const firstCard = track.children[0] as HTMLElement | undefined;
|
||||||
|
if (!firstCard) return;
|
||||||
|
const step = firstCard.offsetWidth + 15;
|
||||||
|
if (animate) {
|
||||||
|
gsap.to(track, { x: index * step, duration: 0.8, ease: "power2.inOut" });
|
||||||
|
} else {
|
||||||
|
gsap.set(track, { x: index * step });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
update(currentIndex);
|
||||||
|
let interval: ReturnType<typeof setInterval> | undefined;
|
||||||
|
if (!isPaused) {
|
||||||
|
interval = setInterval(() => {
|
||||||
|
setCurrentIndex((prev) => {
|
||||||
|
const next = prev + 1;
|
||||||
|
update(next);
|
||||||
|
if (next >= brands.length) {
|
||||||
|
window.setTimeout(() => update(0, false), 800);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, 3200);
|
||||||
|
}
|
||||||
|
|
||||||
|
const onResize = () => update(currentIndex, false);
|
||||||
|
window.addEventListener("resize", onResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (interval) clearInterval(interval);
|
||||||
|
window.removeEventListener("resize", onResize);
|
||||||
|
};
|
||||||
|
}, [brands.length, currentIndex, isPaused]);
|
||||||
|
|
||||||
|
const movePrev = () => {
|
||||||
|
const track = trackRef.current;
|
||||||
|
if (!track || brands.length === 0) return;
|
||||||
|
const firstCard = track.children[0] as HTMLElement | undefined;
|
||||||
|
if (!firstCard) return;
|
||||||
|
const step = firstCard.offsetWidth + 15;
|
||||||
|
const start = currentIndex <= 0 ? brands.length : currentIndex;
|
||||||
|
if (currentIndex <= 0) gsap.set(track, { x: start * step });
|
||||||
|
window.setTimeout(() => {
|
||||||
|
const next = start - 1;
|
||||||
|
setCurrentIndex(next);
|
||||||
|
gsap.to(track, { x: next * step, duration: 0.8, ease: "power2.inOut" });
|
||||||
|
}, 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveNext = () => {
|
||||||
|
const track = trackRef.current;
|
||||||
|
if (!track || brands.length === 0) return;
|
||||||
|
const firstCard = track.children[0] as HTMLElement | undefined;
|
||||||
|
if (!firstCard) return;
|
||||||
|
const step = firstCard.offsetWidth + 15;
|
||||||
|
const next = currentIndex + 1;
|
||||||
|
setCurrentIndex(next >= brands.length ? 0 : next);
|
||||||
|
gsap.to(track, { x: next * step, duration: 0.8, ease: "power2.inOut" });
|
||||||
|
if (next >= brands.length) window.setTimeout(() => gsap.set(track, { x: 0 }), 800);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="brand-slider-shell">
|
||||||
|
<button type="button" className="brand-nav-btn brand-nav-btn-right" onClick={movePrev} aria-label="Previous brand">
|
||||||
|
<ChevronRight size={24} />
|
||||||
|
</button>
|
||||||
|
<button type="button" className="brand-nav-btn brand-nav-btn-left" onClick={moveNext} aria-label="Next brand">
|
||||||
|
<ChevronLeft size={24} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="slider-container"
|
||||||
|
onMouseEnter={() => setIsPaused(true)}
|
||||||
|
onMouseLeave={() => setIsPaused(false)}
|
||||||
|
>
|
||||||
|
<div ref={trackRef} className="slider-track">
|
||||||
|
{extendedBrands.map((brand, index) => (
|
||||||
|
<article key={`${brand.id}-${index}`} className="brand-card group">
|
||||||
|
<a href={brand.link || "#brands"} className="brand-card-link">
|
||||||
|
<div className="brand-image-wrap">
|
||||||
|
<img src={brand.imageUrl} alt={brand.englishName} className="brand-image" />
|
||||||
|
<div className="brand-image-overlay" />
|
||||||
|
<div className="brand-image-shine" />
|
||||||
|
<div className="brand-code-chip">{brand.code}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="brand-card-body">
|
||||||
|
<div className="brand-card-meta">
|
||||||
|
<div className="brand-name-fa">{brand.persianName}</div>
|
||||||
|
<div className="brand-card-code">{brand.code}</div>
|
||||||
|
</div>
|
||||||
|
<div className="brand-card-copy">
|
||||||
|
<div className="brand-name-en">{brand.englishName}</div>
|
||||||
|
<div className="brand-card-line" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
284
src/components/home/home-page.tsx
Normal file
284
src/components/home/home-page.tsx
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
import { Search, BadgeDollarSign, ChevronDown } from "lucide-react";
|
||||||
|
|
||||||
|
import { SiteContent } from "@/types/content";
|
||||||
|
import { BrandSlider } from "@/components/home/brand-slider";
|
||||||
|
import { BranchesMap } from "@/components/home/branches-map";
|
||||||
|
import { ScrollEffects } from "@/components/home/scroll-effects";
|
||||||
|
import { SalesConsultationDialog } from "@/components/home/sales-consultation-dialog";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
content: SiteContent;
|
||||||
|
};
|
||||||
|
|
||||||
|
const placeholderAssets = new Set([
|
||||||
|
"/uploads/logo-placeholder.svg",
|
||||||
|
"/uploads/car-placeholder.svg",
|
||||||
|
"/uploads/iran-placeholder.svg",
|
||||||
|
""
|
||||||
|
]);
|
||||||
|
|
||||||
|
const brandFallbacks: Record<string, string> = {
|
||||||
|
xtrim: "/uploads/brands/xtrim.jpg",
|
||||||
|
fownix: "/uploads/brands/fownix.jpg",
|
||||||
|
mvm: "/uploads/brands/mvm.jpg",
|
||||||
|
"arina-drive": "/uploads/brands/arina-drive.jpg",
|
||||||
|
lamari: "/uploads/brands/lamari.jpg",
|
||||||
|
rubber: "/uploads/brands/rubber.jpg",
|
||||||
|
nissan: "/uploads/brands/nissan.jpg",
|
||||||
|
hyundai: "/uploads/brands/hyundai.jpg",
|
||||||
|
kavir: "/uploads/brands/kavir.jpg",
|
||||||
|
audi: "/uploads/brands/audi.jpg"
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveAsset(url: string | undefined, fallback: string) {
|
||||||
|
if (!url || placeholderAssets.has(url)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeServiceUrl(url: string | undefined) {
|
||||||
|
if (!url || url === "https://ghasempour.co/appointment") {
|
||||||
|
return "https://service.ghasempour.co";
|
||||||
|
}
|
||||||
|
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HomePage({ content }: Props) {
|
||||||
|
const brands = [...content.brandsSection.brands]
|
||||||
|
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||||
|
.map((brand) => ({
|
||||||
|
...brand,
|
||||||
|
imageUrl: resolveAsset(brand.imageUrl, brandFallbacks[brand.id] ?? "/uploads/brands/default.jpg")
|
||||||
|
}));
|
||||||
|
|
||||||
|
const heroImage = resolveAsset(content.hero.backgroundImageUrl, "/uploads/hero-bg.jpg");
|
||||||
|
const servicesImage = resolveAsset(content.services.imageUrl, "/uploads/service-workshop.jpg");
|
||||||
|
const headerLogo = resolveAsset(content.settings.logoUrl, "/uploads/logo.png");
|
||||||
|
const footerLogo = resolveAsset(content.settings.footerLogoUrl, "/uploads/logo.png");
|
||||||
|
const mapImage = resolveAsset(content.branchesSection.mapImageUrl, "/uploads/iran.svg");
|
||||||
|
const afterSalesUrl = normalizeServiceUrl(content.settings.customerClubUrl || content.services.primaryCtaHref);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="brand-font bg-[#0a0a0a] text-white" dir="rtl">
|
||||||
|
<ScrollEffects />
|
||||||
|
|
||||||
|
<nav
|
||||||
|
id="main-nav"
|
||||||
|
className="main-nav-glass fixed z-[100] flex w-full items-center justify-between px-6 py-3 transition-all duration-300"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<img src={headerLogo} alt={content.settings.siteTitle} className="h-16 object-contain md:h-20" />
|
||||||
|
<div className="hidden gap-8 text-xs font-bold uppercase tracking-widest opacity-70 lg:flex">
|
||||||
|
<div className="group relative py-5">
|
||||||
|
<a href="#brands" className="flex items-center gap-1 transition hover:text-red-600">
|
||||||
|
برندها
|
||||||
|
<ChevronDown className="size-3.5" />
|
||||||
|
</a>
|
||||||
|
<div className="invisible absolute right-[-50px] top-full z-[1000] grid w-[650px] translate-y-5 grid-cols-3 gap-[15px] rounded-[24px] border border-white/10 bg-[rgba(5,5,5,0.96)] p-[35px] opacity-0 shadow-[0_40px_100px_rgba(0,0,0,0.9)] backdrop-blur-[22px] transition-all duration-300 group-hover:visible group-hover:translate-y-0 group-hover:opacity-100">
|
||||||
|
{brands.map((brand) => (
|
||||||
|
<a
|
||||||
|
key={brand.id}
|
||||||
|
href={brand.link}
|
||||||
|
className="flex items-center gap-3 rounded-[10px] p-[10px] transition hover:bg-[rgba(165,28,36,0.15)] hover:text-[#A51C24]"
|
||||||
|
>
|
||||||
|
<span>•</span>
|
||||||
|
{brand.englishName}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="#services" className="mt-5 transition hover:text-red-600">
|
||||||
|
خدمات
|
||||||
|
</a>
|
||||||
|
<a href="#arshia" className="mt-5 transition hover:text-red-600">
|
||||||
|
اتو عرشیا
|
||||||
|
</a>
|
||||||
|
<a href={`#${content.branchesSection.sectionId}`} className="mt-5 transition hover:text-red-600">
|
||||||
|
شعب
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div className="hidden text-left md:block">
|
||||||
|
<div className="text-[10px] uppercase opacity-50">{content.settings.centralOfficeLabel}</div>
|
||||||
|
<div className="text-sm font-black">{content.settings.centralOfficePhone}</div>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={afterSalesUrl}
|
||||||
|
className="bg-white px-6 py-2 text-xs font-bold text-black transition hover:bg-red-600 hover:text-white"
|
||||||
|
>
|
||||||
|
خدمات پس از فروش
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{content.hero.isPublished ? (
|
||||||
|
<section className="relative flex h-screen items-center overflow-hidden border-b border-white/5 px-10 md:px-24">
|
||||||
|
<div className="absolute inset-0 z-0">
|
||||||
|
<img src={heroImage} className="h-full w-full object-cover opacity-30" alt="Hero" />
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-l from-black via-transparent to-black" />
|
||||||
|
</div>
|
||||||
|
<div className="relative z-10 max-w-4xl">
|
||||||
|
<h2 className="mb-4 text-sm font-bold uppercase tracking-[0.4em] text-red-600">
|
||||||
|
{content.hero.eyebrow}
|
||||||
|
</h2>
|
||||||
|
<h1 className="mb-8 whitespace-pre-line text-6xl font-black leading-tight md:text-8xl">
|
||||||
|
{content.hero.title}
|
||||||
|
</h1>
|
||||||
|
<p className="mb-10 max-w-2xl text-lg leading-loose text-gray-400 md:text-xl">
|
||||||
|
{content.hero.description}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<SalesConsultationDialog brands={brands} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{content.brandsSection.isPublished ? (
|
||||||
|
<section id="brands" data-reveal className="bg-[#080808] px-6 py-32 md:px-20">
|
||||||
|
<div className="mx-auto mb-20 max-w-7xl text-right">
|
||||||
|
<h2 className="mb-4 text-5xl font-black">
|
||||||
|
{content.brandsSection.title} <span className="text-red-600">{content.brandsSection.accent}</span>
|
||||||
|
</h2>
|
||||||
|
<div className="h-1 w-20 bg-red-600" />
|
||||||
|
</div>
|
||||||
|
<div className="mx-auto max-w-7xl">
|
||||||
|
<BrandSlider brands={brands} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{content.services.isPublished ? (
|
||||||
|
<section id="services" data-reveal className="relative bg-zinc-950 px-6 py-32 md:px-20">
|
||||||
|
<div className="mx-auto grid max-w-7xl items-center gap-16 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<h2 className="mb-8 text-5xl font-black">
|
||||||
|
{content.services.title} <span className="text-red-600">{content.services.accent}</span>
|
||||||
|
</h2>
|
||||||
|
<div className="mb-8 rounded-[24px] border border-white/10 bg-white/5 p-8">
|
||||||
|
<p className="text-lg leading-loose text-gray-300">{content.services.description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
<a
|
||||||
|
href={afterSalesUrl}
|
||||||
|
className="bg-red-700 px-10 py-4 text-sm font-black uppercase tracking-widest text-white transition hover:bg-white hover:text-black"
|
||||||
|
>
|
||||||
|
رزرو نوبت
|
||||||
|
</a>
|
||||||
|
<div className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/5 px-6 py-4">
|
||||||
|
<span className="font-black text-red-600">{content.services.phoneValue}</span>
|
||||||
|
<span className="text-xs uppercase text-gray-500">{content.services.phoneLabel}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="service-image-container">
|
||||||
|
<img src={servicesImage} alt="Automotive Workshop" className="h-[500px] w-full object-cover" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{content.autoArshia.isPublished ? (
|
||||||
|
<section id="arshia" data-reveal className="relative overflow-hidden bg-[#050505] px-6 py-32 md:px-20">
|
||||||
|
<div className="absolute right-0 top-0 -z-10 h-[500px] w-[500px] rounded-full bg-red-600/10 blur-[120px]" />
|
||||||
|
<div className="absolute bottom-0 left-0 -z-10 h-[300px] w-[300px] rounded-full bg-white/5 blur-[100px]" />
|
||||||
|
|
||||||
|
<div className="mx-auto max-w-7xl">
|
||||||
|
<div className="mb-20 flex flex-col items-end justify-between gap-6 md:flex-row">
|
||||||
|
<div>
|
||||||
|
<h2 className="mb-4 text-6xl font-black uppercase tracking-tighter md:text-8xl">
|
||||||
|
{content.autoArshia.title} <span className="text-red-600">{content.autoArshia.accent}</span>
|
||||||
|
</h2>
|
||||||
|
<p className="text-xl font-bold text-gray-400">{content.autoArshia.subtitle}</p>
|
||||||
|
</div>
|
||||||
|
<div className="hidden md:block">
|
||||||
|
<div className="text-left">
|
||||||
|
<span className="text-xs font-black uppercase tracking-[5px] text-gray-600">
|
||||||
|
{content.autoArshia.poweredBy}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-10 md:grid-cols-2">
|
||||||
|
<div className="group relative overflow-hidden rounded-[40px] border border-white/5 p-12 transition-all duration-500 hover:border-red-600/50">
|
||||||
|
<div className="relative z-10">
|
||||||
|
<div className="mb-8 flex h-16 w-16 items-center justify-center rounded-2xl bg-red-600 shadow-[0_0_30px_rgba(165,28,36,0.5)]">
|
||||||
|
<BadgeDollarSign size={32} />
|
||||||
|
</div>
|
||||||
|
<h3 className="mb-4 text-4xl font-black">{content.autoArshia.sellerCard.title}</h3>
|
||||||
|
<p className="mb-10 text-lg leading-loose text-gray-400">
|
||||||
|
{content.autoArshia.sellerCard.description}
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href={content.autoArshia.sellerCard.ctaHref}
|
||||||
|
className="inline-flex rounded-2xl bg-white px-10 py-4 font-black text-black transition-all hover:bg-red-600 hover:text-white group-hover:scale-105"
|
||||||
|
>
|
||||||
|
{content.autoArshia.sellerCard.ctaLabel}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="absolute -bottom-10 -right-10 opacity-5 transition-opacity group-hover:opacity-10">
|
||||||
|
<svg width="300" height="300" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M18.92 6.01C18.72 5.42 18.16 5 17.5 5h-11c-.66 0-1.21.42-1.42 1.01L3 12v8c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-1h12v1c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-8l-2.08-5.99zM6.5 16c-.83 0-1.5-.67-1.5-1.5S5.67 13 6.5 13s1.5.67 1.5 1.5S7.33 16 6.5 16zm11 0c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zM5 11l1.5-4.5h11L19 11H5z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="group relative overflow-hidden rounded-[40px] border border-white/5 bg-gradient-to-br from-white/[0.03] to-transparent p-12 transition-all duration-500 hover:border-white/20">
|
||||||
|
<div className="relative z-10">
|
||||||
|
<div className="mb-8 flex h-16 w-16 items-center justify-center rounded-2xl bg-white text-black shadow-[0_0_30px_rgba(255,255,255,0.2)]">
|
||||||
|
<Search size={32} />
|
||||||
|
</div>
|
||||||
|
<h3 className="mb-4 text-4xl font-black">{content.autoArshia.buyerCard.title}</h3>
|
||||||
|
<p className="mb-10 text-lg leading-loose text-gray-400">
|
||||||
|
{content.autoArshia.buyerCard.description}
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href={content.autoArshia.buyerCard.ctaHref}
|
||||||
|
className="inline-flex rounded-2xl border-2 border-white px-10 py-4 font-black text-white transition-all hover:bg-white hover:text-black group-hover:scale-105"
|
||||||
|
>
|
||||||
|
{content.autoArshia.buyerCard.ctaLabel}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{content.branchesSection.isPublished ? (
|
||||||
|
<BranchesMap section={{ ...content.branchesSection, mapImageUrl: mapImage }} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{content.footer.isPublished ? (
|
||||||
|
<footer data-reveal className="border-t border-white/5 px-10 py-20 text-center">
|
||||||
|
<img src={footerLogo} alt={content.settings.siteTitle} className="mx-auto mb-10 h-20 object-contain" />
|
||||||
|
<p className="mx-auto mb-10 max-w-xl text-sm text-gray-500">{content.footer.description}</p>
|
||||||
|
<div className="flex justify-center gap-10 text-[10px] font-bold tracking-[5px] text-gray-700">
|
||||||
|
{content.settings.socialLinks.map((link) => (
|
||||||
|
<a key={link.id} href={link.href}>
|
||||||
|
{link.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="mt-10 text-xs font-bold tracking-[0.22em] text-gray-500">
|
||||||
|
طراحی توسط{" "}
|
||||||
|
<a
|
||||||
|
href="https://robinnetwork.ir"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-white transition hover:text-red-600"
|
||||||
|
>
|
||||||
|
رابین
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
) : null}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
251
src/components/home/sales-consultation-dialog.tsx
Normal file
251
src/components/home/sales-consultation-dialog.tsx
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { Loader2, X } from "lucide-react";
|
||||||
|
|
||||||
|
type BrandOption = {
|
||||||
|
id: string;
|
||||||
|
englishName: string;
|
||||||
|
persianName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
brands: BrandOption[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type FormState = {
|
||||||
|
fullName: string;
|
||||||
|
mobile: string;
|
||||||
|
brand: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialState: FormState = {
|
||||||
|
fullName: "",
|
||||||
|
mobile: "",
|
||||||
|
brand: "فرقی ندارد",
|
||||||
|
description: ""
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeMobile(input: string) {
|
||||||
|
const digits = input.replace(/\D/g, "");
|
||||||
|
|
||||||
|
if (!digits) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (digits.startsWith("98")) {
|
||||||
|
return `0${digits.slice(2, 12)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (digits.startsWith("0")) {
|
||||||
|
return digits.slice(0, 11);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `0${digits.slice(0, 10)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SalesConsultationDialog({ brands }: Props) {
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [form, setForm] = useState<FormState>(initialState);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeDialog = () => {
|
||||||
|
if (isSubmitting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousOverflow = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape" && !isSubmitting) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = previousOverflow;
|
||||||
|
window.removeEventListener("keydown", onKeyDown);
|
||||||
|
};
|
||||||
|
}, [isOpen, isSubmitting]);
|
||||||
|
|
||||||
|
const brandOptions = [{ id: "any", persianName: "فرقی ندارد", englishName: "No Preference" }, ...brands];
|
||||||
|
|
||||||
|
const onChange = (field: keyof FormState, value: string) => {
|
||||||
|
setMessage("");
|
||||||
|
setError("");
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
[field]: field === "mobile" ? normalizeMobile(value) : value
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setError("");
|
||||||
|
setMessage("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/sales-requests", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
...form,
|
||||||
|
mobile: normalizeMobile(form.mobile)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = (await response.json()) as { ok: boolean; message: string };
|
||||||
|
|
||||||
|
if (!response.ok || !payload.ok) {
|
||||||
|
setError(payload.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMessage(payload.message);
|
||||||
|
setForm(initialState);
|
||||||
|
} catch {
|
||||||
|
setError("ثبت درخواست انجام نشد.");
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const dialog = (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[220] flex items-center justify-center bg-black/72 px-4 backdrop-blur-md"
|
||||||
|
onClick={closeDialog}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative w-full max-w-2xl overflow-hidden rounded-[34px] border border-white/10 bg-[#0b0b0b] shadow-[0_40px_120px_rgba(0,0,0,0.75)]"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="ارتباط با کارشناسان فروش"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="pointer-events-none absolute left-[-90px] top-[-90px] h-52 w-52 rounded-full bg-red-600/18 blur-[90px]" />
|
||||||
|
<div className="pointer-events-none absolute bottom-[-120px] right-[-60px] h-56 w-56 rounded-full bg-white/8 blur-[110px]" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={closeDialog}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="absolute left-5 top-5 z-20 flex h-11 w-11 items-center justify-center rounded-full border border-white/10 bg-white/5 text-white transition hover:bg-white hover:text-black disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
aria-label="بستن"
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="relative z-10 p-6 md:p-10">
|
||||||
|
<div className="mb-8 border-b border-white/10 pb-6">
|
||||||
|
<div className="mb-3 inline-flex rounded-full border border-red-500/25 bg-red-600/10 px-4 py-1 text-[11px] font-black tracking-[0.28em] text-red-200">
|
||||||
|
SALES CONTACT
|
||||||
|
</div>
|
||||||
|
<h3 className="text-3xl font-black text-white md:text-4xl">ارتباط با کارشناسان فروش</h3>
|
||||||
|
<p className="mt-3 max-w-xl text-sm leading-7 text-gray-400">
|
||||||
|
اطلاعات خود را ثبت کنید تا کارشناسان فروش برای راهنمایی و معرفی گزینه مناسب با شما تماس بگیرند.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={onSubmit} className="grid gap-4 md:grid-cols-2">
|
||||||
|
<label className="space-y-2 md:col-span-2">
|
||||||
|
<span className="text-sm text-gray-300">نام و نام خانوادگی</span>
|
||||||
|
<input
|
||||||
|
value={form.fullName}
|
||||||
|
onChange={(event) => onChange("fullName", event.target.value)}
|
||||||
|
className="w-full rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-white outline-none transition placeholder:text-gray-500 focus:border-red-500/40"
|
||||||
|
placeholder="مثال: علی محمدی"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="space-y-2">
|
||||||
|
<span className="text-sm text-gray-300">موبایل</span>
|
||||||
|
<input
|
||||||
|
value={form.mobile}
|
||||||
|
onChange={(event) => onChange("mobile", event.target.value)}
|
||||||
|
className="w-full rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-white outline-none transition placeholder:text-gray-500 focus:border-red-500/40"
|
||||||
|
placeholder="09123456789"
|
||||||
|
inputMode="numeric"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="space-y-2">
|
||||||
|
<span className="text-sm text-gray-300">برند موردعلاقه</span>
|
||||||
|
<select
|
||||||
|
value={form.brand}
|
||||||
|
onChange={(event) => onChange("brand", event.target.value)}
|
||||||
|
className="w-full rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-white outline-none transition focus:border-red-500/40"
|
||||||
|
>
|
||||||
|
{brandOptions.map((brand) => (
|
||||||
|
<option key={brand.id} value={brand.persianName}>
|
||||||
|
{brand.persianName}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="space-y-2 md:col-span-2">
|
||||||
|
<span className="text-sm text-gray-300">توضیحات</span>
|
||||||
|
<textarea
|
||||||
|
value={form.description}
|
||||||
|
onChange={(event) => onChange("description", event.target.value)}
|
||||||
|
className="min-h-[130px] w-full rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-white outline-none transition placeholder:text-gray-500 focus:border-red-500/40"
|
||||||
|
placeholder="اختیاری"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error ? <div className="text-sm text-red-300 md:col-span-2">{error}</div> : null}
|
||||||
|
{message ? <div className="text-sm text-emerald-300 md:col-span-2">{message}</div> : null}
|
||||||
|
|
||||||
|
<div className="mt-2 md:col-span-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="inline-flex min-w-[190px] items-center justify-center gap-2 rounded-2xl bg-red-700 px-8 py-4 text-sm font-black text-white transition hover:bg-white hover:text-black disabled:cursor-not-allowed disabled:opacity-70"
|
||||||
|
>
|
||||||
|
{isSubmitting ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||||
|
ثبت درخواست
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOpen(true)}
|
||||||
|
className="bg-red-700 px-12 py-4 text-sm font-black uppercase tracking-widest text-white transition hover:bg-white hover:text-black"
|
||||||
|
>
|
||||||
|
ارتباط با کارشناسان فروش
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{mounted && isOpen ? createPortal(dialog, document.body) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
src/components/home/scroll-effects.tsx
Normal file
43
src/components/home/scroll-effects.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import gsap from "gsap";
|
||||||
|
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||||
|
|
||||||
|
export function ScrollEffects() {
|
||||||
|
const mountedRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mountedRef.current) return;
|
||||||
|
mountedRef.current = true;
|
||||||
|
|
||||||
|
gsap.registerPlugin(ScrollTrigger);
|
||||||
|
|
||||||
|
gsap.to(".scroll-progress", {
|
||||||
|
width: "100%",
|
||||||
|
scrollTrigger: {
|
||||||
|
scrub: 0.3
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const onScroll = () => {
|
||||||
|
const nav = document.getElementById("main-nav");
|
||||||
|
if (!nav) return;
|
||||||
|
if (window.scrollY > 12) {
|
||||||
|
nav.classList.add("nav-scrolled");
|
||||||
|
} else {
|
||||||
|
nav.classList.remove("nav-scrolled");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("scroll", onScroll);
|
||||||
|
onScroll();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("scroll", onScroll);
|
||||||
|
ScrollTrigger.getAll().forEach((trigger) => trigger.kill());
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <div className="scroll-progress" />;
|
||||||
|
}
|
||||||
484
src/lib/actions/admin.ts
Normal file
484
src/lib/actions/admin.ts
Normal file
@@ -0,0 +1,484 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { hashSync } from "bcryptjs";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { isRedirectError } from "next/dist/client/components/redirect";
|
||||||
|
import { signIn, signOut } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { defaultSiteContent } from "@/lib/default-content";
|
||||||
|
import { hasRole } from "@/lib/permissions";
|
||||||
|
import { getSiteContent, saveSiteContent } from "@/lib/site-content";
|
||||||
|
import {
|
||||||
|
autoArshiaSchema,
|
||||||
|
branchSchema,
|
||||||
|
branchesSectionSchema,
|
||||||
|
brandSchema,
|
||||||
|
createUserSchema,
|
||||||
|
footerSchema,
|
||||||
|
heroSchema,
|
||||||
|
roleSchema,
|
||||||
|
servicesSchema,
|
||||||
|
siteSettingsSchema
|
||||||
|
} from "@/lib/validators/site-content";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { slugify } from "@/lib/utils";
|
||||||
|
|
||||||
|
async function requireRole(required: "EDITOR" | "ADMIN" | "SUPERADMIN") {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
redirect("/mugmanager/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasRole(session.user.role, required)) {
|
||||||
|
throw new Error("شما دسترسی لازم برای این عملیات را ندارید.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return session.user;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCheckbox(formData: FormData, name: string) {
|
||||||
|
return formData.get(name) === "on";
|
||||||
|
}
|
||||||
|
|
||||||
|
function toText(value: FormDataEntryValue | null) {
|
||||||
|
return typeof value === "string" ? value : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function ok(message: string) {
|
||||||
|
return { ok: true, message };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(error: unknown) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: error instanceof Error ? error.message : "خطای ناشناخته"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoginState = {
|
||||||
|
ok: boolean;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function loginActionWithState(
|
||||||
|
_prevState: LoginState,
|
||||||
|
formData: FormData
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const result = await signIn("credentials", {
|
||||||
|
email: toText(formData.get("email")),
|
||||||
|
password: toText(formData.get("password")),
|
||||||
|
redirect: false,
|
||||||
|
redirectTo: "/mugmanager"
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result?.error) {
|
||||||
|
return { ok: false, message: "ایمیل یا رمز عبور نامعتبر است." };
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect("/mugmanager");
|
||||||
|
} catch (error) {
|
||||||
|
if (isRedirectError(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: false, message: "ایمیل یا رمز عبور نامعتبر است." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logoutAction() {
|
||||||
|
await signOut({ redirectTo: "/mugmanager/login" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSiteSettingsAction(formData: FormData) {
|
||||||
|
await requireRole("ADMIN");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = await getSiteContent();
|
||||||
|
const socialLinks = JSON.parse(
|
||||||
|
toText(formData.get("socialLinks")) || "[]"
|
||||||
|
) as typeof content.settings.socialLinks;
|
||||||
|
|
||||||
|
content.settings = siteSettingsSchema.parse({
|
||||||
|
siteTitle: toText(formData.get("siteTitle")),
|
||||||
|
siteSubtitle: toText(formData.get("siteSubtitle")),
|
||||||
|
logoUrl: toText(formData.get("logoUrl")),
|
||||||
|
footerLogoUrl: toText(formData.get("footerLogoUrl")),
|
||||||
|
brandFontUrl: toText(formData.get("brandFontUrl")),
|
||||||
|
centralOfficeLabel: toText(formData.get("centralOfficeLabel")),
|
||||||
|
centralOfficePhone: toText(formData.get("centralOfficePhone")),
|
||||||
|
customerClubLabel: toText(formData.get("customerClubLabel")),
|
||||||
|
customerClubUrl: toText(formData.get("customerClubUrl")),
|
||||||
|
socialLinks
|
||||||
|
});
|
||||||
|
|
||||||
|
await saveSiteContent(content);
|
||||||
|
revalidatePath("/");
|
||||||
|
revalidatePath("/mugmanager");
|
||||||
|
return ok("تنظیمات سایت ذخیره شد.");
|
||||||
|
} catch (error) {
|
||||||
|
return fail(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateHeroAction(formData: FormData) {
|
||||||
|
await requireRole("EDITOR");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = await getSiteContent();
|
||||||
|
content.hero = heroSchema.parse({
|
||||||
|
isPublished: readCheckbox(formData, "isPublished"),
|
||||||
|
eyebrow: toText(formData.get("eyebrow")),
|
||||||
|
title: toText(formData.get("title")),
|
||||||
|
description: toText(formData.get("description")),
|
||||||
|
primaryCtaLabel: toText(formData.get("primaryCtaLabel")),
|
||||||
|
primaryCtaHref: toText(formData.get("primaryCtaHref")),
|
||||||
|
backgroundImageUrl: toText(formData.get("backgroundImageUrl"))
|
||||||
|
});
|
||||||
|
|
||||||
|
await saveSiteContent(content);
|
||||||
|
revalidatePath("/");
|
||||||
|
return ok("هیرو بهروزرسانی شد.");
|
||||||
|
} catch (error) {
|
||||||
|
return fail(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateServicesAction(formData: FormData) {
|
||||||
|
await requireRole("EDITOR");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = await getSiteContent();
|
||||||
|
content.services = servicesSchema.parse({
|
||||||
|
isPublished: readCheckbox(formData, "isPublished"),
|
||||||
|
title: toText(formData.get("title")),
|
||||||
|
accent: toText(formData.get("accent")),
|
||||||
|
description: toText(formData.get("description")),
|
||||||
|
primaryCtaLabel: toText(formData.get("primaryCtaLabel")),
|
||||||
|
primaryCtaHref: toText(formData.get("primaryCtaHref")),
|
||||||
|
phoneLabel: toText(formData.get("phoneLabel")),
|
||||||
|
phoneValue: toText(formData.get("phoneValue")),
|
||||||
|
imageUrl: toText(formData.get("imageUrl"))
|
||||||
|
});
|
||||||
|
|
||||||
|
await saveSiteContent(content);
|
||||||
|
revalidatePath("/");
|
||||||
|
return ok("بخش خدمات ذخیره شد.");
|
||||||
|
} catch (error) {
|
||||||
|
return fail(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAutoArshiaAction(formData: FormData) {
|
||||||
|
await requireRole("EDITOR");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = await getSiteContent();
|
||||||
|
content.autoArshia = autoArshiaSchema.parse({
|
||||||
|
isPublished: readCheckbox(formData, "isPublished"),
|
||||||
|
title: toText(formData.get("title")),
|
||||||
|
accent: toText(formData.get("accent")),
|
||||||
|
subtitle: toText(formData.get("subtitle")),
|
||||||
|
poweredBy: toText(formData.get("poweredBy")),
|
||||||
|
sellerCard: {
|
||||||
|
title: toText(formData.get("sellerTitle")),
|
||||||
|
description: toText(formData.get("sellerDescription")),
|
||||||
|
ctaLabel: toText(formData.get("sellerCtaLabel")),
|
||||||
|
ctaHref: toText(formData.get("sellerCtaHref")),
|
||||||
|
icon: "seller"
|
||||||
|
},
|
||||||
|
buyerCard: {
|
||||||
|
title: toText(formData.get("buyerTitle")),
|
||||||
|
description: toText(formData.get("buyerDescription")),
|
||||||
|
ctaLabel: toText(formData.get("buyerCtaLabel")),
|
||||||
|
ctaHref: toText(formData.get("buyerCtaHref")),
|
||||||
|
icon: "buyer"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await saveSiteContent(content);
|
||||||
|
revalidatePath("/");
|
||||||
|
return ok("بخش Auto Arshia ذخیره شد.");
|
||||||
|
} catch (error) {
|
||||||
|
return fail(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateFooterAction(formData: FormData) {
|
||||||
|
await requireRole("EDITOR");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = await getSiteContent();
|
||||||
|
content.footer = footerSchema.parse({
|
||||||
|
isPublished: readCheckbox(formData, "isPublished"),
|
||||||
|
description: toText(formData.get("description"))
|
||||||
|
});
|
||||||
|
|
||||||
|
await saveSiteContent(content);
|
||||||
|
revalidatePath("/");
|
||||||
|
return ok("فوتر بهروزرسانی شد.");
|
||||||
|
} catch (error) {
|
||||||
|
return fail(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateBranchesSectionAction(formData: FormData) {
|
||||||
|
await requireRole("EDITOR");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = await getSiteContent();
|
||||||
|
content.branchesSection = {
|
||||||
|
...content.branchesSection,
|
||||||
|
...branchesSectionSchema.parse({
|
||||||
|
isPublished: readCheckbox(formData, "isPublished"),
|
||||||
|
sectionId: toText(formData.get("sectionId")),
|
||||||
|
mapImageUrl: toText(formData.get("mapImageUrl"))
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
await saveSiteContent(content);
|
||||||
|
revalidatePath("/");
|
||||||
|
return ok("تنظیمات شعب ذخیره شد.");
|
||||||
|
} catch (error) {
|
||||||
|
return fail(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertBrandAction(formData: FormData) {
|
||||||
|
await requireRole("EDITOR");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = await getSiteContent();
|
||||||
|
const parsed = brandSchema.parse({
|
||||||
|
id: toText(formData.get("id")) || slugify(toText(formData.get("englishName"))),
|
||||||
|
sortOrder: toText(formData.get("sortOrder")),
|
||||||
|
englishName: toText(formData.get("englishName")),
|
||||||
|
persianName: toText(formData.get("persianName")),
|
||||||
|
code: toText(formData.get("code")),
|
||||||
|
imageUrl: toText(formData.get("imageUrl")),
|
||||||
|
link: toText(formData.get("link"))
|
||||||
|
});
|
||||||
|
|
||||||
|
const existingIndex = content.brandsSection.brands.findIndex(
|
||||||
|
(brand) => brand.id === parsed.id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
content.brandsSection.brands[existingIndex] = parsed;
|
||||||
|
} else {
|
||||||
|
content.brandsSection.brands.push(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
content.brandsSection.brands.sort((a, b) => a.sortOrder - b.sortOrder);
|
||||||
|
await saveSiteContent(content);
|
||||||
|
revalidatePath("/");
|
||||||
|
revalidatePath("/mugmanager");
|
||||||
|
return ok("برند ذخیره شد.");
|
||||||
|
} catch (error) {
|
||||||
|
return fail(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteBrandAction(formData: FormData) {
|
||||||
|
await requireRole("EDITOR");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = await getSiteContent();
|
||||||
|
const id = toText(formData.get("id"));
|
||||||
|
content.brandsSection.brands = content.brandsSection.brands.filter(
|
||||||
|
(brand) => brand.id !== id
|
||||||
|
);
|
||||||
|
await saveSiteContent(content);
|
||||||
|
revalidatePath("/");
|
||||||
|
revalidatePath("/mugmanager");
|
||||||
|
return ok("برند حذف شد.");
|
||||||
|
} catch (error) {
|
||||||
|
return fail(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertBranchAction(formData: FormData) {
|
||||||
|
await requireRole("EDITOR");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = await getSiteContent();
|
||||||
|
const parsed = branchSchema.parse({
|
||||||
|
id: toText(formData.get("id")) || slugify(toText(formData.get("name"))),
|
||||||
|
sortOrder: toText(formData.get("sortOrder")),
|
||||||
|
slug: toText(formData.get("slug")) || slugify(toText(formData.get("name"))),
|
||||||
|
tag: toText(formData.get("tag")),
|
||||||
|
name: toText(formData.get("name")),
|
||||||
|
address: toText(formData.get("address")),
|
||||||
|
phone: toText(formData.get("phone")),
|
||||||
|
mapUrl: toText(formData.get("mapUrl")),
|
||||||
|
markerTop: toText(formData.get("markerTop")),
|
||||||
|
markerLeft: toText(formData.get("markerLeft"))
|
||||||
|
});
|
||||||
|
|
||||||
|
const existingIndex = content.branchesSection.branches.findIndex(
|
||||||
|
(branch) => branch.id === parsed.id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
content.branchesSection.branches[existingIndex] = parsed;
|
||||||
|
} else {
|
||||||
|
content.branchesSection.branches.push(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
content.branchesSection.branches.sort((a, b) => a.sortOrder - b.sortOrder);
|
||||||
|
await saveSiteContent(content);
|
||||||
|
revalidatePath("/");
|
||||||
|
revalidatePath("/mugmanager");
|
||||||
|
return ok("شعبه ذخیره شد.");
|
||||||
|
} catch (error) {
|
||||||
|
return fail(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteBranchAction(formData: FormData) {
|
||||||
|
await requireRole("EDITOR");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = await getSiteContent();
|
||||||
|
const id = toText(formData.get("id"));
|
||||||
|
content.branchesSection.branches = content.branchesSection.branches.filter(
|
||||||
|
(branch) => branch.id !== id
|
||||||
|
);
|
||||||
|
await saveSiteContent(content);
|
||||||
|
revalidatePath("/");
|
||||||
|
revalidatePath("/mugmanager");
|
||||||
|
return ok("شعبه حذف شد.");
|
||||||
|
} catch (error) {
|
||||||
|
return fail(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUserAction(formData: FormData) {
|
||||||
|
const currentUser = await requireRole("SUPERADMIN");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = createUserSchema.parse({
|
||||||
|
name: toText(formData.get("name")),
|
||||||
|
email: toText(formData.get("email")),
|
||||||
|
password: toText(formData.get("password")),
|
||||||
|
role: toText(formData.get("role"))
|
||||||
|
});
|
||||||
|
|
||||||
|
if (parsed.role === "SUPERADMIN" && currentUser.role !== "SUPERADMIN") {
|
||||||
|
throw new Error("فقط سوپرادمین میتواند سوپرادمین بسازد.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
name: parsed.name,
|
||||||
|
email: parsed.email,
|
||||||
|
passwordHash: hashSync(parsed.password, 10),
|
||||||
|
role: parsed.role
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/mugmanager");
|
||||||
|
return ok("کاربر جدید ساخته شد.");
|
||||||
|
} catch (error) {
|
||||||
|
return fail(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUserRoleAction(formData: FormData) {
|
||||||
|
await requireRole("SUPERADMIN");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const id = toText(formData.get("id"));
|
||||||
|
const role = roleSchema.parse(toText(formData.get("role")));
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: { role }
|
||||||
|
});
|
||||||
|
revalidatePath("/mugmanager");
|
||||||
|
return ok("نقش کاربر بهروزرسانی شد.");
|
||||||
|
} catch (error) {
|
||||||
|
return fail(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUserAction(formData: FormData) {
|
||||||
|
const currentUser = await requireRole("SUPERADMIN");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const id = toText(formData.get("id"));
|
||||||
|
|
||||||
|
if (currentUser.id === id) {
|
||||||
|
throw new Error("حذف کاربر فعلی مجاز نیست.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.user.delete({
|
||||||
|
where: { id }
|
||||||
|
});
|
||||||
|
revalidatePath("/mugmanager");
|
||||||
|
return ok("کاربر حذف شد.");
|
||||||
|
} catch (error) {
|
||||||
|
return fail(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadAssetAction(formData: FormData) {
|
||||||
|
try {
|
||||||
|
await requireRole("ADMIN");
|
||||||
|
const file = formData.get("file");
|
||||||
|
const label = toText(formData.get("label")) || "Asset";
|
||||||
|
|
||||||
|
if (!(file instanceof File)) {
|
||||||
|
throw new Error("فایل معتبر ارسال نشده است.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
|
const extension = file.name.includes(".")
|
||||||
|
? file.name.split(".").pop()
|
||||||
|
: "bin";
|
||||||
|
const safeName = `${Date.now()}-${slugify(label)}.${extension}`;
|
||||||
|
const relativePath = `/uploads/${safeName}`;
|
||||||
|
const fs = await import("fs/promises");
|
||||||
|
const path = await import("path");
|
||||||
|
|
||||||
|
await fs.mkdir(path.join(process.cwd(), "public", "uploads"), {
|
||||||
|
recursive: true
|
||||||
|
});
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(process.cwd(), "public", "uploads", safeName),
|
||||||
|
buffer
|
||||||
|
);
|
||||||
|
|
||||||
|
const content = await getSiteContent();
|
||||||
|
content.mediaLibrary = [
|
||||||
|
{
|
||||||
|
id: safeName,
|
||||||
|
label,
|
||||||
|
url: relativePath,
|
||||||
|
kind: file.type.includes("font")
|
||||||
|
? "font"
|
||||||
|
: file.type.includes("svg")
|
||||||
|
? "svg"
|
||||||
|
: "image"
|
||||||
|
},
|
||||||
|
...content.mediaLibrary.filter((asset) => asset.id !== safeName)
|
||||||
|
];
|
||||||
|
|
||||||
|
await saveSiteContent(content);
|
||||||
|
revalidatePath("/");
|
||||||
|
revalidatePath("/mugmanager");
|
||||||
|
return ok(`فایل با مسیر ${relativePath} ذخیره شد.`);
|
||||||
|
} catch (error) {
|
||||||
|
return fail(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetContentAction() {
|
||||||
|
await requireRole("SUPERADMIN");
|
||||||
|
await saveSiteContent(defaultSiteContent);
|
||||||
|
revalidatePath("/");
|
||||||
|
revalidatePath("/mugmanager");
|
||||||
|
return ok("محتوای سایت به حالت اولیه برگشت.");
|
||||||
|
}
|
||||||
99
src/lib/auth.ts
Normal file
99
src/lib/auth.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import NextAuth from "next-auth";
|
||||||
|
import Credentials from "next-auth/providers/credentials";
|
||||||
|
import { compareSync } from "bcryptjs";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||||
|
trustHost: true,
|
||||||
|
session: { strategy: "jwt" },
|
||||||
|
pages: {
|
||||||
|
signIn: "/mugmanager/login"
|
||||||
|
},
|
||||||
|
providers: [
|
||||||
|
Credentials({
|
||||||
|
name: "Credentials",
|
||||||
|
credentials: {
|
||||||
|
email: { label: "Email", type: "email" },
|
||||||
|
password: { label: "Password", type: "password" }
|
||||||
|
},
|
||||||
|
async authorize(credentials) {
|
||||||
|
const email = String(credentials?.email ?? "");
|
||||||
|
const password = String(credentials?.password ?? "");
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { email }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
const isValid = compareSync(password, user.passwordHash);
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall through to env-based admin auth when Prisma is unavailable.
|
||||||
|
}
|
||||||
|
|
||||||
|
const envEmail = process.env.SEED_SUPERADMIN_EMAIL;
|
||||||
|
const envPassword = process.env.SEED_SUPERADMIN_PASSWORD;
|
||||||
|
const envName = process.env.SEED_SUPERADMIN_NAME || "Super Admin";
|
||||||
|
|
||||||
|
if (email === envEmail && password === envPassword) {
|
||||||
|
return {
|
||||||
|
id: "env-superadmin",
|
||||||
|
name: envName,
|
||||||
|
email: envEmail,
|
||||||
|
role: "SUPERADMIN"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
],
|
||||||
|
callbacks: {
|
||||||
|
authorized({ auth, request }) {
|
||||||
|
const pathname = request.nextUrl.pathname;
|
||||||
|
|
||||||
|
if (pathname.startsWith("/mugmanager/login")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname.startsWith("/mugmanager")) {
|
||||||
|
return !!auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
jwt({ token, user }) {
|
||||||
|
if (user) {
|
||||||
|
token.role = user.role;
|
||||||
|
token.name = user.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return token;
|
||||||
|
},
|
||||||
|
session({ session, token }) {
|
||||||
|
if (session.user) {
|
||||||
|
session.user.id = token.sub ?? "";
|
||||||
|
session.user.role = (token.role as "SUPERADMIN" | "ADMIN" | "EDITOR") ?? "EDITOR";
|
||||||
|
}
|
||||||
|
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
159
src/lib/default-content.ts
Normal file
159
src/lib/default-content.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import { SiteContent } from "@/types/content";
|
||||||
|
|
||||||
|
export const defaultSiteContent: SiteContent = {
|
||||||
|
settings: {
|
||||||
|
siteTitle: "گروه بازرگانی قاسمپور",
|
||||||
|
siteSubtitle: "Ghasempour Trading Group",
|
||||||
|
logoUrl: "/uploads/logo-placeholder.svg",
|
||||||
|
footerLogoUrl: "/uploads/logo-placeholder.svg",
|
||||||
|
brandFontUrl: "/assets/fonts/modam.woff2",
|
||||||
|
centralOfficeLabel: "CENTRAL OFFICE",
|
||||||
|
centralOfficePhone: "۰۷۱-۳۸۸۹",
|
||||||
|
customerClubLabel: "خدمات پس از فروش",
|
||||||
|
customerClubUrl: "https://service.ghasempour.co",
|
||||||
|
socialLinks: [
|
||||||
|
{ id: "instagram", label: "INSTAGRAM", href: "#" },
|
||||||
|
{ id: "telegram", label: "TELEGRAM", href: "#" },
|
||||||
|
{ id: "linkedin", label: "LINKEDIN", href: "#" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
hero: {
|
||||||
|
isPublished: true,
|
||||||
|
eyebrow: "Ghasempour Commerce Group",
|
||||||
|
title: "تجربه قدرت و اصالت \nدر رانندگی",
|
||||||
|
description:
|
||||||
|
"نمایندگی رسمی برترین برندهای خودرویی با بیش از ۱۵ سال سابقه درخشان در تامین و خدمات خودرو در جنوب کشور.",
|
||||||
|
primaryCtaLabel: "ارتباط با کارشناسان فروش",
|
||||||
|
primaryCtaHref: "#",
|
||||||
|
backgroundImageUrl: "/uploads/car-placeholder.svg"
|
||||||
|
},
|
||||||
|
brandsSection: {
|
||||||
|
isPublished: true,
|
||||||
|
title: "برندهای",
|
||||||
|
accent: "بازرگانی",
|
||||||
|
brands: [
|
||||||
|
{ id: "xtrim", sortOrder: 1, englishName: "XTRIM", persianName: "ایکس تریم", code: "کد ۵۰۵", imageUrl: "/uploads/car-placeholder.svg", link: "#" },
|
||||||
|
{ id: "fownix", sortOrder: 2, englishName: "Fownix", persianName: "فونیکس", code: "کد ۵۰۵", imageUrl: "/uploads/car-placeholder.svg", link: "#" },
|
||||||
|
{ id: "mvm", sortOrder: 3, englishName: "MVM", persianName: "ام وی ام", code: "کد ۵۰۵", imageUrl: "/uploads/car-placeholder.svg", link: "#" },
|
||||||
|
{ id: "arina-drive", sortOrder: 4, englishName: "Arina Drive", persianName: "آرینا درایو", code: "کد ۵۲۵", imageUrl: "/uploads/car-placeholder.svg", link: "#" },
|
||||||
|
{ id: "lamari", sortOrder: 5, englishName: "LAMARI", persianName: "لاماری", code: "کد ۱۷۰۱", imageUrl: "/uploads/car-placeholder.svg", link: "#" },
|
||||||
|
{ id: "rubber", sortOrder: 6, englishName: "Rubber", persianName: "لاستیک قاسمپور", code: "کد ۵۲۵", imageUrl: "/uploads/car-placeholder.svg", link: "#" },
|
||||||
|
{ id: "nissan", sortOrder: 7, englishName: "Nissan", persianName: "نیسان", code: "کد ۱۲۹۱", imageUrl: "/uploads/car-placeholder.svg", link: "#" },
|
||||||
|
{ id: "hyundai", sortOrder: 8, englishName: "Hyundai", persianName: "هیوندای", code: "کد ۷۱۰", imageUrl: "/uploads/car-placeholder.svg", link: "#" },
|
||||||
|
{ id: "kavir", sortOrder: 9, englishName: "Kavir", persianName: "کویر موتور", code: "کد ۵۲۵", imageUrl: "/uploads/car-placeholder.svg", link: "#" },
|
||||||
|
{ id: "audi", sortOrder: 10, englishName: "AUDI", persianName: "آئودی", code: "کد ۵۲۵", imageUrl: "/uploads/car-placeholder.svg", link: "#" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
services: {
|
||||||
|
isPublished: true,
|
||||||
|
title: "خدمات",
|
||||||
|
accent: "پس از فروش",
|
||||||
|
description:
|
||||||
|
"ما در بازرگانی قاسمپور معتقدیم تعهد ما با فروش آغاز میشود. مرکز تخصصی ما مجهز به مدرنترین دستگاههای عیبیابی، خدمات تضمینی و تامین قطعات اصلی تمامی برندهاست.",
|
||||||
|
primaryCtaLabel: "رزرو نوبت",
|
||||||
|
primaryCtaHref: "https://service.ghasempour.co",
|
||||||
|
phoneLabel: "Call center",
|
||||||
|
phoneValue: "۰۷۱-۳۸۸۹",
|
||||||
|
imageUrl: "/uploads/car-placeholder.svg"
|
||||||
|
},
|
||||||
|
autoArshia: {
|
||||||
|
isPublished: true,
|
||||||
|
title: "AUTO",
|
||||||
|
accent: "ARSHIA",
|
||||||
|
subtitle: "هوشمندترین سامانه آنلاین خرید و فروش خودرو در ایران",
|
||||||
|
poweredBy: "Powered by Ghasempour",
|
||||||
|
sellerCard: {
|
||||||
|
title: "قصد فروش دارید؟",
|
||||||
|
description:
|
||||||
|
"خودروی خود را در کمترین زمان ممکن و با قیمت واقعی بازار به شبکه خریداران اتو عرشیا معرفی کنید. فروش فوری، امن و بدون واسطه.",
|
||||||
|
ctaLabel: "ثبت آگهی فروش",
|
||||||
|
ctaHref: "#",
|
||||||
|
icon: "seller"
|
||||||
|
},
|
||||||
|
buyerCard: {
|
||||||
|
title: "دنبال خرید هستید؟",
|
||||||
|
description:
|
||||||
|
"بودجه خود را مشخص کنید تا هوش مصنوعی اتو عرشیا، بهترین خودروهای کارشناسی شده را به شما پیشنهاد دهد. خریدی هوشمندانه و مطمئن.",
|
||||||
|
ctaLabel: "جستجو بر اساس بودجه",
|
||||||
|
ctaHref: "#",
|
||||||
|
icon: "buyer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
branchesSection: {
|
||||||
|
isPublished: true,
|
||||||
|
sectionId: "ghasempour-branches",
|
||||||
|
mapImageUrl: "/uploads/iran-placeholder.svg",
|
||||||
|
branches: [
|
||||||
|
{
|
||||||
|
id: "shiraz",
|
||||||
|
sortOrder: 1,
|
||||||
|
slug: "shiraz",
|
||||||
|
tag: "شعبه مرکزی",
|
||||||
|
name: "بازرگانی قاسمپور (شیراز)",
|
||||||
|
address: "شیراز، بلوار امیرکبیر، نرسیده به پلیس راه، مجتمع خودرویی قاسمپور",
|
||||||
|
phone: "۰۷۱-۳۸۸۹",
|
||||||
|
mapUrl: "#",
|
||||||
|
markerTop: "66%",
|
||||||
|
markerLeft: "51%"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tehran",
|
||||||
|
sortOrder: 2,
|
||||||
|
slug: "tehran",
|
||||||
|
tag: "شعبه پایتخت",
|
||||||
|
name: "مجتمع خودرویی آرشیا (تهران)",
|
||||||
|
address: "تهران، جاده مخصوص کرج، کیلومتر ۱۲، نبش خیابان اصلی",
|
||||||
|
phone: "۰۲۱-۴۴۵۵۶۶۷۷",
|
||||||
|
mapUrl: "#",
|
||||||
|
markerTop: "30%",
|
||||||
|
markerLeft: "48%"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "bandar",
|
||||||
|
sortOrder: 3,
|
||||||
|
slug: "bandar",
|
||||||
|
tag: "شعبه هرمزگان",
|
||||||
|
name: "پرشیا خودرو (بندرعباس)",
|
||||||
|
address: "بندرعباس، بلوار امام خمینی، جنب هتل هما، شعبه بازرگانی قاسمپور",
|
||||||
|
phone: "۰۷۶-۳۳۴۴۵۵۶۶",
|
||||||
|
mapUrl: "#",
|
||||||
|
markerTop: "82%",
|
||||||
|
markerLeft: "68%"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "bushehr",
|
||||||
|
sortOrder: 4,
|
||||||
|
slug: "bushehr",
|
||||||
|
tag: "شعبه بوشهر",
|
||||||
|
name: "نمایشگاه ساحلی (بوشهر)",
|
||||||
|
address: "بوشهر، بلوار ساحلی، میدان خلیج فارس، مجتمع تجاری خودرو",
|
||||||
|
phone: "۰۷۷-۳۳۲۲۱۱۰۰",
|
||||||
|
mapUrl: "#",
|
||||||
|
markerTop: "75%",
|
||||||
|
markerLeft: "38%"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ahvaz",
|
||||||
|
sortOrder: 5,
|
||||||
|
slug: "ahvaz",
|
||||||
|
tag: "شعبه خوزستان",
|
||||||
|
name: "مرکز فروش کارون (اهواز)",
|
||||||
|
address: "اهواز، اتوبان آیتالله بهبهانی، روبروی ترمینال آبادان",
|
||||||
|
phone: "۰۶۱-۳۳۷۷۸۸۹۹",
|
||||||
|
mapUrl: "#",
|
||||||
|
markerTop: "61%",
|
||||||
|
markerLeft: "32%"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
isPublished: true,
|
||||||
|
description:
|
||||||
|
"گروه بازرگانی قاسمپور، نماد اعتماد و پیشرو در ارائه خدمات نوین خودرویی. تمامی حقوق برای این مجموعه محفوظ است."
|
||||||
|
},
|
||||||
|
mediaLibrary: [
|
||||||
|
{ id: "default-logo", label: "لوگوی پیشفرض", url: "/uploads/logo-placeholder.svg", kind: "svg" },
|
||||||
|
{ id: "default-map", label: "نقشه پیشفرض", url: "/uploads/iran-placeholder.svg", kind: "svg" },
|
||||||
|
{ id: "default-car", label: "تصویر پیشفرض", url: "/uploads/car-placeholder.svg", kind: "svg" }
|
||||||
|
]
|
||||||
|
};
|
||||||
11
src/lib/permissions.ts
Normal file
11
src/lib/permissions.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Role } from "@/types/content";
|
||||||
|
|
||||||
|
const rank: Record<Role, number> = {
|
||||||
|
EDITOR: 1,
|
||||||
|
ADMIN: 2,
|
||||||
|
SUPERADMIN: 3
|
||||||
|
};
|
||||||
|
|
||||||
|
export function hasRole(userRole: Role, required: Role) {
|
||||||
|
return rank[userRole] >= rank[required];
|
||||||
|
}
|
||||||
50
src/lib/prisma.ts
Normal file
50
src/lib/prisma.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
// eslint-disable-next-line no-var
|
||||||
|
var prisma: PrismaClient | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let prismaInitError: Error | null = null;
|
||||||
|
|
||||||
|
function createPrismaClient() {
|
||||||
|
if (global.prisma) {
|
||||||
|
return global.prisma;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = new PrismaClient({
|
||||||
|
log: process.env.NODE_ENV === "development" ? ["error"] : ["error"]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
global.prisma = client;
|
||||||
|
}
|
||||||
|
|
||||||
|
prismaInitError = null;
|
||||||
|
return client;
|
||||||
|
} catch (error) {
|
||||||
|
prismaInitError =
|
||||||
|
error instanceof Error ? error : new Error("Prisma client initialization failed.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPrismaClient() {
|
||||||
|
return createPrismaClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const prisma = new Proxy(
|
||||||
|
{} as PrismaClient,
|
||||||
|
{
|
||||||
|
get(_target, prop, receiver) {
|
||||||
|
const client = getPrismaClient();
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
throw prismaInitError ?? new Error("Prisma client is unavailable.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Reflect.get(client, prop, receiver);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
67
src/lib/site-content.ts
Normal file
67
src/lib/site-content.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { defaultSiteContent } from "@/lib/default-content";
|
||||||
|
import { SiteContent, UserRecord } from "@/types/content";
|
||||||
|
|
||||||
|
function normalizeSiteContent(content: Prisma.JsonValue | null | undefined): SiteContent {
|
||||||
|
if (!content || typeof content !== "object" || Array.isArray(content)) {
|
||||||
|
return defaultSiteContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...defaultSiteContent,
|
||||||
|
...(content as SiteContent)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSiteContent(): Promise<SiteContent> {
|
||||||
|
try {
|
||||||
|
const record = await prisma.siteContent.findUnique({
|
||||||
|
where: { id: "main" }
|
||||||
|
});
|
||||||
|
|
||||||
|
return normalizeSiteContent(record?.content);
|
||||||
|
} catch {
|
||||||
|
return defaultSiteContent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveSiteContent(content: SiteContent) {
|
||||||
|
try {
|
||||||
|
await prisma.siteContent.upsert({
|
||||||
|
where: { id: "main" },
|
||||||
|
update: { content: content as unknown as Prisma.JsonObject },
|
||||||
|
create: {
|
||||||
|
id: "main",
|
||||||
|
content: content as unknown as Prisma.JsonObject
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "ذخیرهسازی محتوا به دیتابیس متصل نشد."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUsers(): Promise<UserRecord[]> {
|
||||||
|
try {
|
||||||
|
const users = await prisma.user.findMany({
|
||||||
|
orderBy: { createdAt: "desc" }
|
||||||
|
});
|
||||||
|
|
||||||
|
return users.map((user) => ({
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
createdAt: user.createdAt.toISOString()
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/lib/utils.ts
Normal file
13
src/lib/utils.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { clsx } from "clsx";
|
||||||
|
|
||||||
|
export function cn(...inputs: Array<string | false | null | undefined>) {
|
||||||
|
return clsx(inputs);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function slugify(value: string) {
|
||||||
|
return value
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/[^a-z0-9\u0600-\u06FF]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "");
|
||||||
|
}
|
||||||
103
src/lib/validators/site-content.ts
Normal file
103
src/lib/validators/site-content.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const roleSchema = z.enum(["SUPERADMIN", "ADMIN", "EDITOR"]);
|
||||||
|
|
||||||
|
export const socialLinkSchema = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
label: z.string().min(1),
|
||||||
|
href: z.string().min(1)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const siteSettingsSchema = z.object({
|
||||||
|
siteTitle: z.string().min(1),
|
||||||
|
siteSubtitle: z.string().min(1),
|
||||||
|
logoUrl: z.string().min(1),
|
||||||
|
footerLogoUrl: z.string().min(1),
|
||||||
|
brandFontUrl: z.string(),
|
||||||
|
centralOfficeLabel: z.string().min(1),
|
||||||
|
centralOfficePhone: z.string().min(1),
|
||||||
|
customerClubLabel: z.string().min(1),
|
||||||
|
customerClubUrl: z.string().min(1),
|
||||||
|
socialLinks: z.array(socialLinkSchema)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const heroSchema = z.object({
|
||||||
|
isPublished: z.boolean(),
|
||||||
|
eyebrow: z.string().min(1),
|
||||||
|
title: z.string().min(1),
|
||||||
|
description: z.string().min(1),
|
||||||
|
primaryCtaLabel: z.string().min(1),
|
||||||
|
primaryCtaHref: z.string().min(1),
|
||||||
|
backgroundImageUrl: z.string().min(1)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const brandSchema = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
sortOrder: z.coerce.number(),
|
||||||
|
englishName: z.string().min(1),
|
||||||
|
persianName: z.string().min(1),
|
||||||
|
code: z.string().min(1),
|
||||||
|
imageUrl: z.string().min(1),
|
||||||
|
link: z.string().min(1)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const servicesSchema = z.object({
|
||||||
|
isPublished: z.boolean(),
|
||||||
|
title: z.string().min(1),
|
||||||
|
accent: z.string().min(1),
|
||||||
|
description: z.string().min(1),
|
||||||
|
primaryCtaLabel: z.string().min(1),
|
||||||
|
primaryCtaHref: z.string().min(1),
|
||||||
|
phoneLabel: z.string().min(1),
|
||||||
|
phoneValue: z.string().min(1),
|
||||||
|
imageUrl: z.string().min(1)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const autoArshiaCardSchema = z.object({
|
||||||
|
title: z.string().min(1),
|
||||||
|
description: z.string().min(1),
|
||||||
|
ctaLabel: z.string().min(1),
|
||||||
|
ctaHref: z.string().min(1),
|
||||||
|
icon: z.enum(["seller", "buyer"])
|
||||||
|
});
|
||||||
|
|
||||||
|
export const autoArshiaSchema = z.object({
|
||||||
|
isPublished: z.boolean(),
|
||||||
|
title: z.string().min(1),
|
||||||
|
accent: z.string().min(1),
|
||||||
|
subtitle: z.string().min(1),
|
||||||
|
poweredBy: z.string().min(1),
|
||||||
|
sellerCard: autoArshiaCardSchema,
|
||||||
|
buyerCard: autoArshiaCardSchema
|
||||||
|
});
|
||||||
|
|
||||||
|
export const branchSchema = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
sortOrder: z.coerce.number(),
|
||||||
|
slug: z.string().min(1),
|
||||||
|
tag: z.string().min(1),
|
||||||
|
name: z.string().min(1),
|
||||||
|
address: z.string().min(1),
|
||||||
|
phone: z.string().min(1),
|
||||||
|
mapUrl: z.string().min(1),
|
||||||
|
markerTop: z.string().min(1),
|
||||||
|
markerLeft: z.string().min(1)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const branchesSectionSchema = z.object({
|
||||||
|
isPublished: z.boolean(),
|
||||||
|
sectionId: z.string().min(1),
|
||||||
|
mapImageUrl: z.string().min(1)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const footerSchema = z.object({
|
||||||
|
isPublished: z.boolean(),
|
||||||
|
description: z.string().min(1)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createUserSchema = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(8),
|
||||||
|
role: roleSchema
|
||||||
|
});
|
||||||
5
src/middleware.ts
Normal file
5
src/middleware.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export { auth as middleware } from "@/lib/auth";
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ["/mugmanager/:path*"]
|
||||||
|
};
|
||||||
128
src/types/content.ts
Normal file
128
src/types/content.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
export type Role = "SUPERADMIN" | "ADMIN" | "EDITOR";
|
||||||
|
|
||||||
|
export type SocialLink = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
href: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MediaAsset = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
url: string;
|
||||||
|
kind: "image" | "font" | "svg";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SiteSettings = {
|
||||||
|
siteTitle: string;
|
||||||
|
siteSubtitle: string;
|
||||||
|
logoUrl: string;
|
||||||
|
footerLogoUrl: string;
|
||||||
|
brandFontUrl: string;
|
||||||
|
centralOfficeLabel: string;
|
||||||
|
centralOfficePhone: string;
|
||||||
|
customerClubLabel: string;
|
||||||
|
customerClubUrl: string;
|
||||||
|
socialLinks: SocialLink[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HeroSection = {
|
||||||
|
isPublished: boolean;
|
||||||
|
eyebrow: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
primaryCtaLabel: string;
|
||||||
|
primaryCtaHref: string;
|
||||||
|
backgroundImageUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Brand = {
|
||||||
|
id: string;
|
||||||
|
sortOrder: number;
|
||||||
|
englishName: string;
|
||||||
|
persianName: string;
|
||||||
|
code: string;
|
||||||
|
imageUrl: string;
|
||||||
|
link: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BrandsSection = {
|
||||||
|
isPublished: boolean;
|
||||||
|
title: string;
|
||||||
|
accent: string;
|
||||||
|
brands: Brand[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ServicesSection = {
|
||||||
|
isPublished: boolean;
|
||||||
|
title: string;
|
||||||
|
accent: string;
|
||||||
|
description: string;
|
||||||
|
primaryCtaLabel: string;
|
||||||
|
primaryCtaHref: string;
|
||||||
|
phoneLabel: string;
|
||||||
|
phoneValue: string;
|
||||||
|
imageUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AutoArshiaCard = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
ctaLabel: string;
|
||||||
|
ctaHref: string;
|
||||||
|
icon: "seller" | "buyer";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AutoArshiaSection = {
|
||||||
|
isPublished: boolean;
|
||||||
|
title: string;
|
||||||
|
accent: string;
|
||||||
|
subtitle: string;
|
||||||
|
poweredBy: string;
|
||||||
|
sellerCard: AutoArshiaCard;
|
||||||
|
buyerCard: AutoArshiaCard;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Branch = {
|
||||||
|
id: string;
|
||||||
|
sortOrder: number;
|
||||||
|
slug: string;
|
||||||
|
tag: string;
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
phone: string;
|
||||||
|
mapUrl: string;
|
||||||
|
markerTop: string;
|
||||||
|
markerLeft: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BranchesSection = {
|
||||||
|
isPublished: boolean;
|
||||||
|
sectionId: string;
|
||||||
|
mapImageUrl: string;
|
||||||
|
branches: Branch[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FooterSection = {
|
||||||
|
isPublished: boolean;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UserRecord = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
role: Role;
|
||||||
|
createdAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SiteContent = {
|
||||||
|
settings: SiteSettings;
|
||||||
|
hero: HeroSection;
|
||||||
|
brandsSection: BrandsSection;
|
||||||
|
services: ServicesSection;
|
||||||
|
autoArshia: AutoArshiaSection;
|
||||||
|
branchesSection: BranchesSection;
|
||||||
|
footer: FooterSection;
|
||||||
|
mediaLibrary: MediaAsset[];
|
||||||
|
};
|
||||||
24
src/types/next-auth.d.ts
vendored
Normal file
24
src/types/next-auth.d.ts
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { Role } from "@/types/content";
|
||||||
|
import "next-auth";
|
||||||
|
import "next-auth/jwt";
|
||||||
|
|
||||||
|
declare module "next-auth" {
|
||||||
|
interface Session {
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
name?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
role: Role;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
role: Role;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "next-auth/jwt" {
|
||||||
|
interface JWT {
|
||||||
|
role?: Role;
|
||||||
|
}
|
||||||
|
}
|
||||||
15
tailwind.config.ts
Normal file
15
tailwind.config.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
content: [
|
||||||
|
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
"./src/app/**/*.{js,ts,jsx,tsx,mdx}"
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {}
|
||||||
|
},
|
||||||
|
plugins: []
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
27
tsconfig.json
Normal file
27
tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
1
tsconfig.tsbuildinfo
Normal file
1
tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user