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

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

103
src/lib/api.ts Normal file
View 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
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;