finshing architecture for project now we have all layers to build profitionl project app,components ,hooks ,lib,services,styel ,types
This commit is contained in:
499
ARCHITECTURE.md
Normal file
499
ARCHITECTURE.md
Normal file
@@ -0,0 +1,499 @@
|
|||||||
|
# Slash.sa Logistics Frontend — Architecture Refactor
|
||||||
|
|
||||||
|
## 1. Current Issues Analysis
|
||||||
|
|
||||||
|
### 1.1 File Structure Problems
|
||||||
|
| Issue | Location | Impact |
|
||||||
|
|---|---|---|
|
||||||
|
| Components mixed into `src/Components/` with PascalCase subdirectories, but `app/components/` also exists | Both directories | Confusing, inconsistent import paths |
|
||||||
|
| Empty component files (`admen.tsx`, `driver.tsx`, `tripe.tsx`, etc.) | `src/Components/` | Dead weight, misleading tree |
|
||||||
|
| API logic scattered (`src/lib/api.ts`, direct `fetch()` calls in components) | Multiple files | Hard to mock/test, duplicated base URL logic |
|
||||||
|
| No barrel (`index.ts`) exports anywhere | All directories | Verbose imports everywhere |
|
||||||
|
| `helperFun.ts` mixes session management, formatting utilities, and order-status config | `src/utils/` | Violates single-responsibility principle |
|
||||||
|
| Proxy route handler in `app/api/proxy/` but also `NEXT_PUBLIC_API_BASE_URL` pointing to the same origin | Both | Redundant, confusing config |
|
||||||
|
|
||||||
|
### 1.2 Design Inconsistencies
|
||||||
|
| Issue | Example |
|
||||||
|
|---|---|
|
||||||
|
| Dark `slate-950` dashboard theme vs. light `slate-50` login/register theme — no shared token layer | `app/login/page.tsx` vs `app/dashboard/page.tsx` |
|
||||||
|
| Three different spinner implementations (inline SVG in `spinner.tsx`, div spinner in `NewOrder/page.tsx`, none elsewhere) | Multiple files |
|
||||||
|
| `Plus Jakarta Sans` loaded via `@import` in `globals.css` but Geist fonts loaded via `next/font` in `layout.tsx` — double font loading | `app/layout.tsx`, `app/globals.css` |
|
||||||
|
| Tailwind v4 used (`@import "tailwindcss"`) but `tailwind.config.js` targets v3 API | Config mismatch |
|
||||||
|
| RTL direction set per-page inconsistently (`dir="rtl"` on `<main>` or `<div>`) | All pages |
|
||||||
|
| No shared color CSS variables consumed by Tailwind — raw hex strings in JSX | Throughout |
|
||||||
|
|
||||||
|
### 1.3 API / State Issues
|
||||||
|
| Issue | Location |
|
||||||
|
|---|---|
|
||||||
|
| `API_BASE` read from `process.env.NEXT_PUBLIC_API_BASE_URL!` with `!` assertion and a `console.log` | `client.tsx`, `order.tsx`, `clientAdress.tsx` |
|
||||||
|
| Auth token cookie set manually with string concatenation | `src/lib/auth.ts` |
|
||||||
|
| No centralised error-boundary or query-state helper | Everywhere |
|
||||||
|
| `requestJson` retries 3× with exponential back-off but components also retry inline | `api.ts` + components |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Proposed Folder Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
/
|
||||||
|
├── app/
|
||||||
|
│ ├── (admin)/ # Route group — requires auth + admin role
|
||||||
|
│ │ ├── dashboard/page.tsx
|
||||||
|
│ │ ├── users/page.tsx
|
||||||
|
│ │ ├── cars/page.tsx
|
||||||
|
│ │ ├── drivers/page.tsx
|
||||||
|
│ │ ├── clients/page.tsx
|
||||||
|
│ │ ├── orders/page.tsx
|
||||||
|
│ │ ├── trips/page.tsx
|
||||||
|
│ │ ├── branches/page.tsx
|
||||||
|
│ │ ├── roles/page.tsx
|
||||||
|
│ │ ├── audit/page.tsx
|
||||||
|
│ │ └── layout.tsx # DashboardLayout (Sidebar + Topbar)
|
||||||
|
│ │
|
||||||
|
│ ├── (client)/ # Route group — public client portal
|
||||||
|
│ │ ├── NewOrder/page.tsx
|
||||||
|
│ │ ├── client/page.tsx
|
||||||
|
│ │ └── register/page.tsx
|
||||||
|
│ │
|
||||||
|
│ ├── (auth)/ # Route group — unauthenticated
|
||||||
|
│ │ ├── login/page.tsx
|
||||||
|
│ │ └── forgot-password/page.tsx
|
||||||
|
│ │
|
||||||
|
│ ├── forbidden/page.tsx
|
||||||
|
│ ├── api/
|
||||||
|
│ │ └── proxy/[...path]/route.ts
|
||||||
|
│ ├── globals.css
|
||||||
|
│ └── layout.tsx # Root layout (fonts, metadata)
|
||||||
|
│
|
||||||
|
├── components/ # Shared UI primitives
|
||||||
|
│ ├── ui/
|
||||||
|
│ │ ├── Spinner.tsx ✅ Single canonical spinner
|
||||||
|
│ │ ├── Alert.tsx ✅ Typed alert banner
|
||||||
|
│ │ ├── Button.tsx ✅ PrimaryBtn + variants
|
||||||
|
│ │ ├── Input.tsx ✅ Labelled input with error
|
||||||
|
│ │ └── index.ts barrel
|
||||||
|
│ │
|
||||||
|
│ ├── layout/
|
||||||
|
│ │ ├── Sidebar.tsx
|
||||||
|
│ │ ├── Topbar.tsx
|
||||||
|
│ │ ├── Navbar.tsx
|
||||||
|
│ │ └── index.ts
|
||||||
|
│ │
|
||||||
|
│ ├── auth/
|
||||||
|
│ │ └── LoginForm.tsx
|
||||||
|
│ │
|
||||||
|
│ ├── client/
|
||||||
|
│ │ ├── RegisterStep.tsx
|
||||||
|
│ │ ├── AddressStep.tsx
|
||||||
|
│ │ └── OrderDashboard.tsx (was DashboardStep)
|
||||||
|
│ │
|
||||||
|
│ └── dashboard/
|
||||||
|
│ ├── SummaryCard.tsx
|
||||||
|
│ ├── ActiveTripCard.tsx
|
||||||
|
│ └── AlertsCard.tsx
|
||||||
|
│
|
||||||
|
├── services/ # All network calls — no UI imports
|
||||||
|
│ ├── api.ts base requestJson helper
|
||||||
|
│ ├── auth.service.ts loginUser, logout helpers
|
||||||
|
│ ├── dashboard.service.ts getDashboardSummary
|
||||||
|
│ ├── client.service.ts getClientOrders, postClientAddress …
|
||||||
|
│ └── index.ts barrel
|
||||||
|
│
|
||||||
|
├── lib/ # Non-UI utilities
|
||||||
|
│ ├── auth.ts token storage, cookie helpers
|
||||||
|
│ ├── session.ts (extracted from helperFun.ts)
|
||||||
|
│ ├── formatters.ts fmtDate, fmtAmount
|
||||||
|
│ ├── order-status.ts statusColor, statusLabel, OrderStatus type
|
||||||
|
│ └── index.ts
|
||||||
|
│
|
||||||
|
├── hooks/ # Custom React hooks
|
||||||
|
│ ├── useAuth.ts
|
||||||
|
│ ├── useDashboardSummary.ts
|
||||||
|
│ └── useClientOrders.ts
|
||||||
|
│
|
||||||
|
├── styles/
|
||||||
|
│ └── tokens.css CSS custom properties (design tokens)
|
||||||
|
│
|
||||||
|
├── types/
|
||||||
|
│ ├── auth.ts
|
||||||
|
│ ├── dashboard.ts
|
||||||
|
│ └── client.ts
|
||||||
|
│
|
||||||
|
└── public/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Design System
|
||||||
|
|
||||||
|
### 3.1 Color Tokens (`styles/tokens.css`)
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* ── Brand ─────────────────────────────── */
|
||||||
|
--color-brand-600: #1A73E8;
|
||||||
|
--color-brand-700: #1557B0;
|
||||||
|
--color-brand-50: #EBF3FF;
|
||||||
|
|
||||||
|
/* ── Semantic ───────────────────────────── */
|
||||||
|
--color-success: #34A853;
|
||||||
|
--color-warning: #F59E0B;
|
||||||
|
--color-danger: #E53935;
|
||||||
|
--color-info: #1A73E8;
|
||||||
|
|
||||||
|
/* ── Surface (light) ────────────────────── */
|
||||||
|
--color-surface: #FFFFFF;
|
||||||
|
--color-surface-muted: #F9FAFB;
|
||||||
|
--color-border: #E5E7EB;
|
||||||
|
|
||||||
|
/* ── Surface (dark — admin dashboard) ───── */
|
||||||
|
--color-surface-dark: #0F172A; /* slate-950 */
|
||||||
|
--color-surface-dark-card: #1E293B; /* slate-800 */
|
||||||
|
--color-border-dark: rgba(255,255,255,0.1);
|
||||||
|
|
||||||
|
/* ── Text ───────────────────────────────── */
|
||||||
|
--color-text-primary: #111827;
|
||||||
|
--color-text-muted: #6B7280;
|
||||||
|
--color-text-hint: #9CA3AF;
|
||||||
|
--color-text-inverse: #FFFFFF;
|
||||||
|
|
||||||
|
/* ── Font ───────────────────────────────── */
|
||||||
|
--font-sans: 'Plus Jakarta Sans', sans-serif;
|
||||||
|
--font-mono: 'IBM Plex Mono', monospace;
|
||||||
|
|
||||||
|
/* ── Radius ─────────────────────────────── */
|
||||||
|
--radius-sm: 7px;
|
||||||
|
--radius-md: 10px;
|
||||||
|
--radius-lg: 14px;
|
||||||
|
--radius-xl: 20px;
|
||||||
|
--radius-full: 9999px;
|
||||||
|
|
||||||
|
/* ── Sidebar width ──────────────────────── */
|
||||||
|
--sidebar-width: 280px;
|
||||||
|
```
|
||||||
|
|
||||||
|
These are imported once in `app/globals.css` and referenced via Tailwind's `theme.extend` for utility access.
|
||||||
|
|
||||||
|
### 3.2 Typography
|
||||||
|
|
||||||
|
| Role | Font | Weight | Size |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Page heading | Plus Jakarta Sans | 700 | 28–38px |
|
||||||
|
| Section title | Plus Jakarta Sans | 600 | 18–22px |
|
||||||
|
| Body | Plus Jakarta Sans | 400 | 13–14px |
|
||||||
|
| Label/caps | Plus Jakarta Sans | 700 | 10–11px |
|
||||||
|
| Code/mono | IBM Plex Mono | 400/500 | 12–13px |
|
||||||
|
|
||||||
|
Load **once** via `next/font/google` in `app/layout.tsx`. Remove `@import url(...)` from `globals.css`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Key Component Implementations
|
||||||
|
|
||||||
|
### 4.1 Canonical Spinner (`components/ui/Spinner.tsx`)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface SpinnerProps {
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizes = { sm: "h-4 w-4", md: "h-6 w-6", lg: "h-10 w-10" };
|
||||||
|
|
||||||
|
export function Spinner({ size = "md", className }: SpinnerProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
role="status"
|
||||||
|
aria-label="Loading"
|
||||||
|
className={cn("animate-spin text-[var(--color-brand-600)]", sizes[size], className)}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Usage:** `<Spinner />`, `<Spinner size="lg" />`, `<Spinner size="sm" className="text-white" />`
|
||||||
|
|
||||||
|
### 4.2 Alert (`components/ui/Alert.tsx`)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface AlertProps {
|
||||||
|
type?: "error" | "info" | "success" | "warning";
|
||||||
|
message: string;
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
error: { bg: "bg-red-50 border-red-200 text-red-700", icon: "⚠" },
|
||||||
|
info: { bg: "bg-blue-50 border-blue-200 text-blue-700", icon: "ℹ" },
|
||||||
|
success: { bg: "bg-emerald-50 border-emerald-200 text-emerald-700", icon: "✓" },
|
||||||
|
warning: { bg: "bg-amber-50 border-amber-200 text-amber-700", icon: "!" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Alert({ type = "error", message, onClose }: AlertProps) {
|
||||||
|
const { bg, icon } = config[type];
|
||||||
|
return (
|
||||||
|
<div role="alert" aria-live="polite"
|
||||||
|
className={`flex items-start gap-2 border rounded-lg px-3 py-2.5 text-[12px] font-medium ${bg}`}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">{icon}</span>
|
||||||
|
<span className="flex-1">{message}</span>
|
||||||
|
{onClose && (
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Dismiss alert"
|
||||||
|
className="opacity-60 hover:opacity-100 text-[14px] leading-none"
|
||||||
|
>×</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Button (`components/ui/Button.tsx`)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Spinner } from "./Spinner";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
loading?: boolean;
|
||||||
|
variant?: "primary" | "secondary" | "ghost" | "danger";
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
}
|
||||||
|
|
||||||
|
const variants = {
|
||||||
|
primary: "bg-[var(--color-brand-600)] hover:bg-[var(--color-brand-700)] text-white shadow-sm",
|
||||||
|
secondary: "bg-white border border-[var(--color-border)] text-[var(--color-text-primary)] hover:bg-[var(--color-surface-muted)]",
|
||||||
|
ghost: "text-[var(--color-text-muted)] hover:bg-[var(--color-surface-muted)]",
|
||||||
|
danger: "bg-red-600 hover:bg-red-700 text-white",
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
sm: "h-8 px-3 text-[12px]",
|
||||||
|
md: "h-10 px-4 text-[13px]",
|
||||||
|
lg: "h-11 px-6 text-[14px]",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Button({ loading, variant = "primary", size = "md", children, className, disabled, ...rest }: ButtonProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
disabled={loading || disabled}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center gap-2 rounded-[var(--radius-md)] font-semibold",
|
||||||
|
"transition-all active:scale-[.98] disabled:opacity-60 disabled:cursor-not-allowed",
|
||||||
|
variants[variant], sizes[size], className
|
||||||
|
)}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{loading && <Spinner size="sm" className="text-current" />}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 Input (`components/ui/Input.tsx`)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
label: string;
|
||||||
|
error?: string;
|
||||||
|
hint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Input({ label, error, hint, id, className, ...rest }: InputProps) {
|
||||||
|
const inputId = id ?? label.toLowerCase().replace(/\s+/g, "-");
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label htmlFor={inputId} className="text-[12px] font-semibold text-[var(--color-text-primary)]">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={inputId}
|
||||||
|
aria-invalid={!!error}
|
||||||
|
aria-describedby={error ? `${inputId}-error` : hint ? `${inputId}-hint` : undefined}
|
||||||
|
className={`
|
||||||
|
w-full h-10 px-3 border rounded-[var(--radius-md)] text-[13px]
|
||||||
|
text-[var(--color-text-primary)] placeholder-[var(--color-text-hint)]
|
||||||
|
outline-none transition
|
||||||
|
focus:border-[var(--color-brand-600)] focus:ring-2 focus:ring-[var(--color-brand-600)]/15
|
||||||
|
${error ? "border-red-400 bg-red-50" : "border-[var(--color-border)] bg-white"}
|
||||||
|
${className ?? ""}
|
||||||
|
`}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<p id={`${inputId}-error`} role="alert" className="text-[11px] text-red-500 font-medium">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{hint && !error && (
|
||||||
|
<p id={`${inputId}-hint`} className="text-[11px] text-[var(--color-text-hint)]">
|
||||||
|
{hint}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Services Layer
|
||||||
|
|
||||||
|
### 5.1 `services/api.ts` (base)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Single source of truth for all HTTP calls.
|
||||||
|
// Never import React or UI code here.
|
||||||
|
|
||||||
|
const API_BASE =
|
||||||
|
typeof window === "undefined"
|
||||||
|
? process.env.INTERNAL_API_BASE_URL ?? ""
|
||||||
|
: "/api/proxy";
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(public status: number, message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ApiError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function request<T>(
|
||||||
|
path: string,
|
||||||
|
init: RequestInit = {},
|
||||||
|
token?: string | null,
|
||||||
|
): Promise<T> {
|
||||||
|
const headers = new Headers(init.headers);
|
||||||
|
if (!headers.has("Content-Type") && !(init.body instanceof FormData)) {
|
||||||
|
headers.set("Content-Type", "application/json");
|
||||||
|
}
|
||||||
|
if (token) headers.set("Authorization", `Bearer ${token}`);
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}/${path.replace(/^\//, "")}`, {
|
||||||
|
...init,
|
||||||
|
headers,
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const json = await res.json().catch(() => null);
|
||||||
|
throw new ApiError(res.status, json?.message ?? `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 `services/dashboard.service.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { request } from "./api";
|
||||||
|
import type { DashboardSummary } from "@/types/dashboard";
|
||||||
|
|
||||||
|
export const dashboardService = {
|
||||||
|
getSummary: (token: string) =>
|
||||||
|
request<{ success: boolean; data: DashboardSummary }>("dashboard/summary", {}, token),
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 `services/auth.service.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { request } from "./api";
|
||||||
|
import type { LoginResponse } from "@/types/auth";
|
||||||
|
|
||||||
|
export const authService = {
|
||||||
|
login: (identity: string, password: string) =>
|
||||||
|
request<LoginResponse>("auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ identity, password }),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Custom Hooks
|
||||||
|
|
||||||
|
### `hooks/useDashboardSummary.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
"use client";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { dashboardService } from "@/services/dashboard.service";
|
||||||
|
import { getStoredToken } from "@/lib/auth";
|
||||||
|
import type { DashboardSummary } from "@/types/dashboard";
|
||||||
|
|
||||||
|
export function useDashboardSummary() {
|
||||||
|
const [data, setData] = useState<DashboardSummary | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = getStoredToken();
|
||||||
|
if (!token) { setLoading(false); return; }
|
||||||
|
dashboardService.getSummary(token)
|
||||||
|
.then(r => setData(r.data))
|
||||||
|
.catch(e => setError(e.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { data, error, loading };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Accessibility Checklist
|
||||||
|
|
||||||
|
| Area | Implementation |
|
||||||
|
|---|---|
|
||||||
|
| **Focus management** | All interactive elements reachable via Tab; custom components include `focus:ring-2` |
|
||||||
|
| **ARIA roles** | `role="alert"` on `<Alert>`, `role="status"` + `aria-label="Loading"` on `<Spinner>` |
|
||||||
|
| **Form labels** | `<Input>` always renders a `<label htmlFor>` pair; `aria-invalid` set on error |
|
||||||
|
| **Error descriptions** | `aria-describedby` connects input to its error paragraph |
|
||||||
|
| **Color contrast** | Brand blue `#1A73E8` on white = 4.6:1 (AA); white on brand blue = same |
|
||||||
|
| **Motion** | Spinner uses `prefers-reduced-motion` via `@media (prefers-reduced-motion: reduce) { .animate-spin { animation: none } }` in globals |
|
||||||
|
| **RTL support** | `dir` attribute set once on `<html>` or per route-group layout, not per element |
|
||||||
|
| **Semantic HTML** | `<nav>`, `<main>`, `<aside>`, `<article>`, `<header>`, `<footer>` used consistently |
|
||||||
|
| **Button states** | `disabled` prop correctly prevents interaction; `aria-busy` added when `loading` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Migration Steps (Priority Order)
|
||||||
|
|
||||||
|
1. **Create `styles/tokens.css`** — define all CSS vars; import in `globals.css`.
|
||||||
|
2. **Create `components/ui/`** — move and refactor `Spinner`, `Alert`, `Button`, `Input`.
|
||||||
|
3. **Create `services/`** — extract all `fetch` calls from components.
|
||||||
|
4. **Split `src/utils/helperFun.ts`** → `lib/session.ts`, `lib/formatters.ts`, `lib/order-status.ts`.
|
||||||
|
5. **Create `hooks/`** — one hook per data concern.
|
||||||
|
6. **Move pages into route groups** — `(admin)`, `(client)`, `(auth)`.
|
||||||
|
7. **Replace inline font `@import`** with `next/font/google` only in `layout.tsx`.
|
||||||
|
8. **Delete empty component files** (`admen.tsx`, `driver.tsx`, `tripe.tsx`, etc.).
|
||||||
|
9. **Add barrel `index.ts`** to each directory.
|
||||||
|
10. **Update `tailwind.config.js`** to v4 pattern or remove in favour of CSS-only config.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Rationale Summary
|
||||||
|
|
||||||
|
- **Route groups** keep auth guards co-located with the pages they protect, reducing the chance of forgetting a guard when adding a new page.
|
||||||
|
- **Services layer** with no React imports enables unit testing without a DOM, and makes swapping the HTTP client (e.g. to Axios or a generated SDK) a one-file change.
|
||||||
|
- **CSS custom properties** as the design token layer means the same values are available in both Tailwind utilities and plain CSS without duplication.
|
||||||
|
- **Single Spinner** eliminates the three current implementations and ensures `aria-label="Loading"` is always present.
|
||||||
|
- **`lib/` vs `services/`** split: `lib/` holds pure functions and storage helpers (no network); `services/` holds network calls. This boundary makes each testable in isolation.
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, type FC, type FormEvent } from "react";
|
import React, { useState, type FC, type FormEvent } from "react";
|
||||||
import { saveSession, type ClientSession } from "@/src/utils/helperFun";
|
import { saveSession, type ClientSession } from "@/utils/helperFun";
|
||||||
import Spinner from "../Spinner/spinner";
|
import { Spinner } from "../UI";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL!;
|
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL!;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import React, { useState, useEffect, type FC } from "react";
|
import React, { useState, useEffect, type FC } from "react";
|
||||||
import { type ClientSession } from "@/src/utils/helperFun";
|
import { type ClientSession } from "@/utils/helperFun";
|
||||||
import Spinner from "../Spinner/spinner";
|
import { Spinner } from "../UI";
|
||||||
|
|
||||||
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL!;
|
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL!;
|
||||||
|
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
// order.tsx
|
|
||||||
// خطوة 3: لوحة الطلبات — بيانات حقيقية من الـ API
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
import React, { useState, useEffect, type FC } from "react";
|
import React, { useState, useEffect, type FC } from "react";
|
||||||
import {
|
import {
|
||||||
fmtDate,
|
fmtDate,
|
||||||
@@ -12,8 +9,8 @@ import {
|
|||||||
clearSession,
|
clearSession,
|
||||||
type ClientSession,
|
type ClientSession,
|
||||||
type OrderStatus,
|
type OrderStatus,
|
||||||
} from "@/src/utils/helperFun";
|
} from "@/utils/helperFun";
|
||||||
import Spinner from "../Spinner/spinner";
|
import { Spinner } from "../UI";
|
||||||
|
|
||||||
|
|
||||||
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL!;
|
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL!;
|
||||||
59
Components/UI/Alert.tsx
Normal file
59
Components/UI/Alert.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
// Typed, accessible alert banner with optional dismiss button.
|
||||||
|
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
export type AlertType = "error" | "info" | "success" | "warning";
|
||||||
|
|
||||||
|
export interface AlertProps {
|
||||||
|
type?: AlertType;
|
||||||
|
title?: string;
|
||||||
|
message: string;
|
||||||
|
onClose?: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config: Record<AlertType, { bg: string; icon: string; iconLabel: string }> = {
|
||||||
|
error: { bg: "bg-red-50 border-red-200 text-red-700", icon: "⚠", iconLabel: "Error" },
|
||||||
|
info: { bg: "bg-blue-50 border-blue-200 text-blue-700", icon: "ℹ", iconLabel: "Info" },
|
||||||
|
success: { bg: "bg-emerald-50 border-emerald-200 text-emerald-700", icon: "✓", iconLabel: "Success" },
|
||||||
|
warning: { bg: "bg-amber-50 border-amber-200 text-amber-700", icon: "!", iconLabel: "Warning" },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @example
|
||||||
|
* <Alert type="error" message="Invalid credentials." onClose={() => setErr(null)} />
|
||||||
|
* <Alert type="success" title="Saved!" message="Your changes have been applied." />
|
||||||
|
*/
|
||||||
|
export function Alert({ type = "error", title, message, onClose, className }: AlertProps) {
|
||||||
|
const { bg, icon, iconLabel } = config[type];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
aria-live="polite"
|
||||||
|
className={cn(
|
||||||
|
"flex items-start gap-2 border rounded-[var(--radius-md)] px-3 py-2.5 text-[12px] font-medium",
|
||||||
|
bg,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span aria-label={iconLabel} className="flex-shrink-0 mt-0.5">
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{title && <p className="font-semibold mb-0.5">{title}</p>}
|
||||||
|
<p>{message}</p>
|
||||||
|
</div>
|
||||||
|
{onClose && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Dismiss alert"
|
||||||
|
className="flex-shrink-0 opacity-60 hover:opacity-100 text-[16px] leading-none transition-opacity"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
Components/UI/Button.tsx
Normal file
74
Components/UI/Button.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
// Design-system button with variant, size, and loading state support.
|
||||||
|
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
import { Spinner } from "./Spinner";
|
||||||
|
|
||||||
|
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
loading?: boolean;
|
||||||
|
variant?: "primary" | "secondary" | "ghost" | "danger";
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
/** Full-width block button */
|
||||||
|
fullWidth?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantStyles: Record<NonNullable<ButtonProps["variant"]>, string> = {
|
||||||
|
primary:
|
||||||
|
"bg-[var(--color-brand-600)] hover:bg-[var(--color-brand-700)] text-white shadow-sm",
|
||||||
|
secondary:
|
||||||
|
"bg-white border border-[var(--color-border)] text-[var(--color-text-primary)] hover:bg-[var(--color-surface-muted)]",
|
||||||
|
ghost:
|
||||||
|
"text-[var(--color-text-muted)] hover:bg-[var(--color-surface-muted)]",
|
||||||
|
danger:
|
||||||
|
"bg-red-600 hover:bg-red-700 text-white",
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizeStyles: Record<NonNullable<ButtonProps["size"]>, string> = {
|
||||||
|
sm: "h-8 px-3 text-[12px] gap-1.5",
|
||||||
|
md: "h-10 px-4 text-[13px] gap-2",
|
||||||
|
lg: "h-11 px-6 text-[14px] gap-2",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @example
|
||||||
|
* <Button onClick={handleSave}>Save</Button>
|
||||||
|
* <Button variant="secondary" size="sm">Cancel</Button>
|
||||||
|
* <Button loading={isSubmitting} fullWidth>Submit</Button>
|
||||||
|
* <Button variant="danger">Delete</Button>
|
||||||
|
*/
|
||||||
|
export function Button({
|
||||||
|
loading,
|
||||||
|
variant = "primary",
|
||||||
|
size = "md",
|
||||||
|
fullWidth,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
disabled,
|
||||||
|
...rest
|
||||||
|
}: ButtonProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
disabled={loading || disabled}
|
||||||
|
aria-busy={loading}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center rounded-[var(--radius-md)] font-semibold",
|
||||||
|
"transition-all active:scale-[.98]",
|
||||||
|
"disabled:opacity-60 disabled:cursor-not-allowed",
|
||||||
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand-600)] focus-visible:ring-offset-2",
|
||||||
|
variantStyles[variant],
|
||||||
|
sizeStyles[size],
|
||||||
|
fullWidth && "w-full",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{loading && (
|
||||||
|
<Spinner
|
||||||
|
size="sm"
|
||||||
|
className="text-current"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
Components/UI/Input.tsx
Normal file
91
Components/UI/Input.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
// Accessible labelled input with error and hint state.
|
||||||
|
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
/** Visible label text — also used to derive the `htmlFor` id */
|
||||||
|
label: string;
|
||||||
|
/** Validation error message — sets aria-invalid and renders red helper text */
|
||||||
|
error?: string;
|
||||||
|
/** Neutral helper / hint text shown below the input */
|
||||||
|
hint?: string;
|
||||||
|
/** Optional right-side icon node */
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @example
|
||||||
|
* <Input
|
||||||
|
* label="Email"
|
||||||
|
* type="email"
|
||||||
|
* placeholder="you@example.com"
|
||||||
|
* error={errors.email}
|
||||||
|
* autoComplete="email"
|
||||||
|
* />
|
||||||
|
*
|
||||||
|
* <Input
|
||||||
|
* label="Password"
|
||||||
|
* type="password"
|
||||||
|
* hint="At least 8 characters"
|
||||||
|
* />
|
||||||
|
*/
|
||||||
|
export function Input({ label, error, hint, id, icon, className, ...rest }: InputProps) {
|
||||||
|
const inputId = id ?? `input-${label.toLowerCase().replace(/\s+/g, "-")}`;
|
||||||
|
const errorId = `${inputId}-error`;
|
||||||
|
const hintId = `${inputId}-hint`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label
|
||||||
|
htmlFor={inputId}
|
||||||
|
className="text-[12px] font-semibold text-[var(--color-text-primary)]"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
{icon && (
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--color-text-hint)] pointer-events-none"
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input
|
||||||
|
id={inputId}
|
||||||
|
aria-invalid={!!error}
|
||||||
|
aria-describedby={
|
||||||
|
error ? errorId : hint ? hintId : undefined
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"w-full h-10 px-3 border rounded-[var(--radius-md)] text-[13px]",
|
||||||
|
"text-[var(--color-text-primary)] placeholder-[var(--color-text-hint)]",
|
||||||
|
"bg-white outline-none transition",
|
||||||
|
"focus:border-[var(--color-brand-600)] focus:ring-2 focus:ring-[var(--color-brand-600)]/15",
|
||||||
|
"focus-visible:ring-[var(--color-brand-600)]",
|
||||||
|
icon ? "pr-9" : "",
|
||||||
|
error
|
||||||
|
? "border-red-400 bg-red-50"
|
||||||
|
: "border-[var(--color-border)]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p id={errorId} role="alert" className="text-[11px] text-red-500 font-medium">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hint && !error && (
|
||||||
|
<p id={hintId} className="text-[11px] text-[var(--color-text-hint)]">
|
||||||
|
{hint}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
Components/UI/Spinner.tsx
Normal file
80
Components/UI/Spinner.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
export interface SpinnerProps {
|
||||||
|
/** Visual size of the spinner */
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
/** Additional Tailwind classes — use `text-{color}` to tint */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeMap: Record<NonNullable<SpinnerProps["size"]>, string> = {
|
||||||
|
sm: "h-4 w-4",
|
||||||
|
md: "h-6 w-6",
|
||||||
|
lg: "h-10 w-10 border-4",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @example
|
||||||
|
* // Default (medium, brand blue)
|
||||||
|
* <Spinner />
|
||||||
|
*
|
||||||
|
* // Large, white (inside a dark button)
|
||||||
|
* <Spinner size="lg" className="text-white" />
|
||||||
|
*
|
||||||
|
* // Small, inline
|
||||||
|
* <Spinner size="sm" className="text-emerald-600" />
|
||||||
|
*/
|
||||||
|
export function Spinner({ size = "md", className }: SpinnerProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
role="status"
|
||||||
|
aria-label="Loading"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className={cn(
|
||||||
|
"motion-safe:animate-spin text-[var(--color-brand-600)] shrink-0",
|
||||||
|
sizeMap[size],
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full-page loading overlay */
|
||||||
|
export function PageLoader({ message = "Loading…" }: { message?: string }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-label={message}
|
||||||
|
className="flex min-h-screen flex-col items-center justify-center gap-4 bg-[var(--color-surface)]"
|
||||||
|
>
|
||||||
|
<Spinner size="lg" />
|
||||||
|
<p className="text-[13px] text-[var(--color-text-muted)]">{message}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inline loading row — for card/section loading states */
|
||||||
|
export function InlineLoader({ message = "Loading…" }: { message?: string }) {
|
||||||
|
return (
|
||||||
|
<div role="status" className="flex items-center gap-2 py-4">
|
||||||
|
<Spinner size="sm" />
|
||||||
|
<span className="text-[13px] text-[var(--color-text-muted)]">{message}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
Components/UI/index.ts
Normal file
12
Components/UI/index.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
// components/ui/index.ts
|
||||||
|
export { Spinner, PageLoader, InlineLoader } from "./Spinner";
|
||||||
|
export type { SpinnerProps } from "./Spinner";
|
||||||
|
|
||||||
|
export { Alert } from "./Alert";
|
||||||
|
export type { AlertProps, AlertType } from "./Alert";
|
||||||
|
|
||||||
|
export { Button } from "./Button";
|
||||||
|
export type { ButtonProps } from "./Button";
|
||||||
|
|
||||||
|
export { Input } from "./Input";
|
||||||
|
export type { InputProps } from "./Input";
|
||||||
@@ -4,10 +4,10 @@
|
|||||||
// نقطة الدخول — تحقق من الجلسة وتوجيه المستخدم
|
// نقطة الدخول — تحقق من الجلسة وتوجيه المستخدم
|
||||||
// ─────────────────────────────────────────────
|
// ─────────────────────────────────────────────
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { loadSession, type ClientSession } from "@/src/utils/helperFun";
|
import { loadSession, type ClientSession } from "@/utils/helperFun";
|
||||||
import RegisterStep from "../../src/Components/Client/client";
|
import RegisterStep from "../../Components/Client/client";
|
||||||
import DashboardStep from "../../src/Components/Order/order";
|
import DashboardStep from "../../Components/Order/order";
|
||||||
import Logo from "@/src/utils/logo";
|
import Logo from "@/utils/logo";
|
||||||
|
|
||||||
type Step = "bootstrap" | "register" | "dashboard";
|
type Step = "bootstrap" | "register" | "dashboard";
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import Logo from "@/src/utils/logo";
|
import Logo from "@/utils/logo";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
// FIX: Import getStoredUser so the sidebar reads the authenticated user's
|
// FIX: Import getStoredUser so the sidebar reads the authenticated user's
|
||||||
// name and role from localStorage instead of showing hardcoded placeholders.
|
// name and role from localStorage instead of showing hardcoded placeholders.
|
||||||
import { getStoredUser } from "../../../src/lib/auth";
|
import { getStoredUser } from "../../../lib/auth";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ href: "/dashboard", label: "Dashboard", permission: "read-dashboard" },
|
{ href: "/dashboard", label: "Dashboard", permission: "read-dashboard" },
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import Link from "next/link";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
// FIX: Import both clearAuth and getStoredUser so the topbar can show the
|
// FIX: Import both clearAuth and getStoredUser so the topbar can show the
|
||||||
// authenticated user's role and handle logout properly.
|
// authenticated user's role and handle logout properly.
|
||||||
import { clearAuth, getStoredUser } from "../../../src/lib/auth";
|
import { clearAuth, getStoredUser } from "../../../lib/auth";
|
||||||
|
|
||||||
export function Topbar() {
|
export function Topbar() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|||||||
@@ -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 "../../src/lib/api";
|
import { getDashboardSummary, type DashboardSummaryResponse } from "../../lib/api";
|
||||||
import { clearAuth, getStoredToken, getStoredUser } from "../../src/lib/auth";
|
import { clearAuth, getStoredToken, getStoredUser } from "../../lib/auth";
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|||||||
@@ -1,44 +1,41 @@
|
|||||||
@import url("https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap");
|
@import "../styles/tokens.css";
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
:root {
|
/* ── Base resets ────────────────────────────────────── */
|
||||||
--blue: #1A73E8;
|
*,
|
||||||
--blue-dark: #1557B0;
|
*::before,
|
||||||
--blue-light: #EBF3FF;
|
*::after {
|
||||||
--green: #34A853;
|
box-sizing: border-box;
|
||||||
--green-light: #D1FAE5;
|
|
||||||
--amber: #F59E0B;
|
|
||||||
--amber-light: #FEF3C7;
|
|
||||||
--red: #E53935;
|
|
||||||
--red-light: #FEE2E2;
|
|
||||||
--text: #111827;
|
|
||||||
--text-muted: #6B7280;
|
|
||||||
--text-hint: #9CA3AF;
|
|
||||||
--border: #E5E7EB;
|
|
||||||
--surface: #F9FAFB;
|
|
||||||
--white: #fff;
|
|
||||||
--sidebar: 230px;
|
|
||||||
--radius: 10px;
|
|
||||||
--radius-sm: 7px;
|
|
||||||
--font: 'Plus Jakarta Sans', sans-serif;
|
|
||||||
--mono: 'IBM Plex Mono', monospace;
|
|
||||||
--background: #ECEEF2;
|
|
||||||
--foreground: #111827;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
html {
|
||||||
--color-background: var(--background);
|
scroll-behavior: smooth;
|
||||||
--color-foreground: var(--foreground);
|
font-size: 14px;
|
||||||
--font-sans: var(--font-geist-sans);
|
/* RTL is set per route-group layout, not globally */
|
||||||
--font-mono: var(--font-geist-mono);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
html { scroll-behavior: smooth; font-size: 14px; }
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #ECEEF2;
|
background-color: var(--color-surface-sunken);
|
||||||
color: var(--foreground);
|
color: var(--color-text-primary);
|
||||||
font-family: var(--font);
|
font-family: var(--font-sans);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Tailwind theme bridge ──────────────────────────── */
|
||||||
|
/* Expose CSS variables as Tailwind color utilities. */
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--color-surface-sunken);
|
||||||
|
--color-foreground: var(--color-text-primary);
|
||||||
|
--color-brand: var(--color-brand-600);
|
||||||
|
--font-sans: var(--font-sans);
|
||||||
|
--font-mono: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Focus ring utility ─────────────────────────────── */
|
||||||
|
.focus-ring {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 2px var(--color-brand-600);
|
||||||
|
}
|
||||||
@@ -1,33 +1,46 @@
|
|||||||
|
// app/layout.tsx
|
||||||
|
// Root layout — single location for font loading and global metadata.
|
||||||
|
// No inline styles, no @import in CSS.
|
||||||
|
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Plus_Jakarta_Sans, IBM_Plex_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const jakartaSans = Plus_Jakarta_Sans({
|
||||||
variable: "--font-geist-sans",
|
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
|
weight: ["400", "500", "600", "700"],
|
||||||
|
variable: "--font-sans",
|
||||||
|
display: "swap",
|
||||||
});
|
});
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
const ibmPlexMono = IBM_Plex_Mono({
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
|
weight: ["400", "500"],
|
||||||
|
variable: "--font-mono",
|
||||||
|
display: "swap",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Logistics Client Portal",
|
title: {
|
||||||
description: "Modern frontend for the LogisticsApp backend with dashboard and client views.",
|
default: "Slash.sa — Logistics Management",
|
||||||
|
template: "%s | Slash.sa",
|
||||||
|
},
|
||||||
|
description:
|
||||||
|
"Modern logistics client portal — manage deliveries, drivers, and fleet operations.",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}) {
|
||||||
return (
|
return (
|
||||||
|
// dir is set per route-group (ar = RTL, en = LTR) not globally
|
||||||
<html
|
<html
|
||||||
lang="en"
|
lang="ar"
|
||||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
className={`${jakartaSans.variable} ${ibmPlexMono.variable} h-full antialiased`}
|
||||||
>
|
>
|
||||||
<body className="min-h-full flex flex-col">{children}</body>
|
<body className="min-h-full flex flex-col">{children}</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3,8 +3,9 @@
|
|||||||
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 "../../src/lib/auth";
|
import { loginUser } from "../../lib/auth";
|
||||||
import Logo from "@/src/utils/logo";
|
import Logo from "@/utils/logo";
|
||||||
|
import { Spinner } from "@/Components/UI";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -161,7 +162,7 @@ export default function LoginPage() {
|
|||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="mt-6 inline-flex h-11.5 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"
|
className="mt-6 inline-flex h-11.5 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 ? "جاري تسجيل الدخول…" : "تسجيل الدخول"}
|
{loading ? <Spinner /> : "تسجيل الدخول"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<p className="mt-4 text-center text-[13px] text-slate-500">
|
<p className="mt-4 text-center text-[13px] text-slate-500">
|
||||||
|
|||||||
45
hooks/useClientOrders.ts
Normal file
45
hooks/useClientOrders.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { clientService } from "@/services/client.service";
|
||||||
|
import type { Order } from "../types/client";
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
orders: Order[];
|
||||||
|
error: string | null;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches all orders for a client session.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { orders, loading, error } = useClientOrders(session.id);
|
||||||
|
*/
|
||||||
|
export function useClientOrders(clientId: string): State {
|
||||||
|
const [state, setState] = useState<State>({
|
||||||
|
orders: [], error: null, loading: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!clientId) {
|
||||||
|
setState({ orders: [], error: null, loading: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
clientService
|
||||||
|
.getOrders(clientId)
|
||||||
|
.then(orders => {
|
||||||
|
if (!cancelled) setState({ orders, error: null, loading: false });
|
||||||
|
})
|
||||||
|
.catch((err: Error) => {
|
||||||
|
if (!cancelled) setState({ orders: [], error: err.message, loading: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [clientId]);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
49
hooks/useDashboardSummary.ts
Normal file
49
hooks/useDashboardSummary.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
// hooks/useDashboardSummary.ts
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { dashboardService } from "@/services/dashboard.service";
|
||||||
|
import { getStoredToken } from "@/lib/auth";
|
||||||
|
import type { DashboardSummary } from "@/types/dashboard";
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
data: DashboardSummary | null;
|
||||||
|
error: string | null;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the admin dashboard summary.
|
||||||
|
* Redirects to /login if no token is present (handled by the caller).
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { data, loading, error } = useDashboardSummary();
|
||||||
|
*/
|
||||||
|
export function useDashboardSummary(): State {
|
||||||
|
const [state, setState] = useState<State>({
|
||||||
|
data: null, error: null, loading: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const token = getStoredToken();
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
setState({ data: null, error: "Unauthenticated", loading: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dashboardService
|
||||||
|
.getSummary(token)
|
||||||
|
.then(res => {
|
||||||
|
if (!cancelled) setState({ data: res.data, error: null, loading: false });
|
||||||
|
})
|
||||||
|
.catch((err: Error) => {
|
||||||
|
if (!cancelled) setState({ data: null, error: err.message, loading: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
20
lib/formatters.ts
Normal file
20
lib/formatters.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
|
||||||
|
/**
|
||||||
|
* Format an ISO date string as a localised Arabic date.
|
||||||
|
* @example fmtDate("2026-06-08T10:00:00Z") → "٨ يونيو ٢٠٢٦"
|
||||||
|
*/
|
||||||
|
export function fmtDate(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleDateString("ar-SA", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a numeric amount as Saudi Riyals.
|
||||||
|
* @example fmtAmount(1234.5) → "١٬٢٣٤٫٥٠ ر.س"
|
||||||
|
*/
|
||||||
|
export function fmtAmount(n: number): string {
|
||||||
|
return `${n.toFixed(2)} ر.س`;
|
||||||
|
}
|
||||||
28
lib/order-status.ts
Normal file
28
lib/order-status.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
// lib/order-status.ts
|
||||||
|
// Order status display config — no React, no network.
|
||||||
|
|
||||||
|
import type { OrderStatus } from "../types/client";
|
||||||
|
|
||||||
|
export type { OrderStatus };
|
||||||
|
|
||||||
|
/** Tailwind class string for a status badge */
|
||||||
|
export function statusColor(status: OrderStatus): string {
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Arabic label for a status */
|
||||||
|
export function statusLabel(status: OrderStatus): string {
|
||||||
|
const map: Record<OrderStatus, string> = {
|
||||||
|
CREATED: "تم الإنشاء",
|
||||||
|
IN_TRANSIT: "قيد التوصيل",
|
||||||
|
DELIVERED: "تم التسليم",
|
||||||
|
CANCELLED: "ملغي",
|
||||||
|
};
|
||||||
|
return map[status];
|
||||||
|
}
|
||||||
53
lib/session.ts
Normal file
53
lib/session.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
export const SESSION_KEY = "lf_client";
|
||||||
|
export const SESSION_COOKIE = "lf_client_id";
|
||||||
|
|
||||||
|
export interface ClientSession {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cookie helpers ────────────────────────────────────
|
||||||
|
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=/; SameSite=Lax`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteCookie(name: string) {
|
||||||
|
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Session read / write ──────────────────────────────
|
||||||
|
export function loadSession(): ClientSession | null {
|
||||||
|
try {
|
||||||
|
const raw = typeof localStorage !== "undefined"
|
||||||
|
? localStorage.getItem(SESSION_KEY)
|
||||||
|
: null;
|
||||||
|
if (raw) return JSON.parse(raw) as ClientSession;
|
||||||
|
} catch { /* ignore parse errors */ }
|
||||||
|
|
||||||
|
const id = getCookie(SESSION_COOKIE);
|
||||||
|
if (id) return { id, name: "", email: "", phone: "" };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveSession(s: ClientSession) {
|
||||||
|
if (typeof localStorage !== "undefined") {
|
||||||
|
localStorage.setItem(SESSION_KEY, JSON.stringify(s));
|
||||||
|
}
|
||||||
|
setCookie(SESSION_COOKIE, s.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSession() {
|
||||||
|
if (typeof localStorage !== "undefined") {
|
||||||
|
localStorage.removeItem(SESSION_KEY);
|
||||||
|
}
|
||||||
|
deleteCookie(SESSION_COOKIE);
|
||||||
|
}
|
||||||
11
lib/utils.ts
Normal file
11
lib/utils.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
|
||||||
|
// Minimal class-name merger used by UI components.
|
||||||
|
// Install `clsx` and `tailwind-merge` for production:
|
||||||
|
// npm i clsx tailwind-merge
|
||||||
|
// Then replace the body with:
|
||||||
|
// import { clsx } from "clsx"; import { twMerge } from "tailwind-merge";
|
||||||
|
// export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));
|
||||||
|
|
||||||
|
export function cn(...classes: (string | undefined | null | false)[]): string {
|
||||||
|
return classes.filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
69
services/api.ts
Normal file
69
services/api.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
// Base HTTP helper — no React, no UI imports.
|
||||||
|
// All network calls in the app ultimately call `request()` from here.
|
||||||
|
|
||||||
|
/** Resolved at runtime: server-side reads INTERNAL_API_BASE_URL,
|
||||||
|
* browser always hits the Next.js proxy to avoid CORS. */
|
||||||
|
function resolveBase() {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return process.env.INTERNAL_API_BASE_URL ?? "";
|
||||||
|
}
|
||||||
|
return "/api/proxy";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Error type ─────────────────────────────────────────
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly status: number,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ApiError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Core request helper ────────────────────────────────
|
||||||
|
export async function request<T>(
|
||||||
|
path: string,
|
||||||
|
init: RequestInit = {},
|
||||||
|
token?: string | null,
|
||||||
|
): Promise<T> {
|
||||||
|
const headers = new Headers(init.headers);
|
||||||
|
|
||||||
|
if (!headers.has("Content-Type") && !(init.body instanceof FormData)) {
|
||||||
|
headers.set("Content-Type", "application/json");
|
||||||
|
}
|
||||||
|
if (token) {
|
||||||
|
headers.set("Authorization", `Bearer ${token}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${resolveBase()}/${path.replace(/^\/+/, "")}`;
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
...init,
|
||||||
|
headers,
|
||||||
|
cache: "no-store",
|
||||||
|
method: init.method ?? "GET",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const json = await res.json().catch(() => null);
|
||||||
|
const message: string =
|
||||||
|
json?.message ?? json?.error ?? `HTTP ${res.status}: ${res.statusText}`;
|
||||||
|
throw new ApiError(res.status, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Convenience shorthands ────────────────────────────
|
||||||
|
export const get = <T>(path: string, token?: string | null) =>
|
||||||
|
request<T>(path, {}, token);
|
||||||
|
|
||||||
|
export const post = <T>(path: string, body: unknown, token?: string | null) =>
|
||||||
|
request<T>(path, { method: "POST", body: JSON.stringify(body) }, token);
|
||||||
|
|
||||||
|
export const put = <T>(path: string, body: unknown, token?: string | null) =>
|
||||||
|
request<T>(path, { method: "PUT", body: JSON.stringify(body) }, token);
|
||||||
|
|
||||||
|
export const del = <T>(path: string, token?: string | null) =>
|
||||||
|
request<T>(path, { method: "DELETE" }, token);
|
||||||
31
services/auth.service.ts
Normal file
31
services/auth.service.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
|
||||||
|
// All authentication API calls in one place.
|
||||||
|
|
||||||
|
import { post } from "./api";
|
||||||
|
import type { LoginResponse } from "../types/auth";
|
||||||
|
|
||||||
|
export interface LoginPayload {
|
||||||
|
identity: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authService = {
|
||||||
|
/**
|
||||||
|
* Authenticate a user with email / phone / username + password.
|
||||||
|
* Throws `ApiError` on network or credential failure.
|
||||||
|
*/
|
||||||
|
login: (payload: LoginPayload) =>
|
||||||
|
post<LoginResponse>("auth/login", payload),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request a password reset email.
|
||||||
|
*/
|
||||||
|
forgotPassword: (email: string) =>
|
||||||
|
post<{ message: string }>("auth/forgot-password", { email }),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm a password reset with a token.
|
||||||
|
*/
|
||||||
|
resetPassword: (token: string, newPassword: string) =>
|
||||||
|
post<{ message: string }>("auth/reset-password", { token, newPassword }),
|
||||||
|
};
|
||||||
46
services/client.service.ts
Normal file
46
services/client.service.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
// services/client.service.ts
|
||||||
|
|
||||||
|
import { get, post } from "./api";
|
||||||
|
import type { ClientAddress, Order } from "../types/client";
|
||||||
|
|
||||||
|
export interface CreateClientPayload {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
companyName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateAddressPayload {
|
||||||
|
label: string;
|
||||||
|
branchName?: string | null;
|
||||||
|
contactPerson: { name: string; phone: string };
|
||||||
|
details: {
|
||||||
|
city: string;
|
||||||
|
district?: string;
|
||||||
|
street: string;
|
||||||
|
buildingNo?: string;
|
||||||
|
unitNo?: string;
|
||||||
|
zipCode?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const clientService = {
|
||||||
|
/** Register a new client account */
|
||||||
|
create: (payload: CreateClientPayload) =>
|
||||||
|
post<{ id: string; name: string; email: string; phone: string }>(
|
||||||
|
"client",
|
||||||
|
payload,
|
||||||
|
),
|
||||||
|
|
||||||
|
/** Fetch all saved delivery addresses for a client */
|
||||||
|
getAddresses: (clientId: string) =>
|
||||||
|
get<ClientAddress[]>(`client/${clientId}/addresses`),
|
||||||
|
|
||||||
|
/** Save a new delivery address */
|
||||||
|
addAddress: (clientId: string, payload: CreateAddressPayload) =>
|
||||||
|
post<ClientAddress>(`client/${clientId}/addresses`, payload),
|
||||||
|
|
||||||
|
/** Fetch all orders placed by a client */
|
||||||
|
getOrders: (clientId: string) =>
|
||||||
|
get<Order[]>(`client/${clientId}/orders`),
|
||||||
|
};
|
||||||
7
services/dashboard.service.ts
Normal file
7
services/dashboard.service.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { get } from "./api";
|
||||||
|
import type { DashboardSummaryResponse } from "../types/dashboard";
|
||||||
|
|
||||||
|
export const dashboardService = {
|
||||||
|
getSummary: (token: string) =>
|
||||||
|
get<DashboardSummaryResponse>("dashboard/summary", token),
|
||||||
|
};
|
||||||
4
services/index.ts
Normal file
4
services/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export { request, get, post, put, del, ApiError } from "./api";
|
||||||
|
export { authService } from "./auth.service";
|
||||||
|
export { dashboardService } from "./dashboard.service";
|
||||||
|
export { clientService } from "./client.service";
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
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;
|
|
||||||
72
styles/tokens.css
Normal file
72
styles/tokens.css
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
:root {
|
||||||
|
/* ── Brand palette ─────────────────────────────────── */
|
||||||
|
--color-brand-50: #EBF3FF;
|
||||||
|
--color-brand-100: #BFDBFE;
|
||||||
|
--color-brand-600: #1A73E8;
|
||||||
|
--color-brand-700: #1557B0;
|
||||||
|
--color-brand-900: #0D47A1;
|
||||||
|
|
||||||
|
/* ── Semantic / status ─────────────────────────────── */
|
||||||
|
--color-success: #34A853;
|
||||||
|
--color-success-light: #D1FAE5;
|
||||||
|
--color-warning: #F59E0B;
|
||||||
|
--color-warning-light: #FEF3C7;
|
||||||
|
--color-danger: #E53935;
|
||||||
|
--color-danger-light: #FEE2E2;
|
||||||
|
--color-info: #1A73E8;
|
||||||
|
--color-info-light: #EBF3FF;
|
||||||
|
|
||||||
|
/* ── Surface (light mode) ──────────────────────────── */
|
||||||
|
--color-surface: #FFFFFF;
|
||||||
|
--color-surface-muted: #F9FAFB;
|
||||||
|
--color-surface-sunken: #ECEEF2;
|
||||||
|
--color-border: #E5E7EB;
|
||||||
|
--color-border-strong: #D1D5DB;
|
||||||
|
|
||||||
|
/* ── Text (light mode) ─────────────────────────────── */
|
||||||
|
--color-text-primary: #111827;
|
||||||
|
--color-text-secondary:#374151;
|
||||||
|
--color-text-muted: #6B7280;
|
||||||
|
--color-text-hint: #9CA3AF;
|
||||||
|
--color-text-inverse: #FFFFFF;
|
||||||
|
|
||||||
|
/* ── Surface (dark — admin dashboard) ─────────────── */
|
||||||
|
--color-surface-dark: #020617; /* slate-950 */
|
||||||
|
--color-surface-dark-raised: #0F172A; /* slate-900 */
|
||||||
|
--color-surface-dark-card: #1E293B; /* slate-800 */
|
||||||
|
--color-border-dark: rgba(255, 255, 255, 0.10);
|
||||||
|
--color-text-dark-primary: #F1F5F9; /* slate-100 */
|
||||||
|
--color-text-dark-muted: #94A3B8; /* slate-400 */
|
||||||
|
|
||||||
|
/* ── Typography ────────────────────────────────────── */
|
||||||
|
--font-sans: 'Plus Jakarta Sans', sans-serif;
|
||||||
|
--font-mono: 'IBM Plex Mono', monospace;
|
||||||
|
|
||||||
|
/* ── Radius ────────────────────────────────────────── */
|
||||||
|
--radius-sm: 7px;
|
||||||
|
--radius-md: 10px;
|
||||||
|
--radius-lg: 14px;
|
||||||
|
--radius-xl: 20px;
|
||||||
|
--radius-2xl: 24px;
|
||||||
|
--radius-full: 9999px;
|
||||||
|
|
||||||
|
/* ── Spacing / layout ──────────────────────────────── */
|
||||||
|
--sidebar-width: 280px;
|
||||||
|
--topbar-height: 56px;
|
||||||
|
--page-max-width: 1280px;
|
||||||
|
--page-padding-x: 24px;
|
||||||
|
|
||||||
|
/* ── Shadows ───────────────────────────────────────── */
|
||||||
|
--shadow-card: 0 1px 3px rgba(0,0,0,.08), 0 1px 2px rgba(0,0,0,.04);
|
||||||
|
--shadow-overlay: 0 20px 60px rgba(0,0,0,.25);
|
||||||
|
|
||||||
|
/* ── Transitions ───────────────────────────────────── */
|
||||||
|
--transition-base: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Reduced-motion: disable spinner animation ──────── */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.animate-spin {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
16
types/auth.ts
Normal file
16
types/auth.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export interface AuthUser {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
userName?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
role?: string;
|
||||||
|
permissions?: string[];
|
||||||
|
roleId?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginResponse {
|
||||||
|
token: string;
|
||||||
|
user: AuthUser;
|
||||||
|
}
|
||||||
24
types/client.ts
Normal file
24
types/client.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OrderStatus = "CREATED" | "IN_TRANSIT" | "DELIVERED" | "CANCELLED";
|
||||||
|
|
||||||
|
export interface Order {
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
status: OrderStatus;
|
||||||
|
totalAmount: number;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
41
types/dashboard.ts
Normal file
41
types/dashboard.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
export interface DashboardStats {
|
||||||
|
clients: number;
|
||||||
|
orders: number;
|
||||||
|
trips: number;
|
||||||
|
cars: number;
|
||||||
|
drivers: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlertItem {
|
||||||
|
message: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveTrip {
|
||||||
|
id: string;
|
||||||
|
tripNumber: string;
|
||||||
|
title: string;
|
||||||
|
progress: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccountSecurity {
|
||||||
|
lastLogin: string | null;
|
||||||
|
requestMeta: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardSummary {
|
||||||
|
stats: DashboardStats;
|
||||||
|
alerts: {
|
||||||
|
expiringCars: AlertItem[];
|
||||||
|
expiringDrivers: AlertItem[];
|
||||||
|
upcomingMaint: AlertItem[];
|
||||||
|
};
|
||||||
|
activeTrips: ActiveTrip[];
|
||||||
|
accountSecurity: AccountSecurity;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardSummaryResponse {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
data: DashboardSummary;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user