update user page and logo to be link also update profile page
This commit is contained in:
@@ -16,5 +16,5 @@ export default function ConditionalNavbar() {
|
|||||||
const isDashboardRoute = pathname?.startsWith("/dashboard");
|
const isDashboardRoute = pathname?.startsWith("/dashboard");
|
||||||
if (user || isDashboardRoute) return null;
|
if (user || isDashboardRoute) return null;
|
||||||
|
|
||||||
return <Navbar />;
|
return "";
|
||||||
}
|
}
|
||||||
@@ -325,7 +325,7 @@ function SidebarContent({
|
|||||||
justifyContent: compact ? "center" : "flex-start",
|
justifyContent: compact ? "center" : "flex-start",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{compact ? <BrandIcon size={36} /> : <Logo white />}
|
{compact ? <BrandIcon size={36} /> : <Logo white href="/dashboard" />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav
|
<nav
|
||||||
|
|||||||
40
app/components/system/ExtensionAttributeCleanup.tsx
Normal file
40
app/components/system/ExtensionAttributeCleanup.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
// Some browser extensions (Bitdefender TrafficLight and similar) inject
|
||||||
|
// bis_skin_checked / bis_register attributes onto form elements before
|
||||||
|
// React hydrates the page. This causes a harmless but noisy hydration
|
||||||
|
// mismatch warning. This component strips those attributes after mount
|
||||||
|
// and keeps watching for re-injection, without disabling SSR/hydration.
|
||||||
|
const WATCHED_ATTRS = ["bis_skin_checked", "bis_register", "__processed_by_bitdefender__"];
|
||||||
|
|
||||||
|
export function ExtensionAttributeCleanup() {
|
||||||
|
useEffect(() => {
|
||||||
|
function strip() {
|
||||||
|
const selector = WATCHED_ATTRS.map(a => `[${a}]`).join(",");
|
||||||
|
document.querySelectorAll(selector).forEach(el => {
|
||||||
|
WATCHED_ATTRS.forEach(attr => el.removeAttribute(attr));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial cleanup right after hydration
|
||||||
|
strip();
|
||||||
|
|
||||||
|
// Extensions can re-inject the attribute after every re-render
|
||||||
|
// (e.g. after clicking Save, which re-renders the form), so keep
|
||||||
|
// watching instead of running once.
|
||||||
|
const observer = new MutationObserver(strip);
|
||||||
|
observer.observe(document.body, {
|
||||||
|
attributes: true,
|
||||||
|
subtree: true,
|
||||||
|
attributeFilter: WATCHED_ATTRS,
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Renders nothing — side-effect-only component
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -6,7 +6,6 @@ export default function ProfilePage() {
|
|||||||
const { user, loading, error } = useCurrentUser();
|
const { user, loading, error } = useCurrentUser();
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
// حالة تحميل بسيطة تتماشى مع سبينر الصور اللي عملتها قبل كده في الصفحات التانية
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[60vh] items-center justify-center">
|
<div className="flex min-h-[60vh] items-center justify-center">
|
||||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" />
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" />
|
||||||
@@ -27,12 +26,10 @@ export default function ProfilePage() {
|
|||||||
{ label: "اسم المستخدم", value: user.userName ?? "—" },
|
{ label: "اسم المستخدم", value: user.userName ?? "—" },
|
||||||
{ label: "البريد الإلكتروني", value: user.email ?? "—" },
|
{ label: "البريد الإلكتروني", value: user.email ?? "—" },
|
||||||
{ label: "رقم الهاتف", value: user.phone },
|
{ label: "رقم الهاتف", value: user.phone },
|
||||||
{ label: "الحالة", value: user.isActive ? "نشط" : "غير نشط" },
|
|
||||||
{ label: "تاريخ الإنشاء", value: new Date(user.createdAt).toLocaleDateString("ar-SA") },
|
{ label: "تاريخ الإنشاء", value: new Date(user.createdAt).toLocaleDateString("ar-SA") },
|
||||||
{ label: "آخر تحديث", value: new Date(user.updatedAt).toLocaleDateString("ar-SA") },
|
{ label: "آخر تحديث", value: new Date(user.updatedAt).toLocaleDateString("ar-SA") },
|
||||||
];
|
];
|
||||||
|
|
||||||
// تجميع الصلاحيات حسب الموديول عشان تبقى منظمة وسهلة القراءة
|
|
||||||
const permissionsByModule = (user.role?.permissions ?? []).reduce<Record<string, string[]>>(
|
const permissionsByModule = (user.role?.permissions ?? []).reduce<Record<string, string[]>>(
|
||||||
(acc, entry) => {
|
(acc, entry) => {
|
||||||
const mod = entry.permission.module || "أخرى";
|
const mod = entry.permission.module || "أخرى";
|
||||||
@@ -43,24 +40,43 @@ export default function ProfilePage() {
|
|||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
const modules = Object.keys(permissionsByModule);
|
const modules = Object.keys(permissionsByModule);
|
||||||
|
const totalPermissions = Object.values(permissionsByModule).reduce((n, arr) => n + arr.length, 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-2xl space-y-6 px-4 py-10" dir="rtl">
|
<div className="min-h-screen bg-slate-50" dir="rtl">
|
||||||
{/* بطاقة البيانات الأساسية */}
|
{/* top bar: user info */}
|
||||||
<div className="rounded-2xl bg-white p-6 shadow-md">
|
<div className="border-b border-slate-200 bg-white">
|
||||||
<div className="mb-6 flex flex-col items-center gap-3">
|
<div className="mx-auto flex w-full max-w-6xl flex-col items-center gap-4 px-6 py-10 sm:flex-row sm:items-center">
|
||||||
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-blue-50 text-2xl font-semibold text-blue-600 ring-2 ring-blue-100">
|
<div className="flex h-16 w-16 shrink-0 items-center justify-center rounded-full bg-blue-600 text-xl font-semibold text-white">
|
||||||
{user.name?.trim().charAt(0) ?? "?"}
|
{user.name?.trim().charAt(0) ?? "?"}
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-base font-semibold text-slate-900">{user.name}</h1>
|
<div className="flex-1 text-center sm:text-right">
|
||||||
|
<h1 className="text-lg font-semibold text-slate-900">{user.name}</h1>
|
||||||
|
<p className="mt-0.5 text-sm text-slate-500">{user.email ?? user.phone}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
{user.role?.name && (
|
{user.role?.name && (
|
||||||
<span className="rounded-full bg-blue-50 px-3 py-1 text-xs font-medium text-blue-700">
|
<span className="rounded-full bg-blue-50 px-3 py-1 text-xs font-medium text-blue-700">
|
||||||
{user.role.name}
|
{user.role.name}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-3 py-1 text-xs font-medium ${
|
||||||
|
user.isActive ? "bg-emerald-50 text-emerald-700" : "bg-slate-100 text-slate-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{user.isActive ? "نشط" : "غير نشط"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* عرض البيانات بشكل read-only بدون أي فورم أو زرار تعديل */}
|
{/* main content, full width */}
|
||||||
|
<div className="mx-auto w-full max-w-6xl px-6 py-8">
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
|
{/* account details */}
|
||||||
|
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-100 lg:col-span-1">
|
||||||
|
<h2 className="mb-4 text-sm font-semibold text-slate-900">بيانات الحساب</h2>
|
||||||
<dl className="divide-y divide-slate-100">
|
<dl className="divide-y divide-slate-100">
|
||||||
{fields.map((f) => (
|
{fields.map((f) => (
|
||||||
<div key={f.label} className="flex items-center justify-between py-2.5 text-[13px]">
|
<div key={f.label} className="flex items-center justify-between py-2.5 text-[13px]">
|
||||||
@@ -71,53 +87,51 @@ export default function ProfilePage() {
|
|||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* بطاقة الدور والصلاحيات */}
|
{/* role and permissions */}
|
||||||
{user.role && (
|
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-100 lg:col-span-2">
|
||||||
<div className="rounded-2xl bg-white p-6 shadow-md">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<div className="mb-4">
|
<div>
|
||||||
<h2 className="text-sm font-semibold text-slate-900">الدور والصلاحيات</h2>
|
<h2 className="text-sm font-semibold text-slate-900">الدور والصلاحيات</h2>
|
||||||
{user.role.description && (
|
{user.role?.description && (
|
||||||
<p className="mt-1 text-[13px] text-slate-500">{user.role.description}</p>
|
<p className="mt-1 text-[13px] text-slate-500">{user.role.description}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{modules.length > 0 && (
|
||||||
|
<span className="rounded-full bg-slate-100 px-3 py-1 text-[11px] font-medium text-slate-500">
|
||||||
|
{totalPermissions} صلاحية
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{modules.length === 0 ? (
|
{modules.length === 0 ? (
|
||||||
<p className="text-[13px] text-slate-400">لا توجد صلاحيات مسجّلة لهذا الدور.</p>
|
<p className="text-[13px] text-slate-400">لا توجد صلاحيات مسجّلة لهذا الدور.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
{modules.map((mod) => (
|
{modules.map((mod) => (
|
||||||
<details
|
<div key={mod} className="rounded-xl border border-slate-100 bg-slate-50/60 p-3">
|
||||||
key={mod}
|
<div className="mb-2 flex items-center justify-between">
|
||||||
className="group rounded-lg border border-slate-100 open:bg-slate-50"
|
<span className="text-[13px] font-medium text-slate-700">{mod}</span>
|
||||||
open
|
<span className="rounded-full bg-white px-2 py-0.5 text-[11px] font-normal text-slate-500 ring-1 ring-slate-100">
|
||||||
>
|
|
||||||
<summary className="flex cursor-pointer list-none items-center justify-between px-3 py-2.5 text-[13px] font-medium text-slate-700">
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
{mod}
|
|
||||||
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-normal text-slate-500">
|
|
||||||
{permissionsByModule[mod].length}
|
{permissionsByModule[mod].length}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</div>
|
||||||
<span className="text-slate-400 transition-transform group-open:rotate-180">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
▾
|
|
||||||
</span>
|
|
||||||
</summary>
|
|
||||||
<div className="flex flex-wrap gap-2 px-3 pb-3 pt-1">
|
|
||||||
{permissionsByModule[mod].map((name) => (
|
{permissionsByModule[mod].map((name) => (
|
||||||
<span
|
<span
|
||||||
key={name}
|
key={name}
|
||||||
className="rounded-md bg-blue-50 px-2.5 py-1 text-[12px] font-medium text-blue-700"
|
className="rounded-md bg-blue-50 px-2 py-1 text-[11px] font-medium text-blue-700"
|
||||||
>
|
>
|
||||||
{name}
|
{name}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -65,6 +65,11 @@ export default function UsersPage() {
|
|||||||
{/* Create / Edit modal */}
|
{/* Create / Edit modal */}
|
||||||
{formTarget !== false && (
|
{formTarget !== false && (
|
||||||
<UserFormModal
|
<UserFormModal
|
||||||
|
// CHANGE: added key — forces a full remount when switching between
|
||||||
|
// "new" and different edit targets, so useForm's defaultValues
|
||||||
|
// (roleId/branchId included) always reflect the correct user
|
||||||
|
// instead of possibly reusing stale state from a previous instance.
|
||||||
|
key={formTarget === null ? "new" : formTarget.id}
|
||||||
editUser={formTarget}
|
editUser={formTarget}
|
||||||
roles={roles}
|
roles={roles}
|
||||||
branches={branches}
|
branches={branches}
|
||||||
|
|||||||
@@ -130,12 +130,7 @@ export default function HomePage() {
|
|||||||
>
|
>
|
||||||
تسجيل الدخول للوحة التحكم
|
تسجيل الدخول للوحة التحكم
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
|
||||||
href="/how_work"
|
|
||||||
className="inline-flex h-11 items-center rounded-xl bg-white px-5 text-[13px] font-semibold text-blue-700 shadow-md"
|
|
||||||
>
|
|
||||||
كيف تعمل المنصة؟
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
@@ -214,9 +209,7 @@ export default function HomePage() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<Link href="/features/app" className="text-[13px] font-semibold text-blue-600 hover:text-blue-700">
|
|
||||||
عرض كل المميزات ←
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
@@ -273,12 +266,7 @@ export default function HomePage() {
|
|||||||
>
|
>
|
||||||
ابدأ الآن
|
ابدأ الآن
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
|
||||||
href="/prices"
|
|
||||||
className="inline-flex h-11 items-center rounded-xl border border-white/30 bg-white/10 px-6 text-[13px] font-semibold text-white backdrop-blur"
|
|
||||||
>
|
|
||||||
عرض الأسعار
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
|||||||
import "@tabler/icons-webfont/dist/tabler-icons.min.css";
|
import "@tabler/icons-webfont/dist/tabler-icons.min.css";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import ConditionalNavbar from "./components/layout/ConditionalNavbar";
|
import ConditionalNavbar from "./components/layout/ConditionalNavbar";
|
||||||
|
import { ExtensionAttributeCleanup } from "./components/system/ExtensionAttributeCleanup"; // CHANGE: added — fixes hydration mismatch caused by browser extensions
|
||||||
|
|
||||||
import { Footer } from "./components/layout";
|
import { Footer } from "./components/layout";
|
||||||
|
|
||||||
@@ -19,6 +20,9 @@ export default function RootLayout({
|
|||||||
// ⚠️ اتصلح: الـ Navbar والـ footer كانوا برة <html>/<body> وده HTML غير صالح
|
// ⚠️ اتصلح: الـ Navbar والـ footer كانوا برة <html>/<body> وده HTML غير صالح
|
||||||
<html lang="ar" dir="rtl">
|
<html lang="ar" dir="rtl">
|
||||||
<body className="app-shell" suppressHydrationWarning>
|
<body className="app-shell" suppressHydrationWarning>
|
||||||
|
{/* CHANGE: mounted globally so every route (not just the dashboard
|
||||||
|
form pages) gets protected from extension-injected attributes */}
|
||||||
|
<ExtensionAttributeCleanup />
|
||||||
{/* الناف بار بيظهر بس لو مفيش يوزر مسجل دخول */}
|
{/* الناف بار بيظهر بس لو مفيش يوزر مسجل دخول */}
|
||||||
<ConditionalNavbar />
|
<ConditionalNavbar />
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import type { Notification } from "@/src/hooks/useUser";
|
|
||||||
|
|
||||||
interface ToastProps {
|
|
||||||
notification: Notification | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Toast({ notification }: ToastProps) {
|
|
||||||
const [visible, setVisible] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (notification) {
|
|
||||||
setVisible(true);
|
|
||||||
} else {
|
|
||||||
// dismiss after 250ms to allow exit animation
|
|
||||||
const t = setTimeout(() => setVisible(false), 300);
|
|
||||||
return () => clearTimeout(t);
|
|
||||||
}
|
|
||||||
}, [notification]);
|
|
||||||
|
|
||||||
if (!visible && !notification) return null;
|
|
||||||
|
|
||||||
const isSuccess = notification?.type === "success";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="status" aria-live="polite" aria-atomic="true"
|
|
||||||
style={{
|
|
||||||
position: "fixed", bottom: 24, left: "50%",
|
|
||||||
transform: `translateX(-50%) translateY(${notification ? "0" : "16px"})`,
|
|
||||||
zIndex: 9999,
|
|
||||||
transition: "transform 250ms ease, opacity 250ms ease",
|
|
||||||
opacity: notification ? 1 : 0,
|
|
||||||
pointerEvents: "none",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{
|
|
||||||
display: "flex", alignItems: "center", gap: 10,
|
|
||||||
padding: "0.75rem 1.25rem",
|
|
||||||
borderRadius: "var(--radius-full)",
|
|
||||||
background: isSuccess ? "#065F46" : "#7F1D1D",
|
|
||||||
color: "#FFFFFF",
|
|
||||||
fontSize: 13, fontWeight: 600,
|
|
||||||
boxShadow: "0 8px 32px rgba(0,0,0,0.25)",
|
|
||||||
maxWidth: "90vw", whiteSpace: "nowrap",
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
}}>
|
|
||||||
{/* icon */}
|
|
||||||
<span style={{ fontSize: 16 }}>{isSuccess ? "✓" : "⚠"}</span>
|
|
||||||
<span>{notification?.message}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import type { Resolver } from "react-hook-form";
|
import type { Resolver } from "react-hook-form";
|
||||||
import { yupResolver } from "@hookform/resolvers/yup";
|
import { yupResolver } from "@hookform/resolvers/yup";
|
||||||
import { Alert, Button, Input, Select } from "../UI";
|
import { Alert, Button, Input, Select } from "../UI";
|
||||||
import { createUserSchema, updateUserSchema } from "@/src/validations/user.validator";
|
import { createUserSchema, updateUserSchema } from "@/src/validations/user.validator";
|
||||||
|
import { useEditFormSync } from "@/src/hooks/useEditFormSync"; // CHANGE: added hook — fixes roleId/branchId not pre-selecting on edit
|
||||||
import type { Branch } from "@/src/types/branch";
|
import type { Branch } from "@/src/types/branch";
|
||||||
import type { Role } from "@/src/types/role";
|
import type { Role } from "@/src/types/role";
|
||||||
import type { User, UserFormData } from "@/src/types/user";
|
import type { User, UserFormData } from "@/src/types/user";
|
||||||
@@ -30,11 +30,6 @@ export function UserFormModal({ editUser, roles, branches, onClose, onSubmit }:
|
|||||||
const resolver: Resolver<UserFormData> = async (values, context, options) => {
|
const resolver: Resolver<UserFormData> = async (values, context, options) => {
|
||||||
const schema = isNew ? createUserSchema : updateUserSchema;
|
const schema = isNew ? createUserSchema : updateUserSchema;
|
||||||
const data = !isNew && !values.password ? { ...values, password: undefined } : values;
|
const data = !isNew && !values.password ? { ...values, password: undefined } : values;
|
||||||
// Cast both the schema argument and yupResolver's own return value — same
|
|
||||||
// fix already applied in TripFormModal/DriverFormModal/CarFormModal:
|
|
||||||
// passing an explicit <UserFormData> generic here forces a structural
|
|
||||||
// comparison between yup's inferred optional-field shape and
|
|
||||||
// UserFormData that fails the same way it did for those forms.
|
|
||||||
return (yupResolver(schema as any) as any)(data as UserFormData, context, options);
|
return (yupResolver(schema as any) as any)(data as UserFormData, context, options);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -56,25 +51,26 @@ export function UserFormModal({ editUser, roles, branches, onClose, onSubmit }:
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// roles/branches are passed in as props rather than fetched inside this
|
// CHANGE: replaced the two manual useEffect blocks with useEditFormSync —
|
||||||
// modal (unlike Trip/Driver/Car), but the same timing issue applies if the
|
// same behavior (re-apply saved id once its option list contains it),
|
||||||
// parent still has them loading when the modal first mounts: defaultValues
|
// reused across Driver/Car/Trip forms that have the same bug pattern.
|
||||||
// are applied before the matching <option> exists, so the <select>
|
useEditFormSync(setValue, "roleId", editUser?.role?.id, roles);
|
||||||
// silently falls back to "". Reapply the saved id once each list actually
|
useEditFormSync(setValue, "branchId", editUser?.branch?.id, branches);
|
||||||
// contains it.
|
|
||||||
|
// CHANGE: surface an inline error if the saved role/branch no longer
|
||||||
|
// exists in the fetched list, instead of silently falling back to the
|
||||||
|
// placeholder with no explanation.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const savedRoleId = editUser?.role?.id;
|
if (editUser?.role?.id && roles.length && !roles.some(r => r.id === editUser.role!.id)) {
|
||||||
if (savedRoleId && roles.some(r => r.id === savedRoleId)) {
|
setError("roleId", { message: "الدور المحفوظ لم يعد متاحًا، الرجاء اختيار دور آخر" });
|
||||||
setValue("roleId", savedRoleId);
|
|
||||||
}
|
}
|
||||||
}, [roles, editUser, setValue]);
|
}, [roles, editUser, setError]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const savedBranchId = editUser?.branch?.id;
|
if (editUser?.branch?.id && branches.length && !branches.some(b => b.id === editUser.branch!.id)) {
|
||||||
if (savedBranchId && branches.some(b => b.id === savedBranchId)) {
|
setError("branchId", { message: "الفرع المحفوظ لم يعد متاحًا، الرجاء اختيار فرع آخر" });
|
||||||
setValue("branchId", savedBranchId);
|
|
||||||
}
|
}
|
||||||
}, [branches, editUser, setValue]);
|
}, [branches, editUser, setError]);
|
||||||
|
|
||||||
const submitHandler = async (data: UserFormData) => {
|
const submitHandler = async (data: UserFormData) => {
|
||||||
const payload: Partial<UserFormData> = { ...data };
|
const payload: Partial<UserFormData> = { ...data };
|
||||||
@@ -136,7 +132,17 @@ export function UserFormModal({ editUser, roles, branches, onClose, onSubmit }:
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* body */}
|
{/* body */}
|
||||||
<form onSubmit={handleSubmit(submitHandler)} noValidate style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
|
{/* CHANGE: added suppressHydrationWarning — form elements are the
|
||||||
|
most common target for browser-extension-injected attributes
|
||||||
|
(e.g. bis_skin_checked from Bitdefender), which caused the
|
||||||
|
hydration mismatch error on Save. See ExtensionAttributeCleanup
|
||||||
|
for the global fix; this is a scoped safety net. */}
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit(submitHandler)}
|
||||||
|
noValidate
|
||||||
|
suppressHydrationWarning
|
||||||
|
style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}
|
||||||
|
>
|
||||||
{errors.name?.type === "manual" && (
|
{errors.name?.type === "manual" && (
|
||||||
<Alert type="error" message={errors.name.message ?? ""} onClose={() => {}} />
|
<Alert type="error" message={errors.name.message ?? ""} onClose={() => {}} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ import { yupResolver } from "@hookform/resolvers/yup";
|
|||||||
import { Alert, Button, Input } from "@/src/Components/UI";
|
import { Alert, Button, Input } from "@/src/Components/UI";
|
||||||
import { useAuth } from "@/src/hooks/useAuth";
|
import { useAuth } from "@/src/hooks/useAuth";
|
||||||
import Logo from "@/src/utils/logo";
|
import Logo from "@/src/utils/logo";
|
||||||
import { loginSchema, type LoginFormData } from "@/src/validations/auth.validator";
|
import {
|
||||||
|
loginSchema,
|
||||||
|
type LoginFormData,
|
||||||
|
} from "@/src/validations/auth.validator";
|
||||||
|
|
||||||
export function LoginForm() {
|
export function LoginForm() {
|
||||||
const { login, loading, error, clearError } = useAuth();
|
const { login, loading, error, clearError } = useAuth();
|
||||||
@@ -25,47 +28,212 @@ export function LoginForm() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main style={{ minHeight: "100vh", background: "var(--color-surface-muted)", color: "var(--color-text-primary)" }}>
|
<main
|
||||||
<section style={{ minHeight: "100vh", width: "100%", display: "grid", gridTemplateColumns: "1fr 1fr" }}>
|
style={{
|
||||||
<aside style={{ position: "relative", overflow: "hidden", background: "linear-gradient(135deg, #0f172a 0%, #2563EB 50%, #3b82f6 100%)", color: "#FFF", padding: "2.5rem 3rem", display: "flex", flexDirection: "column", justifyContent: "space-between" }}>
|
minHeight: "100vh",
|
||||||
<div aria-hidden="true" style={{ position: "absolute", inset: 0, opacity: 0.28, backgroundImage: "radial-gradient(circle, rgba(255,255,255,0.18) 1px, transparent 1px)", backgroundSize: "40px 40px" }} />
|
background: "var(--color-surface-muted)",
|
||||||
<div style={{ position: "relative", zIndex: 1, display: "flex", flexDirection: "column", justifyContent: "space-between", height: "100%" }}>
|
color: "var(--color-text-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
minHeight: "100vh",
|
||||||
|
width: "100%",
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "1fr 1fr",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<aside
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
overflow: "hidden",
|
||||||
|
background:
|
||||||
|
"linear-gradient(135deg, #0f172a 0%, #2563EB 50%, #3b82f6 100%)",
|
||||||
|
color: "#FFF",
|
||||||
|
padding: "2.5rem 3rem",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
opacity: 0.28,
|
||||||
|
backgroundImage:
|
||||||
|
"radial-gradient(circle, rgba(255,255,255,0.18) 1px, transparent 1px)",
|
||||||
|
backgroundSize: "40px 40px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
zIndex: 1,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<Logo white={true} />
|
<Logo white={true} href="/" />
|
||||||
<h1 style={{ marginTop: "2.5rem", maxWidth: "28rem", fontSize: "2.25rem", fontWeight: 700, lineHeight: 1.2, letterSpacing: "-0.02em" }}>
|
<h1
|
||||||
|
style={{
|
||||||
|
marginTop: "2.5rem",
|
||||||
|
maxWidth: "28rem",
|
||||||
|
fontSize: "2.25rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: 1.2,
|
||||||
|
letterSpacing: "-0.02em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
اللوجستيات ببساطة، والتسليم بثقة.
|
اللوجستيات ببساطة، والتسليم بثقة.
|
||||||
</h1>
|
</h1>
|
||||||
<p style={{ marginTop: "1rem", maxWidth: "28rem", fontSize: "0.95rem", lineHeight: 1.8, color: "rgba(255,255,255,0.75)" }}>
|
<p
|
||||||
راقب عمليات التسليم، وقم بتنظيم السائقين، وحافظ على رؤية كل طلب من خلال تسجيل دخول آمن واحد.
|
style={{
|
||||||
|
marginTop: "1rem",
|
||||||
|
maxWidth: "28rem",
|
||||||
|
fontSize: "0.95rem",
|
||||||
|
lineHeight: 1.8,
|
||||||
|
color: "rgba(255,255,255,0.75)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
راقب عمليات التسليم، وقم بتنظيم السائقين، وحافظ على رؤية كل طلب
|
||||||
|
من خلال تسجيل دخول آمن واحد.
|
||||||
</p>
|
</p>
|
||||||
<div style={{ marginTop: "2rem", display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: "2rem",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "0.75rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{[
|
{[
|
||||||
["التنسيق اللحظي", "تتبع كل طلب من الاستلام إلى التسليم في مكان واحد."],
|
[
|
||||||
["وصول آمن", "الجلسات المعتمدة على الصلاحيات تبقي مسارات العميل والإدارة منفصلة."],
|
"التنسيق اللحظي",
|
||||||
["عمليات سريعة", "استخدم لوحة الإدارة لمراجعة الحالة والتنبيهات وحركة الرحلات."],
|
"تتبع كل طلب من الاستلام إلى التسليم في مكان واحد.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"وصول آمن",
|
||||||
|
"الجلسات المعتمدة على الصلاحيات تبقي مسارات العميل والإدارة منفصلة.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"عمليات سريعة",
|
||||||
|
"استخدم لوحة الإدارة لمراجعة الحالة والتنبيهات وحركة الرحلات.",
|
||||||
|
],
|
||||||
].map(([title, desc]) => (
|
].map(([title, desc]) => (
|
||||||
<article key={title} style={{ display: "flex", alignItems: "flex-start", gap: "0.75rem", borderRadius: "0.75rem", border: "1px solid rgba(255,255,255,0.15)", background: "rgba(255,255,255,0.1)", padding: "0.85rem" }}>
|
<article
|
||||||
<div aria-hidden="true" style={{ marginTop: "0.125rem", flexShrink: 0, width: "2.25rem", height: "2.25rem", borderRadius: "0.6rem", background: "rgba(255,255,255,0.15)", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "0.95rem" }}>
|
key={title}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
gap: "0.75rem",
|
||||||
|
borderRadius: "0.75rem",
|
||||||
|
border: "1px solid rgba(255,255,255,0.15)",
|
||||||
|
background: "rgba(255,255,255,0.1)",
|
||||||
|
padding: "0.85rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
marginTop: "0.125rem",
|
||||||
|
flexShrink: 0,
|
||||||
|
width: "2.25rem",
|
||||||
|
height: "2.25rem",
|
||||||
|
borderRadius: "0.6rem",
|
||||||
|
background: "rgba(255,255,255,0.15)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontSize: "0.95rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
•
|
•
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 style={{ fontSize: "0.9rem", fontWeight: 600, margin: 0 }}>{title}</h2>
|
<h2
|
||||||
<p style={{ marginTop: "0.25rem", fontSize: "0.8rem", color: "rgba(255,255,255,0.8)" }}>{desc}</p>
|
style={{
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
marginTop: "0.25rem",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
color: "rgba(255,255,255,0.8)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{desc}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p style={{ fontSize: "0.75rem", color: "rgba(255,255,255,0.5)" }}>مصمم لفرق العمليات التي تحتاج إلى الوضوح والسرعة والثقة.</p>
|
<p style={{ fontSize: "0.75rem", color: "rgba(255,255,255,0.5)" }}>
|
||||||
|
مصمم لفرق العمليات التي تحتاج إلى الوضوح والسرعة والثقة.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section style={{ display: "flex", alignItems: "center", justifyContent: "center", background: "var(--color-surface)", padding: "2.5rem 1.5rem" }}>
|
<section
|
||||||
<form onSubmit={handleSubmit(onSubmit)} style={{ width: "100%", maxWidth: "26rem", borderRadius: "0.9rem", border: "1px solid var(--color-border)", background: "#FFF", padding: "2rem", boxShadow: "var(--shadow-card)" }} noValidate>
|
style={{
|
||||||
<p style={{ fontSize: "1.75rem", fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>مرحبًا بعودتك</p>
|
display: "flex",
|
||||||
<p style={{ marginTop: "0.35rem", fontSize: "0.9rem", color: "var(--color-text-muted)" }}>سجل الدخول لإدارة عمليات التسليم</p>
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
padding: "2.5rem 1.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: "26rem",
|
||||||
|
borderRadius: "0.9rem",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
background: "#FFF",
|
||||||
|
padding: "2rem",
|
||||||
|
boxShadow: "var(--shadow-card)",
|
||||||
|
}}
|
||||||
|
noValidate
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: "1.75rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
مرحبًا بعودتك
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
marginTop: "0.35rem",
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
سجل الدخول لإدارة عمليات التسليم
|
||||||
|
</p>
|
||||||
|
|
||||||
<div style={{ marginTop: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: "1.5rem",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
label="البريد الإلكتروني أو رقم الهاتف أو اسم المستخدم"
|
label="البريد الإلكتروني أو رقم الهاتف أو اسم المستخدم"
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
@@ -84,8 +252,23 @@ export function LoginForm() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginTop: "0.75rem", display: "flex", justifyContent: "flex-end" }}>
|
<div
|
||||||
<Link href="/forgot-password" style={{ fontSize: "0.8rem", color: "var(--color-brand-600)", textDecoration: "none" }}>
|
style={{
|
||||||
|
marginTop: "0.75rem",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href="/forgot-password"
|
||||||
|
style={{
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
color: "var(--color-brand-600)",
|
||||||
|
textDecoration: "none",
|
||||||
|
pointerEvents: "none",
|
||||||
|
opacity: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
هل نسيت كلمة المرور؟
|
هل نسيت كلمة المرور؟
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -96,13 +279,35 @@ export function LoginForm() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button type="submit" loading={loading} fullWidth className="mt-6 h-11">
|
<Button
|
||||||
|
type="submit"
|
||||||
|
loading={loading}
|
||||||
|
fullWidth
|
||||||
|
className="mt-6 h-11"
|
||||||
|
>
|
||||||
{loading ? "جاري تسجيل الدخول…" : "تسجيل الدخول"}
|
{loading ? "جاري تسجيل الدخول…" : "تسجيل الدخول"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<p style={{ marginTop: "1rem", textAlign: "center", fontSize: "0.85rem", color: "var(--color-text-muted)" }}>
|
<p
|
||||||
|
style={{
|
||||||
|
marginTop: "1rem",
|
||||||
|
textAlign: "center",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
ليس لديك حساب؟{" "}
|
ليس لديك حساب؟{" "}
|
||||||
<Link href="/register" style={{ color: "var(--color-brand-600)", textDecoration: "none" }}>
|
<Link
|
||||||
|
href="/register"
|
||||||
|
style={{
|
||||||
|
color: "var(--color-brand-600)",
|
||||||
|
textDecoration: "none",
|
||||||
|
pointerEvents: "none",
|
||||||
|
opacity: 0.5,
|
||||||
|
}}
|
||||||
|
aria-disabled="true"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
إنشاء حساب
|
إنشاء حساب
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
22
src/hooks/useEditFormSync.ts
Normal file
22
src/hooks/useEditFormSync.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import type { UseFormSetValue, FieldValues, Path } from "react-hook-form";
|
||||||
|
|
||||||
|
// Re-applies a saved id to a select field once its option list has
|
||||||
|
// finished loading — covers the case where the edit modal mounts
|
||||||
|
// before roles/branches finish fetching from the API.
|
||||||
|
export function useEditFormSync<T extends FieldValues>(
|
||||||
|
setValue: UseFormSetValue<T>,
|
||||||
|
fieldName: Path<T>,
|
||||||
|
savedId: string | undefined,
|
||||||
|
options: { id: string }[],
|
||||||
|
) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (savedId && options.some(o => o.id === savedId)) {
|
||||||
|
// shouldDirty: false — this is a programmatic sync, not a user edit
|
||||||
|
setValue(fieldName, savedId as never, { shouldDirty: false });
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [options, savedId, setValue]);
|
||||||
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
import { FC } from "react";
|
import { FC } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
interface LogoProps {
|
interface LogoProps {
|
||||||
white?: boolean;
|
white?: boolean;
|
||||||
|
href?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Logo: FC<LogoProps> = ({ white = false }) => (
|
const Logo: FC<LogoProps> = ({ white = false, href }) => {
|
||||||
|
const content = (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div style={{
|
<div style={{
|
||||||
width: 32, height: 32,
|
width: 32, height: 32,
|
||||||
@@ -30,4 +33,15 @@ const Logo: FC<LogoProps> = ({ white = false }) => (
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (href) {
|
||||||
|
return (
|
||||||
|
<Link href={href} style={{ textDecoration: "none" }}>
|
||||||
|
{content}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
|
};
|
||||||
|
|
||||||
export default Logo;
|
export default Logo;
|
||||||
Reference in New Issue
Block a user