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:
m7amedez5511
2026-06-08 23:05:19 +03:00
parent 70853958f5
commit 53c03e9867
45 changed files with 1408 additions and 78 deletions

View File

View File

@@ -0,0 +1,213 @@
"use client";
import React, { useState, type FC, type FormEvent } from "react";
import { saveSession, type ClientSession } from "@/utils/helperFun";
import { Spinner } from "../UI";
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 "@/utils/helperFun";
import { Spinner } from "../UI";
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

0
Components/Home/home.tsx Normal file
View File

257
Components/Order/order.tsx Normal file
View File

@@ -0,0 +1,257 @@
"use client";
import React, { useState, useEffect, type FC } from "react";
import {
fmtDate,
fmtAmount,
statusColor,
statusLabel,
clearSession,
type ClientSession,
type OrderStatus,
} from "@/utils/helperFun";
import { Spinner } from "../UI";
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

View File

59
Components/UI/Alert.tsx Normal file
View 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
View 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
View 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
View 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
View 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";

0
Components/User/user.tsx Normal file
View File

0
Components/car/car.tsx Normal file
View File