handel login page and logout alson andel create order page to client and build spinner page and complet project structuer , create logo tamplet

This commit is contained in:
m7amedez5511
2026-06-08 17:24:53 +03:00
parent 5554f5238c
commit 70853958f5
27 changed files with 1425 additions and 303 deletions

94
app/NewOrder/page.tsx Normal file
View File

@@ -0,0 +1,94 @@
"use client";
// ─────────────────────────────────────────────
// page.tsx
// نقطة الدخول — تحقق من الجلسة وتوجيه المستخدم
// ─────────────────────────────────────────────
import { useState, useEffect, useCallback } from "react";
import { loadSession, type ClientSession } from "@/src/utils/helperFun";
import RegisterStep from "../../src/Components/Client/client";
import DashboardStep from "../../src/Components/Order/order";
import Logo from "@/src/utils/logo";
type Step = "bootstrap" | "register" | "dashboard";
/* شريط التنقل */
function Navbar({ session }: { session: ClientSession | null }) {
return (
<nav className="bg-white border-b border-[#E5E7EB] flex items-center justify-between px-5 h-14 sticky top-0 z-30">
<div className="flex items-center gap-2">
{session?.name && (
<span className="text-[12px] text-[#6B7280] hidden sm:block">{session.name}</span>
)}
<div className="w-8 h-8 rounded-full bg-[#EBF3FF] flex items-center justify-center">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#1A73E8" strokeWidth="2">
<circle cx="12" cy="8" r="4"/>
<path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/>
</svg>
</div>
</div>
<Logo />
</nav>
);
}
/* شاشة التحميل */
function BootstrapScreen() {
return (
<div className="flex flex-col items-center justify-center py-10 gap-3">
<div className="w-10 h-10 rounded-full border-4 border-[#1A73E8] border-t-transparent animate-spin" />
<p className="text-[13px] text-[#6B7280]">جارٍ تحميل بيانات الجلسة</p>
</div>
);
}
export default function ClientOrderPage() {
const [step, setStep] = useState<Step>("bootstrap");
const [session, setSession] = useState<ClientSession | null>(null);
useEffect(() => {
const saved = loadSession();
if (saved?.id && saved.name) {
// user has valid session → dashboard
setSession(saved);
setStep("dashboard");
} else {
// new or invalid session → register
setStep("register");
}
}, []);
const handleRegistered = useCallback((s: ClientSession) => {
setSession(s);
setStep("dashboard");
}, []);
return (
<div
className="min-h-screen bg-[#ECEEF2]"
dir="rtl"
lang="ar"
style={{ fontFamily: "'Cairo', 'IBM Plex Sans Arabic', Tahoma, sans-serif" }}
>
<Navbar session={session} />
<div className="max-w-lg mx-auto px-4 py-8">
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-6 shadow-sm">
{step === "bootstrap" && <BootstrapScreen />}
{step === "register" && (
<RegisterStep onSuccess={handleRegistered} />
)}
{step === "dashboard" && session && (
<DashboardStep session={session} />
)}
</div>
<p className="text-center text-[11px] text-[#9CA3AF] mt-4">
LogiFlow · آمن ومشفر · المملكة العربية السعودية 🇸🇦
</p>
</div>
</div>
);
}

View File

@@ -1,3 +0,0 @@
export default function ClientsPage() {
return <section className="rounded-3xl border border-white/10 bg-slate-900/80 p-6 text-slate-100 shadow-2xl shadow-slate-950/20">Clients module is ready for the address and order management views.</section>;
}

View File

@@ -1,3 +1,5 @@
import Logo from "@/src/utils/logo";
import Link from "next/link";
@@ -11,26 +13,10 @@ const navLinks = [
export default function Navbar() { export default function Navbar() {
return ( return (
<nav dir="rtl" className="w-full border-b border-slate-200 bg-white text-slate-900 shadow-sm"> <nav dir="rtl" className="w-full border-b border-slate-200 bg-white text-slate-900 shadow-sm">x
<div className="mx-auto flex h-14 max-w-7xl items-center gap-4 px-6 lg:px-8"> <div className="mx-auto flex h-14 max-w-7xl items-center gap-4 px-6 lg:px-8">
<a href="#" className="flex items-center gap-3 text-right"> <a href="/" className="flex items-center gap-3 text-right">
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-blue-600 shadow-sm"> <Logo />
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
className="h-5 w-5 text-white"
aria-hidden="true"
>
<path d="M3 7h11l3 4h4l-2 4H8" />
<circle cx="9" cy="18" r="1.7" fill="currentColor" stroke="none" />
<circle cx="18" cy="18" r="1.7" fill="currentColor" stroke="none" />
</svg>
</span>
<span className="text-[16px] font-extrabold text-slate-900">LogiFlow</span>
</a> </a>
<div className="hidden flex-1 items-center justify-center gap-6 md:flex"> <div className="hidden flex-1 items-center justify-center gap-6 md:flex">
@@ -48,12 +34,12 @@ export default function Navbar() {
</div> </div>
<div className="mr-auto flex items-center gap-2"> <div className="mr-auto flex items-center gap-2">
<button className="flex h-[34px] items-center rounded-lg border border-slate-200 bg-white px-3 text-[13px] font-semibold text-slate-700 shadow-sm transition hover:bg-slate-50"> <Link href="/NewOrder" className="flex h-[34px] items-center rounded-lg border border-slate-200 bg-white px-3 text-[13px] font-semibold text-slate-700 shadow-sm transition hover:bg-slate-50">
إنشاء حساب عميل إنشاء طلب جديد
</button> </Link>
<button className="flex h-[34px] items-center rounded-lg bg-blue-600 px-3 text-[13px] font-bold text-white shadow-sm transition hover:bg-blue-700"> <Link href="/login" className="flex h-[34px] items-center rounded-lg bg-blue-600 px-3 text-[13px] font-bold text-white shadow-sm transition hover:bg-blue-700">
تسجيل الدخول تسجيل الدخول
</button> </Link>
</div> </div>
</div> </div>
</nav> </nav>

View File

@@ -1,6 +1,10 @@
// File: app/components/layout/Sidebar.tsx
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
// FIX: Import getStoredUser so the sidebar reads the authenticated user's
// name and role from localStorage instead of showing hardcoded placeholders.
import { getStoredUser } from "../../../src/lib/auth";
const navItems = [ const navItems = [
{ href: "/dashboard", label: "Dashboard", permission: "read-dashboard" }, { href: "/dashboard", label: "Dashboard", permission: "read-dashboard" },
@@ -16,19 +20,31 @@ const navItems = [
]; ];
export function Sidebar() { export function Sidebar() {
const permissions = navItems.map((item) => item.permission); // FIX: Read the stored user on render. suppressHydrationWarning handles
// the SSR/CSR mismatch since localStorage is browser-only.
const user = getStoredUser();
const permissions = user?.permissions ?? navItems.map((i) => i.permission);
return ( return (
<aside suppressHydrationWarning className="hidden w-72 shrink-0 border-r border-white/10 bg-slate-950/80 p-6 lg:flex lg:flex-col"> <aside
suppressHydrationWarning
className="hidden w-72 shrink-0 border-r border-white/10 bg-slate-950/80 p-6 lg:flex lg:flex-col"
>
<div> <div>
<p className="text-xs uppercase tracking-[0.35em] text-cyan-200">Logistics</p> <p className="text-xs uppercase tracking-[0.35em] text-cyan-200">Logistics</p>
<h2 className="mt-3 text-xl font-semibold text-white">Ops Console</h2> <h2 className="mt-3 text-xl font-semibold text-white">Ops Console</h2>
<p className="mt-2 text-sm text-slate-300">Fleet, clients, orders, and compliance in one place.</p> <p className="mt-2 text-sm text-slate-300">
Fleet, clients, orders, and compliance in one place.
</p>
</div> </div>
<nav className="mt-8 space-y-2"> <nav className="mt-8 space-y-2">
{navItems.map((item) => { {navItems.map((item) => {
const hasPermission = permissions.includes(item.permission) || item.permission === "read-dashboard"; // FIX: Check the real user permissions array fetched from stored auth.
// Dashboard is always visible regardless of permissions.
const hasPermission =
item.permission === "read-dashboard" ||
permissions.includes(item.permission);
if (!hasPermission) return null; if (!hasPermission) return null;
return ( return (
@@ -46,8 +62,14 @@ export function Sidebar() {
<div className="mt-auto rounded-3xl border border-emerald-400/20 bg-emerald-400/10 p-4 text-sm text-emerald-50"> <div className="mt-auto rounded-3xl border border-emerald-400/20 bg-emerald-400/10 p-4 text-sm text-emerald-50">
<p className="text-xs uppercase tracking-[0.25em] text-emerald-100">Authenticated</p> <p className="text-xs uppercase tracking-[0.25em] text-emerald-100">Authenticated</p>
<p suppressHydrationWarning className="mt-2 font-semibold">Operations user</p> {/* FIX: Show the real user name and role from the stored auth session,
<p suppressHydrationWarning className="text-xs text-emerald-100/90">Signed in</p> replacing the previous hardcoded "Operations user" / "Signed in" text. */}
<p suppressHydrationWarning className="mt-2 font-semibold">
{user?.name ?? user?.userName ?? "—"}
</p>
<p suppressHydrationWarning className="text-xs text-emerald-100/90">
{user?.role ? `Role: ${user.role}` : "Signed in"}
</p>
</div> </div>
</aside> </aside>
); );

View File

@@ -1,28 +1,60 @@
// File: app/components/layout/Topbar.tsx
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { clearAuth } from "../../lib/auth"; // FIX: Import both clearAuth and getStoredUser so the topbar can show the
// authenticated user's role and handle logout properly.
import { clearAuth, getStoredUser } from "../../../src/lib/auth";
export function Topbar() { export function Topbar() {
const router = useRouter(); const router = useRouter();
function handleLogout() { function handleLogout() {
// FIX: clearAuth already existed and is correct — it removes both the
// token and user from localStorage, fully ending the session.
clearAuth(); clearAuth();
router.replace("/login"); router.replace("/login");
} }
// FIX: Read the stored user to display their real role in the header
// instead of the hardcoded "Manager" placeholder.
const user = getStoredUser();
// Capitalize the role label for display (e.g. "admin" → "Admin")
const roleLabel = user?.role
? user.role.charAt(0).toUpperCase() + user.role.slice(1)
: "—";
return ( return (
<header suppressHydrationWarning className="border-b border-white/10 bg-slate-950/70 px-4 py-4 lg:px-6"> <header
suppressHydrationWarning
className="border-b border-white/10 bg-slate-950/70 px-4 py-4 lg:px-6"
>
<div className="mx-auto flex max-w-7xl items-center justify-between gap-4"> <div className="mx-auto flex max-w-7xl items-center justify-between gap-4">
<div> <div>
<p className="text-xs uppercase tracking-[0.35em] text-cyan-200">Operations dashboard</p> <p className="text-xs uppercase tracking-[0.35em] text-cyan-200">
<h1 className="text-xl font-semibold text-white lg:text-2xl">Logistics delivery management</h1> Operations dashboard
</p>
<h1 className="text-xl font-semibold text-white lg:text-2xl">
Logistics delivery management
</h1>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Link href="/" className="rounded-full border border-white/10 bg-slate-900/80 px-4 py-2 text-sm text-slate-200 hover:bg-slate-800">Overview</Link> <Link
<div suppressHydrationWarning className="hidden rounded-full border border-white/10 bg-slate-900/80 px-4 py-2 text-sm text-slate-200 md:block">Manager</div> href="/"
className="rounded-full border border-white/10 bg-slate-900/80 px-4 py-2 text-sm text-slate-200 hover:bg-slate-800"
>
Overview
</Link>
{/* FIX: Replace the hardcoded "Manager" string with the actual
authenticated user's role read from localStorage. */}
<div
suppressHydrationWarning
className="hidden rounded-full border border-white/10 bg-slate-900/80 px-4 py-2 text-sm text-slate-200 md:block"
>
{roleLabel}
</div>
<button <button
type="button" type="button"
onClick={handleLogout} onClick={handleLogout}

View File

@@ -3,8 +3,8 @@
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { getDashboardSummary, type DashboardSummaryResponse } from "../lib/api"; import { getDashboardSummary, type DashboardSummaryResponse } from "../../src/lib/api";
import { getStoredToken } from "../lib/auth"; import { clearAuth, getStoredToken, getStoredUser } from "../../src/lib/auth";
export default function DashboardPage() { export default function DashboardPage() {
const router = useRouter(); const router = useRouter();
@@ -18,6 +18,14 @@ export default function DashboardPage() {
return; return;
} }
// FIX: Implement role-based access control by checking the authenticated user's role from localStorage. Only users with "user" or "admin" roles can access the dashboard. If the role is missing or not allowed, clear the auth state and redirect to login.
const storedUser = getStoredUser(); // add this import at top of file
const role = typeof storedUser?.role === "string" ? storedUser.role : "";
if (!role || ["driver", "سائق"].includes(role)) {
clearAuth(); // add this import at top of file
router.replace("/login");
return;
}
let mounted = true; let mounted = true;
async function load() { async function load() {
@@ -87,7 +95,7 @@ export default function DashboardPage() {
<p className="text-sm uppercase tracking-[0.25em] text-cyan-200">الرحلات النشطة</p> <p className="text-sm uppercase tracking-[0.25em] text-cyan-200">الرحلات النشطة</p>
<h2 className="mt-2 text-xl font-semibold text-white">تقدم الرحلات</h2> <h2 className="mt-2 text-xl font-semibold text-white">تقدم الرحلات</h2>
</div> </div>
<span className="rounded-full bg-emerald-400/10 px-3 py-1 text-xs text-emerald-200">مباشر من /v1/dashboard/summary</span> <span className="rounded-full bg-emerald-400/10 px-3 py-1 text-xs text-emerald-200">مباشر من /dashboard/summary</span>
</div> </div>
<div className="mt-6 space-y-4"> <div className="mt-6 space-y-4">
{activeTrips.length ? activeTrips.map((trip) => ( {activeTrips.length ? activeTrips.map((trip) => (

View File

@@ -1,92 +0,0 @@
import { API_BASE_URL } from "./api";
const AUTH_TOKEN_KEY = "auth_token";
const AUTH_USER_KEY = "auth_user";
export interface AuthUser {
id?: string;
name?: string;
email?: string;
role?: string;
permissions?: string[];
roleId?: string;
userName?: string;
phone?: string;
[key: string]: unknown;
}
export interface LoginResponse {
token: string;
user: AuthUser;
}
export function getStoredToken() {
if (typeof window === "undefined") return null;
return localStorage.getItem(AUTH_TOKEN_KEY);
}
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;
}
}
export function saveAuth(token: string, user: AuthUser) {
if (typeof window === "undefined") return;
localStorage.setItem(AUTH_TOKEN_KEY, token);
localStorage.setItem(AUTH_USER_KEY, JSON.stringify(user));
}
export function clearAuth() {
if (typeof window === "undefined") return;
localStorage.removeItem(AUTH_TOKEN_KEY);
localStorage.removeItem(AUTH_USER_KEY);
}
export async function loginUser(identity: string, password: string) {
const response = await fetch(`${API_BASE_URL}/v1/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identity, password }),
});
const payload = (await response.json().catch(() => ({}))) as {
success?: boolean;
message?: string;
data?: {
token?: string;
user?: AuthUser;
};
token?: string;
user?: AuthUser;
};
if (!response.ok) {
throw new Error(payload.message || "Login failed. Please check your credentials.");
}
const token = payload.data?.token ?? payload.token;
const user = payload.data?.user ?? payload.user;
if (!token || !user) {
throw new Error("The server did not return a valid session payload.");
}
const rolePermissions = (user as { role?: { permissions?: Array<{ permission?: { slug?: string } }> } }).role?.permissions ?? [];
const permissions = Array.isArray(rolePermissions)
? rolePermissions
.map((entry) => entry?.permission?.slug)
.filter((slug): slug is string => Boolean(slug))
: [];
saveAuth(token, { ...user, permissions });
return { token, user: { ...user, permissions } } as LoginResponse;
}

View File

@@ -3,7 +3,8 @@
import Link from "next/link"; import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { loginUser } from "../lib/auth"; import { loginUser } from "../../src/lib/auth";
import Logo from "@/src/utils/logo";
export default function LoginPage() { export default function LoginPage() {
const router = useRouter(); const router = useRouter();
@@ -19,9 +20,27 @@ export default function LoginPage() {
try { try {
await loginUser(identity, password); await loginUser(identity, password);
router.replace("/dashboard");
window.location.href = "/dashboard"; // Use full page reload to ensure all client state is reset after login
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "System temporarily unavailable. Please try later."); // FIX: Surface specific error messages from auth.ts (invalid credentials,
// role denial, network failure) directly to the user.
if (err instanceof Error) {
// Network/fetch failure signals
if (
err.message.includes("fetch") ||
err.message.includes("network") ||
err.message.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 { } finally {
setLoading(false); setLoading(false);
} }
@@ -31,23 +50,46 @@ export default function LoginPage() {
<main className="min-h-screen bg-slate-100 text-slate-900"> <main className="min-h-screen bg-slate-100 text-slate-900">
<section className="grid min-h-screen w-full lg:grid-cols-[1fr_1fr]"> <section className="grid min-h-screen w-full lg:grid-cols-[1fr_1fr]">
<aside className="relative overflow-hidden bg-linear-to-br from-blue-900 via-blue-600 to-blue-700 px-8 py-10 text-white lg:px-12 lg:py-14"> <aside className="relative overflow-hidden bg-linear-to-br from-blue-900 via-blue-600 to-blue-700 px-8 py-10 text-white lg:px-12 lg:py-14">
<div className="absolute inset-0 opacity-30" style={{ backgroundImage: "radial-gradient(circle, rgba(255,255,255,0.18) 1px, transparent 1px)", backgroundSize: "40px 40px" }} /> <div
className="absolute inset-0 opacity-30"
style={{
backgroundImage:
"radial-gradient(circle, rgba(255,255,255,0.18) 1px, transparent 1px)",
backgroundSize: "40px 40px",
}}
/>
<div className="relative z-10 flex h-full flex-col justify-between"> <div className="relative z-10 flex h-full flex-col justify-between">
<div> <div>
<div className="flex items-center gap-3"> <Logo />
<div className="flex h-11 w-11 items-center justify-center rounded-xl border border-white/30 bg-white/20">🚚</div> <h1 className="mt-10 max-w-md text-[36px] font-bold leading-tight tracking-[-0.5px]">
<span className="text-[22px] font-bold">LogiFlow</span> اللوجستيات ببساطة، والتسليم بثقة.
</div> </h1>
<h1 className="mt-10 max-w-md text-[36px] font-bold leading-tight tracking-[-0.5px]">اللوجستيات ببساطة، والتسليم بثقة.</h1> <p className="mt-4 max-w-md text-[15px] leading-7 text-white/75">
<p className="mt-4 max-w-md text-[15px] leading-7 text-white/75">راقب عمليات التسليم، وقم بتنظيم السائقين، وحافظ على رؤية كل طلب من خلال تسجيل دخول آمن واحد.</p> راقب عمليات التسليم، وقم بتنظيم السائقين، وحافظ على رؤية كل طلب
من خلال تسجيل دخول آمن واحد.
</p>
<div className="mt-8 space-y-3"> <div className="mt-8 space-y-3">
{[ {[
["التنسيق اللحظي", "تتبع كل طلب من الاستلام إلى التسليم في مكان واحد."], [
["وصول آمن", "الجلسات المعتمدة على الصلاحيات تبقي مسارات العميل والإدارة منفصلة."], "التنسيق اللحظي",
["عمليات سريعة", "استخدم لوحة الإدارة لمراجعة الحالة والتنبيهات وحركة الرحلات."], "تتبع كل طلب من الاستلام إلى التسليم في مكان واحد.",
],
[
"وصول آمن",
"الجلسات المعتمدة على الصلاحيات تبقي مسارات العميل والإدارة منفصلة.",
],
[
"عمليات سريعة",
"استخدم لوحة الإدارة لمراجعة الحالة والتنبيهات وحركة الرحلات.",
],
].map(([title, desc]) => ( ].map(([title, desc]) => (
<article key={title} className="flex items-start gap-3 rounded-[10px] border border-white/15 bg-white/10 p-3"> <article
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-white/15 text-sm"></div> key={title}
className="flex items-start gap-3 rounded-[10px] border border-white/15 bg-white/10 p-3"
>
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-white/15 text-sm">
</div>
<div> <div>
<h2 className="text-sm font-semibold">{title}</h2> <h2 className="text-sm font-semibold">{title}</h2>
<p className="text-xs text-white/80">{desc}</p> <p className="text-xs text-white/80">{desc}</p>
@@ -56,23 +98,32 @@ export default function LoginPage() {
))} ))}
</div> </div>
</div> </div>
<p className="text-[12px] text-white/50">مصمم لفرق العمليات التي تحتاج إلى الوضوح والسرعة والثقة.</p> <p className="text-[12px] text-white/50">
مصمم لفرق العمليات التي تحتاج إلى الوضوح والسرعة والثقة.
</p>
</div> </div>
</aside> </aside>
<section className="flex items-center justify-center bg-slate-50 px-6 py-10 lg:px-8"> <section className="flex items-center justify-center bg-slate-50 px-6 py-10 lg:px-8">
<form onSubmit={handleSubmit} className="w-full max-w-100 rounded-[14px] border border-slate-200 bg-white p-8 shadow-sm"> <form
<p className="text-[28px] font-bold text-slate-900">مرحبًا بعودتك</p> onSubmit={handleSubmit}
<p className="mt-1 text-[14px] text-slate-500">سجل الدخول لإدارة عمليات التسليم</p> className="w-full max-w-100 rounded-[14px] border border-slate-200 bg-white p-8 shadow-sm"
>
<p className="text-[28px] font-bold text-slate-900">
مرحبًا بعودتك
</p>
<p className="mt-1 text-[14px] text-slate-500">
سجل الدخول لإدارة عمليات التسليم
</p>
<label className="mt-6 block text-[11px] font-bold uppercase tracking-[0.5px] text-slate-500"> <label className="mt-6 block text-[11px] font-bold uppercase tracking-[0.5px] text-slate-500">
البريد الإلكتروني البريد الإلكتروني أو رقم الهاتف أو اسم المستخدم
<input <input
autoComplete="email" autoComplete="username"
className="mt-2 h-11 w-full rounded-[9px] border border-slate-200 bg-white px-3 text-[14px] text-slate-900 outline-none transition focus:border-blue-500 focus:ring-4 focus:ring-blue-100" className="mt-2 h-11 w-full rounded-[9px] border border-slate-200 bg-white px-3 text-[14px] text-slate-900 outline-none transition focus:border-blue-500 focus:ring-4 focus:ring-blue-100"
value={identity} value={identity}
onChange={(event) => setIdentity(event.target.value)} onChange={(event) => setIdentity(event.target.value)}
placeholder="name@company.com" placeholder="name@company.com / 05xxxxxxxx / username"
required required
/> />
</label> </label>
@@ -91,10 +142,19 @@ export default function LoginPage() {
</label> </label>
<div className="mt-3 flex justify-end"> <div className="mt-3 flex justify-end">
<Link href="/forgot-password" className="text-[12px] text-blue-600 hover:underline">هل نسيت كلمة المرور؟</Link> <Link
href="/forgot-password"
className="text-[12px] text-blue-600 hover:underline"
>
هل نسيت كلمة المرور؟
</Link>
</div> </div>
{error ? <p className="mt-4 rounded-[9px] border border-red-200 bg-red-50 p-3 text-[13px] text-red-600">{error}</p> : null} {error ? (
<p className="mt-4 rounded-[9px] border border-red-200 bg-red-50 p-3 text-[13px] text-red-600">
{error}
</p>
) : null}
<button <button
type="submit" type="submit"
@@ -105,7 +165,10 @@ export default function LoginPage() {
</button> </button>
<p className="mt-4 text-center text-[13px] text-slate-500"> <p className="mt-4 text-center text-[13px] text-slate-500">
ليس لديك حساب؟ <Link href="/register" className="text-blue-600 hover:underline">إنشاء حساب</Link> ليس لديك حساب؟{" "}
<Link href="/register" className="text-blue-600 hover:underline">
إنشاء حساب
</Link>
</p> </p>
</form> </form>
</section> </section>

View File

@@ -1,115 +0,0 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { loginUser } from "../lib/auth";
export default function LoginPage() {
const router = useRouter();
const [identity, setIdentity] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
setLoading(true);
try {
await loginUser(identity, password);
router.replace("/dashboard");
} catch (err) {
setError(err instanceof Error ? err.message : "System temporarily unavailable. Please try later.");
} finally {
setLoading(false);
}
}
return (
<main className="min-h-screen bg-slate-100 text-slate-900">
<section className="grid min-h-screen w-full lg:grid-cols-[1fr_1fr]">
<aside className="relative overflow-hidden bg-gradient-to-br from-blue-900 via-blue-600 to-blue-700 px-8 py-10 text-white lg:px-12 lg:py-14">
<div className="absolute inset-0 opacity-30" style={{ backgroundImage: "radial-gradient(circle, rgba(255,255,255,0.18) 1px, transparent 1px)", backgroundSize: "40px 40px" }} />
<div className="relative z-10 flex h-full flex-col justify-between">
<div>
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-xl border border-white/30 bg-white/20">🚚</div>
<span className="text-[22px] font-bold">LogiFlow</span>
</div>
<h1 className="mt-10 max-w-md text-[36px] font-bold leading-tight tracking-[-0.5px]">Logistics made simple, delivery made certain.</h1>
<p className="mt-4 max-w-md text-[15px] leading-7 text-white/75">Monitor deliveries, coordinate drivers, and keep every order visible from one secure sign-in.</p>
<div className="mt-8 space-y-3">
{[
["Live dispatch", "Track every order from pickup to delivery in one place."],
["Secure access", "Role-aware sessions keep client and admin routes separate."],
["Fast operations", "Use the dashboard to review status, alerts, and trip movement."],
].map(([title, desc]) => (
<article key={title} className="flex items-start gap-3 rounded-[10px] border border-white/15 bg-white/10 p-3">
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-white/15 text-sm">•</div>
<div>
<h2 className="text-sm font-semibold">{title}</h2>
<p className="text-xs text-white/80">{desc}</p>
</div>
</article>
))}
</div>
</div>
<p className="text-[12px] text-white/50">Built for operations teams that need clarity, speed, and confidence.</p>
</div>
</aside>
<section className="flex items-center justify-center bg-slate-50 px-6 py-10 lg:px-8">
<form onSubmit={handleSubmit} className="w-full max-w-[400px] rounded-[14px] border border-slate-200 bg-white p-8 shadow-sm">
<p className="text-[28px] font-bold text-slate-900">Welcome back</p>
<p className="mt-1 text-[14px] text-slate-500">Sign in to manage your deliveries</p>
<label className="mt-6 block text-[11px] font-bold uppercase tracking-[0.5px] text-slate-500">
Email address
<input
autoComplete="email"
className="mt-2 h-11 w-full rounded-[9px] border border-slate-200 bg-white px-3 text-[14px] text-slate-900 outline-none transition focus:border-blue-500 focus:ring-4 focus:ring-blue-100"
value={identity}
onChange={(event) => setIdentity(event.target.value)}
placeholder="name@company.com"
required
/>
</label>
<label className="mt-4 block text-[11px] font-bold uppercase tracking-[0.5px] text-slate-500">
Password
<input
type="password"
autoComplete="current-password"
className="mt-2 h-11 w-full rounded-[9px] border border-slate-200 bg-white px-3 text-[14px] text-slate-900 outline-none transition focus:border-blue-500 focus:ring-4 focus:ring-blue-100"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="••••••••"
required
/>
</label>
<div className="mt-3 flex justify-end">
<Link href="/forgot-password" className="text-[12px] text-blue-600 hover:underline">Forgot password?</Link>
</div>
{error ? <p className="mt-4 rounded-[9px] border border-red-200 bg-red-50 p-3 text-[13px] text-red-600">{error}</p> : null}
<button
type="submit"
disabled={loading}
className="mt-6 inline-flex h-[46px] w-full items-center justify-center rounded-[9px] bg-blue-600 text-[15px] font-semibold text-white transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-70"
>
{loading ? "Signing in…" : "Sign in"}
</button>
<p className="mt-4 text-center text-[13px] text-slate-500">
Don&apos;t have an account? <Link href="/register" className="text-blue-600 hover:underline">Register</Link>
</p>
</form>
</section>
</section>
</main>
);
}

View File

@@ -1,3 +1,4 @@
import Link from "next/link";
import Navbar from "./components/layout/Navbar"; import Navbar from "./components/layout/Navbar";
const stats = [ const stats = [
@@ -75,8 +76,12 @@ export default function Home() {
أدِر كل شحنة وعميل وسائق من منصة واحدة آمنة. مصممة للمشرفين والعملاء والمستخدمين الميدانيين. أدِر كل شحنة وعميل وسائق من منصة واحدة آمنة. مصممة للمشرفين والعملاء والمستخدمين الميدانيين.
</p> </p>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
<button className="h-11 rounded-xl bg-white px-5 text-[13px] font-semibold text-blue-600 shadow-md">إنشاء حساب عميل</button> <Link href="/NewOrder" className="h-11 cursor-pointer rounded-xl pt-3 bg-white px-5 text-[13px] font-semibold text-blue-600 shadow-md">
<button className="h-11 rounded-xl border border-white/30 bg-white/15 px-5 text-[13px] font-semibold text-white shadow-md backdrop-blur">تسجيل الدخول للوحة التحكم</button> إنشاء طلب جديد
</Link>
<Link href="/login" className="h-11 pt-3 rounded-xl border border-white/30 bg-white/15 px-5 text-[13px] font-semibold text-white shadow-md backdrop-blur">
تسجيل الدخول للوحة التحكم
</Link>
</div> </div>
</article> </article>
@@ -167,8 +172,8 @@ export default function Home() {
<footer className="bg-[#0F172A] text-white"> <footer className="bg-[#0F172A] text-white">
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4 px-6 py-6 text-[13px] lg:flex-row lg:items-center lg:justify-between lg:px-8"> <div className="mx-auto flex w-full max-w-7xl flex-col gap-4 px-6 py-6 text-[13px] lg:flex-row lg:items-center lg:justify-between lg:px-8">
<div className="text-right font-extrabold text-white">LogiFlow</div> <div className="text-right font-extrabold text-white">Slash.so</div>
<div className="text-center text-white/40">© 2026 LogiFlow. جميع الحقوق محفوظة.</div> <div className="text-center text-white/40">© 2026 Slash.so. جميع الحقوق محفوظة.</div>
<div className="flex items-center gap-4 text-white/40">الخصوصية · الشروط · الحالة</div> <div className="flex items-center gap-4 text-white/40">الخصوصية · الشروط · الحالة</div>
</div> </div>
</footer> </footer>

View File

@@ -1,7 +1,19 @@
// ─────────────────────────────────────────────
// next.config.ts
// Proxy للـ API عشان نتفادى CORS في المتصفح
// ─────────────────────────────────────────────
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ async rewrites() {
return [
{
// كل طلب لـ /api/proxy/... يتحول لـ logiapi.slash.sa/api/v1/...
source: "/api/proxy/:path*",
destination: "https://logiapi.slash.sa/api/v1/:path*",
},
];
},
}; };
export default nextConfig; export default nextConfig;

View File

View File

@@ -0,0 +1,212 @@
"use client";
import React, { useState, type FC, type FormEvent } from "react";
import { saveSession, type ClientSession } from "@/src/utils/helperFun";
import Spinner from "../Spinner/spinner";
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL!;
console.log("API_BASE:", API_BASE);
//make sure API_BASE ends without slash
// ─── Types ───────────────────────────────────
interface RegForm {
name: string;
email: string;
phone: string;
companyName: string;
}
interface RegErrors {
name?: string;
email?: string;
phone?: string;
}
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label: string;
error?: string;
icon?: React.ReactNode;
}
const Input: FC<InputProps> = ({ label, error, icon, className = "", ...rest }) => (
<div className="flex flex-col gap-1">
<label className="text-[12px] font-semibold text-[#374151]">{label}</label>
<div className="relative">
{icon && <div className="absolute right-3 top-1/2 -translate-y-1/2 text-[#9CA3AF]">{icon}</div>}
<input
className={`w-full h-10 ${icon ? "pr-9" : "pr-3"} pl-3 border rounded-lg text-[13px] text-[#111827]
placeholder-[#9CA3AF] outline-none transition focus:border-[#1A73E8] focus:ring-2
focus:ring-[#1A73E8]/15 text-right
${error ? "border-red-400 bg-red-50" : "border-[#E5E7EB] bg-white"} ${className}`}
dir="rtl"
{...rest}
/>
</div>
{error && <p className="text-[11px] text-red-500 font-medium text-right">{error}</p>}
</div>
);
interface BtnProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
loading?: boolean;
}
const PrimaryBtn: FC<BtnProps> = ({ loading, children, className = "", disabled, ...rest }) => (
<button
disabled={loading || disabled}
className={`flex items-center justify-center gap-2 h-11 px-6 bg-[#1A73E8] hover:bg-[#1557B0]
active:scale-[.98] text-white font-bold text-[13px] rounded-lg transition-all duration-150
disabled:opacity-60 disabled:cursor-not-allowed ${className}`}
{...rest}
>
{loading && <Spinner />}
{children}
</button>
);
const Alert: FC<{ type?: "error" | "info" | "success"; message: string; onClose?: () => void }> = ({
type = "error", message, onClose,
}) => {
const styles = {
error: "bg-red-50 border-red-200 text-red-700",
info: "bg-blue-50 border-blue-200 text-blue-700",
success: "bg-emerald-50 border-emerald-200 text-emerald-700",
};
const icons = { error: "⚠", info: "", success: "✓" };
return (
<div className={`flex items-start gap-2 border rounded-lg px-3 py-2.5 text-[12px] font-medium ${styles[type]}`} dir="rtl">
<span className="flex-shrink-0">{icons[type]}</span>
<span className="flex-1">{message}</span>
{onClose && (
<button onClick={onClose} className="flex-shrink-0 opacity-60 hover:opacity-100 text-[14px] leading-none">×</button>
)}
</div>
);
};
// ─── RegisterStep ────────────────────────────
interface RegisterStepProps {
onSuccess: (s: ClientSession) => void;
}
const RegisterStep: FC<RegisterStepProps> = ({ onSuccess }) => {
const [form, setForm] = useState<RegForm>({ name: "", email: "", phone: "", companyName: "" });
const [errors, setErrors] = useState<RegErrors>({});
const [loading, setLoading] = useState(false);
const [apiError, setApiError] = useState("");
const validate = (): boolean => {
const e: RegErrors = {};
if (!form.name.trim())
e.name = "الاسم الكامل مطلوب";
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email))
e.email = "أدخل بريدًا إلكترونيًا صحيحًا";
if (!/^(\+966|0)?[5][0-9]{8}$/.test(form.phone.replace(/\s/g, "")))
e.phone = "أدخل رقم هاتف سعودي صحيح (+966 5x xxx xxxx)";
setErrors(e);
return Object.keys(e).length === 0;
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (!validate()) return;
setLoading(true);
setApiError("");
try {
const res = await fetch(`${API_BASE}/client`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: form.name.trim(),
email: form.email.trim(),
phone: form.phone.trim(),
companyName: form.companyName.trim() || undefined,
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.message ?? "فشل التسجيل");
// save session and proceed
const session: ClientSession = {
id: data.id ?? data._id,
name: data.name,
email: data.email,
phone: data.phone,
};
saveSession(session);
onSuccess(session);
} catch (err: unknown) {
setApiError(err instanceof Error ? err.message : "فشل التسجيل، يرجى المحاولة مجددًا.");
} finally {
setLoading(false);
}
};
const set = (field: keyof RegForm) => (e: React.ChangeEvent<HTMLInputElement>) => {
setForm(p => ({ ...p, [field]: e.target.value }));
if (errors[field as keyof RegErrors]) setErrors(p => ({ ...p, [field]: undefined }));
};
return (
<div dir="rtl">
<div className="mb-6">
<div className="inline-flex items-center gap-1.5 bg-[#EBF3FF] border border-[#BFDBFE] rounded-full px-3 py-1 text-[11px] font-bold text-[#1E3A8A] mb-3">
<span className="w-1.5 h-1.5 rounded-full bg-[#1A73E8]" />
عميل جديد
</div>
<h2 className="text-[22px] font-bold text-[#111827] tracking-tight leading-tight">إنشاء حساب جديد</h2>
<p className="text-[13px] text-[#6B7280] mt-1">أدخل بياناتك للبدء مع Slash.so.</p>
</div>
{apiError && (
<div className="mb-4">
<Alert message={apiError} onClose={() => setApiError("")} />
</div>
)}
<form onSubmit={handleSubmit} className="flex flex-col gap-4" noValidate>
<Input
label="الاسم الكامل *"
placeholder="أحمد الرشيدي"
value={form.name}
onChange={set("name")}
error={errors.name}
icon={<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg>}
/>
<Input
label="البريد الإلكتروني *"
type="email"
placeholder="ahmed@company.sa"
value={form.email}
onChange={set("email")}
error={errors.email}
icon={<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="2" y="4" width="20" height="16" rx="2"/><polyline points="2,4 12,13 22,4"/></svg>}
/>
<Input
label="رقم الجوال *"
type="tel"
placeholder="+966 50 123 4567"
value={form.phone}
onChange={set("phone")}
error={errors.phone}
icon={<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07A19.5 19.5 0 0 1 4.69 10.5 19.79 19.79 0 0 1 1.64 1.9a2 2 0 0 1 1.99-2.18h3a2 2 0 0 1 2 1.72c.127.96.36 1.903.7 2.81a2 2 0 0 1-.45 2.11L7.91 7.91a16 16 0 0 0 6.18 6.18l.96-.96a2 2 0 0 1 2.11-.45c.907.34 1.85.573 2.81.7A2 2 0 0 1 21.9 16.9l.02.02z"/></svg>}
/>
<Input
label="اسم الشركة (اختياري)"
placeholder="شركة لوجي فلو للتوصيل"
value={form.companyName}
onChange={set("companyName")}
icon={<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="2" y="7" width="20" height="15" rx="1"/><path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2"/></svg>}
/>
<div className="pt-2">
<PrimaryBtn type="submit" loading={loading} className="w-full">
إنشاء الحساب
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="15 18 9 12 15 6"/></svg>
</PrimaryBtn>
</div>
</form>
</div>
);
};
export default RegisterStep;

View File

@@ -0,0 +1,376 @@
"use client";
import React, { useState, useEffect, type FC } from "react";
import { type ClientSession } from "@/src/utils/helperFun";
import Spinner from "../Spinner/spinner";
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL!;
// ─── Types ───────────────────────────────────
export interface ClientAddress {
_id: string;
label: string;
branchName: string | null;
contactPerson: { name: string; phone: string };
details: {
city: string;
district?: string;
street: string;
buildingNo?: string;
unitNo?: string;
zipCode?: string;
};
}
interface AddrForm {
label: string;
branchName: string;
contactName: string;
contactPhone: string;
city: string;
district: string;
street: string;
buildingNo: string;
unitNo: string;
zipCode: string;
}
interface AddrErrors {
city?: string;
street?: string;
zipCode?: string;
contactPhone?: string;
}
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label: string;
error?: string;
}
const Input: FC<InputProps> = ({ label, error, className = "", ...rest }) => (
<div className="flex flex-col gap-1">
<label className="text-[12px] font-semibold text-[#374151]">{label}</label>
<input
className={`w-full h-10 px-3 border rounded-lg text-[13px] text-[#111827]
placeholder-[#9CA3AF] outline-none transition focus:border-[#1A73E8]
focus:ring-2 focus:ring-[#1A73E8]/15 text-right
${error ? "border-red-400 bg-red-50" : "border-[#E5E7EB] bg-white"} ${className}`}
dir="rtl"
{...rest}
/>
{error && <p className="text-[11px] text-red-500 font-medium text-right">{error}</p>}
</div>
);
interface BtnProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
loading?: boolean;
}
const PrimaryBtn: FC<BtnProps> = ({ loading, children, className = "", disabled, ...rest }) => (
<button
disabled={loading || disabled}
className={`flex items-center justify-center gap-2 h-11 px-6 bg-[#1A73E8] hover:bg-[#1557B0]
active:scale-[.98] text-white font-bold text-[13px] rounded-lg transition-all duration-150
disabled:opacity-60 disabled:cursor-not-allowed ${className}`}
{...rest}
>
{loading && <Spinner />}
{children}
</button>
);
const Alert: FC<{ type?: "error" | "info" | "success"; message: string; onClose?: () => void }> = ({
type = "error", message, onClose,
}) => {
const styles = {
error: "bg-red-50 border-red-200 text-red-700",
info: "bg-blue-50 border-blue-200 text-blue-700",
success: "bg-emerald-50 border-emerald-200 text-emerald-700",
};
const icons = { error: "⚠", info: "", success: "✓" };
return (
<div className={`flex items-start gap-2 border rounded-lg px-3 py-2.5 text-[12px] font-medium ${styles[type]}`} dir="rtl">
<span className="flex-shrink-0">{icons[type]}</span>
<span className="flex-1">{message}</span>
{onClose && (
<button onClick={onClose} className="flex-shrink-0 opacity-60 hover:opacity-100 text-[14px] leading-none">×</button>
)}
</div>
);
};
// ─── AddressStep ─────────────────────────────
interface AddressStepProps {
session: ClientSession;
onSuccess: () => void;
}
const LABEL_OPTIONS = ["عام", "المنزل", "المكتب الرئيسي", "المستودع", "الفرع"];
const AddressStep: FC<AddressStepProps> = ({ session, onSuccess }) => {
const [savedAddresses, setSavedAddresses] = useState<ClientAddress[]>([]);
const [loadingAddrs, setLoadingAddrs] = useState(true);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [showNew, setShowNew] = useState(false);
const [loading, setLoading] = useState(false);
const [apiError, setApiError] = useState("");
const [formErrors, setFormErrors] = useState<AddrErrors>({});
const [form, setForm] = useState<AddrForm>({
label: "عام", branchName: "", contactName: "", contactPhone: "",
city: "", district: "", street: "", buildingNo: "", unitNo: "", zipCode: "",
});
// get saved addresses on mount
useEffect(() => {
(async () => {
try {
const res = await fetch(`${API_BASE}/client/${session.id}/addresses`);
const data = await res.json();
if (res.ok) {
// api might return either { addresses: [...] } or just [...]
const list: ClientAddress[] = Array.isArray(data) ? data : (data.addresses ?? []);
setSavedAddresses(list);
// auto-select first address if available
if (list.length > 0) setSelectedId(list[0]._id);
}
} catch {
// ignore fetch errors, user can add address manually
setShowNew(true);
} finally {
setLoadingAddrs(false);
}
})();
}, [session.id]);
const setF =
(field: keyof AddrForm) =>
(e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
setForm(p => ({ ...p, [field]: e.target.value }));
if (formErrors[field as keyof AddrErrors])
setFormErrors(p => ({ ...p, [field]: undefined }));
};
const validateAddress = (): boolean => {
const e: AddrErrors = {};
if (!form.city.trim()) e.city = "المدينة مطلوبة";
if (!form.street.trim()) e.street = "الشارع مطلوب";
if (form.zipCode && !/^\d{5}$/.test(form.zipCode))
e.zipCode = "الرمز البريدي يجب أن يتكون من 5 أرقام";
if (form.contactPhone && !/^(\+966|0)?[5][0-9]{8}$/.test(form.contactPhone.replace(/\s/g, "")))
e.contactPhone = "أدخل رقم جوال سعودي صحيح";
setFormErrors(e);
return Object.keys(e).length === 0;
};
const canProceed = selectedId !== null || showNew;
const handleNext = async () => {
if (selectedId) {
// existing address selected → proceed without API call
setLoading(true);
await new Promise(r => setTimeout(r, 400));
setLoading(false);
onSuccess();
return;
}
if (showNew && !validateAddress()) return;
setLoading(true);
setApiError("");
try {
const res = await fetch(`${API_BASE}/client/${session.id}/addresses`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
label: form.label,
branchName: form.branchName || null,
contactPerson: {
name: form.contactName,
phone: form.contactPhone,
},
details: {
city: form.city.trim(),
district: form.district.trim() || undefined,
street: form.street.trim(),
buildingNo: form.buildingNo.trim() || undefined,
unitNo: form.unitNo.trim() || undefined,
zipCode: form.zipCode.trim() || undefined,
},
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.message ?? "تعذّر حفظ العنوان");
onSuccess();
} catch (err: unknown) {
setApiError(err instanceof Error ? err.message : "تعذّر حفظ العنوان، يرجى المحاولة مجددًا.");
} finally {
setLoading(false);
}
};
return (
<div dir="rtl">
<div className="mb-6">
<div className="inline-flex items-center gap-1.5 bg-[#D1FAE5] border border-[#A7F3D0] rounded-full px-3 py-1 text-[11px] font-bold text-[#065F46] mb-3">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3"><polyline points="20 6 9 17 4 12"/></svg>
تم إنشاء الحساب
</div>
<h2 className="text-[22px] font-bold text-[#111827] tracking-tight leading-tight">اختر عنوان التوصيل</h2>
<p className="text-[13px] text-[#6B7280] mt-1">
العنوان <strong className="text-[#111827]">مطلوب</strong> قبل تقديم أي طلب.
</p>
</div>
{apiError && (
<div className="mb-4">
<Alert message={apiError} onClose={() => setApiError("")} />
</div>
)}
{/* loading state */}
{loadingAddrs ? (
<div className="flex items-center justify-center py-8 gap-2">
<Spinner />
<span className="text-[13px] text-[#6B7280]">جارٍ تحميل العناوين</span>
</div>
) : (
<>
{/* saved addresses */}
{savedAddresses.length > 0 && (
<div className="mb-5">
<p className="text-[11px] font-bold text-[#9CA3AF] uppercase tracking-wide mb-2">العناوين المحفوظة</p>
<div className="flex flex-col gap-2">
{savedAddresses.map(addr => (
<button
key={addr._id}
type="button"
onClick={() => { setSelectedId(addr._id); setShowNew(false); }}
className={`w-full text-right border rounded-xl p-3.5 transition-all duration-150 ${
selectedId === addr._id
? "border-[#1A73E8] bg-[#EBF3FF] ring-2 ring-[#1A73E8]/15"
: "border-[#E5E7EB] bg-white hover:border-[#1A73E8]/40"
}`}
>
<div className="flex items-start justify-between gap-2">
<div className={`w-5 h-5 rounded-full border-2 flex-shrink-0 mt-0.5 flex items-center justify-center transition-all ${
selectedId === addr._id ? "border-[#1A73E8] bg-[#1A73E8]" : "border-[#D1D5DB]"
}`}>
{selectedId === addr._id && (
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3.5"><polyline points="20 6 9 17 4 12"/></svg>
)}
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className={`text-[10px] font-bold px-2 py-0.5 rounded-full ${
addr.label === "المكتب الرئيسي"
? "bg-[#EBF3FF] text-[#1E3A8A]"
: "bg-[#FEF3C7] text-[#92400E]"
}`}>
{addr.label}
</span>
{addr.branchName && (
<span className="text-[12px] text-[#6B7280]">{addr.branchName}</span>
)}
</div>
<p className="text-[13px] font-semibold text-[#111827]">
{addr.details.street}
{addr.details.buildingNo && `، مبنى ${addr.details.buildingNo}`}
، {addr.details.city}
</p>
{addr.details.district && (
<p className="text-[12px] text-[#6B7280]">
{addr.details.district}
{addr.details.zipCode && ` · ${addr.details.zipCode}`}
</p>
)}
<p className="text-[11px] text-[#9CA3AF] mt-1">
{addr.contactPerson.name} · {addr.contactPerson.phone}
</p>
</div>
</div>
</button>
))}
</div>
</div>
)}
{/* new address button */}
<button
type="button"
onClick={() => { setShowNew(v => !v); setSelectedId(null); }}
className={`w-full border-2 border-dashed rounded-xl p-3 flex items-center justify-center gap-2
text-[13px] font-semibold transition-all ${
showNew
? "border-[#1A73E8] text-[#1A73E8] bg-[#EBF3FF]"
: "border-[#E5E7EB] text-[#6B7280] hover:border-[#1A73E8]/50 hover:text-[#1A73E8]"
}`}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
إضافة عنوان جديد
</button>
{/* new address form */}
{showNew && (
<div className="mt-4 border border-[#E5E7EB] rounded-xl p-4 bg-[#FAFAFA] flex flex-col gap-3">
<p className="text-[11px] font-bold text-[#9CA3AF] uppercase tracking-wide">تفاصيل العنوان الجديد</p>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1">
<label className="text-[12px] font-semibold text-[#374151]">التصنيف</label>
<select
value={form.label}
onChange={setF("label")}
className="h-10 px-3 border border-[#E5E7EB] rounded-lg text-[13px] text-[#111827]
bg-white outline-none focus:border-[#1A73E8] focus:ring-2
focus:ring-[#1A73E8]/15 text-right"
dir="rtl"
>
{LABEL_OPTIONS.map(l => <option key={l}>{l}</option>)}
</select>
</div>
<Input label="اسم الفرع" placeholder="مقر الرياض" value={form.branchName} onChange={setF("branchName")} />
</div>
<div className="grid grid-cols-2 gap-3">
<Input label="اسم جهة الاتصال" placeholder="أحمد الرشيدي" value={form.contactName} onChange={setF("contactName")} />
<Input label="رقم جوال جهة الاتصال" placeholder="+966 50 123 4567" value={form.contactPhone} onChange={setF("contactPhone")} error={formErrors.contactPhone} />
</div>
<div className="h-px bg-[#E5E7EB]" />
<div className="grid grid-cols-2 gap-3">
<Input label="المدينة *" placeholder="الرياض" value={form.city} onChange={setF("city")} error={formErrors.city} />
<Input label="الحي" placeholder="العليا" value={form.district} onChange={setF("district")} />
</div>
<Input label="الشارع *" placeholder="طريق الملك فهد" value={form.street} onChange={setF("street")} error={formErrors.street} />
<div className="grid grid-cols-3 gap-3">
<Input label="رقم المبنى" placeholder="12" value={form.buildingNo} onChange={setF("buildingNo")} />
<Input label="رقم الوحدة" placeholder="3ب" value={form.unitNo} onChange={setF("unitNo")} />
<Input label="الرمز البريدي" placeholder="12241" value={form.zipCode} onChange={setF("zipCode")} error={formErrors.zipCode} />
</div>
</div>
)}
</>
)}
{!canProceed && !loadingAddrs && (
<p className="text-[12px] text-[#F59E0B] font-medium mt-3 flex items-center gap-1.5">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
يرجى اختيار عنوان أو إضافة عنوان جديد للمتابعة.
</p>
)}
<div className="mt-6">
<PrimaryBtn onClick={handleNext} loading={loading} disabled={!canProceed || loadingAddrs} className="w-full">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="15 18 9 12 15 6"/></svg>
التالي عرض الطلبات
</PrimaryBtn>
</div>
</div>
);
};
export default AddressStep;

View File

View File

View File

@@ -0,0 +1,260 @@
"use client";
// ─────────────────────────────────────────────
// order.tsx
// خطوة 3: لوحة الطلبات — بيانات حقيقية من الـ API
// ─────────────────────────────────────────────
import React, { useState, useEffect, type FC } from "react";
import {
fmtDate,
fmtAmount,
statusColor,
statusLabel,
clearSession,
type ClientSession,
type OrderStatus,
} from "@/src/utils/helperFun";
import Spinner from "../Spinner/spinner";
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL!;
// ─── Types ───────────────────────────────────
export interface Order {
id: string;
createdAt: string;
status: OrderStatus;
totalAmount: number;
description: string;
}
// ─── Order Tracker ───────────────────────────
const STATUS_STEPS: OrderStatus[] = ["CREATED", "IN_TRANSIT", "DELIVERED"];
const STEP_LABELS = ["تم الإنشاء", "قيد التوصيل", "تم التسليم"];
const STEP_ICONS = [
<svg key="c" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="9" y="3" width="6" height="4" rx="1"/>
<path d="M4 7h16v13a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1z"/>
</svg>,
<svg key="t" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="2" y="7" width="20" height="14" rx="2"/>
<path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2"/>
<line x1="12" y1="12" x2="12" y2="16"/>
<line x1="10" y1="14" x2="14" y2="14"/>
</svg>,
<svg key="d" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="20 6 9 17 4 12"/>
</svg>,
];
const OrderTracker: FC<{ status: OrderStatus }> = ({ status }) => {
const idx = STATUS_STEPS.indexOf(status === "CANCELLED" ? "CREATED" : status);
return (
<div className="flex items-center gap-0 py-2" dir="rtl">
{STATUS_STEPS.map((s, i) => (
<React.Fragment key={s}>
<div className="flex flex-col items-center gap-1">
<div className={`w-9 h-9 rounded-full flex items-center justify-center transition-all ${
status === "CANCELLED"
? "bg-[#FEE2E2] text-red-500 border-2 border-red-200"
: i <= idx
? "bg-[#1A73E8] text-white"
: "bg-white border-2 border-[#E5E7EB] text-[#D1D5DB]"
}`}>
{STEP_ICONS[i]}
</div>
<span className={`text-[10px] font-semibold whitespace-nowrap ${
status === "CANCELLED"
? "text-red-400"
: i <= idx ? "text-[#1A73E8]" : "text-[#9CA3AF]"
}`}>
{STEP_LABELS[i]}
</span>
</div>
{i < STATUS_STEPS.length - 1 && (
<div className={`flex-1 h-0.5 mb-4 mx-1 ${
status === "CANCELLED" ? "bg-red-200" : i < idx ? "bg-[#1A73E8]" : "bg-[#E5E7EB]"
}`} />
)}
</React.Fragment>
))}
</div>
);
};
// ─── DashboardStep ───────────────────────────
interface DashboardStepProps {
session: ClientSession;
}
const DashboardStep: FC<DashboardStepProps> = ({ session }) => {
const [orders, setOrders] = useState<Order[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
// جلب الطلبات من الـ API
useEffect(() => {
(async () => {
try {
const res = await fetch(`${API_BASE}/client/${session.id}/orders`);
const data = await res.json();
if (!res.ok) throw new Error(data.message ?? "فشل جلب الطلبات");
// الـ API يُرجع: { orders: [...] } أو مصفوفة مباشرة
const list: Order[] = Array.isArray(data) ? data : (data.orders ?? []);
setOrders(list);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "تعذّر تحميل الطلبات");
} finally {
setLoading(false);
}
})();
}, [session.id]);
// أحدث طلب نشط (CREATED أو IN_TRANSIT)
const activeOrder = orders.find(
o => o.status === "CREATED" || o.status === "IN_TRANSIT"
);
const pastOrders = orders.filter(o => o !== activeOrder);
const summary = {
total: orders.length,
delivered: orders.filter(o => o.status === "DELIVERED").length,
pending: orders.filter(o => o.status === "CREATED" || o.status === "IN_TRANSIT").length,
};
return (
<div dir="rtl">
{/* الرأس */}
<div className="mb-6">
<div className="flex items-center justify-between mb-1">
<div>
<h2 className="text-[22px] font-bold text-[#111827] tracking-tight">لوحة الطلبات</h2>
<p className="text-[13px] text-[#6B7280] mt-0.5">
مرحبًا بعودتك، <strong className="text-[#111827]">{session.name}</strong>
</p>
</div>
<div className="flex items-center gap-1.5 bg-[#D1FAE5] border border-[#A7F3D0] rounded-full px-3 py-1.5">
<span className="w-2 h-2 rounded-full bg-[#10B981] animate-pulse" />
<span className="text-[11px] font-bold text-[#065F46]">نشط</span>
</div>
</div>
</div>
{/* حالة التحميل أو الخطأ */}
{loading ? (
<div className="flex flex-col items-center justify-center py-14 gap-3 text-[#6B7280]">
<Spinner />
<p className="text-[13px]">جارٍ تحميل الطلبات</p>
</div>
) : error ? (
<div className="flex flex-col items-center justify-center py-10 gap-3">
<p className="text-[13px] text-red-500">{error}</p>
<button
onClick={() => { setLoading(true); setError(""); }}
className="text-[12px] text-[#1A73E8] underline"
>
إعادة المحاولة
</button>
</div>
) : (
<>
{/* إحصائيات */}
<div className="grid grid-cols-3 gap-3 mb-6">
{[
{ label: "إجمالي الطلبات", value: summary.total, color: "text-[#111827]" },
{ label: "مُسلَّمة", value: summary.delivered, color: "text-[#34A853]" },
{ label: "قيد التنفيذ", value: summary.pending, color: "text-[#1A73E8]" },
].map(s => (
<div key={s.label} className="bg-white border border-[#E5E7EB] rounded-xl p-3.5">
<p className="text-[10px] font-bold text-[#9CA3AF] mb-1">{s.label}</p>
<p className={`text-[26px] font-bold ${s.color}`}>{s.value}</p>
</div>
))}
</div>
{/* متتبع الطلب النشط */}
{activeOrder ? (
<div className="bg-gradient-to-bl from-[#0D47A1] to-[#1A73E8] rounded-xl p-4 mb-6">
<div className="flex items-center justify-between mb-3">
<div>
<p className="text-[11px] font-bold text-white/60 tracking-wide">الطلب الحالي</p>
<p className="text-[14px] font-bold text-white font-mono">{activeOrder.id}</p>
</div>
<span className={`text-[11px] font-bold border px-2.5 py-1 rounded-full ${
activeOrder.status === "CREATED"
? "bg-white/15 border-white/30 text-white"
: "bg-[#D1FAE5] border-[#A7F3D0] text-[#065F46]"
}`}>
{statusLabel(activeOrder.status)}
</span>
</div>
<OrderTracker status={activeOrder.status} />
</div>
) : (
<div className="border border-dashed border-[#E5E7EB] rounded-xl p-5 mb-6 text-center">
<p className="text-[13px] text-[#9CA3AF]">لا يوجد طلب نشط حاليًا.</p>
</div>
)}
{/* جدول الطلبات السابقة */}
{pastOrders.length > 0 && (
<div className="bg-white border border-[#E5E7EB] rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b border-[#E5E7EB] flex items-center justify-between">
<p className="text-[13px] font-bold text-[#111827]">سجل الطلبات</p>
<span className="text-[11px] text-[#9CA3AF] font-medium">{pastOrders.length} طلبات</span>
</div>
<div className="overflow-x-auto">
<table className="w-full" dir="rtl">
<thead>
<tr className="bg-[#F9FAFB]">
{["رقم الطلب", "التاريخ", "الوصف", "الحالة", "المبلغ"].map(h => (
<th key={h} className="px-4 py-2.5 text-right text-[10px] font-bold text-[#9CA3AF] uppercase tracking-wide">{h}</th>
))}
</tr>
</thead>
<tbody>
{pastOrders.map((order, i) => (
<tr
key={order.id}
className={`border-t border-[#F3F4F6] hover:bg-[#FAFAFA] transition-colors ${i % 2 === 0 ? "" : "bg-[#FAFAFA]/30"}`}
>
<td className="px-4 py-3">
<span className="font-mono text-[12px] font-semibold text-[#1A73E8]">{order.id}</span>
</td>
<td className="px-4 py-3 text-[12px] text-[#6B7280] whitespace-nowrap">{fmtDate(order.createdAt)}</td>
<td className="px-4 py-3 text-[12px] text-[#374151]">{order.description}</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center gap-1.5 text-[11px] font-bold border px-2.5 py-0.5 rounded-full whitespace-nowrap ${statusColor(order.status)}`}>
<span className="w-1.5 h-1.5 rounded-full bg-current" />
{statusLabel(order.status)}
</span>
</td>
<td className="px-4 py-3 text-[12px] font-semibold text-[#111827] font-mono whitespace-nowrap">
{fmtAmount(order.totalAmount)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</>
)}
{/* مسح الجلسة */}
<button
type="button"
onClick={() => { clearSession(); window.location.reload(); }}
className="mt-4 text-[11px] text-[#9CA3AF] hover:text-red-400 underline transition-colors"
>
مسح الجلسة وإعادة البدء
</button>
</div>
);
};
export default DashboardStep;

View File

@@ -0,0 +1,10 @@
import { FC } from "react";
const Spinner: FC = () => (
<svg className="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/>
</svg>
);
export default Spinner;

View File

View File

View File

View File

@@ -7,8 +7,14 @@ function normalizePath(path: string) {
return path.startsWith("/") ? path : `/${path}`; return path.startsWith("/") ? path : `/${path}`;
} }
async function requestJson<T>(path: string, init: RequestInit = {}): Promise<T> { // ─── API REQUESTS ─────────────────────────────
const token = typeof window !== "undefined" ? localStorage.getItem("auth_token") : null; export async function requestJson<T>(
path: string,
init: RequestInit = {},
): Promise<T> {
const token =
typeof window !== "undefined" ? localStorage.getItem("auth_token") : null;
const headers = new Headers(init.headers || {}); const headers = new Headers(init.headers || {});
if (!headers.has("Content-Type") && !(init.body instanceof FormData)) { if (!headers.has("Content-Type") && !(init.body instanceof FormData)) {
@@ -34,24 +40,30 @@ async function requestJson<T>(path: string, init: RequestInit = {}): Promise<T>
}); });
if (!response.ok) { if (!response.ok) {
const text = await response.text(); // try to parse error message from response, fallback to status text
throw new Error(text || `Request failed with status ${response.status}`); const json = await response.json().catch(() => null);
const message =
json?.message ||
json?.error ||
`Request failed with status ${response.status}`;
throw new Error(message);
} }
return (await response.json()) as T; return (await response.json()) as T;
} catch (error) { } catch (error) {
attempt += 1; attempt += 1;
if (attempt >= maxAttempts) { if (attempt >= maxAttempts) throw error;
throw error; // exponential back-off: 600ms, 1200ms
} await new Promise((resolve) =>
setTimeout(resolve, 2 ** attempt * 300),
await new Promise((resolve) => setTimeout(resolve, 2 ** attempt * 300)); );
} }
} }
throw new Error("Request failed after retries."); throw new Error("Request failed after retries.");
} }
// ─── Types ───────────────────────────────────
export type DashboardSummaryResponse = { export type DashboardSummaryResponse = {
success: boolean; success: boolean;
message: string; message: string;
@@ -81,8 +93,9 @@ export type DashboardSummaryResponse = {
}; };
}; };
// ─── API calls ───────────────────────────────
export async function getDashboardSummary() { export async function getDashboardSummary() {
return requestJson<DashboardSummaryResponse>("/v1/dashboard/summary"); return requestJson<DashboardSummaryResponse>("/dashboard/summary");
} }
export async function getHealth() { export async function getHealth() {

138
src/lib/auth.ts Normal file
View File

@@ -0,0 +1,138 @@
import { requestJson } from "./api";
const AUTH_TOKEN_KEY = "auth_token";
const AUTH_USER_KEY = "auth_user";
const AUTH_TOKEN_COOKIE = "auth_token"; // cookie name must match what the server expects (e.g., in middleware)
// ─── Cookie helpers ───────────────────────────
function setTokenCookie(token: string) {
if (typeof document === "undefined") return;
const exp = new Date(Date.now() + 7 * 864e5).toUTCString(); // 7 أيام
document.cookie = `${AUTH_TOKEN_COOKIE}=${encodeURIComponent(token)}; expires=${exp}; path=/; SameSite=Lax`;
}
function deleteTokenCookie() {
if (typeof document === "undefined") return;
document.cookie = `${AUTH_TOKEN_COOKIE}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/`;
}
// ─── Types ───────────────────────────────────
export interface AuthUser {
id?: string;
name?: string;
email?: string;
role?: string;
permissions?: string[];
roleId?: string;
userName?: string;
phone?: string;
[key: string]: unknown;
}
export interface LoginResponse {
token: string;
user: AuthUser;
}
// ─── Storage ─────────────────────────────────
export function getStoredToken(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem(AUTH_TOKEN_KEY);
}
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;
}
}
export function saveAuth(token: string, user: AuthUser) {
if (typeof window === "undefined") return;
//save in localStorage for client-side access
localStorage.setItem(AUTH_TOKEN_KEY, token);
localStorage.setItem(AUTH_USER_KEY, JSON.stringify(user));
// save in cookie for server-side access (e.g., in middleware)
setTokenCookie(token);
}
export function clearAuth() {
if (typeof window === "undefined") return;
localStorage.removeItem(AUTH_TOKEN_KEY);
localStorage.removeItem(AUTH_USER_KEY);
deleteTokenCookie();
}
// ─── Blocked Roles ────────────────────────────
const BLOCKED_ROLES = ["driver", "سائق"] as const;
type RawLoginPayload = {
success?: boolean;
message?: string;
data?: { token?: string; user?: AuthUser };
token?: string;
user?: AuthUser;
};
type RawUser = {
role?: {
name?: string;
permissions?: Array<{ permission?: { slug?: string } }>;
} | string;
};
// ─── Login ────────────────────────────────────
export async function loginUser(
identity: string,
password: string,
): Promise<LoginResponse> {
const payload = await requestJson<RawLoginPayload>("/auth/login", {
method: "POST",
body: JSON.stringify({ identity, password }),
});
console.log("Login response payload:", payload);
const token = payload.data?.token ?? payload.token;
const user = payload.data?.user ?? payload.user;
if (!token || !user) {
throw new Error("اسم المستخدم أو كلمة المرور غير صحيحة.");
}
// destracture role from user and determine roleName
const rawRole = (user as unknown as RawUser).role;
const roleName: string | undefined =
typeof rawRole === "string"
? rawRole
: typeof rawRole === "object" && rawRole !== null
? rawRole.name
: undefined;
// reject if role is in blocked list
if (roleName && (BLOCKED_ROLES as readonly string[]).includes(roleName)) {
throw new Error("غير مصرح لك بالوصول إلى هذه اللوحة.");
}
//fetch permissions
const rolePermissions =
typeof rawRole === "object" && rawRole !== null
? rawRole.permissions ?? []
: [];
const permissions = Array.isArray(rolePermissions)
? rolePermissions
.map((entry) => entry?.permission?.slug)
.filter((slug): slug is string => Boolean(slug))
: [];
const fullUser: AuthUser = { ...user, role: roleName, permissions };
// save auth data in both localStorage and cookie for client and server access
saveAuth(token, fullUser);
return { token, user: fullUser };
}

84
src/utils/helperFun.ts Normal file
View File

@@ -0,0 +1,84 @@
// ─── fexed ───────────────────────────────────
export const SESSION_KEY = "lf_client";
export const SESSION_COOKIE = "lf_client_id";
// ─── Types ───────────────────────────────────
export interface ClientSession {
id: string;
name: string;
email: string;
phone: string;
}
// ─── Cookies ─────────────────────────────────
export function getCookie(name: string): string | null {
if (typeof document === "undefined") return null;
const match = document.cookie.match(new RegExp(`(^| )${name}=([^;]+)`));
return match ? decodeURIComponent(match[2]) : null;
}
export function setCookie(name: string, value: string, days = 30) {
const exp = new Date(Date.now() + days * 864e5).toUTCString();
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${exp}; path=/`;
}
export function deleteCookie(name: string) {
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/`;
}
// ─── Session ─────────────────────────────────
export function loadSession(): ClientSession | null {
try {
const raw = localStorage.getItem(SESSION_KEY);
if (raw) return JSON.parse(raw) as ClientSession;
} catch {}
const id = getCookie(SESSION_COOKIE);
if (id) return { id, name: "", email: "", phone: "" };
return null;
}
export function saveSession(s: ClientSession) {
localStorage.setItem(SESSION_KEY, JSON.stringify(s));
setCookie(SESSION_COOKIE, s.id);
}
export function clearSession() {
localStorage.removeItem(SESSION_KEY);
deleteCookie(SESSION_COOKIE);
}
// ─── Formatting ──────────────────────────────
export function fmtDate(iso: string) {
return new Date(iso).toLocaleDateString("ar-SA", {
year: "numeric",
month: "short",
day: "numeric",
});
}
export function fmtAmount(n: number) {
return `${n.toFixed(2)} ر.س`;
}
// ─── Order Status ──────────────────────────────
export type OrderStatus = "CREATED" | "IN_TRANSIT" | "DELIVERED" | "CANCELLED";
export function statusColor(status: OrderStatus) {
const map: Record<OrderStatus, string> = {
CREATED: "bg-blue-50 text-blue-700 border-blue-200",
IN_TRANSIT: "bg-amber-50 text-amber-700 border-amber-200",
DELIVERED: "bg-emerald-50 text-emerald-700 border-emerald-200",
CANCELLED: "bg-red-50 text-red-600 border-red-200",
};
return map[status];
}
export function statusLabel(status: OrderStatus) {
const map: Record<OrderStatus, string> = {
CREATED: "تم الإنشاء",
IN_TRANSIT: "قيد التوصيل",
DELIVERED: "تم التسليم",
CANCELLED: "ملغي",
};
return map[status];
}

17
src/utils/logo.tsx Normal file
View File

@@ -0,0 +1,17 @@
import { FC } from "react";
const Logo: FC = () => (
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-[#1A73E8] rounded-lg flex items-center justify-center flex-shrink-0">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M5 17H3a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11a2 2 0 0 1 2 2v3" />
<rect x="9" y="11" width="14" height="10" rx="2" />
<circle cx="12" cy="21" r="1" />
<circle cx="20" cy="21" r="1" />
</svg>
</div>
<span className="font-bold text-[15px] text-[#111827]">Slash.sa</span>
</div>
);
export default Logo;