first commit

This commit is contained in:
2026-05-24 11:49:14 +03:30
commit 82e401e2f6
46 changed files with 11606 additions and 0 deletions

12
.dockerignore Normal file
View 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
View File

@@ -0,0 +1,3 @@
{
"extends": ["next/core-web-vitals"]
}

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
.next
node_modules
.env
.env.local
coverage
dist
uploads
*.log

32
Dockerfile Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1,4 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
export default nextConfig;

6921
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

39
package.json Normal file
View 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
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

31
prisma/schema.prisma Normal file
View 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
View 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);
});

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,3 @@
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;

View 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
View 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
View 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>
);
}

View File

@@ -0,0 +1,9 @@
import { AdminShell } from "@/components/admin/admin-shell";
export default function MugmanagerProtectedLayout({
children
}: {
children: React.ReactNode;
}) {
return <AdminShell>{children}</AdminShell>;
}

View 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}
/>
);
}

View File

@@ -0,0 +1,7 @@
export default function MugmanagerRootLayout({
children
}: {
children: React.ReactNode;
}) {
return children;
}

View 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
View 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} />;
}

View 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>
);
}

View 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>
);
}

View 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>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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}
</>
);
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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, "");
}

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

File diff suppressed because one or more lines are too long