first commit2

This commit is contained in:
2026-05-24 15:14:32 +03:30
parent f368dd8895
commit 603b84fdb9
28 changed files with 54 additions and 2647 deletions

View File

@@ -1,36 +1,26 @@
FROM node:20-slim AS base
FROM node:20-slim AS deps
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json ./
COPY prisma ./prisma
RUN npm ci
FROM base AS builder
FROM node:20-slim AS builder
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/prisma ./prisma
COPY . .
RUN npx next build
RUN npm run build
FROM base AS runner
FROM nginx:alpine AS runner
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
COPY --from=builder /app/out /usr/share/nginx/html
RUN sed -i 's/listen 80;/listen 3000;/' /etc/nginx/conf.d/default.conf
EXPOSE 3000
CMD ["sh", "-c", "npx prisma migrate deploy && npm run start"]
CMD ["nginx", "-g", "daemon off;"]

View File

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

704
package-lock.json generated
View File

@@ -8,13 +8,10 @@
"name": "ghasempour-website",
"version": "0.1.0",
"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"
@@ -27,9 +24,7 @@
"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"
}
},
@@ -46,35 +41,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@auth/core": {
"version": "0.41.2",
"resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.2.tgz",
"integrity": "sha512-Hx5MNBxN2fJTbJKGUKAA0wca43D0Akl3TvufY54Gn8lop7F+34vU1zA1pn0vQfIoVuLIrpfc2nkyjwIaPJMW7w==",
"license": "ISC",
"dependencies": {
"@panva/hkdf": "^1.2.1",
"jose": "^6.0.6",
"oauth4webapi": "^3.3.0",
"preact": "10.24.3",
"preact-render-to-string": "6.5.11"
},
"peerDependencies": {
"@simplewebauthn/browser": "^9.0.1",
"@simplewebauthn/server": "^9.0.2",
"nodemailer": "^7.0.7"
},
"peerDependenciesMeta": {
"@simplewebauthn/browser": {
"optional": true
},
"@simplewebauthn/server": {
"optional": true
},
"nodemailer": {
"optional": true
}
}
},
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
@@ -109,448 +75,6 @@
"tslib": "^2.4.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@eslint-community/eslint-utils": {
"version": "4.9.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
@@ -959,15 +483,6 @@
"node": ">=12.4.0"
}
},
"node_modules/@panva/hkdf": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
"integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -979,74 +494,6 @@
"node": ">=14"
}
},
"node_modules/@prisma/client": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz",
"integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==",
"hasInstallScript": true,
"license": "Apache-2.0",
"engines": {
"node": ">=16.13"
},
"peerDependencies": {
"prisma": "*"
},
"peerDependenciesMeta": {
"prisma": {
"optional": true
}
}
},
"node_modules/@prisma/debug": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz",
"integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/engines": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz",
"integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "5.22.0",
"@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
"@prisma/fetch-engine": "5.22.0",
"@prisma/get-platform": "5.22.0"
}
},
"node_modules/@prisma/engines-version": {
"version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz",
"integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/fetch-engine": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz",
"integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "5.22.0",
"@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
"@prisma/get-platform": "5.22.0"
}
},
"node_modules/@prisma/get-platform": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz",
"integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "5.22.0"
}
},
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -2072,12 +1519,6 @@
"node": ">=6.0.0"
}
},
"node_modules/bcryptjs": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
"license": "MIT"
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -2754,48 +2195,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/esbuild": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.0",
"@esbuild/android-arm": "0.28.0",
"@esbuild/android-arm64": "0.28.0",
"@esbuild/android-x64": "0.28.0",
"@esbuild/darwin-arm64": "0.28.0",
"@esbuild/darwin-x64": "0.28.0",
"@esbuild/freebsd-arm64": "0.28.0",
"@esbuild/freebsd-x64": "0.28.0",
"@esbuild/linux-arm": "0.28.0",
"@esbuild/linux-arm64": "0.28.0",
"@esbuild/linux-ia32": "0.28.0",
"@esbuild/linux-loong64": "0.28.0",
"@esbuild/linux-mips64el": "0.28.0",
"@esbuild/linux-ppc64": "0.28.0",
"@esbuild/linux-riscv64": "0.28.0",
"@esbuild/linux-s390x": "0.28.0",
"@esbuild/linux-x64": "0.28.0",
"@esbuild/netbsd-arm64": "0.28.0",
"@esbuild/netbsd-x64": "0.28.0",
"@esbuild/openbsd-arm64": "0.28.0",
"@esbuild/openbsd-x64": "0.28.0",
"@esbuild/openharmony-arm64": "0.28.0",
"@esbuild/sunos-x64": "0.28.0",
"@esbuild/win32-arm64": "0.28.0",
"@esbuild/win32-ia32": "0.28.0",
"@esbuild/win32-x64": "0.28.0"
}
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -4354,15 +3753,6 @@
"jiti": "bin/jiti.js"
}
},
"node_modules/jose": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -4724,33 +4114,6 @@
}
}
},
"node_modules/next-auth": {
"version": "5.0.0-beta.31",
"resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.31.tgz",
"integrity": "sha512-1OBgCKPzo+S7UWWMp3xgvGvIJ0OpV7B3vR4ZDRqD9a4Ch+OT6dakLXG9ivhtmIWVa71nTSXattOHyCg8sNi8/Q==",
"license": "ISC",
"dependencies": {
"@auth/core": "0.41.2"
},
"peerDependencies": {
"@simplewebauthn/browser": "^9.0.1",
"@simplewebauthn/server": "^9.0.2",
"next": "^14.0.0-0 || ^15.0.0 || ^16.0.0",
"nodemailer": "^7.0.7",
"react": "^18.2.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@simplewebauthn/browser": {
"optional": true
},
"@simplewebauthn/server": {
"optional": true
},
"nodemailer": {
"optional": true
}
}
},
"node_modules/next/node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
@@ -4825,15 +4188,6 @@
"node": ">=0.10.0"
}
},
"node_modules/oauth4webapi": {
"version": "3.8.6",
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.6.tgz",
"integrity": "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -5346,25 +4700,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/preact": {
"version": "10.24.3",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz",
"integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/preact-render-to-string": {
"version": "6.5.11",
"resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz",
"integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==",
"license": "MIT",
"peerDependencies": {
"preact": ">=10"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -5375,26 +4710,6 @@
"node": ">= 0.8.0"
}
},
"node_modules/prisma": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz",
"integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/engines": "5.22.0"
},
"bin": {
"prisma": "build/index.js"
},
"engines": {
"node": ">=16.13"
},
"optionalDependencies": {
"fsevents": "2.3.3"
}
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -6426,25 +5741,6 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tsx": {
"version": "4.22.1",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.1.tgz",
"integrity": "sha512-TvncJykhxAzFCk0VQZKBTClall4Pm7qXDSodb6uxi8QFa8X8mT6ABjxxsQ2opDRYxG7AzcRWXaFtruz5HJKuWg==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.28.0"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",

View File

@@ -4,21 +4,15 @@
"private": true,
"scripts": {
"dev": "next dev",
"build": "prisma generate && next build",
"build": "next build",
"start": "next start",
"lint": "next lint",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:seed": "tsx prisma/seed.ts"
"lint": "next lint"
},
"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"
@@ -31,9 +25,7 @@
"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"
}
}

View File

@@ -1,31 +0,0 @@
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
}

View File

@@ -1,37 +0,0 @@
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);
});

View File

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

View File

@@ -1,88 +0,0 @@
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 });
}
}

View File

@@ -1,23 +1,18 @@
import type { Metadata } from "next";
import "@/app/globals.css";
import { getSiteContent } from "@/lib/site-content";
import { defaultSiteContent } from "@/lib/default-content";
export async function generateMetadata(): Promise<Metadata> {
const content = await getSiteContent();
return {
title: content.settings.siteTitle,
description: content.hero.description
export const metadata: Metadata = {
title: defaultSiteContent.settings.siteTitle,
description: defaultSiteContent.hero.description
};
}
export default async function RootLayout({
export default function RootLayout({
children
}: Readonly<{
children: React.ReactNode;
}>) {
await getSiteContent();
return (
<html lang="fa" dir="rtl">
<body>{children}</body>

View File

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

View File

@@ -1,47 +0,0 @@
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

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

View File

@@ -1,27 +0,0 @@
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>
);
}

View File

@@ -1,9 +1,7 @@
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();
export default function Page() {
const content = getSiteContent();
return <HomePage content={content} />;
}

View File

@@ -1,60 +0,0 @@
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

@@ -1,697 +0,0 @@
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

@@ -1,62 +0,0 @@
"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

@@ -1,14 +0,0 @@
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

@@ -133,7 +133,11 @@ export function HomePage({ content }: Props) {
{content.hero.description}
</p>
<div className="flex gap-4">
<SalesConsultationDialog brands={brands} />
<SalesConsultationDialog
brands={brands}
contactLabel={content.settings.centralOfficeLabel}
contactPhone={content.settings.centralOfficePhone}
/>
</div>
</div>
</section>

View File

@@ -2,7 +2,7 @@
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { Loader2, X } from "lucide-react";
import { PhoneCall, X } from "lucide-react";
type BrandOption = {
id: string;
@@ -12,6 +12,8 @@ type BrandOption = {
type Props = {
brands: BrandOption[];
contactLabel: string;
contactPhone: string;
};
type FormState = {
@@ -46,26 +48,15 @@ function normalizeMobile(input: string) {
return `0${digits.slice(0, 10)}`;
}
export function SalesConsultationDialog({ brands }: Props) {
export function SalesConsultationDialog({ brands, contactLabel, contactPhone }: 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;
@@ -75,7 +66,7 @@ export function SalesConsultationDialog({ brands }: Props) {
document.body.style.overflow = "hidden";
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape" && !isSubmitting) {
if (event.key === "Escape") {
setIsOpen(false);
}
};
@@ -86,55 +77,22 @@ export function SalesConsultationDialog({ brands }: Props) {
document.body.style.overflow = previousOverflow;
window.removeEventListener("keydown", onKeyDown);
};
}, [isOpen, isSubmitting]);
}, [isOpen]);
const brandOptions = [{ id: "any", persianName: "فرقی ندارد", englishName: "No Preference" }, ...brands];
const contactHref = `tel:${contactPhone.replace(/[^\d+]/g, "")}`;
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}
onClick={() => setIsOpen(false)}
>
<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)]"
@@ -148,9 +106,8 @@ export function SalesConsultationDialog({ brands }: Props) {
<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"
onClick={() => setIsOpen(false)}
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"
aria-label="بستن"
>
<X className="size-4" />
@@ -163,11 +120,11 @@ export function SalesConsultationDialog({ brands }: Props) {
</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">
<div 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
@@ -175,7 +132,6 @@ export function SalesConsultationDialog({ brands }: Props) {
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>
@@ -187,7 +143,6 @@ export function SalesConsultationDialog({ brands }: Props) {
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>
@@ -216,20 +171,21 @@ export function SalesConsultationDialog({ brands }: Props) {
/>
</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="rounded-2xl border border-white/10 bg-white/5 px-5 py-4 text-sm text-gray-300 md:col-span-2">
<div className="text-xs uppercase tracking-[0.24em] text-gray-500">{contactLabel}</div>
<div className="mt-2 text-lg font-black text-white">{contactPhone}</div>
</div>
<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"
<a
href={contactHref}
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"
>
{isSubmitting ? <Loader2 className="size-4 animate-spin" /> : null}
ثبت درخواست
</button>
<PhoneCall className="size-4" />
تماس با تیم فروش
</a>
</div>
</div>
</form>
</div>
</div>
</div>

View File

@@ -1,484 +0,0 @@
"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("محتوای سایت به حالت اولیه برگشت.");
}

View File

@@ -1,99 +0,0 @@
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;
}
}
});

View File

@@ -1,11 +0,0 @@
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];
}

View File

@@ -1,50 +0,0 @@
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);
}
}
);

View File

@@ -1,67 +1,6 @@
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";
import { SiteContent } from "@/types/content";
function normalizeSiteContent(content: Prisma.JsonValue | null | undefined): SiteContent {
if (!content || typeof content !== "object" || Array.isArray(content)) {
export function getSiteContent(): SiteContent {
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 [];
}
}

View File

@@ -1,5 +0,0 @@
export { auth as middleware } from "@/lib/auth";
export const config = {
matcher: ["/mugmanager/:path*"]
};

View File

@@ -1,5 +1,3 @@
export type Role = "SUPERADMIN" | "ADMIN" | "EDITOR";
export type SocialLink = {
id: string;
label: string;
@@ -108,14 +106,6 @@ export type FooterSection = {
description: string;
};
export type UserRecord = {
id: string;
name: string;
email: string;
role: Role;
createdAt?: string;
};
export type SiteContent = {
settings: SiteSettings;
hero: HeroSection;

View File

@@ -1,24 +0,0 @@
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;
}
}