diff --git a/app/login/page.tsx b/app/login/page.tsx index 38d42de..2e8ab5f 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -1,153 +1,5 @@ -"use client"; - -import Link from "next/link"; -import { useState } from "react"; -import { loginUser } from "@/src/lib/auth"; -import Logo from "@/src/utils/logo"; -import { Button } from "@/src/Components/UI"; -import { Input } from "@/src/Components/UI"; -import { Alert } from "@/src/Components/UI"; +import { LoginForm } from "@/src/Components/auth/login"; export default function LoginPage() { - const [identity, setIdentity] = useState(""); - const [password, setPassword] = useState(""); - const [error, setError] = useState(null); - const [loading, setLoading] = useState(false); - - async function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - setError(null); - setLoading(true); - - try { - await loginUser(identity, password); - console.log("Login successful", { identity }); - window.location.href = "/dashboard"; - } catch (err) { - if (err instanceof Error) { - const msg = err.message.toLowerCase(); - if (msg.includes("fetch") || msg.includes("network") || msg.includes("failed to fetch")) { - setError("Unable to connect. Please check your internet connection and try again later."); - } else { - setError(err.message); - } - } else { - setError("System temporarily unavailable. Please try later."); - } - } finally { - setLoading(false); - } - } - - return ( -
-
- - {/* ── Left panel ─────────────────────────────────── */} - - - {/* ── Right panel ────────────────────────────────── */} -
-
-

مرحبًا بعودتك

-

سجل الدخول لإدارة عمليات التسليم

- -
- setIdentity(e.target.value)} - placeholder="name@company.com / 05xxxxxxxx / username" - required - /> - - setPassword(e.target.value)} - placeholder="••••••••" - required - /> -
- -
- - هل نسيت كلمة المرور؟ - -
- - {error && ( -
- setError(null)} /> -
- )} - - - -

- ليس لديك حساب؟{" "} - - إنشاء حساب - -

-
-
-
-
- ); + return ; } \ No newline at end of file diff --git a/src/Components/Driver/DriverPhotos.tsx b/src/Components/Driver/DriverPhotos.tsx index 19b49f2..b204520 100644 --- a/src/Components/Driver/DriverPhotos.tsx +++ b/src/Components/Driver/DriverPhotos.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { Spinner } from "../UI"; export function PhotoCard({ url, @@ -8,10 +9,14 @@ export function PhotoCard({ label: string; }) { const [imgError, setImgError] = useState(false); + const [imgLoaded, setImgLoaded] = useState(false); - // Reset error state when url changes so updated images get a fresh load attempt + // Reset error/loaded state when url changes so updated images get a fresh load attempt useEffect(() => { - queueMicrotask(() => setImgError(false)); + queueMicrotask(() => { + setImgError(false); + setImgLoaded(false); + }); }, [url]); return ( @@ -27,6 +32,7 @@ export function PhotoCard({
{url && !imgError ? ( - // key={url} forces a full remount whenever the URL changes, - // clearing any stale error state from a previous failed load. - // eslint-disable-next-line @next/next/no-img-element - {label} setImgError(true)} - /> + <> + {!imgLoaded && ( +
+ +
+ )} + {/* key={url} forces a full remount whenever the URL changes, + clearing any stale error/loaded state from a previous load. */} + {/* eslint-disable-next-line @next/next/no-img-element */} + {label} setImgLoaded(true)} + onError={() => setImgError(true)} + /> + ) : ( {}); - get<{ data: { data: CarOption[] } }>("cars?limit=100&status=Active", token) + get<{ data: { data: CarOption[] } }>("cars?limit=100¤tStatus=Active", token) .then((res) => setCars( (res as unknown as { data: { data: CarOption[] } }).data?.data ?? [], @@ -137,7 +137,7 @@ export function TripFormModal({ ) .catch(() => {}); }, []); - + //console.log("cars", cars); // ── Form state ──────────────────────────────────────────────────────────── const [title, setTitle] = useState(editTrip?.title ?? ""); const [driverId, setDriverId] = useState(editTrip?.driverId ?? ""); @@ -338,7 +338,7 @@ export function TripFormModal({ gap: "1rem", }} > - {/* ── Section: أساسيات الرحلة ── */} + {/* ── Section:trip management── */}

أساسيات الرحلة

{/* Title */} diff --git a/src/Components/auth/login.tsx b/src/Components/auth/login.tsx new file mode 100644 index 0000000..ee92abd --- /dev/null +++ b/src/Components/auth/login.tsx @@ -0,0 +1,114 @@ +"use client"; + +import Link from "next/link"; +import { useForm } from "react-hook-form"; +import { yupResolver } from "@hookform/resolvers/yup"; +import { Alert, Button, Input } from "@/src/Components/UI"; +import { useAuth } from "@/src/hooks/useAuth"; +import Logo from "@/src/utils/logo"; +import { loginSchema, type LoginFormData } from "@/src/validations/auth.validator"; + +export function LoginForm() { + const { login, loading, error, clearError } = useAuth(); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: yupResolver(loginSchema), + mode: "onBlur", + }); + + const onSubmit = async (values: LoginFormData) => { + await login(values.identity, values.password); + }; + + return ( +
+
+ + +
+
+

مرحبًا بعودتك

+

سجل الدخول لإدارة عمليات التسليم

+ +
+ + + +
+ +
+ + هل نسيت كلمة المرور؟ + +
+ + {error && ( +
+ +
+ )} + + + +

+ ليس لديك حساب؟{" "} + + إنشاء حساب + +

+
+
+
+
+ ); +} diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts new file mode 100644 index 0000000..8141502 --- /dev/null +++ b/src/hooks/useAuth.ts @@ -0,0 +1,82 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useCallback, useState } from "react"; +import { saveAuth } from "@/src/lib/auth"; +import { authService } from "@/src/service/auth.service"; +import type { AuthUser, LoginRequest, User } from "@/src/types/auth"; +import { detectIdentityField } from "@/src/validations/auth.validator"; + +type RawRole = { name?: string; permissions?: Array<{ permission?: { slug?: string } }> } | string; + +export function useAuth() { + const router = useRouter(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const clearError = useCallback(() => setError(null), []); + + const login = useCallback(async (identity: string, password: string) => { + setError(null); + setLoading(true); + + try { + const field = detectIdentityField(identity); + const payload: LoginRequest = { password }; + + if (field) { + payload[field] = identity; + } + + const response = await authService.login(payload); + const envelope = (response as { data?: { data?: { token?: string; user?: User }; token?: string; user?: User } }).data ?? response; + const body = (envelope as { data?: { token?: string; user?: User }; token?: string; user?: User }).data ?? envelope; + const token = body?.token; + const user = body?.user; + + if (!token || !user) { + throw new Error("اسم المستخدم أو كلمة المرور غير صحيحة."); + } + + const rawRole = (user as unknown as { role?: RawRole }).role; + const roleName = typeof rawRole === "string" + ? rawRole + : typeof rawRole === "object" && rawRole !== null + ? rawRole.name + : undefined; + + const rolePermissions = + typeof rawRole === "object" && rawRole !== null + ? (rawRole as Exclude).permissions ?? [] + : []; + + const permissions = Array.isArray(rolePermissions) + ? rolePermissions + .map((entry) => entry?.permission?.slug) + .filter((slug): slug is string => Boolean(slug)) + : []; + + const normalizedUser: AuthUser = { ...user, role: roleName, permissions }; + saveAuth(token, normalizedUser); + router.replace("/dashboard"); + return true; + } catch (err) { + const message = err instanceof Error ? err.message : "حدث خطأ غير متوقع. يرجى المحاولة لاحقًا."; + const normalized = message.toLowerCase(); + + if (normalized.includes("network") || normalized.includes("fetch") || normalized.includes("timeout")) { + setError("تعذر الاتصال بالخادم. يرجى التحقق من الاتصال والمحاولة مرة أخرى."); + } else if (normalized.includes("401") || normalized.includes("unauthorized")) { + setError("اسم المستخدم أو كلمة المرور غير صحيحة."); + } else { + setError(message); + } + + return false; + } finally { + setLoading(false); + } + }, [router]); + + return { login, loading, error, clearError }; +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 25f107d..cf62282 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -5,6 +5,42 @@ const AUTH_TOKEN_KEY = "auth_token"; const AUTH_USER_KEY = "auth_user"; const AUTH_TOKEN_COOKIE = "auth_token"; +function normalizeUser(user: AuthUser | null | undefined): AuthUser | null { + if (!user) return null; + + const rawRole = (user as unknown as { role?: unknown }).role; + const roleName = typeof rawRole === "string" + ? rawRole + : typeof rawRole === "object" && rawRole !== null && "name" in rawRole && typeof (rawRole as { name?: unknown }).name === "string" + ? (rawRole as { name: string }).name + : undefined; + + const rawPermissions = Array.isArray((user as unknown as { permissions?: unknown[] }).permissions) + ? (user as unknown as { permissions?: unknown[] }).permissions + : typeof rawRole === "object" && rawRole !== null && "permissions" in rawRole + ? ((rawRole as { permissions?: unknown[] }).permissions ?? []) + : []; + + const permissions = Array.isArray(rawPermissions) + ? rawPermissions + .map((entry) => { + if (typeof entry === "string") return entry; + if (entry && typeof entry === "object") { + if ("slug" in entry && typeof (entry as { slug?: unknown }).slug === "string") { + return (entry as { slug: string }).slug; + } + if ("permission" in entry && entry.permission && typeof (entry.permission as { slug?: unknown }).slug === "string") { + return (entry.permission as { slug: string }).slug; + } + } + return null; + }) + .filter((entry): entry is string => Boolean(entry)) + : []; + + return { ...user, role: roleName, permissions }; +} + // ── Cookie helpers ───────────────────────────────────────────────────────── function setTokenCookie(token: string) { if (typeof document === "undefined") return; @@ -27,13 +63,14 @@ export function getStoredUser(): AuthUser | null { if (typeof window === "undefined") return null; const raw = localStorage.getItem(AUTH_USER_KEY); if (!raw) return null; - try { return JSON.parse(raw) as AuthUser; } catch { return null; } + try { return normalizeUser(JSON.parse(raw) as AuthUser); } catch { return null; } } export function saveAuth(token: string, user: AuthUser) { if (typeof window === "undefined") return; + const normalizedUser = normalizeUser(user); localStorage.setItem(AUTH_TOKEN_KEY, token); - localStorage.setItem(AUTH_USER_KEY, JSON.stringify(user)); + localStorage.setItem(AUTH_USER_KEY, JSON.stringify(normalizedUser)); setTokenCookie(token); } diff --git a/src/service/auth.service.ts b/src/service/auth.service.ts new file mode 100644 index 0000000..ad07837 --- /dev/null +++ b/src/service/auth.service.ts @@ -0,0 +1,14 @@ +import axios from "axios"; +import type { LoginRequest, LoginResponse } from "@/src/types/auth"; + +const authApi = axios.create({ + baseURL: "/api/proxy", + timeout: 20000, +}); + +export const authService = { + login: async (payload: LoginRequest): Promise => { + const response = await authApi.post("/auth/login", payload); + return response.data; + }, +}; diff --git a/src/types/auth.ts b/src/types/auth.ts index d212d2c..e1d7d04 100644 --- a/src/types/auth.ts +++ b/src/types/auth.ts @@ -1,4 +1,4 @@ -export interface AuthUser { +export interface User { id?: string; name?: string; userName?: string; @@ -10,11 +10,25 @@ export interface AuthUser { [key: string]: unknown; } -export interface LoginResponse { - token: string; - user: AuthUser; +export type AuthUser = User; + +export interface LoginRequest { + userName?: string; + email?: string; + phone?: string; + password: string; } + export interface LoginPayload { identity: string; password: string; +} + +export interface LoginResponse { + token?: string; + user?: User; + data?: { + token?: string; + user?: User; + }; } \ No newline at end of file diff --git a/src/validations/auth.validator.ts b/src/validations/auth.validator.ts new file mode 100644 index 0000000..0fccbe8 --- /dev/null +++ b/src/validations/auth.validator.ts @@ -0,0 +1,51 @@ +import * as yup from "yup"; + +// phhone regex for saudi numbers +const saudiPhoneRegex = /^(\+966|966|0)?5[0-9]{8}$/; + +function isEmail(value: string) { + return yup.string().email().isValidSync(value); +} + +function isPhone(value: string) { + return saudiPhoneRegex.test(value); +} + +function isUserName(value: string) { + return value.length >= 2; +} + +export function detectIdentityField(identity: string) { + const value = identity.trim(); + + if (!value) return null; + if (isEmail(value)) return "email"; + if (isPhone(value)) return "phone"; + if (isUserName(value)) return "userName"; + return null; +} + +/** + * lopginSchema is a Yup validation schema for validating login form data. It checks that the identity field is either a valid email, a valid Saudi phone number, or a username with at least 2 characters. The password field must be at least 8 characters long. + */ +export const loginSchema = yup.object({ + identity: yup + .string() + .trim() + .required("البريد الإلكتروني أو رقم الهاتف أو اسم المستخدم مطلوب") + .test( + "valid-identity", + "يجب إدخال بريد إلكتروني صحيح أو رقم هاتف سعودي صحيح أو اسم مستخدم لا يقل عن حرفين", + (value) => { + if (!value) return false; + return isEmail(value) || isPhone(value) || isUserName(value); + } + ), + + password: yup + .string() + .required("كلمة المرور مطلوبة") + .min(8, "كلمة المرور يجب أن تكون 8 أحرف على الأقل"), +}); + +export type LoginFormData = yup.InferType; \ No newline at end of file