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:
103
lib/api.ts
Normal file
103
lib/api.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
const DEFAULT_BASE_URL = "/api/proxy";
|
||||
|
||||
export const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL || DEFAULT_BASE_URL;
|
||||
|
||||
function normalizePath(path: string) {
|
||||
return path.startsWith("/") ? path : `/${path}`;
|
||||
}
|
||||
|
||||
// ─── API REQUESTS ─────────────────────────────
|
||||
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 || {});
|
||||
|
||||
if (!headers.has("Content-Type") && !(init.body instanceof FormData)) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
if (token) {
|
||||
headers.set("Authorization", `Bearer ${token}`);
|
||||
}
|
||||
|
||||
const endpoint = `${API_BASE_URL}${normalizePath(path)}`;
|
||||
|
||||
let attempt = 0;
|
||||
const maxAttempts = 3;
|
||||
|
||||
while (attempt < maxAttempts) {
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
...init,
|
||||
headers,
|
||||
cache: "no-store",
|
||||
method: init.method || "GET",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// try to parse error message from response, fallback to status text
|
||||
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;
|
||||
} catch (error) {
|
||||
attempt += 1;
|
||||
if (attempt >= maxAttempts) throw error;
|
||||
// exponential back-off: 600ms, 1200ms
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, 2 ** attempt * 300),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Request failed after retries.");
|
||||
}
|
||||
|
||||
// ─── Types ───────────────────────────────────
|
||||
export type DashboardSummaryResponse = {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data: {
|
||||
stats: {
|
||||
clients: number;
|
||||
orders: number;
|
||||
trips: number;
|
||||
cars: number;
|
||||
drivers: number;
|
||||
};
|
||||
alerts: {
|
||||
expiringCars: Array<Record<string, unknown>>;
|
||||
expiringDrivers: Array<Record<string, unknown>>;
|
||||
upcomingMaint: Array<Record<string, unknown>>;
|
||||
};
|
||||
activeTrips: Array<{
|
||||
id: string;
|
||||
tripNumber: string;
|
||||
title: string;
|
||||
progress: number;
|
||||
}>;
|
||||
accountSecurity: {
|
||||
lastLogin: string | null;
|
||||
requestMeta: Record<string, unknown> | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
// ─── API calls ───────────────────────────────
|
||||
export async function getDashboardSummary() {
|
||||
return requestJson<DashboardSummaryResponse>("/dashboard/summary");
|
||||
}
|
||||
|
||||
export async function getHealth() {
|
||||
return requestJson<{ status: string; message: string }>("/health");
|
||||
}
|
||||
138
lib/auth.ts
Normal file
138
lib/auth.ts
Normal 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 };
|
||||
}
|
||||
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(" ");
|
||||
}
|
||||
Reference in New Issue
Block a user