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:
0
Components/Admen/admen.tsx
Normal file
0
Components/Admen/admen.tsx
Normal file
213
Components/Client/client.tsx
Normal file
213
Components/Client/client.tsx
Normal 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;
|
||||
376
Components/Client_Adress/clientAdress.tsx
Normal file
376
Components/Client_Adress/clientAdress.tsx
Normal 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;
|
||||
0
Components/Driver/driver.tsx
Normal file
0
Components/Driver/driver.tsx
Normal file
0
Components/Driver_Report/driverReport.tsx
Normal file
0
Components/Driver_Report/driverReport.tsx
Normal file
0
Components/Home/home.tsx
Normal file
0
Components/Home/home.tsx
Normal file
257
Components/Order/order.tsx
Normal file
257
Components/Order/order.tsx
Normal 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;
|
||||
0
Components/Trip/tripe.tsx
Normal file
0
Components/Trip/tripe.tsx
Normal file
0
Components/Trip_Report/tripe_report.tsx
Normal file
0
Components/Trip_Report/tripe_report.tsx
Normal file
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";
|
||||
0
Components/User/user.tsx
Normal file
0
Components/User/user.tsx
Normal file
0
Components/car/car.tsx
Normal file
0
Components/car/car.tsx
Normal file
Reference in New Issue
Block a user