Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a8d214762 | |||
| 3ce26d4d1a |
+3
-3
@@ -7,11 +7,11 @@ RUN apt-get update && apt-get install -y openssl libc6
|
||||
WORKDIR /app
|
||||
|
||||
# کپی فایلهای نصب پکیج
|
||||
COPY package.json package-lock.json* ./
|
||||
COPY package.json ./
|
||||
COPY prisma ./prisma/
|
||||
|
||||
# نصب پکیجها و جنریت کردن کلاینت Prisma
|
||||
RUN npm install
|
||||
# نصب پکیجها و دریافت باینریهای مناسب لینوکس
|
||||
RUN rm -rf package-lock.json && npm install
|
||||
RUN npx prisma generate
|
||||
|
||||
# مرحله ۲: بیلد پروژه
|
||||
|
||||
+8
-1
@@ -1,6 +1,13 @@
|
||||
import withPWAInit from "@ducanh2912/next-pwa";
|
||||
|
||||
const withPWA = withPWAInit({
|
||||
dest: "public",
|
||||
disable: process.env.NODE_ENV === "development",
|
||||
});
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
export default withPWA(nextConfig);
|
||||
|
||||
Generated
+5350
File diff suppressed because it is too large
Load Diff
@@ -9,15 +9,21 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ducanh2912/next-pwa": "^10.2.9",
|
||||
"@prisma/client": "^6.4.1",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@simplewebauthn/server": "^13.3.1",
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"@yudiel/react-qr-scanner": "^2.6.0",
|
||||
"axios": "^1.7.9",
|
||||
"bcrypt": "^5.1.1",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"framer-motion": "^12.40.0",
|
||||
"jose": "^6.2.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lucide-react": "^1.17.0",
|
||||
"next": "15.1.7",
|
||||
"next-themes": "^0.4.6",
|
||||
"postcss": "^8.5.2",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
||||
@@ -20,11 +20,27 @@ model User {
|
||||
mobile String?
|
||||
role Role @default(USER)
|
||||
orgId Int?
|
||||
avatarUrl String? @db.LongText
|
||||
challenge String? @db.Text
|
||||
countings Counting[]
|
||||
authenticators Authenticator[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Authenticator {
|
||||
id String @id @default(cuid())
|
||||
credentialID String @unique
|
||||
credentialPublicKey Bytes
|
||||
counter BigInt
|
||||
credentialDeviceType String
|
||||
credentialBackedUp Boolean
|
||||
transports String?
|
||||
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model Location {
|
||||
id Int @id @default(autoincrement())
|
||||
code String @unique
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 318 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 318 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 375 KiB |
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "پردیس رایانه - سیستم انبارگردانی",
|
||||
"short_name": "انبارگردانی",
|
||||
"description": "سیستم جامع مدیریت قفسهها و انبارگردانی",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#f8fafc",
|
||||
"orientation": "portrait",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
import Header from '@/components/Header';
|
||||
import Link from 'next/link';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Layers, Users, BarChart3 } from 'lucide-react';
|
||||
|
||||
export default function AdminDashboard() {
|
||||
|
||||
const container = {
|
||||
hidden: { opacity: 0 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
transition: { staggerChildren: 0.1 }
|
||||
}
|
||||
};
|
||||
|
||||
const item = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
show: { opacity: 1, y: 0, transition: { type: 'spring', stiffness: 300, damping: 24 } }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full min-h-screen bg-gray-50 flex flex-col pb-20">
|
||||
<Header title="پنل مدیریت" showBack={true} />
|
||||
|
||||
<motion.div
|
||||
variants={container}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="p-5 flex flex-col gap-4"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<motion.div variants={item}>
|
||||
<Link href="/admin/locations" className="bg-white/80 backdrop-blur-sm border border-gray-100 rounded-3xl shadow-[0_4px_20px_rgb(0,0,0,0.03)] p-6 flex flex-col items-center justify-center aspect-square gap-3 hover:bg-white hover:scale-[1.02] active:scale-95 transition-all">
|
||||
<div className="w-12 h-12 bg-purple-50 text-purple-600 rounded-2xl flex items-center justify-center mb-1">
|
||||
<Layers strokeWidth={1.5} size={24} />
|
||||
</div>
|
||||
<span className="font-extrabold text-xs text-gray-700">مدیریت قفسهها</span>
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={item}>
|
||||
<Link href="/admin/stats" className="bg-white/80 backdrop-blur-sm border border-gray-100 rounded-3xl shadow-[0_4px_20px_rgb(0,0,0,0.03)] p-6 flex flex-col items-center justify-center aspect-square gap-3 hover:bg-white hover:scale-[1.02] active:scale-95 transition-all">
|
||||
<div className="w-12 h-12 bg-blue-50 text-blue-500 rounded-2xl flex items-center justify-center mb-1">
|
||||
<BarChart3 strokeWidth={1.5} size={24} />
|
||||
</div>
|
||||
<span className="font-extrabold text-xs text-gray-700">آمار طبقات</span>
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<motion.div variants={item}>
|
||||
<div className="opacity-60 bg-white/80 backdrop-blur-sm border border-gray-100 rounded-3xl shadow-[0_4px_20px_rgb(0,0,0,0.03)] p-5 flex items-center justify-between transition-all cursor-not-allowed">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 bg-gray-100 text-gray-400 rounded-xl flex items-center justify-center">
|
||||
<Users strokeWidth={1.5} size={20} />
|
||||
</div>
|
||||
<span className="font-extrabold text-sm text-gray-500">مدیریت کاربران (به زودی)</span>
|
||||
</div>
|
||||
<div className="w-8 h-8 bg-gray-50 rounded-full flex items-center justify-center">
|
||||
<svg className="w-4 h-4 text-gray-300 rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 5l7 7-7 7" /></svg>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
'use client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import Header from '@/components/Header';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export default function AdminStats() {
|
||||
const [stats, setStats] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/admin/stats')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setStats(data);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const container = {
|
||||
hidden: { opacity: 0 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
transition: { staggerChildren: 0.1 }
|
||||
}
|
||||
};
|
||||
|
||||
const item = {
|
||||
hidden: { opacity: 0, y: 10 },
|
||||
show: { opacity: 1, y: 0 }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full min-h-screen bg-gray-50 flex flex-col pb-20">
|
||||
<Header title="آمار طبقات" showBack={true} />
|
||||
|
||||
<div className="p-5 flex flex-col gap-4">
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center h-40">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600"></div>
|
||||
</div>
|
||||
) : stats.length === 0 ? (
|
||||
<div className="text-center text-gray-400 p-10 text-sm font-medium bg-white/60 rounded-3xl border border-gray-100">
|
||||
هیچ آماری یافت نشد.
|
||||
</div>
|
||||
) : (
|
||||
<motion.div variants={container} initial="hidden" animate="show" className="flex flex-col gap-3">
|
||||
{stats.map((stat, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
variants={item}
|
||||
className="bg-white/80 backdrop-blur-sm border border-gray-100 rounded-2xl shadow-sm p-4 flex items-center justify-between hover:scale-[1.01] transition-transform"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-blue-50 text-blue-600 font-extrabold text-xl rounded-xl flex items-center justify-center">
|
||||
{stat.floor}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-extrabold text-gray-800 text-sm">شمارشهای ثبت شده</span>
|
||||
<span className="text-xs text-gray-500 mt-0.5">در تمامی مناطق و قطاعها</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-2xl font-black text-gray-900">
|
||||
{stat.totalCountings}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export async function POST(req) {
|
||||
|
||||
const token = signToken({ id: user.id, username: user.username, name: user.name, orgId: user.orgId, role: user.role });
|
||||
|
||||
return Response.json({ message: 'با موفقیت وارد شدید', token, user: { id: user.id, name: user.name, orgId: user.orgId, role: user.role } });
|
||||
return Response.json({ message: 'با موفقیت وارد شدید', token, user: { id: user.id, name: user.name, orgId: user.orgId, role: user.role, avatarUrl: user.avatarUrl } });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return Response.json({ error: 'خطای سرور رخ داد.' }, { status: 500 });
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { generateAuthenticationOptions } from '@simplewebauthn/server';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export async function POST(req) {
|
||||
try {
|
||||
const url = new URL(req.url);
|
||||
const rpID = url.hostname;
|
||||
const { username } = await req.json();
|
||||
|
||||
if (!username) return NextResponse.json({ error: 'لطفاً ابتدا شماره موبایل را وارد کنید' }, { status: 400 });
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { username },
|
||||
include: { authenticators: true }
|
||||
});
|
||||
|
||||
if (!user) return NextResponse.json({ error: 'حسابی با این شماره یافت نشد' }, { status: 404 });
|
||||
if (!user.authenticators.length) return NextResponse.json({ error: 'اثر انگشتی روی این حساب فعال نیست' }, { status: 400 });
|
||||
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID,
|
||||
allowCredentials: user.authenticators.map(auth => ({
|
||||
id: Buffer.from(auth.credentialID, 'base64url'),
|
||||
type: 'public-key',
|
||||
transports: auth.transports ? auth.transports.split(',') : [],
|
||||
})),
|
||||
userVerification: 'preferred',
|
||||
});
|
||||
|
||||
await prisma.user.update({ where: { id: user.id }, data: { challenge: options.challenge } });
|
||||
|
||||
return NextResponse.json(options);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { verifyAuthenticationResponse } from '@simplewebauthn/server';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { signToken } from '@/lib/auth';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export async function POST(req) {
|
||||
try {
|
||||
const url = new URL(req.url);
|
||||
const rpID = url.hostname;
|
||||
const expectedOrigin = url.origin;
|
||||
|
||||
const body = await req.json();
|
||||
const { username, response } = body;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { username },
|
||||
include: { authenticators: true }
|
||||
});
|
||||
|
||||
if (!user) return NextResponse.json({ error: 'کاربر یافت نشد' }, { status: 404 });
|
||||
|
||||
const authenticator = user.authenticators.find(a => a.credentialID === response.id);
|
||||
if (!authenticator) return NextResponse.json({ error: 'اطلاعات سنسور مطابقت ندارد' }, { status: 400 });
|
||||
|
||||
const verification = await verifyAuthenticationResponse({
|
||||
response,
|
||||
expectedChallenge: user.challenge,
|
||||
expectedOrigin,
|
||||
expectedRPID: rpID,
|
||||
authenticator: {
|
||||
credentialID: Buffer.from(authenticator.credentialID, 'base64url'),
|
||||
credentialPublicKey: Buffer.from(authenticator.credentialPublicKey),
|
||||
counter: Number(authenticator.counter),
|
||||
},
|
||||
});
|
||||
|
||||
if (verification.verified) {
|
||||
await prisma.authenticator.update({
|
||||
where: { id: authenticator.id },
|
||||
data: { counter: BigInt(verification.authenticationInfo.newCounter) }
|
||||
});
|
||||
const token = signToken({ id: user.id, username: user.username, name: user.name, role: user.role });
|
||||
return NextResponse.json({ verified: true, token, user: { id: user.id, name: user.name, role: user.role } });
|
||||
}
|
||||
return NextResponse.json({ verified: false }, { status: 400 });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { generateRegistrationOptions } from '@simplewebauthn/server';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { verifyToken } from '@/lib/auth';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const rpName = 'پردیس رایانه';
|
||||
|
||||
export async function GET(req) {
|
||||
try {
|
||||
const url = new URL(req.url);
|
||||
const rpID = url.hostname;
|
||||
|
||||
let token = req.cookies.get('token')?.value;
|
||||
if (!token) {
|
||||
const authHeader = req.headers.get('authorization');
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) return NextResponse.json({ error: 'عدم دسترسی' }, { status: 401 });
|
||||
token = authHeader.split(' ')[1];
|
||||
}
|
||||
|
||||
const payload = verifyToken(token);
|
||||
if (!payload || !payload.id) return NextResponse.json({ error: 'عدم دسترسی' }, { status: 401 });
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: payload.id },
|
||||
include: { authenticators: true }
|
||||
});
|
||||
|
||||
if (!user) return NextResponse.json({ error: 'کاربر یافت نشد' }, { status: 404 });
|
||||
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName,
|
||||
rpID,
|
||||
userID: Buffer.from(user.id.toString(), 'utf-8'),
|
||||
userName: user.username,
|
||||
attestationType: 'none',
|
||||
authenticatorSelection: {
|
||||
residentKey: 'required',
|
||||
userVerification: 'preferred',
|
||||
},
|
||||
excludeCredentials: user.authenticators.map(auth => ({
|
||||
id: Buffer.from(auth.credentialID, 'base64url'),
|
||||
type: 'public-key',
|
||||
transports: auth.transports ? auth.transports.split(',') : [],
|
||||
})),
|
||||
});
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { challenge: options.challenge }
|
||||
});
|
||||
|
||||
return NextResponse.json(options);
|
||||
} catch (error) {
|
||||
console.error('generateOptions error:', error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { verifyRegistrationResponse } from '@simplewebauthn/server';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { verifyToken } from '@/lib/auth';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export async function POST(req) {
|
||||
try {
|
||||
const url = new URL(req.url);
|
||||
const rpID = url.hostname;
|
||||
const expectedOrigin = url.origin;
|
||||
|
||||
let token = req.cookies.get('token')?.value;
|
||||
if (!token) {
|
||||
const authHeader = req.headers.get('authorization');
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) return NextResponse.json({ error: 'عدم دسترسی' }, { status: 401 });
|
||||
token = authHeader.split(' ')[1];
|
||||
}
|
||||
|
||||
const payload = verifyToken(token);
|
||||
if (!payload || !payload.id) return NextResponse.json({ error: 'عدم دسترسی' }, { status: 401 });
|
||||
|
||||
const body = await req.json();
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: payload.id } });
|
||||
if (!user) return NextResponse.json({ error: 'کاربر یافت نشد' }, { status: 404 });
|
||||
|
||||
const verification = await verifyRegistrationResponse({
|
||||
response: body,
|
||||
expectedChallenge: user.challenge,
|
||||
expectedOrigin,
|
||||
expectedRPID: rpID,
|
||||
});
|
||||
|
||||
if (verification.verified) {
|
||||
const { registrationInfo } = verification;
|
||||
const { credentialPublicKey, credentialID, counter } = registrationInfo;
|
||||
|
||||
await prisma.authenticator.create({
|
||||
data: {
|
||||
credentialID: Buffer.from(credentialID).toString('base64url'),
|
||||
credentialPublicKey: Buffer.from(credentialPublicKey),
|
||||
counter: BigInt(counter),
|
||||
transports: body.response.transports?.join(',') || '',
|
||||
userId: user.id
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ verified: true });
|
||||
}
|
||||
|
||||
return NextResponse.json({ verified: false }, { status: 400 });
|
||||
} catch (error) {
|
||||
console.error('verify error:', error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { verifyToken } from '@/lib/auth';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export async function PUT(req) {
|
||||
try {
|
||||
const authHeader = req.headers.get('authorization');
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return NextResponse.json({ error: 'عدم دسترسی (توکن ارسال نشده)' }, { status: 401 });
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
const payload = verifyToken(token);
|
||||
|
||||
if (!payload || !payload.id) {
|
||||
return NextResponse.json({ error: 'عدم دسترسی (توکن نامعتبر)' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { name, password, avatarUrl } = body;
|
||||
|
||||
const dataToUpdate = {};
|
||||
if (name) dataToUpdate.name = name;
|
||||
if (avatarUrl) dataToUpdate.avatarUrl = avatarUrl;
|
||||
if (password) {
|
||||
dataToUpdate.password = await bcrypt.hash(password, 10);
|
||||
}
|
||||
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: payload.id },
|
||||
data: dataToUpdate,
|
||||
select: { id: true, username: true, name: true, role: true, avatarUrl: true }
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, user: updatedUser });
|
||||
} catch (error) {
|
||||
console.error('Settings error:', error);
|
||||
return NextResponse.json({ error: 'خطای سرور' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
+17
-3
@@ -1,15 +1,29 @@
|
||||
import './globals.css'
|
||||
|
||||
export const viewport = {
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
themeColor: '#f8fafc',
|
||||
}
|
||||
|
||||
export const metadata = {
|
||||
title: 'Pardis Counting',
|
||||
title: 'پردیس رایانه - انبارگردانی',
|
||||
description: 'اپلیکیشن انبارگردانی پردیس',
|
||||
manifest: "/manifest.json",
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: "default",
|
||||
title: "انبارگردانی",
|
||||
},
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }) {
|
||||
return (
|
||||
<html lang="fa" dir="rtl">
|
||||
<body>
|
||||
<main className="w-full min-h-screen flex flex-col justify-start items-center bg-gray-100">
|
||||
<body className="bg-gray-100 transition-colors">
|
||||
<main className="w-full min-h-screen flex flex-col justify-start items-center">
|
||||
<div className="w-full max-w-md bg-white min-h-screen shadow-lg relative pb-16">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
+320
-66
@@ -1,20 +1,54 @@
|
||||
'use client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Fingerprint, Download, Share } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { motion } from 'framer-motion';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
export default function LoginPage() {
|
||||
export default function EntryPage() {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showSplash, setShowSplash] = useState(true);
|
||||
const [showInstall, setShowInstall] = useState(false);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [deferredPrompt, setDeferredPrompt] = useState(null);
|
||||
const [isIOS, setIsIOS] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
router.push('/dashboard');
|
||||
}
|
||||
const handleBeforeInstallPrompt = (e) => {
|
||||
e.preventDefault();
|
||||
setDeferredPrompt(e);
|
||||
};
|
||||
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
||||
|
||||
const ua = window.navigator.userAgent;
|
||||
const isIOSDevice = /iPad|iPhone|iPod/.test(ua) && !window.MSStream;
|
||||
setIsIOS(isIOSDevice);
|
||||
|
||||
const checkAuth = async () => {
|
||||
const token = localStorage.getItem('token');
|
||||
await new Promise(resolve => setTimeout(resolve, 2500));
|
||||
|
||||
const isStandalone = window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone;
|
||||
|
||||
if (!isStandalone && process.env.NODE_ENV !== 'development') {
|
||||
setShowSplash(false);
|
||||
setShowInstall(true);
|
||||
} else {
|
||||
if (token) {
|
||||
router.push('/dashboard');
|
||||
} else {
|
||||
setShowSplash(false);
|
||||
setShowInstall(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
checkAuth();
|
||||
|
||||
return () => window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
||||
}, [router]);
|
||||
|
||||
const handleLogin = async (e) => {
|
||||
@@ -28,7 +62,6 @@ export default function LoginPage() {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
@@ -46,71 +79,292 @@ export default function LoginPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-screen flex flex-col justify-center items-center bg-gradient-to-br from-gray-50 to-gray-100 p-4 relative overflow-hidden">
|
||||
<div className="fixed inset-0 w-full h-[100dvh] bg-gradient-to-br from-gray-50 to-gray-100 overflow-hidden flex flex-col justify-between items-center">
|
||||
|
||||
{/* Decorative Blur Backgrounds */}
|
||||
<div className="absolute top-[-10%] left-[-10%] w-64 h-64 bg-purple-400/20 rounded-full blur-3xl"></div>
|
||||
<div className="absolute bottom-[-10%] right-[-10%] w-64 h-64 bg-blue-400/20 rounded-full blur-3xl"></div>
|
||||
<div className="absolute top-[-10%] left-[-10%] w-64 h-64 bg-purple-400/20 rounded-full blur-3xl pointer-events-none"></div>
|
||||
<div className="absolute bottom-[-10%] right-[-10%] w-64 h-64 bg-blue-400/20 rounded-full blur-3xl pointer-events-none"></div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, ease: [0.22, 1, 0.36, 1] }}
|
||||
className="w-full max-w-sm p-8 bg-white/60 backdrop-blur-2xl rounded-3xl shadow-[0_8px_30px_rgb(0,0,0,0.04)] border border-white/50 z-10"
|
||||
>
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<div className="w-16 h-16 bg-gradient-to-tr from-purple-600 to-blue-500 rounded-2xl shadow-lg shadow-purple-500/30 flex items-center justify-center mb-4 text-white font-extrabold text-2xl">
|
||||
P
|
||||
</div>
|
||||
<h1 className="text-xl font-extrabold text-gray-800 tracking-tight">پردیس رایانه</h1>
|
||||
<p className="text-xs text-gray-500 mt-1 font-medium">اپلیکیشن مدیریت انبار</p>
|
||||
</div>
|
||||
<AnimatePresence mode="wait">
|
||||
{showSplash ? (
|
||||
<motion.div
|
||||
key="splash"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0, scale: 1.1, filter: "blur(10px)" }}
|
||||
transition={{ duration: 0.6, ease: "easeInOut" }}
|
||||
className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-gray-50/50 backdrop-blur-md"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.5, opacity: 0, y: 20 }}
|
||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, ease: [0.22, 1, 0.36, 1], delay: 0.2 }}
|
||||
className="flex flex-col items-center"
|
||||
>
|
||||
<motion.div
|
||||
className="w-24 h-24 rounded-3xl shadow-sm border border-gray-100 mb-6 overflow-hidden flex items-center justify-center bg-white"
|
||||
>
|
||||
<img src="/icons/icon-512x512.png" alt="پردیس رایانه" className="w-full h-full object-cover" />
|
||||
</motion.div>
|
||||
|
||||
{error && (
|
||||
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} className="mb-6 p-3 bg-red-50/80 border border-red-100 text-red-600 text-xs rounded-xl text-center">
|
||||
{error}
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.6 }}
|
||||
className="text-2xl font-black text-gray-800 tracking-tight"
|
||||
>
|
||||
انبارگردانی هوشمند
|
||||
</motion.h1>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.8 }}
|
||||
className="text-sm text-gray-500 mt-2 font-medium"
|
||||
>
|
||||
مدیریت حرفهای انبار و قفسهها
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 1, delay: 1 }}
|
||||
className="absolute bottom-12 flex flex-col items-center"
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center opacity-40 hover:opacity-100 transition-opacity gap-1">
|
||||
<span className="text-[11px] text-gray-800 font-black tracking-[0.3em] uppercase font-sans">
|
||||
H U K A
|
||||
</span>
|
||||
<span className="text-[9px] text-gray-500 font-medium tracking-widest">طراحی توسط هوکا</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, width: 0 }}
|
||||
animate={{ opacity: 1, width: "120px" }}
|
||||
transition={{ duration: 1.5, delay: 0.5, ease: "easeInOut" }}
|
||||
className="absolute bottom-6 h-1 bg-gradient-to-r from-purple-500 to-blue-500 rounded-full"
|
||||
/>
|
||||
</motion.div>
|
||||
) : showInstall ? (
|
||||
<motion.div
|
||||
key="install"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="w-full h-full flex flex-col justify-center items-center px-8 z-10 relative max-w-md mx-auto"
|
||||
>
|
||||
<div className="w-28 h-28 rounded-3xl shadow-sm border border-gray-100 mb-8 overflow-hidden flex items-center justify-center bg-white p-2">
|
||||
<img src="/icons/icon-512x512.png" alt="پردیس رایانه" className="w-full h-full object-cover" />
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-black text-gray-800 tracking-tight mb-3 text-center">
|
||||
تجربه بهتر در اپلیکیشن
|
||||
</h2>
|
||||
|
||||
<p className="text-sm text-gray-500 font-medium text-center leading-relaxed mb-10">
|
||||
برای تجربه سریعتر، دسترسی آفلاین و استفاده از <b className="text-gray-700">ورود امن با اثر انگشت</b>، پیشنهاد میکنیم اپلیکیشن انبارگردانی را روی دستگاه خود نصب کنید.
|
||||
</p>
|
||||
|
||||
<div className="w-full flex flex-col gap-3">
|
||||
{isIOS ? (
|
||||
<div className="bg-gray-100/50 border border-gray-200 rounded-[24px] p-5 mb-2">
|
||||
<p className="text-xs font-bold text-gray-700 text-center mb-3">راهنمای نصب در آیفون (iOS):</p>
|
||||
<div className="flex items-center gap-2 text-[11px] text-gray-500 font-medium justify-center">
|
||||
<span>۱. لمس <Share className="inline w-4 h-4 mx-0.5 text-blue-500"/></span>
|
||||
<span>۲. انتخاب <b className="text-gray-800 border bg-white px-1.5 py-0.5 rounded-md shadow-sm">Add to Home Screen</b></span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<motion.button
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={async () => {
|
||||
if (deferredPrompt) {
|
||||
deferredPrompt.prompt();
|
||||
const { outcome } = await deferredPrompt.userChoice;
|
||||
if (outcome === 'accepted') {
|
||||
setDeferredPrompt(null);
|
||||
}
|
||||
} else {
|
||||
alert('مرورگر شما از نصب مستقیم پشتیبانی نمیکند. از منوی مرورگر Add to Home screen را انتخاب کنید.');
|
||||
}
|
||||
}}
|
||||
className="w-full py-4 bg-gray-900 text-white text-sm font-extrabold rounded-[20px] transition-all flex items-center justify-center shadow-sm gap-2"
|
||||
>
|
||||
<Download size={18} strokeWidth={2.5} />
|
||||
نصب مستقیم اپلیکیشن
|
||||
</motion.button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowInstall(false);
|
||||
if (localStorage.getItem('token')) router.push('/dashboard');
|
||||
}}
|
||||
className="w-full py-4 text-gray-400 hover:text-gray-600 text-xs font-bold transition-colors"
|
||||
>
|
||||
فعلاً نه، ادامه در مرورگر
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-12 flex flex-col items-center justify-center opacity-40">
|
||||
<span className="text-[11px] text-gray-800 font-black tracking-[0.3em] uppercase font-sans">
|
||||
H U K A
|
||||
</span>
|
||||
<span className="text-[9px] text-gray-500 font-medium tracking-widest">طراحی توسط هوکا</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="login"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="w-full h-full flex flex-col justify-between z-10 relative max-w-md mx-auto"
|
||||
>
|
||||
{/* Top Area: Logo & Welcome (Shrinks when focused to make room for keyboard) */}
|
||||
<motion.div
|
||||
animate={{
|
||||
height: isFocused ? "10%" : "auto",
|
||||
opacity: isFocused ? 0 : 1,
|
||||
y: isFocused ? -50 : 0
|
||||
}}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 25 }}
|
||||
className="flex flex-col items-center justify-center flex-1 pt-10"
|
||||
>
|
||||
<div className="w-20 h-20 rounded-3xl shadow-sm border border-gray-100 mb-6 overflow-hidden flex items-center justify-center bg-white">
|
||||
<img src="/icons/icon-512x512.png" alt="پردیس رایانه" className="w-full h-full object-cover" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-black text-gray-800 tracking-tight mb-2">
|
||||
خوش آمدید
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 font-medium">
|
||||
لطفاً برای ادامه وارد حساب خود شوید
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Bottom Area: Bottom Sheet Form */}
|
||||
<motion.div
|
||||
animate={{
|
||||
y: isFocused ? -20 : 0,
|
||||
paddingBottom: isFocused ? "40px" : "40px"
|
||||
}}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||
className="w-full bg-white/95 backdrop-blur-3xl rounded-t-[40px] p-8 shadow-sm border-t border-gray-100 flex flex-col"
|
||||
>
|
||||
{error && (
|
||||
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} className="mb-6 p-3 bg-red-50 text-red-600 text-xs rounded-2xl text-center font-bold">
|
||||
{error}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleLogin} className="flex flex-col gap-4">
|
||||
<div className="flex flex-col">
|
||||
<label className="text-[11px] font-extrabold text-gray-400 mb-1.5 ml-2 uppercase tracking-widest">شماره موبایل</label>
|
||||
<input
|
||||
type="tel"
|
||||
dir="ltr"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
className="px-5 py-4 bg-gray-50/80 rounded-[20px] focus:outline-none focus:bg-gray-100 transition-colors text-base font-bold text-gray-800 placeholder-gray-400"
|
||||
placeholder="09xxxxxxxxx"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<label className="text-[11px] font-extrabold text-gray-400 mb-1.5 ml-2 uppercase tracking-widest">رمز عبور</label>
|
||||
<input
|
||||
type="password"
|
||||
dir="ltr"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
className="px-5 py-4 bg-gray-50/80 rounded-[20px] focus:outline-none focus:bg-gray-100 transition-colors text-base font-bold text-gray-800 placeholder-gray-400"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mt-2">
|
||||
<motion.button
|
||||
whileTap={{ scale: 0.95 }}
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (!username) {
|
||||
setError('ابتدا شماره موبایل را وارد کنید');
|
||||
setTimeout(() => setError(''), 3000);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { startAuthentication } = await import('@simplewebauthn/browser');
|
||||
const res = await fetch('/api/auth/webauthn/login/generate-options', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username })
|
||||
});
|
||||
const options = await res.json();
|
||||
if (options.error) throw new Error(options.error);
|
||||
|
||||
const asseResp = await startAuthentication(options);
|
||||
|
||||
const verifyRes = await fetch('/api/auth/webauthn/login/verify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, response: asseResp })
|
||||
});
|
||||
|
||||
const verification = await verifyRes.json();
|
||||
if (verification.verified) {
|
||||
localStorage.setItem('token', verification.token);
|
||||
localStorage.setItem('user', JSON.stringify(verification.user));
|
||||
router.push('/dashboard');
|
||||
} else {
|
||||
throw new Error('احراز هویت ناموفق بود');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message || 'خطا در احراز هویت با اثر انگشت');
|
||||
setTimeout(() => setError(''), 3000);
|
||||
}
|
||||
}}
|
||||
className="w-14 h-14 bg-gray-50 rounded-[20px] flex items-center justify-center text-gray-400 hover:text-indigo-500 hover:bg-indigo-50 transition-colors shrink-0"
|
||||
>
|
||||
<Fingerprint size={24} strokeWidth={2} />
|
||||
</motion.button>
|
||||
|
||||
<motion.button
|
||||
whileTap={{ scale: 0.98 }}
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex-1 py-4 bg-gray-900 text-white text-sm font-extrabold rounded-[20px] transition-all disabled:opacity-70 flex items-center justify-center shadow-sm"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
|
||||
) : 'ورود'}
|
||||
</motion.button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mt-8 mb-2 flex justify-center">
|
||||
<div className="flex items-center justify-center opacity-60 hover:opacity-100 transition-opacity">
|
||||
<span className="text-[10px] text-gray-500 font-medium tracking-widest">طراحی توسط هوکا</span>
|
||||
<span className="mx-2 text-gray-300">|</span>
|
||||
<span className="text-[9px] text-gray-600 font-black tracking-[0.2em] uppercase font-sans mt-0.5">DESIGNED BY HUKA</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* iOS style bottom home indicator placeholder */}
|
||||
<div className="mt-4 flex items-center justify-center">
|
||||
<div className="w-12 h-1 bg-gray-200 rounded-full"></div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleLogin} className="flex flex-col gap-5">
|
||||
<div className="flex flex-col">
|
||||
<label className="text-xs font-bold text-gray-600 mb-1.5 ml-1">شماره موبایل</label>
|
||||
<input
|
||||
type="text"
|
||||
dir="ltr"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="px-4 py-3 bg-white/50 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500 focus:bg-white transition-all text-sm"
|
||||
placeholder="09xxxxxxxxx"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<label className="text-xs font-bold text-gray-600 mb-1.5 ml-1">رمز عبور</label>
|
||||
<input
|
||||
type="password"
|
||||
dir="ltr"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="px-4 py-3 bg-white/50 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500 focus:bg-white transition-all text-sm"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.01 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-3.5 bg-gray-900 hover:bg-black text-white text-sm font-bold rounded-xl transition-colors mt-2 shadow-lg shadow-gray-900/20 disabled:opacity-70"
|
||||
>
|
||||
{loading ? 'در حال ورود...' : 'ورود به سیستم'}
|
||||
</motion.button>
|
||||
</form>
|
||||
<p className="text-[10px] text-center text-gray-400 mt-6 font-medium">رمز عبور پیشفرض: 123456</p>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import Header from '@/components/Header';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Fingerprint, Lock, User, Save, Camera } from 'lucide-react';
|
||||
import { startRegistration } from '@simplewebauthn/browser';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [user, setUser] = useState(null);
|
||||
const [name, setName] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [avatarBase64, setAvatarBase64] = useState(null);
|
||||
const [biometricEnabled, setBiometricEnabled] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [isError, setIsError] = useState(false);
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const userData = localStorage.getItem('user');
|
||||
if (userData) {
|
||||
const parsedUser = JSON.parse(userData);
|
||||
setUser(parsedUser);
|
||||
setName(parsedUser.name || '');
|
||||
}
|
||||
const bio = localStorage.getItem('biometric_enabled');
|
||||
if (bio === 'true') setBiometricEnabled(true);
|
||||
}, []);
|
||||
|
||||
const showToast = (msg, error = false) => {
|
||||
setMessage(msg);
|
||||
setIsError(error);
|
||||
setTimeout(() => setMessage(''), 4000);
|
||||
};
|
||||
|
||||
const handleBiometricToggle = async () => {
|
||||
if (biometricEnabled) {
|
||||
setBiometricEnabled(false);
|
||||
localStorage.setItem('biometric_enabled', 'false');
|
||||
showToast('ورود با اثر انگشت غیرفعال شد');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
showToast('در حال آمادهسازی سنسور...', false);
|
||||
const res = await fetch('/api/auth/webauthn/register/generate-options', {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
const options = await res.json();
|
||||
|
||||
if (options.error) throw new Error(options.error);
|
||||
|
||||
// Trigger native biometric prompt
|
||||
const attResp = await startRegistration(options);
|
||||
|
||||
const verificationRes = await fetch('/api/auth/webauthn/register/verify', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: JSON.stringify(attResp),
|
||||
});
|
||||
|
||||
const verification = await verificationRes.json();
|
||||
if (verification.verified) {
|
||||
setBiometricEnabled(true);
|
||||
localStorage.setItem('biometric_enabled', 'true');
|
||||
showToast('اثر انگشت با موفقیت فعال شد');
|
||||
} else {
|
||||
throw new Error('Verification failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showToast('خطا در فعالسازی یا لغو توسط کاربر', true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => setAvatarBase64(reader.result);
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/user/settings', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
password: password ? password : undefined,
|
||||
avatarUrl: avatarBase64 ? avatarBase64 : undefined
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
localStorage.setItem('user', JSON.stringify(data.user));
|
||||
setUser(data.user);
|
||||
setPassword('');
|
||||
showToast('تغییرات شما با موفقیت ذخیره شد.');
|
||||
} else {
|
||||
showToast(data.error || 'خطا در ذخیره تغییرات', true);
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('خطا در ارتباط با سرور', true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const container = {
|
||||
hidden: { opacity: 0 },
|
||||
show: { opacity: 1, transition: { staggerChildren: 0.1 } }
|
||||
};
|
||||
const item = { hidden: { opacity: 0, y: 10 }, show: { opacity: 1, y: 0 } };
|
||||
|
||||
return (
|
||||
<div className="w-full min-h-screen bg-[#F8FAFC] flex flex-col pb-20 relative">
|
||||
<Header title="تنظیمات حساب" showBack={true} />
|
||||
|
||||
{/* Global Toast Notification */}
|
||||
<AnimatePresence>
|
||||
{message && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 50, scale: 0.9 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 20, scale: 0.9 }}
|
||||
className={`fixed bottom-8 left-1/2 -translate-x-1/2 z-[100] px-5 py-3 rounded-full shadow-[0_10px_40px_rgb(0,0,0,0.1)] backdrop-blur-xl border text-[11px] font-extrabold whitespace-nowrap flex items-center justify-center ${isError ? 'bg-red-500/95 border-red-400 text-white shadow-red-500/30' : 'bg-gray-800/95 border-gray-700 text-white shadow-gray-900/30'}`}
|
||||
>
|
||||
{message}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<motion.div variants={container} initial="hidden" animate="show" className="px-6 pt-2 flex flex-col gap-8">
|
||||
|
||||
<motion.section variants={item} className="flex flex-col gap-5 items-center">
|
||||
<div className="relative group mt-2">
|
||||
<div className="w-28 h-28 rounded-full shadow-[0_10px_40px_rgb(0,0,0,0.08)] overflow-hidden border-[4px] border-white bg-gradient-to-tr from-indigo-50 to-purple-50">
|
||||
<img src={avatarBase64 || user?.avatarUrl || "/images/default_avatar.png"} alt="avatar" className="w-full h-full object-cover" />
|
||||
</div>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="absolute bottom-0 right-0 w-10 h-10 bg-white border border-gray-100 text-indigo-500 rounded-full shadow-lg flex items-center justify-center hover:scale-105 active:scale-95 transition-all"
|
||||
>
|
||||
<Camera size={18} strokeWidth={2.5} />
|
||||
</button>
|
||||
<input type="file" ref={fileInputRef} onChange={handleFileChange} accept="image/*" className="hidden" />
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-black text-gray-800">{user?.name || 'کاربر'}</h3>
|
||||
<p className="text-xs text-gray-400 font-medium mt-1 uppercase tracking-wider">{user?.role === 'ADMIN' ? 'مدیریت کل' : 'کاربر عادی'}</p>
|
||||
</div>
|
||||
</motion.section>
|
||||
|
||||
<motion.section variants={item} className="flex flex-col gap-4">
|
||||
<h2 className="text-[11px] font-extrabold text-gray-400 mr-2 uppercase tracking-widest">مشخصات کاربری</h2>
|
||||
|
||||
<div className="bg-white/90 backdrop-blur-3xl border border-gray-100/50 rounded-[28px] shadow-[0_8px_30px_rgb(0,0,0,0.04)] p-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
|
||||
<div className="flex items-center gap-4 bg-gray-50/50 hover:bg-gray-50 p-4 rounded-[20px] transition-colors">
|
||||
<div className="w-10 h-10 bg-white text-indigo-500 rounded-[14px] flex items-center justify-center shadow-sm">
|
||||
<User size={18} strokeWidth={2.5} />
|
||||
</div>
|
||||
<div className="flex flex-col flex-1">
|
||||
<label className="text-[10px] font-bold text-gray-400 mb-0.5">نام و نام خانوادگی</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
className="bg-transparent border-none text-sm font-bold text-gray-800 outline-none w-full"
|
||||
placeholder="نام شما"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 bg-gray-50/50 hover:bg-gray-50 p-4 rounded-[20px] transition-colors">
|
||||
<div className="w-10 h-10 bg-white text-rose-500 rounded-[14px] flex items-center justify-center shadow-sm">
|
||||
<Lock size={18} strokeWidth={2.5} />
|
||||
</div>
|
||||
<div className="flex flex-col flex-1">
|
||||
<label className="text-[10px] font-bold text-gray-400 mb-0.5">رمز عبور جدید (اختیاری)</label>
|
||||
<input
|
||||
type="password"
|
||||
dir="ltr"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
className="bg-transparent border-none text-sm font-bold text-gray-800 outline-none w-full placeholder-gray-300"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</motion.section>
|
||||
|
||||
<motion.section variants={item} className="flex flex-col gap-4">
|
||||
<h2 className="text-[11px] font-extrabold text-gray-400 mr-2 uppercase tracking-widest">امنیت</h2>
|
||||
<div className="bg-white/90 backdrop-blur-3xl border border-gray-100/50 rounded-[28px] shadow-[0_8px_30px_rgb(0,0,0,0.04)] p-2">
|
||||
|
||||
<div className="flex items-center justify-between p-4 hover:bg-gray-50 rounded-[20px] transition-colors">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`w-10 h-10 rounded-[14px] flex items-center justify-center transition-colors shadow-sm ${biometricEnabled ? 'bg-green-500 text-white shadow-green-500/20' : 'bg-gray-100 text-gray-400'}`}>
|
||||
<Fingerprint size={18} strokeWidth={2.5} />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-bold text-gray-800">ورود با اثر انگشت</span>
|
||||
<span className="text-[10px] text-gray-400 font-medium mt-0.5">Face ID / Touch ID</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleBiometricToggle}
|
||||
className={`relative flex items-center w-12 h-6 rounded-full px-1 transition-colors duration-300 ease-in-out shrink-0 ${biometricEnabled ? 'bg-green-500' : 'bg-gray-200'}`}
|
||||
>
|
||||
<motion.div
|
||||
className="w-4 h-4 bg-white rounded-full shadow-sm"
|
||||
animate={{ x: biometricEnabled ? -24 : 0 }}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</motion.section>
|
||||
|
||||
<motion.div variants={item} className="mt-4">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={loading}
|
||||
className="w-full py-4 bg-gradient-to-r from-gray-900 to-gray-800 hover:from-black hover:to-gray-900 text-white text-sm font-extrabold rounded-[20px] transition-all shadow-xl shadow-gray-900/20 disabled:opacity-70 flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
|
||||
) : (
|
||||
<>
|
||||
<Save size={18} strokeWidth={2.5} />
|
||||
ذخیره اطلاعات
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</motion.div>
|
||||
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+66
-42
@@ -3,11 +3,10 @@ import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { motion } from 'framer-motion';
|
||||
import { LogOut, ChevronRight, Wifi, WifiOff } from 'lucide-react';
|
||||
import { LogOut, ChevronRight, Settings } from 'lucide-react';
|
||||
|
||||
export default function Header({ title = 'داشبورد', showBack = false }) {
|
||||
const [user, setUser] = useState(null);
|
||||
const [isOnline, setIsOnline] = useState(true);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -17,19 +16,6 @@ export default function Header({ title = 'داشبورد', showBack = false }) {
|
||||
} else {
|
||||
router.push('/');
|
||||
}
|
||||
|
||||
setIsOnline(navigator.onLine);
|
||||
|
||||
const handleOnline = () => setIsOnline(true);
|
||||
const handleOffline = () => setIsOnline(false);
|
||||
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
};
|
||||
}, [router]);
|
||||
|
||||
const handleLogout = () => {
|
||||
@@ -38,41 +24,79 @@ export default function Header({ title = 'داشبورد', showBack = false }) {
|
||||
router.push('/');
|
||||
};
|
||||
|
||||
const getInitial = (name) => {
|
||||
if (!name) return 'U';
|
||||
return name.charAt(0);
|
||||
};
|
||||
|
||||
if (!showBack) {
|
||||
// Style 4: Modern Floating Island (Pill) Design
|
||||
return (
|
||||
<motion.header
|
||||
initial={{ y: -50, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 24 }}
|
||||
className="mx-4 mt-6 mb-4 p-2 bg-white/70 backdrop-blur-xl border border-white rounded-[28px] flex items-center justify-between shadow-[0_8px_30px_rgb(0,0,0,0.04)]"
|
||||
>
|
||||
<Link href="/settings" className="flex items-center gap-3 hover:opacity-80 transition-opacity">
|
||||
<div className="w-12 h-12 bg-gradient-to-tr from-purple-600 to-indigo-500 rounded-[20px] flex items-center justify-center text-white text-lg font-bold shadow-md shadow-purple-500/20 overflow-hidden">
|
||||
{user?.avatarUrl ? (
|
||||
<img src={user.avatarUrl} alt={user.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
getInitial(user?.name)
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col pr-1">
|
||||
<span className="text-[10px] text-gray-400 font-extrabold tracking-wider mb-0.5">پردیس رایانه</span>
|
||||
<span className="text-sm font-black text-gray-800 tracking-tight">
|
||||
{user?.name || 'کاربر'}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-2 pl-1">
|
||||
{user?.role === 'ADMIN' && (
|
||||
<Link
|
||||
href="/admin"
|
||||
className="w-10 h-10 bg-gray-50 text-gray-600 rounded-[18px] hover:bg-gray-100 transition-colors flex items-center justify-center"
|
||||
title="پنل مدیریت"
|
||||
>
|
||||
<Settings size={18} strokeWidth={2.5} />
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-10 h-10 bg-red-50 text-red-500 rounded-[18px] hover:bg-red-100 transition-colors flex items-center justify-center"
|
||||
title="خروج"
|
||||
>
|
||||
<LogOut size={18} strokeWidth={2.5} />
|
||||
</button>
|
||||
</div>
|
||||
</motion.header>
|
||||
);
|
||||
}
|
||||
|
||||
// Inner pages (showBack = true)
|
||||
return (
|
||||
<motion.header
|
||||
initial={{ y: -50, opacity: 0 }}
|
||||
initial={{ y: -20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.4, ease: [0.22, 1, 0.36, 1] }}
|
||||
className="sticky top-0 z-50 w-full h-16 bg-white/70 backdrop-blur-xl border-b border-gray-200/50 flex items-center justify-between px-4 text-gray-800"
|
||||
className="sticky top-4 z-50 mx-4 mb-6 p-2 bg-white/80 backdrop-blur-xl border border-white rounded-[24px] flex items-center justify-between shadow-[0_8px_30px_rgb(0,0,0,0.06)]"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{showBack ? (
|
||||
<button onClick={() => router.back()} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
||||
<ChevronRight size={20} className="text-gray-600" />
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={handleLogout} className="p-2 hover:bg-red-50 text-red-500 rounded-full transition-colors flex items-center justify-center">
|
||||
<LogOut size={18} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="w-10 h-10 bg-gray-50 text-gray-700 rounded-[18px] hover:bg-gray-100 transition-all flex items-center justify-center"
|
||||
>
|
||||
<ChevronRight size={20} strokeWidth={2.5} />
|
||||
</button>
|
||||
|
||||
<div className="font-extrabold text-sm tracking-tight text-gray-800">
|
||||
<div className="font-extrabold text-sm text-gray-800 absolute left-1/2 -translate-x-1/2">
|
||||
{title}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="text-xs font-bold text-gray-700">{user?.name || 'کاربر'}</span>
|
||||
{user?.role === 'ADMIN' && <Link href="/admin/locations" className="text-[10px] text-purple-600 font-bold hover:underline">مدیریت قفسهها</Link>}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 bg-gray-50/50 p-1.5 rounded-full border border-gray-100">
|
||||
{isOnline ? (
|
||||
<Wifi size={14} className="text-green-500" />
|
||||
) : (
|
||||
<WifiOff size={14} className="text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
<div className="w-10 h-10 flex items-center justify-center">
|
||||
{/* Decorative dot for symmetry */}
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-gray-300"></div>
|
||||
</div>
|
||||
</motion.header>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
'use client';
|
||||
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
||||
|
||||
export function ThemeProvider({ children }) {
|
||||
return <NextThemesProvider attribute="class" defaultTheme="light">{children}</NextThemesProvider>;
|
||||
}
|
||||
Reference in New Issue
Block a user