Merge pull request 'main' (#1) from main into development

Reviewed-on: https://gitea.maskantech.in/gitea_admin/aeroresolve_frontend/pulls/1
Reviewed-by: Syed Waseem khadri Rafai <waseem.khadri@maskatech.com>
This commit is contained in:
Syed Waseem khadri Rafai
2026-07-07 06:01:20 +00:00
32 changed files with 3428 additions and 79 deletions
+377 -4
View File
@@ -1,9 +1,382 @@
import { useState } from "react";
import CustomButton from "../../components/custom/CustomButton";
import CustomSearchableDropdown from "../../components/custom/CustomSearchableDropdown";
import CustomDropdown from "../../components/custom/CustomDropdown";
import CustomMultiSelect from "../../components/custom/CustomMultiSelect";
import CustomModal from "../../components/custom/CustomModal";
import CustomInput from "../../components/custom/CustomInput";
import CustomTextArea from "../../components/custom/CustomTextArea";
import CustomStatus from "../../components/custom/CustomStatus";
import CustomCheckBox from "../../components/custom/CustomCheckBox";
import CustomRadio from "../../components/custom/CustomRadio";
import CustomSwitch from "../../components/custom/CustomSwitch";
import CustomTable, { type Column } from "../../components/custom/CustomTable";
import CustomActionMenu, { CustomActionItem } from "../../components/custom/CustomActionMenu";
import { FileText, ChevronRight, Plus, Check, X, Copy, Edit2, Trash2 } from "lucide-react";
function HomePage() {
const [searchValue, setSearchValue] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [dropdownValue, setDropdownValue] = useState("active");
const [multiSelectValue, setMultiSelectValue] = useState<(string | number)[]>(["active"]);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isChecked, setIsChecked] = useState(true);
const [radioValue, setRadioValue] = useState("option1");
const [isSwitchOn, setIsSwitchOn] = useState(true);
const tableData = [
{
name: "Strategic Accounts",
description: "Key corporate account travelers.",
status: "active",
lastModified: "4 Jun 2026, 4:09pm",
lastModifiedBy: "John Doe",
},
{
name: "Families with Infants",
description: "Passengers traveling with children < 2yrs.",
status: "active",
lastModified: "4 Jun 2026, 4:09pm",
lastModifiedBy: "John Doe",
},
{
name: "Inactive Users",
description: "Users who haven't booked in 12 months.",
status: "inactive",
lastModified: "3 Jun 2026, 2:15pm",
lastModifiedBy: "Jane Smith",
},
];
const tableColumns: Column<typeof tableData[0]>[] = [
{
header: "Cohort Name",
accessor: (row) => <span className="font-bold text-gray-900">{row.name}</span>,
},
{
header: "Description",
accessor: "description",
},
{
header: "Status",
accessor: (row) => <CustomStatus status={row.status} />,
},
{
header: "Last Modified",
accessor: "lastModified",
sortable: true,
},
{
header: "Last Modified By",
accessor: "lastModifiedBy",
filterable: true,
},
{
header: "Action",
accessor: () => (
<div className="flex justify-center w-full">
<CustomActionMenu>
<CustomActionItem variant="success" icon={<Check size={16} />}>Activate</CustomActionItem>
<CustomActionItem icon={<X size={16} />}>Deactivate</CustomActionItem>
<CustomActionItem icon={<Copy size={16} />}>Duplicate</CustomActionItem>
<CustomActionItem icon={<Edit2 size={16} />}>Edit</CustomActionItem>
<CustomActionItem variant="danger" icon={<Trash2 size={16} />}>Delete</CustomActionItem>
</CustomActionMenu>
</div>
),
className: "text-center w-24",
},
];
const options = [
{ label: "Draft", value: "draft" },
{ label: "Active", value: "active" },
{ label: "Inactive", value: "inactive" },
];
return (
<section className="page home-page">
<h1>Home Page</h1>
<p>Welcome to the home page. Use the navigation links to switch pages.</p>
</section>
<div className="p-8 max-w-5xl mx-auto flex flex-col gap-8">
<div>
<h1 className="text-2xl font-bold mb-2 text-gray-900">Dashboard (UI Test Page)</h1>
<p className="text-gray-500">Test all button variants and the custom components here.</p>
</div>
<div className="bg-white p-8 rounded-[20px] border border-gray-100 shadow-sm flex flex-col gap-6">
<h2 className="text-[15px] font-bold text-gray-900 border-b border-gray-100 pb-3">Table Component</h2>
<CustomTable
columns={tableColumns}
data={tableData}
searchValue={searchValue}
onSearchChange={setSearchValue}
searchPlaceholder="Search cohorts..."
currentPage={currentPage}
totalPages={3}
totalItems={12}
startIndex={1}
endIndex={3}
onPageChange={setCurrentPage}
itemName="cohorts"
rightHeaderActions={
<div className="flex items-center gap-4">
<div className="w-40">
<CustomDropdown
options={options}
value="active"
placeholder="Choose Status"
size="md"
/>
</div>
<CustomButton variant="primary" size="md" leftIcon={<Plus size={16} />}>
Create Cohort
</CustomButton>
</div>
}
/>
</div>
<div className="bg-white p-8 rounded-[20px] border border-gray-100 shadow-sm flex flex-col gap-6">
<h2 className="text-[15px] font-bold text-gray-900 border-b border-gray-100 pb-3">Modal Components</h2>
<div className="flex flex-wrap gap-4 items-center">
<CustomButton variant="primary" onClick={() => setIsModalOpen(true)}>
Open Test Modal
</CustomButton>
</div>
</div>
<div className="bg-white p-8 rounded-[20px] border border-gray-100 shadow-sm flex flex-col gap-6">
<h2 className="text-[15px] font-bold text-gray-900 border-b border-gray-100 pb-3">Status Pills & Toggles</h2>
<div className="flex flex-col gap-6">
<div className="flex flex-wrap gap-4 items-center">
<span className="text-sm font-semibold w-24">Status Pills:</span>
<CustomStatus status="active" />
<CustomStatus status="inactive" />
<CustomStatus status="pending" />
<CustomStatus status="shipped" />
<CustomStatus status="neutral" />
</div>
<div className="flex flex-wrap gap-4 items-center">
<span className="text-sm font-semibold w-24">Checkboxes:</span>
<CustomCheckBox checked={isChecked} onChange={() => setIsChecked(!isChecked)} label="Checked State" />
<CustomCheckBox checked={false} onChange={() => {}} label="Unchecked State" />
</div>
<div className="flex flex-wrap gap-4 items-center">
<span className="text-sm font-semibold w-24">Radios:</span>
<CustomRadio checked={radioValue === "option1"} onChange={() => setRadioValue("option1")} label="Option 1" />
<CustomRadio checked={radioValue === "option2"} onChange={() => setRadioValue("option2")} label="Option 2" />
</div>
<div className="flex flex-wrap gap-4 items-center">
<span className="text-sm font-semibold w-24">Switches:</span>
<CustomSwitch checked={isSwitchOn} onChange={() => setIsSwitchOn(!isSwitchOn)} label="Toggle Feature" />
<CustomSwitch checked={false} onChange={() => {}} label="Off Toggle" />
</div>
</div>
</div>
<div className="bg-white p-8 rounded-[20px] border border-gray-100 shadow-sm flex flex-col gap-6">
<h2 className="text-[15px] font-bold text-gray-900 border-b border-gray-100 pb-3">Button Variants</h2>
<div className="flex flex-wrap gap-4 items-center">
<CustomButton variant="primary">Primary</CustomButton>
<CustomButton variant="secondary">Secondary</CustomButton>
<CustomButton variant="outlined">Outlined</CustomButton>
<CustomButton variant="text">Text Button</CustomButton>
<CustomButton variant="link">Link Button</CustomButton>
</div>
<div className="flex flex-wrap gap-4 items-center mt-2">
<CustomButton variant="primary" size="sm">Small</CustomButton>
<CustomButton variant="primary" size="md">Medium</CustomButton>
<CustomButton variant="primary" size="lg">Large</CustomButton>
<CustomButton variant="primary" loading>Loading</CustomButton>
<CustomButton variant="primary" disabled>Disabled</CustomButton>
</div>
</div>
<div className="bg-white p-8 rounded-[20px] border border-gray-100 shadow-sm flex flex-col gap-6">
<h2 className="text-[15px] font-bold text-gray-900 border-b border-gray-100 pb-3">Input & TextArea Variants</h2>
<div className="flex flex-wrap gap-6 items-end">
<div className="w-48">
<CustomInput
label="Small (sm)"
size="sm"
placeholder="Enter text..."
/>
</div>
<div className="w-56">
<CustomInput
label="Medium (md)"
size="md"
placeholder="Enter text..."
/>
</div>
<div className="w-64">
<CustomInput
label="Large (lg)"
size="lg"
placeholder="Enter text..."
/>
</div>
</div>
<div className="flex flex-wrap gap-6 items-end mt-4">
<div className="w-48">
<CustomTextArea
label="Small (sm)"
size="sm"
placeholder="Enter text..."
rows={2}
/>
</div>
<div className="w-56">
<CustomTextArea
label="Medium (md)"
size="md"
placeholder="Enter text..."
rows={3}
/>
</div>
<div className="w-64">
<CustomTextArea
label="Large (lg)"
size="lg"
placeholder="Enter text..."
rows={4}
/>
</div>
</div>
</div>
<div className="bg-white p-8 rounded-[20px] border border-gray-100 shadow-sm flex flex-col gap-6">
<h2 className="text-[15px] font-bold text-gray-900 border-b border-gray-100 pb-3">Standard Dropdown Variants</h2>
<div className="flex flex-wrap gap-6 items-end">
<div className="w-48">
<CustomDropdown
label="Small (sm)"
size="sm"
options={options}
value={dropdownValue}
onChange={setDropdownValue}
/>
</div>
<div className="w-56">
<CustomDropdown
label="Medium (md)"
size="md"
options={options}
value={dropdownValue}
onChange={setDropdownValue}
/>
</div>
</div>
</div>
<div className="bg-white p-8 rounded-[20px] border border-gray-100 shadow-sm flex flex-col gap-6">
<h2 className="text-[15px] font-bold text-gray-900 border-b border-gray-100 pb-3">Searchable Dropdown Variants</h2>
<div className="flex flex-wrap gap-6 items-end">
<div className="w-48">
<CustomSearchableDropdown
label="Small (sm)"
size="sm"
options={options}
value={dropdownValue}
onChange={setDropdownValue}
placeholder="Small size..."
/>
</div>
<div className="w-56">
<CustomSearchableDropdown
label="Medium (md)"
size="md"
options={options}
value={dropdownValue}
onChange={setDropdownValue}
placeholder="Medium size..."
/>
</div>
<div className="w-64">
<CustomSearchableDropdown
label="Large (lg)"
size="lg"
options={options}
value={dropdownValue}
onChange={setDropdownValue}
placeholder="Large size..."
/>
</div>
</div>
</div>
<div className="bg-white p-8 rounded-[20px] border border-gray-100 shadow-sm flex flex-col gap-6 mb-32">
<h2 className="text-[15px] font-bold text-gray-900 border-b border-gray-100 pb-3">Multi-Select Dropdown</h2>
<div className="w-[400px]">
<CustomMultiSelect
label="Select Multiple Statuses"
options={options}
value={multiSelectValue}
onChange={setMultiSelectValue}
placeholder="Select statuses..."
/>
</div>
</div>
<CustomModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
title="New Dynamic Cohorts"
description="Configure targeting criteria for high-precision recovery."
icon={<FileText size={24} />}
primaryAction={{
label: "Next Step",
onClick: () => setIsModalOpen(false),
icon: <ChevronRight size={18} />
}}
secondaryAction={{
label: "Cancel",
onClick: () => setIsModalOpen(false)
}}
size="md"
>
<div className="flex flex-col gap-6 h-64">
<div className="flex justify-between border-b border-gray-100 pb-2">
<div>
<span className="text-[10px] font-bold text-[#1B9869] tracking-wider uppercase">1 - Information</span>
<h3 className="text-sm font-bold text-[#1B9869]">Cohort Identity</h3>
</div>
<div className="text-right">
<span className="text-[10px] font-bold text-gray-400 tracking-wider uppercase">2 - Targeting</span>
<h3 className="text-sm font-bold text-gray-400">Targeting Criteria</h3>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="w-full">
<label className="text-xs font-bold text-gray-700 mb-1 block">Cohort Name</label>
<input type="text" placeholder="Enter" className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-[#1B9869]" />
</div>
<div className="w-full">
<CustomDropdown
label="Status"
options={[{label: "Selected Option", value: "selected"}]}
value="selected"
/>
</div>
</div>
<div className="w-full">
<label className="text-xs font-bold text-gray-700 mb-1 block">Description</label>
<textarea placeholder="Enter" className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm h-24 focus:outline-none focus:border-[#1B9869]"></textarea>
</div>
</div>
</CustomModal>
</div>
)
}
+147
View File
@@ -0,0 +1,147 @@
import React, { useCallback, useEffect, useRef, useState, useLayoutEffect } from "react";
import { createPortal } from "react-dom";
import { MoreVertical } from "lucide-react";
interface CustomActionMenuProps {
children: React.ReactNode;
}
export const CustomActionMenu: React.FC<CustomActionMenuProps> = ({
children,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState({ top: 0, left: 0 });
const buttonRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const updatePosition = useCallback(() => {
if (buttonRef.current && isOpen) {
const buttonRect = buttonRef.current.getBoundingClientRect();
const menuRect = menuRef.current?.getBoundingClientRect();
let top = buttonRect.bottom + 4;
let left = buttonRect.right - (menuRect?.width || 160); // Default items width
// Check if it fits vertically
if (menuRect && top + menuRect.height > window.innerHeight) {
top = buttonRect.top - menuRect.height - 4; // Flip up
}
// Check if it fits horizontally
if (left < 0) {
left = buttonRect.left; // Align left if no space on right
}
setPosition({ top, left });
}
}, [isOpen]);
useLayoutEffect(() => {
updatePosition();
}, [updatePosition]);
useEffect(() => {
const handleScrollOrResize = () => {
if (isOpen) updatePosition();
};
window.addEventListener("scroll", handleScrollOrResize, true);
window.addEventListener("resize", handleScrollOrResize);
return () => {
window.removeEventListener("scroll", handleScrollOrResize, true);
window.removeEventListener("resize", handleScrollOrResize);
};
}, [isOpen, updatePosition]);
// Handle click outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement;
if (
isOpen &&
buttonRef.current &&
!buttonRef.current.contains(target) &&
!target.closest(".custom-action-menu-dropdown")
) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isOpen]);
return (
<>
<button
ref={buttonRef}
onClick={(e) => {
e.stopPropagation();
setIsOpen(!isOpen);
}}
className={`flex h-8 w-8 items-center justify-center rounded-full transition-colors focus:outline-none ${isOpen ? 'bg-gray-100 text-gray-900' : 'text-gray-400 hover:bg-gray-100 hover:text-gray-900'}`}
aria-label="Actions"
>
<MoreVertical size={18} />
</button>
{isOpen &&
createPortal(
<div
ref={menuRef}
className="custom-action-menu-dropdown fixed z-[9999] min-w-[160px] rounded-xl bg-white shadow-[0_4px_20px_-4px_rgba(0,0,0,0.1)] border border-gray-100 focus:outline-none p-1.5"
style={{
top: position.top,
left: position.left,
}}
onClick={() => setIsOpen(false)}
>
<div className="flex flex-col gap-0.5">
{children}
</div>
</div>,
document.body
)}
</>
);
};
export interface CustomActionItemProps {
children: React.ReactNode;
onClick?: () => void;
variant?: "default" | "success" | "danger";
icon?: React.ReactNode;
}
export const CustomActionItem: React.FC<CustomActionItemProps> = ({
children,
onClick,
variant = "default",
icon
}) => {
const variantClasses = {
default: "text-gray-700 hover:bg-gray-50 hover:text-gray-900",
success: "text-[#1B9869] bg-[#EBF7F2] hover:brightness-95",
danger: "text-red-600 hover:bg-red-50 hover:text-red-700",
};
return (
<div
className={`group flex items-center gap-2.5 w-full cursor-pointer px-3 py-2 text-[13px] font-medium transition-all rounded-md ${variantClasses[variant]}`}
onClick={(e) => {
e.stopPropagation();
onClick?.();
}}
>
{icon && <span className="flex items-center justify-center w-4 h-4">{icon}</span>}
{children}
</div>
);
};
export default CustomActionMenu;
@@ -0,0 +1,85 @@
import { type FC, useEffect } from "react";
import { AlertTriangle, CircleCheck, Info, XCircle, X } from 'lucide-react';
type AlertType = 'error' | 'success' | 'warning' | 'info';
interface AlertBannerProps {
message: string;
type?: AlertType;
onClose?: () => void;
autoClose?: boolean;
duration?: number;
}
const alertConfig = {
error: {
icon: <XCircle className="h-5 w-5" />,
containerClasses: 'bg-red-50 border-red-500 text-red-800',
},
success: {
icon: <CircleCheck className="h-5 w-5" />,
containerClasses: 'bg-green-50 border-green-500 text-green-800',
},
warning: {
icon: <AlertTriangle className="h-5 w-5" />,
containerClasses: 'bg-yellow-50 border-yellow-500 text-yellow-800',
},
info: {
icon: <Info className="h-5 w-5" />,
containerClasses: 'bg-blue-50 border-blue-500 text-blue-800',
},
};
const AlertBanner: FC<AlertBannerProps> = ({
message,
type = 'error',
onClose,
autoClose = true,
duration = 5000
}) => {
useEffect(() => {
if (autoClose && message && onClose) {
const timer = setTimeout(() => {
onClose();
}, duration);
return () => clearTimeout(timer);
}
}, [message, autoClose, duration, onClose]);
// If no message, don't render anything
if (!message) {
return null;
}
const { icon, containerClasses } = alertConfig[type as AlertType] || alertConfig.error;
return (
<div
className={`flex items-center justify-between p-4 mb-4 text-sm rounded-md border-l-4 ${containerClasses} shadow-sm border border-gray-100 transition-all duration-500 animate-in fade-in slide-in-from-top-2`}
role="alert"
>
<div className="flex items-center flex-1">
<div className="mr-3 flex-shrink-0">{icon}</div>
<div className="flex-1 min-w-0">
<span className="font-medium">{message}</span>
</div>
</div>
{onClose && (
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onClose();
}}
type="button"
className="ml-4 p-1 rounded-full hover:bg-black/5 transition-colors focus:outline-none"
aria-label="Close alert"
>
<X className="h-4 w-4" />
</button>
)}
</div>
);
};
export default AlertBanner;
+53
View File
@@ -0,0 +1,53 @@
import { useState, useEffect } from "react";
import { Users, Briefcase, Building2 } from "lucide-react";
const CustomAppLoader = () => {
const [currentIndex, setCurrentIndex] = useState(0);
// HRM related icons
const icons = [
{ component: Users, key: "users" },
{ component: Briefcase, key: "briefcase" },
{ component: Building2, key: "building" },
];
useEffect(() => {
const interval = setInterval(() => {
setCurrentIndex((prev) => (prev + 1) % icons.length);
}, 800); // Slower, smoother transition
return () => clearInterval(interval);
}, [icons.length]);
const ActiveIcon = icons[currentIndex].component;
return (
<div className="flex flex-col items-center justify-center space-y-6 h-screen w-full ">
<div className="relative flex items-center justify-center">
{/* Outer Spinning Ring */}
<div className="w-20 h-20 border-4 border-gray-200 border-t-[#0B3B6A] rounded-full animate-spin"></div>
{/* Icon Container */}
<div className="absolute flex items-center justify-center">
<ActiveIcon
key={icons[currentIndex].key}
size={32}
className="text-[#0B3B6A] transition-all duration-500 opacity-100"
strokeWidth={1.5}
/>
</div>
</div>
<div className="flex flex-col items-center gap-1">
<h3 className="text-[#0B3B6A] font-semibold text-lg tracking-wide">
HRM System
</h3>
<span className="text-gray-500 text-sm animate-pulse">
Loading resources...
</span>
</div>
</div>
);
};
export default CustomAppLoader;
@@ -0,0 +1,51 @@
import React from "react";
import { Link, useNavigate } from "react-router-dom";
import { MoveLeft } from "lucide-react";
interface CustomBackButtonProps {
to?: string;
label?: string;
tooltip?: string;
className?: string;
}
const CustomBackButton: React.FC<CustomBackButtonProps> = ({
to,
label = "",
tooltip,
className = "",
}) => {
const navigate = useNavigate();
const handleBack = (e: React.MouseEvent) => {
if (!to) {
e.preventDefault();
navigate(-1);
}
};
const content = (
<div
className={`group relative inline-flex items-center gap-2 text-gray-500 hover:text-gray-900 transition-colors cursor-pointer ${className}`}
>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-100 hover:bg-gray-200 transition-colors">
<MoveLeft className="h-4 w-4" />
</div>
{label && <span className="text-sm font-medium">{label}</span>}
{tooltip && !label && (
<div className="absolute top-full mt-2 left-0 hidden whitespace-nowrap rounded bg-gray-800 px-2 py-1 text-xs text-white shadow-md group-hover:block z-50">
{tooltip}
</div>
)}
</div>
);
if (to) {
return <Link to={to}>{content}</Link>;
}
return <div onClick={handleBack}>{content}</div>;
};
export default CustomBackButton;
+69
View File
@@ -0,0 +1,69 @@
import CustomLoader from "./CustomLoader";
type ButtonVariant = "primary" | "secondary" | "outlined" | "text" | "link";
type ButtonSize = "sm" | "md" | "lg";
interface CustomButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
loading?: boolean;
children: React.ReactNode;
}
const CustomButton: React.FC<CustomButtonProps> = ({
variant = "primary",
size = "md",
leftIcon,
rightIcon,
loading = false,
disabled,
className = "",
children,
...props
}) => {
const baseClasses =
"inline-flex items-center justify-center font-medium rounded-lg transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2";
const variantClasses = {
primary:
"bg-gradient-to-b from-[#1B9869] to-[#14704E] text-white shadow-sm hover:shadow-md hover:from-[#188A5F] hover:to-[#126446] focus:ring-[#1B9869] disabled:from-gray-300 disabled:to-gray-300 disabled:text-gray-500 disabled:shadow-none",
secondary:
"bg-gray-600 text-white hover:bg-gray-700 focus:ring-gray-500 disabled:bg-gray-300 disabled:text-gray-500",
outlined:
"border-2 border-[#1B9869] text-[#1B9869] bg-transparent hover:bg-gray-50 focus:ring-[#1B9869] disabled:border-gray-300 disabled:text-gray-400 disabled:hover:bg-transparent",
text: "text-[#1B9869] bg-transparent hover:bg-gray-50 focus:ring-[#1B9869] disabled:text-gray-400 disabled:hover:bg-transparent",
link: "text-[#1B9869] bg-transparent hover:underline p-0 h-auto focus:ring-transparent disabled:text-gray-400",
};
const sizeClasses = {
sm: "px-3 h-[36px] text-sm gap-1.5",
md: "px-4 h-[42px] text-sm gap-2",
lg: "px-6 h-[48px] text-base gap-2",
};
const isDisabled = disabled || loading;
const loaderVariant = variant === "outlined" || variant === "text" ? "primary" : "white";
return (
<button
className={`${baseClasses} ${variantClasses[variant]} ${variant === "link" ? "" : sizeClasses[size]
} ${isDisabled ? "cursor-not-allowed" : "cursor-pointer"} ${className}`}
disabled={isDisabled}
{...props}
>
{loading ? (
<CustomLoader size="sm" variant={loaderVariant} />
) : leftIcon ? (
<span className="flex items-center justify-center">{leftIcon}</span>
) : null}
{children}
{rightIcon ? <span className="flex items-center justify-center">{rightIcon}</span> : null}
</button>
);
};
export default CustomButton;
+57
View File
@@ -0,0 +1,57 @@
import React, { forwardRef } from "react";
import { Check } from "lucide-react";
interface CustomCheckBoxProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type"> {
label?: string;
}
const CustomCheckBox = forwardRef<HTMLInputElement, CustomCheckBoxProps>(
({ label, className = "", disabled, ...props }, ref) => {
return (
<label
className={`
inline-flex items-center gap-2.5 cursor-pointer
${disabled ? "cursor-not-allowed opacity-60" : ""}
${className}
`}
>
<div className="relative flex items-center">
<input
ref={ref}
type="checkbox"
className="peer sr-only"
disabled={disabled}
{...props}
/>
<div
className={`
w-5 h-5 border rounded
transition-all duration-200
flex items-center justify-center
border-gray-300 bg-white
peer-checked:bg-[#1B9869] peer-checked:border-[#1B9869]
peer-checked:[&_svg]:opacity-100
peer-hover:border-[#1B9869]
peer-focus:ring-2 peer-focus:ring-[#1B9869]/20
peer-disabled:bg-gray-100 peer-disabled:border-gray-200
`}
>
<Check
size={14}
className="text-white opacity-0 transition-opacity duration-200"
strokeWidth={3}
/>
</div>
</div>
{label && (
<span className="text-sm font-medium text-gray-700 select-none">
{label}
</span>
)}
</label>
);
}
);
export default CustomCheckBox;
@@ -0,0 +1,74 @@
import React from "react";
import CustomModal from "./CustomModal";
import CustomButton from "./CustomButton";
import { AlertTriangle, CheckCircle } from "lucide-react";
interface ConfirmationModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
description: string;
confirmText?: string;
cancelText?: string;
variant?: "danger" | "primary" | "warning" | "success"; // To style the confirm button
isLoading?: boolean;
}
const ConfirmationModal: React.FC<ConfirmationModalProps> = ({
isOpen,
onClose,
onConfirm,
title,
description,
confirmText = "Confirm",
cancelText = "Cancel",
variant = "danger",
isLoading = false,
}) => {
return (
<CustomModal
isOpen={isOpen}
onClose={onClose}
title={title}
size="sm"
showCloseButton={false}
>
<div className="flex flex-col items-center text-center">
<div className={`p-3 rounded-full mb-4 ${variant === 'danger' ? 'bg-[#0B3B6A]/10 text-[#0B3B6A]' :
variant === 'warning' ? 'bg-amber-50 text-amber-600' :
variant === 'success' ? 'bg-emerald-50 text-emerald-600' :
'bg-blue-50 text-blue-600'
}`}>
{variant === 'success' ? <CheckCircle size={32} /> : <AlertTriangle size={32} />}
</div>
<p className="text-gray-500 mb-6 font-medium">
{description}
</p>
<div className="flex gap-3 w-full">
<CustomButton
variant="outlined"
onClick={onClose}
disabled={isLoading}
className="flex-1"
>
{cancelText}
</CustomButton>
<CustomButton
variant={variant === 'danger' ? 'primary' : 'primary'} // CustomButton usually has 'primary' or 'outlined'. We might need to handle color via other props if needed, but 'primary' (red in this app) is usually danger.
onClick={onConfirm}
loading={isLoading}
disabled={isLoading}
className="flex-1"
>
{confirmText}
</CustomButton>
</div>
</div>
</CustomModal>
);
};
export default ConfirmationModal;
@@ -0,0 +1,54 @@
import React, { forwardRef } from "react";
import { Calendar } from "lucide-react";
interface CustomDatePickerProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type"> {
label?: string;
error?: string;
}
const CustomDatePicker = forwardRef<HTMLInputElement, CustomDatePickerProps>(
({ label, error, className = "", disabled, required, ...props }, ref) => {
return (
<div className="w-full flex flex-col gap-1.5">
{label && (
<label className="text-sm font-semibold text-gray-700">
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</label>
)}
<div className="relative w-full">
<input
ref={ref}
type="date"
disabled={disabled}
className={`
w-full rounded-lg
bg-white text-black text-sm
border border-gray-300
pl-10 pr-3 py-3
outline-none
transition-all duration-200
hover:border-[#1B9869]
focus:border-[#1B9869] focus:ring-2 focus:ring-[#1B9869]/20
disabled:bg-gray-100 disabled:text-gray-500 disabled:cursor-not-allowed disabled:border-gray-200
placeholder:text-gray-300
${error
? "border-red-500 focus:border-red-500 focus:ring-red-500/20"
: ""
}
${className}
`}
{...props}
/>
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none">
<Calendar size={18} />
</span>
</div>
{error && <p className="text-xs text-red-500 mt-0.5">{error}</p>}
</div>
);
}
);
export default CustomDatePicker;
@@ -0,0 +1,55 @@
import React, { forwardRef } from "react";
import { CalendarClock } from "lucide-react";
interface CustomDateTimePickerProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type"> {
label?: string;
error?: string;
}
const CustomDateTimePicker = forwardRef<
HTMLInputElement,
CustomDateTimePickerProps
>(({ label, error, className = "", disabled, required, ...props }, ref) => {
return (
<div className="w-full flex flex-col gap-1.5">
{label && (
<label className="text-sm font-semibold text-gray-700">
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</label>
)}
<div className="relative w-full">
<input
ref={ref}
type="datetime-local"
disabled={disabled}
className={`
w-full rounded-lg
bg-white text-black text-sm
border border-gray-300
pl-10 pr-3 py-3
outline-none
transition-all duration-200
hover:border-[#1B9869]
focus:border-[#1B9869] focus:ring-2 focus:ring-[#1B9869]/20
disabled:bg-gray-100 disabled:text-gray-500 disabled:cursor-not-allowed disabled:border-gray-200
placeholder:text-gray-300
${error
? "border-red-500 focus:border-red-500 focus:ring-red-500/20"
: ""
}
${className}
`}
{...props}
/>
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none">
<CalendarClock size={18} />
</span>
</div>
{error && <p className="text-xs text-red-500 mt-0.5">{error}</p>}
</div>
);
});
export default CustomDateTimePicker;
+167
View File
@@ -0,0 +1,167 @@
import React, { useState, useRef, useEffect } from "react";
import { ChevronDown, Check } from "lucide-react";
interface Option {
label: string;
value: string | number;
disabled?: boolean;
}
interface CustomDropdownProps {
label?: string;
options: Option[];
value?: string | number;
onChange?: (value: string) => void;
placeholder?: string;
leftIcon?: React.ReactNode;
size?: "sm" | "md" | "lg";
disabled?: boolean;
required?: boolean;
className?: string;
error?: string;
}
const CustomDropdown = React.forwardRef<HTMLDivElement, CustomDropdownProps>(
(
{
label,
options,
value,
onChange,
placeholder = "Select...",
disabled,
className = "",
required,
leftIcon,
error,
size = "md",
},
ref
) => {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const sizeClasses = {
sm: "py-1.5 text-sm h-[36px]",
md: "py-2.5 text-sm h-[42px]",
lg: "py-3 text-base h-[48px]",
};
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const handleSelect = (option: Option) => {
if (option.disabled) return;
onChange?.(String(option.value));
setIsOpen(false);
};
const selectedOption = options.find((opt) => String(opt.value) === String(value));
return (
<div className="w-full flex flex-col gap-1.5" ref={ref}>
{label && (
<label className="text-sm font-medium text-gray-900">
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</label>
)}
<div className="relative w-full" ref={dropdownRef}>
<div
onClick={() => !disabled && setIsOpen(!isOpen)}
className={`
w-full rounded-lg
bg-white text-gray-900
border ${error ? 'border-red-500' : 'border-gray-300'}
${sizeClasses[size]}
px-3
outline-none
transition-all duration-200
flex items-center
${!disabled ? 'cursor-pointer hover:border-[#1B9869]' : 'cursor-not-allowed bg-gray-50 text-gray-500'}
${isOpen ? 'border-[#1B9869] ring-2 ring-[#1B9869]/20' : ''}
${leftIcon ? "pl-10" : ""}
pr-10
${className}
`}
>
{leftIcon && (
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 pointer-events-none">
{leftIcon}
</span>
)}
<div className="flex-1 truncate text-left">
{selectedOption ? (
<span>{selectedOption.label}</span>
) : (
<span className="text-gray-500">{placeholder}</span>
)}
</div>
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 pointer-events-none flex items-center">
<ChevronDown
size={16}
className={`transition-transform duration-200 ${isOpen ? "rotate-180" : ""}`}
/>
</span>
</div>
{/* Dropdown Menu */}
{isOpen && !disabled && (
<div className="absolute z-[9999] w-full mt-2 bg-white border border-gray-200 rounded-lg shadow-lg max-h-60 overflow-y-auto flex flex-col">
{options.length === 0 ? (
<div className="px-4 py-3 text-sm text-gray-500 text-center">No options available</div>
) : (
options.map((option) => {
const isSelected = String(option.value) === String(value);
return (
<button
key={option.value}
type="button"
disabled={option.disabled}
onClick={(e) => {
e.stopPropagation();
handleSelect(option);
}}
className={`
w-full text-left px-4 py-2.5 text-[15px]
transition-colors duration-150 flex items-center gap-2
${isSelected
? "bg-[#F0FDF4] text-[#14704E] font-medium"
: option.disabled
? "text-gray-400 cursor-not-allowed"
: "text-gray-700 hover:bg-gray-50"
}
`}
>
{isSelected && <Check size={16} className="text-[#1B9869] shrink-0" strokeWidth={2.5} />}
<span className={isSelected ? "ml-1" : "ml-6"}>{option.label}</span>
</button>
);
})
)}
</div>
)}
</div>
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
</div>
);
}
);
CustomDropdown.displayName = "CustomDropdown";
export default CustomDropdown;
@@ -0,0 +1,238 @@
import React, { useRef, useState, useEffect } from "react";
import { Upload, X, File } from "lucide-react";
interface CustomFileUploaderProps {
label?: string;
required?: boolean;
multiple?: boolean;
maxFiles?: number;
accept?: string;
value?: File[];
onChange?: (files: File[]) => void;
initialUrl?: string | null;
onRemoveInitial?: () => void;
disabled?: boolean;
className?: string;
}
const CustomFileUploader: React.FC<CustomFileUploaderProps> = ({
label,
required,
multiple = false,
maxFiles,
accept,
value = [],
onChange,
initialUrl,
onRemoveInitial,
disabled,
className = "",
}) => {
const inputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [previews, setPreviews] = useState<{ [key: string]: string }>({});
useEffect(() => {
// Cleanup object URLs to avoid memory leaks
return () => {
Object.values(previews).forEach((url) => URL.revokeObjectURL(url));
};
}, [previews]);
useEffect(() => {
// Generate previews for new files
const newPreviews: { [key: string]: string } = {};
value.forEach((file) => {
if (file.type.startsWith("image/") && !previews[file.name]) {
newPreviews[file.name] = URL.createObjectURL(file);
}
});
if (Object.keys(newPreviews).length > 0) {
setPreviews((prev) => ({ ...prev, ...newPreviews }));
}
}, [value, previews]);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (disabled) return;
const files = Array.from(e.target.files || []);
handleFiles(files);
};
const handleFiles = (newFiles: File[]) => {
let updatedFiles = multiple ? [...value, ...newFiles] : newFiles;
if (maxFiles && updatedFiles.length > maxFiles) {
updatedFiles = updatedFiles.slice(0, maxFiles);
}
onChange?.(updatedFiles);
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
if (!disabled) setIsDragging(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
if (disabled) return;
const files = Array.from(e.dataTransfer.files);
handleFiles(files);
};
const removeFile = (e: React.MouseEvent, index: number) => {
e.stopPropagation();
if (disabled) return;
const fileToRemove = value[index];
const newFiles = value.filter((_, i) => i !== index);
onChange?.(newFiles);
// Cleanup preview if it exists
if (previews[fileToRemove.name]) {
URL.revokeObjectURL(previews[fileToRemove.name]);
setPreviews((prev) => {
const newPrev = { ...prev };
delete newPrev[fileToRemove.name];
return newPrev;
});
}
};
return (
<div className={`w-full flex flex-col gap-1.5 ${className}`}>
{label && (
<label className="text-sm font-semibold text-gray-700">
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</label>
)}
<div
className={`
relative w-full rounded-lg border-2 border-dashed
flex flex-col items-center justify-center p-6
transition-all duration-200
${isDragging
? "border-[#0B3B6A] bg-[#0B3B6A]/5"
: "border-gray-300 bg-gray-50 hover:bg-[#0B3B6A]/5 hover:border-[#0B3B6A]/50"
}
${disabled ? "opacity-60 cursor-not-allowed" : "cursor-pointer"}
`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => !disabled && inputRef.current?.click()}
>
<input
ref={inputRef}
type="file"
multiple={multiple}
accept={accept}
className="hidden"
onChange={handleFileChange}
disabled={disabled}
/>
<div className="flex flex-col items-center gap-2 text-gray-500">
<Upload size={24} />
<span className="text-sm font-medium">
{isDragging ? "Drop files here" : "Click or drag to upload"}
</span>
{maxFiles && (
<span className="text-xs text-gray-400">
Max {maxFiles} file{maxFiles > 1 ? "s" : ""}
</span>
)}
</div>
</div>
{
(value.length > 0 || initialUrl) && (
<div className="flex flex-col gap-2 mt-2">
{/* Initial Image Preview */}
{initialUrl && value.length === 0 && (
<div className="flex items-center justify-between p-3 bg-white border border-gray-200 rounded-lg">
<div className="flex items-center gap-3 overflow-hidden">
<div className="w-10 h-10 shrink-0 rounded-lg bg-gray-100 flex items-center justify-center overflow-hidden border border-gray-200">
<img
src={initialUrl}
alt="Existing Logo"
className="w-full h-full object-cover"
/>
</div>
<div className="flex flex-col overflow-hidden">
<span className="text-sm font-medium text-gray-700 truncate">
Current Logo
</span>
<span className="text-xs text-gray-500">
Already uploaded
</span>
</div>
</div>
{!disabled && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRemoveInitial?.();
}}
className="p-1 text-gray-400 hover:text-red-500 transition-colors rounded-full hover:bg-gray-100"
>
<X size={18} />
</button>
)}
</div>
)}
{/* New Files Previews */}
{value.map((file, index) => (
<div
key={`${file.name}-${index}`}
className="flex items-center justify-between p-3 bg-white border border-gray-200 rounded-lg"
>
<div className="flex items-center gap-3 overflow-hidden">
<div className="w-10 h-10 shrink-0 rounded-lg bg-gray-100 flex items-center justify-center overflow-hidden border border-gray-200">
{file.type.startsWith("image/") && previews[file.name] ? (
<img
src={previews[file.name]}
alt={file.name}
className="w-full h-full object-cover"
/>
) : (
<File size={20} className="text-gray-500" />
)}
</div>
<div className="flex flex-col overflow-hidden">
<span className="text-sm font-medium text-gray-700 truncate">
{file.name}
</span>
<span className="text-xs text-gray-500">
{(file.size / 1024).toFixed(1)} KB
</span>
</div>
</div>
{!disabled && (
<button
onClick={(e) => removeFile(e, index)}
className="p-1 text-gray-400 hover:text-[#0B3B6A] transition-colors rounded-full hover:bg-gray-100"
>
<X size={18} />
</button>
)}
</div>
))}
</div>
)
}
</div >
);
};
export default CustomFileUploader;
+77
View File
@@ -0,0 +1,77 @@
import React, { useEffect } from "react";
import { X } from "lucide-react";
interface FullModalProps {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
showCloseButton?: boolean;
className?: string; // For additional overrides if absolutely needed
contentClassName?: string;
}
const FullModal: React.FC<FullModalProps> = ({
isOpen,
onClose,
children,
showCloseButton = false, // Default false as callers might want custom close buttons
className = "",
contentClassName = "",
}) => {
useEffect(() => {
const handleEsc = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
}
};
if (isOpen) {
document.addEventListener("keydown", handleEsc);
document.body.style.overflow = "hidden";
}
return () => {
document.removeEventListener("keydown", handleEsc);
document.body.style.overflow = "";
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/60 backdrop-blur-sm">
{/*
Modal Container:
- Mobile: w-screen h-screen (Full Screen), rounded-none
- Desktop (md): max-w-7xl h-[90vh] rounded-2xl
*/}
<div
role="dialog"
aria-modal="true"
className={`
relative flex flex-col overflow-hidden bg-white shadow-2xl
w-screen h-screen rounded-none
md:w-full md:max-w-7xl md:h-[90vh] md:rounded-2xl md:mx-4
${className}
`}
>
{/* Close Button (Optional default) */}
{showCloseButton && (
<button
onClick={onClose}
className="absolute top-4 right-4 z-50 p-2 bg-black/10 hover:bg-black/20 rounded-full transition-colors md:top-6 md:right-6"
>
<X size={24} className="text-gray-700" />
</button>
)}
{/* Content Area */}
<div className={`flex-1 w-full h-full overflow-hidden ${contentClassName}`}>
{children}
</div>
</div>
</div>
);
};
export default FullModal;
+140
View File
@@ -0,0 +1,140 @@
import React, { useState, forwardRef } from "react";
import { Phone, Eye, EyeOff } from "lucide-react";
type InputType = "text" | "password" | "number" | "email" | "tel" | "date" | "month" | "datetime-local";
interface CustomInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string;
type?: InputType;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
maxLength?: number;
phonePrefix?: string;
error?: string;
containerClassName?: string;
size?: "sm" | "md" | "lg";
}
const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>(
(
{
label,
type = "text",
leftIcon,
rightIcon,
maxLength,
phonePrefix,
disabled,
className = "",
containerClassName = "",
error,
onInput,
size = "md",
...props
},
ref
) => {
const isPassword = type === "password";
const isPhone = phonePrefix !== undefined;
const [showPassword, setShowPassword] = useState(false);
const inputType = isPassword && showPassword ? "text" : type;
const sizeClasses = {
sm: "py-1.5 text-sm h-[36px]",
md: "py-2.5 text-sm h-[42px]",
lg: "py-3 text-base h-[48px]",
};
const handleInput = (e: React.FormEvent<HTMLInputElement>) => {
if (maxLength && (type === "number" || type === "tel")) {
const target = e.target as HTMLInputElement;
if (target.value.length > maxLength) {
target.value = target.value.slice(0, maxLength);
}
}
onInput?.(e);
};
return (
<div className={`w-full flex flex-col gap-1.5 ${containerClassName}`}>
{label && (
<label htmlFor={props.id} className="block text-sm font-medium text-gray-900">
{label}
{props.required && <span className="ml-1 text-red-500">*</span>}
</label>
)}
<div
className={`relative w-full ${isPhone
? "flex overflow-hidden rounded-lg border border-gray-300 bg-white focus-within:border-[#1B9869] focus-within:ring-2 focus-within:ring-[#1B9869]/20 transition-all duration-200"
: ""
}`}
>
{isPhone && (
<div className="flex items-center gap-2 border-r border-gray-300 bg-gray-50 px-3 text-sm font-semibold text-gray-900">
<Phone size={16} className="text-gray-500" />
{phonePrefix}
</div>
)}
{!isPhone && leftIcon && (
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">
{leftIcon}
</span>
)}
<input
ref={ref}
type={inputType}
maxLength={
type === "number" || type === "tel" ? undefined : maxLength
}
onInput={handleInput}
disabled={disabled}
className={`
${isPhone
? `w-full px-3 ${sizeClasses[size]} bg-white outline-none placeholder:text-gray-500`
: `
w-full rounded-lg
bg-white text-gray-900
border ${error ? "border-red-500" : "border-gray-300"}
px-3 ${sizeClasses[size]}
outline-none
transition-all duration-200
hover:border-[#1B9869]
focus:border-[#1B9869] focus:ring-2 focus:ring-[#1B9869]/20
disabled:bg-gray-50 disabled:text-gray-500 disabled:cursor-not-allowed disabled:border-gray-300
placeholder:text-gray-500
${leftIcon ? "pl-10" : ""}
${rightIcon || isPassword ? "pr-10" : ""}
`
}
${className}
`}
{...props}
/>
{!isPhone && isPassword ? (
<span
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 cursor-pointer text-slate-400 select-none hover:text-[#1B9869]"
>
{showPassword ? <Eye size={20} /> : <EyeOff size={20} />}
</span>
) : (
!isPhone &&
rightIcon && (
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400">
{rightIcon}
</span>
)
)}
</div>
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
</div>
);
}
);
export default CustomInput;
+85
View File
@@ -0,0 +1,85 @@
import type { ComponentProps } from "react";
type LoaderProps = {
label?: string;
size?: "sm" | "md" | "lg" | "xl";
variant?: "primary" | "white" | "gray" | "red";
inline?: boolean;
className?: string;
} & ComponentProps<"div">;
const variantColorMap = {
primary: "text-[#0B3B6A]", // Brand Dark Blue
white: "text-white",
gray: "text-gray-500",
red: "text-[#0B3B6A]",
};
export function CustomLoader({
label,
size = "md",
variant = "primary",
inline = false,
className,
...rest
}: LoaderProps) {
const containerClass = [
"flex flex-col items-center justify-center gap-3",
inline ? "flex-row" : "",
className,
]
.filter(Boolean)
.join(" ");
const colorClass = variantColorMap[variant];
// Using a modern SVG spinner with a track
// We map sizes to pixel values for the SVG
const pixelSize = {
sm: 20,
md: 32,
lg: 48,
xl: 64,
}[size];
return (
<div
className={containerClass}
aria-live="polite"
aria-busy="true"
{...rest}
>
<div className={`relative ${colorClass}`} style={{ width: pixelSize, height: pixelSize }}>
{/* Track - subtle opacity */}
<svg
className="animate-spin absolute inset-0"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<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-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</div>
{label && (
<span className={`text-sm font-bold tracking-wide ${variant === 'white' ? 'text-white' : 'text-[#0B3B6A]'}`}>
{label}
</span>
)}
</div>
);
}
export default CustomLoader;
+190
View File
@@ -0,0 +1,190 @@
import React, { useEffect } from "react";
import { createPortal } from "react-dom";
import { XCircle } from "lucide-react";
import CustomButton from "./CustomButton";
type ModalSize = "sm" | "md" | "lg" | "xl" | "full";
interface CustomModalProps {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
title?: React.ReactNode;
description?: React.ReactNode;
icon?: React.ReactNode;
footer?: React.ReactNode;
primaryAction?: {
label: string;
onClick: () => void;
loading?: boolean;
disabled?: boolean;
icon?: React.ReactNode;
};
secondaryAction?: {
label: string;
onClick: () => void;
disabled?: boolean;
};
size?: ModalSize;
showCloseButton?: boolean;
allowBackdropClose?: boolean;
className?: string;
contentClassName?: string;
overlayClassName?: string;
}
const SIZE_CLASS_MAP: Record<ModalSize, string> = {
sm: "max-w-md",
md: "max-w-2xl",
lg: "max-w-4xl",
xl: "max-w-6xl",
full: "max-w-none",
};
const CustomModal: React.FC<CustomModalProps> = ({
isOpen,
onClose,
children,
title,
description,
icon,
footer,
primaryAction,
secondaryAction,
size = "lg",
showCloseButton = true,
allowBackdropClose = false,
className = "",
contentClassName = "",
overlayClassName = "",
}) => {
useEffect(() => {
const handleEsc = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
if (isOpen) {
document.addEventListener("keydown", handleEsc);
document.body.style.overflow = "hidden";
}
return () => {
document.removeEventListener("keydown", handleEsc);
document.body.style.overflow = "";
};
}, [isOpen, onClose]);
if (!isOpen) return null;
const widthClasses =
size === "full"
? "w-screen h-screen"
: `w-full ${SIZE_CLASS_MAP[size]} mx-4`;
const roundingClass = size === "full" ? "rounded-none" : "rounded-[20px]";
const heightClass = size === "full" ? "h-full" : "max-h-[85vh]";
return createPortal(
<div className="fixed inset-0 z-[10000] flex items-center justify-center">
{/* Overlay */}
<div
className={`absolute inset-0 bg-black/40 backdrop-blur-sm transition-opacity ${overlayClassName}`}
onClick={() => allowBackdropClose && onClose()}
/>
{/* Modal */}
<div
role="dialog"
aria-modal="true"
className={`relative z-10 flex flex-col overflow-hidden bg-white text-gray-900 shadow-2xl
${roundingClass} ${widthClasses} ${className}
${heightClass}
animate-in fade-in zoom-in-95 duration-200`}
>
{/* Header */}
{(title || icon || showCloseButton) && (
<div className="relative border-b border-gray-100 px-6 py-5 flex-shrink-0 flex items-start gap-3">
{icon && (
<div className="mt-1 flex-shrink-0 text-[#1B9869]">
{icon}
</div>
)}
<div className="flex-1 pr-8">
{title && (
<h2 className="text-[17px] font-bold text-[#111827]">
{title}
</h2>
)}
{description && (
<p className="mt-0.5 text-[13px] font-medium text-slate-500">
{description}
</p>
)}
</div>
{showCloseButton && (
<button
type="button"
onClick={onClose}
aria-label="Close modal"
className="absolute right-4 top-4 flex h-8 w-8 items-center justify-center rounded-full
text-slate-400 transition hover:text-slate-600 hover:bg-slate-100"
>
<XCircle size={20} strokeWidth={2} />
</button>
)}
</div>
)}
{/* Content */}
<div
className={`flex-1 overflow-y-auto min-h-0 px-6 py-5 no-scrollbar ${contentClassName}`}
>
{children}
</div>
{/* Footer */}
{(footer || primaryAction || secondaryAction) && (
<div className={`flex items-center ${footer ? 'justify-end' : 'justify-between'} gap-3 border-t border-gray-100 bg-white px-6 py-4 flex-shrink-0`}>
{footer}
{!footer && (
<>
<div>
{secondaryAction && (
<CustomButton
variant="outlined"
onClick={secondaryAction.onClick}
disabled={secondaryAction.disabled}
>
{secondaryAction.label}
</CustomButton>
)}
</div>
<div>
{primaryAction && (
<CustomButton
variant="primary"
onClick={primaryAction.onClick}
loading={primaryAction.loading}
disabled={primaryAction.disabled}
rightIcon={primaryAction.icon}
>
{primaryAction.label}
</CustomButton>
)}
</div>
</>
)}
</div>
)}
</div>
</div>,
document.body
);
};
export default CustomModal;
+196
View File
@@ -0,0 +1,196 @@
import React, { useState, useRef, useEffect } from "react";
import { ChevronDown, X, Check } from "lucide-react";
interface Option {
label: string;
value: string | number;
}
interface CustomMultiSelectProps {
label?: string;
options: Option[];
value?: (string | number)[];
onChange?: (value: (string | number)[]) => void;
placeholder?: string;
leftIcon?: React.ReactNode;
disabled?: boolean;
required?: boolean;
className?: string;
error?: string;
}
const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
label,
options,
value = [],
onChange,
placeholder = "Select options...",
leftIcon,
disabled,
required,
className = "",
error,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
setIsOpen(false);
setSearchTerm("");
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
const handleSelect = (optionValue: string | number) => {
if (disabled) return;
const newValue = value.includes(optionValue)
? value.filter((v) => v !== optionValue)
: [...value, optionValue];
onChange?.(newValue);
};
const removeValue = (e: React.MouseEvent, optionValue: string | number) => {
e.stopPropagation();
if (disabled) return;
onChange?.(value.filter((v) => v !== optionValue));
};
const selectedOptions = options.filter((opt) => value.includes(opt.value));
return (
<div className="w-full flex flex-col gap-1.5" ref={containerRef}>
{label && (
<label className="text-sm font-semibold text-gray-900">
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</label>
)}
<div className="relative w-full">
<div
onClick={() => !disabled && setIsOpen(!isOpen)}
className={`
w-full rounded-lg
bg-white text-gray-900 text-sm
border ${isOpen
? "border-[#1B9869] ring-2 ring-[#1B9869]/20"
: error ? "border-red-500" : "border-gray-300"
}
px-3 py-0 h-[46px]
outline-none
transition-all duration-200
hover:border-[#1B9869]
cursor-pointer
flex items-center gap-2
overflow-hidden
${disabled
? "bg-gray-50 text-gray-500 cursor-not-allowed border-gray-300"
: ""
}
${leftIcon ? "pl-10" : ""}
pr-10
${className}
`}
>
{leftIcon && (
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 pointer-events-none">
{leftIcon}
</span>
)}
<div className="flex items-center gap-2 overflow-hidden w-full h-full">
{selectedOptions.length === 0 ? (
<span className="text-gray-500/50 text-nowrap">{placeholder}</span>
) : selectedOptions.length === 1 ? (
<span
key={selectedOptions[0].value}
className="flex items-center gap-1 px-2 py-0.5 rounded bg-[#1B9869]/10 dark:bg-[#1B9869]/20 text-[#1B9869] dark:text-emerald-400 text-xs font-medium border border-[#1B9869]/20 dark:border-emerald-800 min-w-0"
>
<span className="truncate">{selectedOptions[0].label}</span>
<X
size={14}
className="cursor-pointer hover:text-[#1B9869] dark:hover:text-emerald-200 shrink-0"
onClick={(e) => removeValue(e, selectedOptions[0].value)}
/>
</span>
) : (
<span className="inline-flex items-center px-2 py-0.5 rounded bg-[#1B9869]/10 dark:bg-[#1B9869]/20 text-[#1B9869] dark:text-emerald-400 text-xs font-medium border border-[#1B9869]/20 dark:border-emerald-800">
{selectedOptions.length} Selected
</span>
)}
</div>
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 pointer-events-none">
<ChevronDown
size={16}
className={`transition-transform duration-200 ${isOpen ? "rotate-180" : ""
}`}
/>
</span>
</div>
{isOpen && !disabled && (
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg max-h-60 overflow-hidden flex flex-col py-1">
<div className="px-2 py-1 border-b border-gray-300">
<input
type="text"
placeholder="Search..."
className="w-full px-2 py-1 text-sm bg-transparent outline-none text-gray-900 placeholder:text-gray-500"
onClick={(e) => e.stopPropagation()}
onChange={(e) => setSearchTerm(e.target.value)}
value={searchTerm}
autoFocus
/>
</div>
<div className="overflow-y-auto max-h-48">
{options
.filter(opt => opt.label.toLowerCase().includes(searchTerm.toLowerCase()))
.length === 0 ? (
<div className="px-3 py-2 text-sm text-gray-500">
No options available
</div>
) : (
options
.filter(opt => opt.label.toLowerCase().includes(searchTerm.toLowerCase()))
.map((option) => {
const isSelected = value.includes(option.value);
return (
<div
key={option.value}
onClick={() => handleSelect(option.value)}
className={`
px-3 py-2 text-sm cursor-pointer flex items-center justify-between
${isSelected
? "bg-[#F0FDF4] text-[#14704E] font-medium"
: "text-gray-900 hover:bg-gray-50"
}
`}
>
{option.label}
{isSelected && <Check size={16} className="text-[#1B9869]" strokeWidth={2.5} />}
</div>
);
})
)}
</div>
</div>
)}
</div>
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
</div>
);
};
export default CustomMultiSelect;
+101
View File
@@ -0,0 +1,101 @@
import React, { useRef, useEffect } from "react";
interface CustomOTPInputProps {
length?: number;
value: string;
onChange: (value: string) => void;
label?: string;
error?: string;
}
const CustomOTPInput: React.FC<CustomOTPInputProps> = ({
length = 6,
value,
onChange,
label,
error,
}) => {
const inputs = useRef<(HTMLInputElement | null)[]>([]);
useEffect(() => {
if (inputs.current[0]) {
inputs.current[0].focus();
}
}, []);
const handleChange = (
e: React.ChangeEvent<HTMLInputElement>,
index: number
) => {
const val = e.target.value;
if (isNaN(Number(val))) return;
const newOtp = value.split("");
newOtp[index] = val.substring(val.length - 1);
const combinedOtp = newOtp.join("");
onChange(combinedOtp);
// Move to next input if value is entered
if (val && index < length - 1 && inputs.current[index + 1]) {
inputs.current[index + 1]?.focus();
}
};
const handleKeyDown = (
e: React.KeyboardEvent<HTMLInputElement>,
index: number
) => {
if (
e.key === "Backspace" &&
!value[index] &&
index > 0 &&
inputs.current[index - 1]
) {
// Move to previous input on backspace if current is empty
inputs.current[index - 1]?.focus();
}
};
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
e.preventDefault();
const pastedData = e.clipboardData.getData("text").slice(0, length);
if (/^\d+$/.test(pastedData)) {
onChange(pastedData);
// Focus the last filled input or the next empty one
const nextIndex = Math.min(pastedData.length, length - 1);
inputs.current[nextIndex]?.focus();
}
};
return (
<div className="w-full flex flex-col gap-2">
{label && (
<label className="text-sm font-semibold text-gray-900">{label}</label>
)}
<div className="flex gap-2 justify-between sm:justify-start">
{Array.from({ length }).map((_, index) => (
<input
key={index}
ref={(el) => { inputs.current[index] = el }}
type="text"
maxLength={1}
value={value[index] || ""}
onChange={(e) => handleChange(e, index)}
onKeyDown={(e) => handleKeyDown(e, index)}
onPaste={handlePaste}
className={`w-10 h-12 sm:w-12 sm:h-14 text-center text-xl font-bold rounded-lg border outline-none transition-all duration-200
bg-white text-gray-800 border-gray-300 focus:border-[#0B3B6A] focus:ring-2 focus:ring-[#0B3B6A]/20
${error
? "border-red-500 focus:border-red-500 focus:ring-red-500/20"
: ""
}
`}
/>
))}
</div>
{error && <p className="text-sm text-red-500 mt-1">{error}</p>}
</div>
);
};
export default CustomOTPInput;
+52
View File
@@ -0,0 +1,52 @@
import React, { forwardRef } from "react";
interface CustomRadioProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type"> {
label?: string;
}
const CustomRadio = forwardRef<HTMLInputElement, CustomRadioProps>(
({ label, className = "", disabled, ...props }, ref) => {
return (
<label
className={`
inline-flex items-center gap-2.5 cursor-pointer
${disabled ? "cursor-not-allowed opacity-60" : ""}
${className}
`}
>
<div className="relative flex items-center">
<input
ref={ref}
type="radio"
className="peer sr-only"
disabled={disabled}
{...props}
/>
<div
className={`
w-5 h-5 border rounded-full
transition-all duration-200
flex items-center justify-center
border-gray-300 bg-white
peer-checked:border-[#1B9869] peer-checked:bg-[#1B9869]
peer-checked:[&>div]:opacity-100
peer-hover:border-[#1B9869]
peer-focus:ring-2 peer-focus:ring-[#1B9869]/20
peer-disabled:bg-gray-100 peer-disabled:border-gray-200
`}
>
<div className="w-2 h-2 bg-white rounded-full opacity-0 transition-opacity duration-200" />
</div>
</div>
{label && (
<span className="text-sm font-medium text-gray-700 select-none">
{label}
</span>
)}
</label>
);
}
);
export default CustomRadio;
@@ -0,0 +1,261 @@
import React, { useState, useRef, useEffect } from "react";
import { ChevronDown, X, Check } from "lucide-react";
interface Option {
label: string;
value: string | number;
}
interface CustomSearchableDropdownProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'size'> {
label?: string;
options: Option[];
value?: string | number;
onChange?: (value: string) => void;
placeholder?: string;
leftIcon?: React.ReactNode;
error?: string;
size?: "sm" | "md" | "lg";
}
const CustomSearchableDropdown = React.forwardRef<
HTMLDivElement,
CustomSearchableDropdownProps
>(
(
{
label,
options,
value,
onChange,
placeholder = "Select...",
disabled,
className = "",
required,
leftIcon,
size = "md",
...props
},
ref
) => {
const [isOpen, setIsOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const [highlightedIndex, setHighlightedIndex] = useState(0);
const dropdownRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const sizeClasses = {
sm: "py-1 text-sm",
md: "py-1.5 text-sm",
lg: "py-2.5 text-base",
};
// Sync search term with selected value on mount or value update
useEffect(() => {
const selectedOption = options.find((opt) => String(opt.value) === String(value));
if (selectedOption) {
setSearchTerm(selectedOption.label);
} else {
setSearchTerm("");
}
}, [value, options]);
const filteredOptions = options.filter((option) =>
option.label.toLowerCase().includes(searchTerm.toLowerCase())
);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
// Revert to selected value label if closing without selection
const selectedOption = options.find((opt) => String(opt.value) === String(value));
if (selectedOption) {
setSearchTerm(selectedOption.label);
} else if (!value) {
setSearchTerm("");
}
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [value, options]);
useEffect(() => {
if (!isOpen) {
setHighlightedIndex(0);
}
}, [isOpen]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchTerm(e.target.value);
setIsOpen(true);
if (e.target.value === "") {
onChange?.(""); // Clear selection if input is cleared
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (!isOpen && e.key !== "Tab") {
setIsOpen(true);
}
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setHighlightedIndex((prev) =>
prev < filteredOptions.length - 1 ? prev + 1 : prev
);
break;
case "ArrowUp":
e.preventDefault();
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : prev));
break;
case "Enter":
e.preventDefault();
if (isOpen && filteredOptions[highlightedIndex]) {
handleSelect(filteredOptions[highlightedIndex]);
}
break;
case "Escape":
e.preventDefault();
setIsOpen(false);
break;
case "Tab":
setIsOpen(false);
break;
}
};
const handleSelect = (option: Option) => {
onChange?.(String(option.value));
setSearchTerm(option.label);
setIsOpen(false);
};
const handleClear = (e: React.MouseEvent) => {
e.stopPropagation();
onChange?.("");
setSearchTerm("");
inputRef.current?.focus();
};
const handleFocus = () => {
if (!disabled) setIsOpen(true);
};
return (
<div className="w-full flex flex-col gap-1.5" ref={ref}>
{label && (
<label className="text-sm font-medium text-gray-900">
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</label>
)}
<div className="relative w-full" ref={dropdownRef}>
{leftIcon && (
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 pointer-events-none z-[99999]">
{leftIcon}
</span>
)}
<input
{...props}
ref={inputRef}
type="text"
value={searchTerm}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onFocus={handleFocus}
placeholder={placeholder}
disabled={disabled}
className={`
w-full rounded-lg
bg-white text-gray-900
border ${props.error ? 'border-red-500' : 'border-gray-300'}
${sizeClasses[size]}
outline-none
transition-all duration-200
hover:border-[#1B9869]
focus:border-[#1B9869] focus:ring-2 focus:ring-[#1B9869]/20
disabled:bg-gray-50 disabled:text-gray-500 disabled:cursor-not-allowed disabled:border-gray-300
placeholder:text-gray-500
${leftIcon ? "pl-10" : "px-3"}
pr-16
${className}
`}
autoComplete="off"
/>
<div className="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-1">
{!value && (
<button
type="button"
onClick={() => !disabled && setIsOpen(!isOpen)}
className="text-gray-500 hover:text-gray-900 cursor-pointer disabled:cursor-not-allowed p-0.5"
disabled={disabled}
tabIndex={-1}
>
<ChevronDown
size={16}
className={`transition-transform duration-200 ${isOpen ? "rotate-180" : ""}`}
/>
</button>
)}
{searchTerm && !disabled && (
<button type="button" onClick={handleClear} className="text-gray-500 hover:text-gray-900 p-0.5">
<X size={16} />
</button>
)}
</div>
{/* Dropdown Menu */}
{isOpen && !disabled && filteredOptions.length > 0 && (
<div className="absolute z-[9999] w-full mt-2 bg-white border border-gray-200 rounded-lg shadow-lg max-h-60 overflow-y-auto flex flex-col">
{filteredOptions.map((option, index) => {
const isSelected = String(option.value) === String(value);
return (
<button
key={option.value}
type="button"
onClick={() => handleSelect(option)}
onMouseEnter={() => setHighlightedIndex(index)}
className={`
w-full text-left px-4 py-2.5 text-[15px]
transition-colors duration-150 flex items-center gap-2
${isSelected
? "bg-[#F0FDF4] text-[#14704E] font-medium"
: highlightedIndex === index
? "bg-gray-50 text-gray-900"
: "text-gray-700 hover:bg-gray-50"
}
`}
>
{isSelected && <Check size={16} className="text-[#1B9869] shrink-0" strokeWidth={2.5} />}
<span className={isSelected ? "ml-1" : "ml-6"}>{option.label}</span>
</button>
);
})}
</div>
)}
{isOpen && !disabled && filteredOptions.length === 0 && (
<div className="absolute z-[9999] w-full mt-2 bg-white border border-gray-200 rounded-lg shadow-lg p-4 text-center text-sm text-gray-500">
No results found
</div>
)}
</div>
{props.error && <p className="text-xs text-red-500 mt-1">{props.error}</p>}
</div>
);
}
);
CustomSearchableDropdown.displayName = "CustomSearchableDropdown";
export default CustomSearchableDropdown;
+36
View File
@@ -0,0 +1,36 @@
import React from "react";
interface SkeletonProps {
className?: string;
variant?: "text" | "circular" | "rectangular";
width?: string | number;
height?: string | number;
}
const Skeleton: React.FC<SkeletonProps> = ({
className = "",
variant = "rectangular",
width,
height,
}) => {
const baseStyles = "bg-gray-200 animate-pulse";
const variantStyles = {
text: "rounded",
circular: "rounded-full",
rectangular: "rounded-md",
};
const style = {
width,
height,
};
return (
<div
className={`${baseStyles} ${variantStyles[variant]} ${className}`}
style={style}
/>
);
};
export default Skeleton;
+70
View File
@@ -0,0 +1,70 @@
import React from "react";
export type StatusVariant =
| "success"
| "error"
| "warning"
| "info"
| "neutral"
| "brand";
interface CustomStatusProps {
status: string;
variant?: StatusVariant;
className?: string;
onClick?: () => void;
}
const getVariantFromStatus = (status: string): StatusVariant => {
const lower = status.toLowerCase();
if (["inactive", "failed", "rejected", "error", "deleted", "banned", "absent"].some(s => lower.includes(s))) return "error";
if (["active", "success", "completed", "paid", "delivered", "present"].some(s => lower.includes(s))) return "success";
if (["pending", "processing", "draft", "warning", "hold", "ready", "late", "early", "suspended"].some(s => lower.includes(s))) return "warning";
if (["info", "shipped", "in_transit", "half", "holiday", "off"].some(s => lower.includes(s))) return "info";
return "neutral";
};
const CustomStatus: React.FC<CustomStatusProps> = ({
status,
variant,
className = "",
onClick
}) => {
const finalVariant = variant || getVariantFromStatus(status);
const styles = {
success: { bg: "bg-[#EBF7F2]", text: "text-[#1B9869]", dot: "bg-[#1B9869]" },
error: { bg: "bg-rose-50", text: "text-rose-600", dot: "bg-rose-600" },
warning: { bg: "bg-amber-50", text: "text-amber-600", dot: "bg-amber-600" },
info: { bg: "bg-sky-50", text: "text-sky-600", dot: "bg-sky-600" },
neutral: { bg: "bg-slate-100", text: "text-slate-600", dot: "bg-slate-600" },
brand: { bg: "bg-[#EBF7F2]", text: "text-[#1B9869]", dot: "bg-[#1B9869]" },
};
const currentStyle = styles[finalVariant];
const isClickable = !!onClick;
// Capitalize first letter
const displayStatus = status ? status.charAt(0).toUpperCase() + status.slice(1).toLowerCase() : "";
return (
<button
type="button"
onClick={isClickable ? onClick : undefined}
aria-disabled={!isClickable}
className={`
inline-flex items-center justify-center gap-2 px-3 py-1.5 rounded-full
text-[13px] font-semibold antialiased transition-all duration-300
${currentStyle.bg} ${currentStyle.text}
${isClickable ? "hover:brightness-95 active:scale-95 cursor-pointer" : "cursor-default pointer-events-none"}
${className}
`}
>
<span className={`w-2 h-2 rounded-full ${currentStyle.dot}`}></span>
{displayStatus}
</button>
);
};
export default CustomStatus;
@@ -0,0 +1,90 @@
import React, { useEffect } from "react";
interface CustomSuccessModalProps {
isOpen: boolean;
onClose: () => void;
title?: string;
description?: string;
buttonText?: string;
}
const CustomSuccessModal: React.FC<CustomSuccessModalProps> = ({
isOpen,
onClose,
title = "Success!",
description = "Your action has been completed successfully.",
buttonText = "Continue",
}) => {
useEffect(() => {
const handleEsc = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
if (isOpen) {
document.addEventListener("keydown", handleEsc);
document.body.style.overflow = "hidden";
}
return () => {
document.removeEventListener("keydown", handleEsc);
document.body.style.overflow = "";
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[10000] flex items-center justify-center">
{/* Overlay */}
<div
className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm transition-opacity"
onClick={onClose}
/>
{/* Modal Content */}
<div
className="relative z-10 flex w-full max-w-sm flex-col items-center justify-center overflow-hidden rounded-3xl bg-white p-8 text-center shadow-2xl animate-in zoom-in-95 fade-in duration-300 sm:max-w-md"
role="dialog"
aria-modal="true"
>
{/* Animated Icon Background */}
<div className="mb-6 flex h-24 w-24 items-center justify-center rounded-full bg-[#1B9869]/10">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-[#1B9869]/20 animate-pulse">
<svg
className="h-8 w-8 text-[#1B9869]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
</div>
{/* Content */}
<h3 className="mb-2 text-2xl font-bold text-gray-900">
{title}
</h3>
<p className="mb-8 text-center text-gray-500 leading-relaxed">
{description}
</p>
{/* Action Button */}
<button
onClick={onClose}
className="w-full rounded-xl bg-[#1B9869] py-3.5 px-4 text-base font-semibold text-white shadow-lg transition-all hover:bg-[#14704E] hover:shadow-xl hover:-translate-y-0.5 active:translate-y-0 focus:outline-none focus:ring-2 focus:ring-[#1B9869] focus:ring-offset-2"
>
{buttonText}
</button>
</div>
</div>
);
};
export default CustomSuccessModal;
+49
View File
@@ -0,0 +1,49 @@
import React, { forwardRef } from "react";
interface CustomSwitchProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type"> {
label?: string;
}
const CustomSwitch = forwardRef<HTMLInputElement, CustomSwitchProps>(
({ label, className = "", disabled, ...props }, ref) => {
return (
<label
className={`
inline-flex items-center gap-3 cursor-pointer
${disabled ? "cursor-not-allowed opacity-60" : ""}
${className}
`}
>
<div className="relative inline-flex items-center">
<input
ref={ref}
type="checkbox"
className="peer sr-only"
disabled={disabled}
{...props}
/>
<div
className={`
w-11 h-6 bg-gray-200 rounded-full
peer-focus:ring-2 peer-focus:ring-[#1B9869]/20
peer-checked:after:translate-x-full peer-checked:after:border-white
after:content-[''] after:absolute after:top-[2px] after:left-[2px]
after:bg-white after:border-gray-300 after:border after:rounded-full
after:h-5 after:w-5 after:transition-all
peer-checked:bg-[#1B9869]
peer-disabled:bg-gray-100
`}
/>
</div>
{label && (
<span className="text-sm font-medium text-gray-700 select-none">
{label}
</span>
)}
</label>
);
}
);
export default CustomSwitch;
+187
View File
@@ -0,0 +1,187 @@
import React from "react";
import { Search, ChevronLeft, ChevronRight, MoreVertical, Filter, ArrowDownUp } from "lucide-react";
import CustomInput from "./CustomInput";
export interface Column<T> {
header: string | React.ReactNode;
accessor: keyof T | ((row: T) => React.ReactNode);
sortable?: boolean;
filterable?: boolean;
className?: string;
}
interface CustomTableProps<T> {
columns: Column<T>[];
data: T[];
// Header Props
searchPlaceholder?: string;
searchValue?: string;
onSearchChange?: (val: string) => void;
leftHeaderActions?: React.ReactNode;
rightHeaderActions?: React.ReactNode;
// Pagination Props
currentPage?: number;
totalPages?: number;
totalItems?: number;
startIndex?: number;
endIndex?: number;
onPageChange?: (page: number) => void;
itemName?: string;
// Table Props
onRowClick?: (row: T) => void;
}
export function CustomTable<T>({
columns,
data,
searchPlaceholder = "Search...",
searchValue,
onSearchChange,
leftHeaderActions,
rightHeaderActions,
currentPage = 1,
totalPages = 1,
totalItems = 0,
startIndex = 0,
endIndex = 0,
onPageChange,
itemName = "items",
onRowClick,
}: CustomTableProps<T>) {
const handlePageChange = (newPage: number) => {
if (newPage >= 1 && newPage <= totalPages && onPageChange) {
onPageChange(newPage);
}
};
const getPageNumbers = () => {
const pages = [];
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
return pages;
};
return (
<div className="w-full flex flex-col bg-white rounded-[20px] shadow-sm border border-gray-100 overflow-hidden">
{/* Top Header Section */}
<div className="flex items-center justify-between p-4 border-b border-gray-100">
<div className="flex items-center gap-4 flex-1">
{onSearchChange !== undefined && (
<div className="w-80">
<CustomInput
placeholder={searchPlaceholder}
value={searchValue}
onChange={(e) => onSearchChange(e.target.value)}
leftIcon={<Search size={18} />}
size="sm"
containerClassName="!gap-0"
/>
</div>
)}
{leftHeaderActions}
</div>
<div className="flex items-center gap-3">
{rightHeaderActions}
</div>
</div>
{/* Table Section */}
<div className="w-full overflow-x-auto">
<table className="w-full text-left border-collapse min-w-[800px]">
<thead>
<tr className="bg-gray-50/80 border-b border-gray-100">
{columns.map((col, index) => (
<th
key={index}
className={`py-4 px-6 text-[13px] font-semibold text-gray-500 whitespace-nowrap ${col.className || ""}`}
>
<div className="flex items-center gap-2">
{col.header}
{col.sortable && <ArrowDownUp size={14} className="cursor-pointer hover:text-gray-900" />}
{col.filterable && <Filter size={14} className="cursor-pointer hover:text-gray-900" />}
</div>
</th>
))}
</tr>
</thead>
<tbody>
{data.length > 0 ? (
data.map((row, rowIndex) => (
<tr
key={rowIndex}
onClick={() => onRowClick?.(row)}
className={`border-b border-gray-50 hover:bg-gray-50/50 transition-colors ${onRowClick ? "cursor-pointer" : ""}`}
>
{columns.map((col, colIndex) => (
<td key={colIndex} className={`py-5 px-6 text-sm text-gray-700 ${col.className || ""}`}>
{typeof col.accessor === "function"
? col.accessor(row)
: (row[col.accessor] as React.ReactNode)}
</td>
))}
</tr>
))
) : (
<tr>
<td colSpan={columns.length} className="py-12 text-center text-gray-500 text-sm">
No {itemName} found.
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Pagination Footer */}
<div className="flex items-center justify-between p-4 bg-gray-50/30 border-t border-gray-100">
<div className="text-[13px] font-medium text-gray-500">
Showing {totalItems > 0 ? startIndex : 0} to {endIndex} of {totalItems} {itemName}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
className="w-8 h-8 flex items-center justify-center rounded-lg border border-gray-200 bg-white text-gray-500 hover:bg-gray-50 hover:text-gray-900 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<ChevronLeft size={16} />
</button>
<div className="flex items-center gap-1">
{getPageNumbers().map(page => (
<button
key={page}
onClick={() => handlePageChange(page)}
className={`w-8 h-8 flex items-center justify-center rounded-lg text-sm font-semibold transition-colors
${currentPage === page
? "bg-[#1B9869] text-white border border-[#1B9869]"
: "bg-white text-gray-600 border border-gray-200 hover:bg-gray-50 hover:text-gray-900"
}
`}
>
{page}
</button>
))}
</div>
<button
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className="w-8 h-8 flex items-center justify-center rounded-lg border border-gray-200 bg-white text-gray-500 hover:bg-gray-50 hover:text-gray-900 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<ChevronRight size={16} />
</button>
</div>
</div>
</div>
);
}
export default CustomTable;
+131
View File
@@ -0,0 +1,131 @@
import React, { useEffect, useId, useMemo, useState } from "react";
export type TabItem = {
id: string;
label: string;
content: React.ReactNode;
disabled?: boolean;
badge?: string | number;
icon?: React.ReactNode;
};
interface CustomTabsProps {
tabs: TabItem[];
value?: string;
defaultValue?: string;
onChange?: (tabId: string) => void;
className?: string;
tabListClassName?: string;
tabClassName?: string;
activeTabClassName?: string;
contentClassName?: string;
}
const CustomTabs: React.FC<CustomTabsProps> = ({
tabs,
value,
defaultValue,
onChange,
className = "",
tabListClassName = "",
tabClassName = "",
activeTabClassName = "",
contentClassName = "",
}) => {
const defaultTabId = tabs[0]?.id || "";
const [internalValue, setInternalValue] = useState(defaultValue || defaultTabId);
const activeValue = value ?? internalValue;
const baseId = useId();
const activeTab = useMemo(
() => tabs.find((tab) => tab.id === activeValue) ?? tabs[0],
[tabs, activeValue]
);
useEffect(() => {
if (!tabs.length) {
return;
}
if (!tabs.some((tab) => tab.id === activeValue)) {
setInternalValue(tabs[0].id);
}
}, [tabs, activeValue]);
const handleTabClick = (tabId: string, disabled?: boolean) => {
if (disabled) {
return;
}
if (value === undefined) {
setInternalValue(tabId);
}
onChange?.(tabId);
};
return (
<div className={`w-full ${className}`}>
<div
role="tablist"
aria-orientation="horizontal"
className={`inline-flex overflow-x-auto whitespace-nowrap scrollbar-hide items-center gap-1 bg-white rounded-2xl p-1.5 shadow-[0_2px_8px_-4px_rgba(0,0,0,0.1)] border border-slate-100 ${tabListClassName}`}
>
{tabs.map((tab) => {
const isActive = tab.id === activeTab?.id;
const tabId = `${baseId}-tab-${tab.id}`;
const panelId = `${baseId}-panel-${tab.id}`;
return (
<button
key={tab.id}
id={tabId}
role="tab"
type="button"
aria-controls={panelId}
aria-selected={isActive}
disabled={tab.disabled}
onClick={() => handleTabClick(tab.id, tab.disabled)}
className={`
relative inline-flex items-center justify-center gap-2 px-5 py-2 text-sm font-medium rounded-xl transition-all duration-200
${isActive
? "bg-[#0B3B6A] text-white shadow-sm"
: "text-slate-500 hover:text-slate-800 hover:bg-slate-50"
}
${tab.disabled ? "cursor-not-allowed opacity-60" : "cursor-pointer"}
${tabClassName}
${isActive ? activeTabClassName : ""}
`}
>
{tab.icon && <span className={isActive ? "text-white" : "text-slate-400"}>{tab.icon}</span>}
<span>{tab.label}</span>
{tab.badge !== undefined && tab.badge !== null && tab.badge !== 0 && (
<span
className={`rounded-md px-1.5 py-0.5 text-[11px] font-bold leading-none flex items-center justify-center min-w-[22px] h-[22px] ${
isActive
? "bg-white/20 text-white"
: "bg-[#0B3B6A] text-white"
}`}
>
{tab.badge}
</span>
)}
</button>
);
})}
</div>
<div
role="tabpanel"
id={`${baseId}-panel-${activeTab?.id ?? "tab"}`}
aria-labelledby={`${baseId}-tab-${activeTab?.id ?? "tab"}`}
className={`mt-4 ${contentClassName}`}
>
{activeTab?.content}
</div>
</div>
);
};
export default CustomTabs;
+86
View File
@@ -0,0 +1,86 @@
import React, { forwardRef } from "react";
interface CustomTextAreaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: string;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
maxLength?: number;
error?: string;
size?: "sm" | "md" | "lg";
}
const CustomTextArea = forwardRef<HTMLTextAreaElement, CustomTextAreaProps>(
(
{
label,
leftIcon,
rightIcon,
maxLength,
disabled,
className = "",
rows = 4,
size = "md",
...props
},
ref
) => {
const sizeClasses = {
sm: "py-2 text-sm",
md: "py-2.5 text-sm",
lg: "py-3 text-base",
};
return (
<div className="w-full flex flex-col gap-1.5">
{label && (
<label className="text-sm font-semibold text-gray-700">
{label}
{props.required && <span className="text-red-500 ml-1">*</span>}
</label>
)}
<div className="relative w-full">
{leftIcon && (
<span className="absolute left-3 top-3 text-slate-400">
{leftIcon}
</span>
)}
<textarea
ref={ref}
rows={rows}
maxLength={maxLength}
disabled={disabled}
className={`
w-full rounded-lg
bg-white text-gray-900
border ${props.error ? 'border-red-500' : 'border-gray-300'}
px-3 ${sizeClasses[size]}
outline-none
transition-all duration-200
hover:border-[#1B9869]
focus:border-[#1B9869] focus:ring-2 focus:ring-[#1B9869]/20
disabled:bg-gray-50 disabled:text-gray-500 disabled:cursor-not-allowed disabled:border-gray-300
placeholder:text-gray-500
resize-y
${leftIcon ? "pl-10" : ""}
${rightIcon ? "pr-10" : ""}
${className}
`}
{...props}
/>
{rightIcon && (
<span className="absolute right-3 top-3 text-slate-400">
{rightIcon}
</span>
)}
</div>
{props.error && <p className="text-xs text-red-500 mt-1">{props.error}</p>}
</div>
);
}
);
export default CustomTextArea;
@@ -0,0 +1,56 @@
import React, { forwardRef } from "react";
import { Clock } from "lucide-react";
interface CustomTimePickerProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type"> {
label?: string;
error?: string;
}
const CustomTimePicker = forwardRef<HTMLInputElement, CustomTimePickerProps>(
({ label, error, className = "", disabled, required, ...props }, ref) => {
return (
<div className="w-full flex flex-col gap-1.5">
{label && (
<label className="text-sm font-semibold text-gray-700">
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</label>
)}
<div className="relative w-full">
<input
ref={ref}
type="time"
disabled={disabled}
className={`
w-full rounded-lg
bg-white text-black text-sm
border border-gray-300
pl-10 pr-3 py-3
outline-none
transition-all duration-200
hover:border-[#1B9869]
focus:border-[#1B9869] focus:ring-2 focus:ring-[#1B9869]/20
disabled:bg-gray-100 disabled:text-gray-500 disabled:cursor-not-allowed disabled:border-gray-200
placeholder:text-gray-300
${error
? "border-red-500 focus:border-red-500 focus:ring-red-500/20"
: ""
}
${className}
`}
{...props}
/>
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none">
<Clock size={18} />
</span>
</div>
{error && <p className="text-xs text-red-500 mt-0.5">{error}</p>}
</div>
);
}
);
CustomTimePicker.displayName = "CustomTimePicker";
export default CustomTimePicker;
+51
View File
@@ -0,0 +1,51 @@
import CustomInput from "./CustomInput";
import CustomTextArea from "./CustomTextArea";
export { default as CustomBackButton } from "./CustomBackButton";
import CustomDropdown from "./CustomDropdown";
import CustomSearchableDropdown from "./CustomSearchableDropdown";
import CustomMultiSelect from "./CustomMultiSelect";
import CustomFileUploader from "./CustomFileUploader";
import CustomCheckBox from "./CustomCheckBox";
import CustomTable from "./CustomTable";
import CustomButton from "./CustomButton";
import CustomRadio from "./CustomRadio";
import CustomSwitch from "./CustomSwitch";
import CustomModal from "./CustomModal";
import CustomConfirmationModal from "./CustomConfirmationModal";
import CustomDatePicker from "./CustomDatePicker";
import CustomDateTimePicker from "./CustomDateTimePicker";
import CustomOTPInput from "./CustomOTPInput";
import CustomTabs from "./CustomTabs";
import CustomLoader from "./CustomLoader";
import CustomActionMenu, { CustomActionItem } from "./CustomActionMenu";
import CustomStatus from "./CustomStatus";
import CustomAlertBanner from "./CustomAlertBanner";
import Skeleton from "./CustomSkeleton";
import CustomTimePicker from "./CustomTimePicker";
export {
CustomInput,
CustomTextArea,
CustomDropdown,
CustomSearchableDropdown,
CustomMultiSelect,
CustomFileUploader,
CustomCheckBox,
CustomTable,
CustomButton,
CustomRadio,
CustomSwitch,
CustomModal,
CustomConfirmationModal,
CustomDatePicker,
CustomDateTimePicker,
CustomOTPInput,
CustomTabs,
CustomLoader,
CustomActionMenu,
CustomActionItem,
CustomStatus,
CustomAlertBanner,
Skeleton,
CustomTimePicker
};
+22 -10
View File
@@ -1,4 +1,4 @@
import { Bell } from 'lucide-react';
import { Bell, Menu } from 'lucide-react';
import { useLocation } from 'react-router-dom';
const PAGE_META: Record<string, { title: string; subtitle: string }> = {
@@ -6,20 +6,32 @@ const PAGE_META: Record<string, { title: string; subtitle: string }> = {
'/cohorts': { title: 'Cohort Management', subtitle: 'Dynamic passenger segmentation for targeted recovery and recovery intelligence.' },
};
export default function AppHeader() {
interface AppHeaderProps {
onMenuClick?: () => void;
}
export default function AppHeader({ onMenuClick }: AppHeaderProps) {
const location = useLocation();
const meta = PAGE_META[location.pathname] || { title: 'Aero Resolve', subtitle: 'Managing passenger recovery workflows' };
return (
<header className="bg-white border-b border-gray-200 px-8 py-5 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">{meta.title}</h1>
<p className="text-sm text-gray-500 mt-1">{meta.subtitle}</p>
<header className="bg-white border-b border-gray-100 px-4 md:px-6 h-[76px] flex items-center justify-between rounded-t-none lg:rounded-t-[20px] shrink-0">
<div className="flex items-center gap-3">
<button
onClick={onMenuClick}
className="lg:hidden p-2 text-slate-500 hover:bg-slate-100 rounded-lg transition-colors"
>
<Menu size={24} strokeWidth={2} />
</button>
<div className="flex flex-col justify-center">
<h1 className="text-lg md:text-[18px] font-bold text-[#111827] tracking-tight">{meta.title}</h1>
<p className="hidden md:block text-[12px] font-medium text-slate-400">{meta.subtitle}</p>
</div>
</div>
<div className="flex items-center gap-4">
<button className="relative p-2 text-gray-400 hover:bg-gray-100 rounded-full transition-colors">
<Bell size={20} />
<span className="absolute top-1.5 right-2 w-2 h-2 bg-red-500 rounded-full border border-white"></span>
<div className="flex items-center gap-2 md:gap-4">
<button className="relative p-2 text-slate-500 hover:bg-slate-100 rounded-full transition-colors">
<Bell size={20} strokeWidth={2} />
<span className="absolute top-2 right-2 w-2 h-2 bg-red-500 rounded-full border-[1.5px] border-white"></span>
</button>
</div>
</header>
+10 -5
View File
@@ -1,3 +1,4 @@
import { useState } from 'react';
import type { ReactNode } from 'react';
import AppSidebar from './AppSidebar';
import AppHeader from './AppHeader';
@@ -7,12 +8,16 @@ interface LayoutProps {
}
function Layout({ children }: LayoutProps) {
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
return (
<div className="flex h-screen overflow-hidden bg-gray-50 font-sans">
<AppSidebar />
<div className="flex-1 flex flex-col overflow-hidden">
<AppHeader />
<main className="flex-1 overflow-y-auto p-8">
<div className="flex h-screen overflow-hidden bg-[#F4F7F6] font-sans">
<AppSidebar isOpen={isSidebarOpen} onClose={() => setIsSidebarOpen(false)} />
{/* Main Content Area Wrapper */}
<div className="flex-1 flex flex-col overflow-hidden bg-white rounded-[12px] shadow-[0px_0px_10px_rgba(0,0,0,0.05)] border border-gray-100/50 relative z-10 my-2 mr-2 ml-1">
<AppHeader onMenuClick={() => setIsSidebarOpen(true)} />
<main className="flex-1 overflow-y-auto px-8 py-6 bg-white rounded-b-[12px]">
{children}
</main>
</div>
+111 -60
View File
@@ -1,91 +1,142 @@
import { useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import {
LayoutDashboard,
Settings2,
RefreshCcw,
Users,
Shield,
ShieldCheck,
Settings,
History,
LogOut,
HelpCircle,
Plane
ChevronsLeft,
ChevronsRight,
LayoutGrid
} from 'lucide-react';
const NAV_ITEMS = [
{ label: 'Dashboard', path: '/', icon: LayoutDashboard },
{ label: 'Dashboard', path: '/', icon: LayoutGrid },
{ label: 'Simulation Engine', path: '/simulation', icon: Settings2 },
{ label: 'Recovery Incidents', path: '/recovery', icon: RefreshCcw },
{ label: 'Recovery Incidents', path: '/recovery', icon: RefreshCcw, dot: true },
{ label: 'Cohort Management', path: '/cohorts', icon: Users },
{ label: 'Policy Engine', path: '/policy', icon: Shield },
{ label: 'Policy Engine', path: '/policy', icon: ShieldCheck },
{ label: 'Configuration', path: '/config', icon: Settings },
{ label: 'Audit Logs', path: '/audit', icon: History },
];
export default function AppSidebar() {
interface AppSidebarProps {
isOpen: boolean;
onClose: () => void;
}
export default function AppSidebar({ isOpen, onClose }: AppSidebarProps) {
const location = useLocation();
const [isCollapsed, setIsCollapsed] = useState(false);
return (
<div className="w-64 h-screen flex flex-col bg-[#F3F6F5] border-r border-gray-200">
<div className="p-6 flex items-center gap-3">
<div className="bg-gray-800 p-2 rounded-lg text-white">
<Plane size={24} />
<>
{/* Mobile overlay */}
{isOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
onClick={onClose}
/>
)}
<div
className={`fixed inset-y-0 left-0 transform ${isOpen ? 'translate-x-0' : '-translate-x-full'} lg:relative lg:translate-x-0 z-50 ${isCollapsed ? 'w-[88px]' : 'w-[260px]'} h-screen flex flex-col bg-[#F4F7F6] font-sans transition-all duration-300 ease-in-out`}
>
{/* Logo Area */}
<div className={`pt-6 pb-4 flex items-center ${isCollapsed ? 'px-0 justify-center flex-col gap-4' : 'px-5 justify-between'}`}>
<div className="flex items-center gap-3">
<div className="w-[42px] h-[42px] bg-[#4B4B4B] rounded-[14px] flex flex-col items-center justify-center text-white shadow-sm shrink-0">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="mb-0.5">
<path d="M17.8 19.2 16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.2-1.1.7l-1.2 3.3c-.2.5.1 1.1.6 1.2l6.9 1.7-2.9 2.9-3.6-.9c-.5-.1-.9.2-1.1.7l-1.3 3.5c-.2.5.1 1.1.6 1.2l12.4 3.1c.5.1.9-.2 1.1-.7l.8-2.3c.1-.5-.2-1.1-.7-1.2z" />
</svg>
<div className="w-[18px] h-[2px] bg-white rounded-full"></div>
</div>
{!isCollapsed && (
<span className="text-[17px] font-extrabold text-[#111827] tracking-tight whitespace-nowrap">Aero Resolve</span>
)}
</div>
<button onClick={onClose} className="text-slate-400 hover:text-slate-600 transition-colors lg:hidden">
<ChevronsLeft size={20} strokeWidth={2} />
</button>
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className="text-slate-400 hover:text-slate-600 transition-colors hidden lg:block"
>
{isCollapsed ? <ChevronsRight size={20} strokeWidth={2} /> : <ChevronsLeft size={20} strokeWidth={2} />}
</button>
</div>
<span className="text-xl font-bold text-gray-800">Aero Resolve</span>
</div>
<div className="px-6 py-2">
<div className="flex items-center justify-between text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">
<span>Global Terminal</span>
<span className="px-2 py-0.5 rounded-full bg-green-100 text-green-700 border border-green-200">Active</span>
</div>
</div>
{/* Navigation */}
<nav className={`flex-1 py-3 space-y-1 overflow-y-auto overflow-x-hidden ${isCollapsed ? 'px-3' : 'px-4'}`}>
{NAV_ITEMS.map((item) => {
const isActive = location.pathname === item.path || (item.path !== '/' && location.pathname.startsWith(item.path));
const Icon = item.icon;
return (
<Link
key={item.path}
to={item.path}
title={isCollapsed ? item.label : undefined}
onClick={() => {
if (window.innerWidth < 1024) onClose();
}}
className={`group flex items-center ${isCollapsed ? 'justify-center px-0 w-12 mx-auto' : 'gap-3 px-3.5'} py-[10px] rounded-[12px] text-[13px] transition-all duration-200 relative ${
isActive
? 'bg-gradient-to-b from-[#1B9869] to-[#14704E] text-white shadow-md shadow-[#1B9869]/20 font-semibold'
: 'text-[#475569] font-medium hover:bg-slate-200/40 hover:text-slate-900'
}`}
>
<Icon
size={18}
className={`${isActive ? 'text-white' : 'text-slate-600 group-hover:text-slate-800'} shrink-0`}
strokeWidth={isActive ? 2 : 1.5}
/>
{!isCollapsed && (
<span className="whitespace-nowrap">{item.label}</span>
)}
<nav className="flex-1 px-4 py-4 space-y-1 overflow-y-auto">
{NAV_ITEMS.map((item) => {
const isActive = location.pathname === item.path || (item.path !== '/' && location.pathname.startsWith(item.path));
const Icon = item.icon;
{item.dot && (
<div className={`${isCollapsed ? 'absolute top-2 right-2' : 'ml-auto'} w-1.5 h-1.5 rounded-full ${isActive ? 'bg-white' : 'bg-[#1B9869]'}`} />
)}
</Link>
);
})}
</nav>
{/* Bottom Section Card */}
<div className={`mb-2 mt-2 bg-gradient-to-br from-[#FAFCFB] to-[#E3EFE9] border border-white rounded-[24px] shadow-[0_4px_12px_-4px_rgba(0,0,0,0.05)] relative overflow-hidden transition-all duration-300 ${isCollapsed ? 'mx-2 p-2 flex flex-col items-center gap-3' : 'mx-4 p-3'}`}>
{/* Soft decorative glow */}
{!isCollapsed && <div className="absolute -top-10 -right-10 w-32 h-32 bg-white/60 rounded-full blur-2xl pointer-events-none" />}
return (
<Link
key={item.path}
to={item.path}
className={`flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors ${
isActive
? 'bg-[#1B9869] text-white shadow-sm'
: 'text-gray-700 hover:bg-white hover:text-gray-900'
}`}
>
<Icon size={18} className={isActive ? 'text-white' : 'text-gray-500'} />
{item.label}
{item.label === 'Recovery Incidents' && (
<div className="ml-auto w-2 h-2 rounded-full bg-green-500" />
<div className={`relative z-10 flex flex-col ${isCollapsed ? 'gap-2 w-full' : 'gap-0.5'}`}>
<button title={isCollapsed ? "Sign Out" : undefined} className={`flex items-center ${isCollapsed ? 'justify-center px-0 h-10 w-full' : 'gap-3 px-3 py-2'} text-[13px] font-semibold text-[#E02424] hover:bg-red-50/50 rounded-[10px] transition-colors`}>
<LogOut size={17} className="text-[#E02424] shrink-0" strokeWidth={2} />
{!isCollapsed && <span className="whitespace-nowrap">Sign Out</span>}
</button>
<button title={isCollapsed ? "Help Center" : undefined} className={`flex items-center ${isCollapsed ? 'justify-center px-0 h-10 w-full' : 'gap-3 px-3 py-2'} text-[13px] font-medium text-slate-700 hover:bg-white/40 rounded-[10px] transition-colors`}>
<HelpCircle size={17} className="text-slate-700 shrink-0" strokeWidth={1.5} />
{!isCollapsed && <span className="whitespace-nowrap">Help center</span>}
</button>
<div title={isCollapsed ? "Admin Demo" : undefined} className={`p-2 bg-white rounded-[16px] shadow-[0_2px_8px_-4px_rgba(0,0,0,0.08)] border border-white flex items-center cursor-pointer hover:shadow-md transition-shadow ${isCollapsed ? 'justify-center mt-1 w-full h-12' : 'gap-2.5 mt-2'}`}>
<div className="w-8 h-8 rounded-full bg-[#C2D1E0] flex-shrink-0" />
{!isCollapsed && (
<div className="flex flex-col justify-center overflow-hidden">
<span className="text-[13px] font-bold text-[#111827] leading-tight truncate">Admin Demo</span>
<span className="text-[10px] text-slate-500 font-medium mt-0.5 leading-tight truncate">System Administrator</span>
</div>
)}
</Link>
);
})}
</nav>
<div className="p-4 border-t border-gray-200">
<button className="flex items-center gap-3 px-4 py-2 w-full text-sm font-medium text-red-600 hover:bg-red-50 rounded-lg transition-colors">
<LogOut size={18} />
Sign Out
</button>
<button className="flex items-center gap-3 px-4 py-2 w-full text-sm font-medium text-gray-700 hover:bg-gray-100 rounded-lg transition-colors mt-1">
<HelpCircle size={18} className="text-gray-500" />
Help center
</button>
<div className="mt-4 p-3 bg-white rounded-xl shadow-sm border border-gray-100 flex items-center gap-3 cursor-pointer">
<div className="w-8 h-8 rounded-full bg-blue-100 flex items-center justify-center text-blue-700 font-bold text-sm">
AD
</div>
<div className="flex flex-col">
<span className="text-sm font-bold text-gray-900">Admin Demo</span>
<span className="text-xs text-gray-500">System Administrator</span>
</div>
</div>
</div>
</div>
</div>
</>
);
}