Initial commit: igny8 project

This commit is contained in:
igny8
2025-11-09 10:27:02 +00:00
commit 60b8188111
27265 changed files with 4360521 additions and 0 deletions

View File

@@ -0,0 +1,132 @@
import { useModal } from "../../hooks/useModal";
import { Modal } from "../ui/modal";
import Button from "../ui/button/Button";
import Input from "../form/input/InputField";
import Label from "../form/Label";
export default function UserAddressCard() {
const { isOpen, openModal, closeModal } = useModal();
const handleSave = () => {
// Handle save logic here
console.log("Saving changes...");
closeModal();
};
return (
<>
<div className="p-5 border border-gray-200 rounded-2xl dark:border-gray-800 lg:p-6">
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
<div>
<h4 className="text-lg font-semibold text-gray-800 dark:text-white/90 lg:mb-6">
Address
</h4>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:gap-7 2xl:gap-x-32">
<div>
<p className="mb-2 text-xs leading-normal text-gray-500 dark:text-gray-400">
Country
</p>
<p className="text-sm font-medium text-gray-800 dark:text-white/90">
United States.
</p>
</div>
<div>
<p className="mb-2 text-xs leading-normal text-gray-500 dark:text-gray-400">
City/State
</p>
<p className="text-sm font-medium text-gray-800 dark:text-white/90">
Phoenix, Arizona, United States.
</p>
</div>
<div>
<p className="mb-2 text-xs leading-normal text-gray-500 dark:text-gray-400">
Postal Code
</p>
<p className="text-sm font-medium text-gray-800 dark:text-white/90">
ERT 2489
</p>
</div>
<div>
<p className="mb-2 text-xs leading-normal text-gray-500 dark:text-gray-400">
TAX ID
</p>
<p className="text-sm font-medium text-gray-800 dark:text-white/90">
AS4568384
</p>
</div>
</div>
</div>
<button
onClick={openModal}
className="flex w-full items-center justify-center gap-2 rounded-full border border-gray-300 bg-white px-4 py-3 text-sm font-medium text-gray-700 shadow-theme-xs hover:bg-gray-50 hover:text-gray-800 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.03] dark:hover:text-gray-200 lg:inline-flex lg:w-auto"
>
<svg
className="fill-current"
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M15.0911 2.78206C14.2125 1.90338 12.7878 1.90338 11.9092 2.78206L4.57524 10.116C4.26682 10.4244 4.0547 10.8158 3.96468 11.2426L3.31231 14.3352C3.25997 14.5833 3.33653 14.841 3.51583 15.0203C3.69512 15.1996 3.95286 15.2761 4.20096 15.2238L7.29355 14.5714C7.72031 14.4814 8.11172 14.2693 8.42013 13.9609L15.7541 6.62695C16.6327 5.74827 16.6327 4.32365 15.7541 3.44497L15.0911 2.78206ZM12.9698 3.84272C13.2627 3.54982 13.7376 3.54982 14.0305 3.84272L14.6934 4.50563C14.9863 4.79852 14.9863 5.2734 14.6934 5.56629L14.044 6.21573L12.3204 4.49215L12.9698 3.84272ZM11.2597 5.55281L5.6359 11.1766C5.53309 11.2794 5.46238 11.4099 5.43238 11.5522L5.01758 13.5185L6.98394 13.1037C7.1262 13.0737 7.25666 13.003 7.35947 12.9002L12.9833 7.27639L11.2597 5.55281Z"
fill=""
/>
</svg>
Edit
</button>
</div>
</div>
<Modal isOpen={isOpen} onClose={closeModal} className="max-w-[700px] m-4">
<div className="relative w-full p-4 overflow-y-auto bg-white no-scrollbar rounded-3xl dark:bg-gray-900 lg:p-11">
<div className="px-2 pr-14">
<h4 className="mb-2 text-2xl font-semibold text-gray-800 dark:text-white/90">
Edit Address
</h4>
<p className="mb-6 text-sm text-gray-500 dark:text-gray-400 lg:mb-7">
Update your details to keep your profile up-to-date.
</p>
</div>
<form className="flex flex-col">
<div className="px-2 overflow-y-auto custom-scrollbar">
<div className="grid grid-cols-1 gap-x-6 gap-y-5 lg:grid-cols-2">
<div>
<Label>Country</Label>
<Input type="text" value="United States" />
</div>
<div>
<Label>City/State</Label>
<Input type="text" value="Arizona, United States." />
</div>
<div>
<Label>Postal Code</Label>
<Input type="text" value="ERT 2489" />
</div>
<div>
<Label>TAX ID</Label>
<Input type="text" value="AS4568384" />
</div>
</div>
</div>
<div className="flex items-center gap-3 px-2 mt-6 lg:justify-end">
<Button size="sm" variant="outline" onClick={closeModal}>
Close
</Button>
<Button size="sm" onClick={handleSave}>
Save Changes
</Button>
</div>
</form>
</div>
</Modal>
</>
);
}

View File

@@ -0,0 +1,184 @@
import { useModal } from "../../hooks/useModal";
import { Modal } from "../ui/modal";
import Button from "../ui/button/Button";
import Input from "../form/input/InputField";
import Label from "../form/Label";
export default function UserInfoCard() {
const { isOpen, openModal, closeModal } = useModal();
const handleSave = () => {
// Handle save logic here
console.log("Saving changes...");
closeModal();
};
return (
<div className="p-5 border border-gray-200 rounded-2xl dark:border-gray-800 lg:p-6">
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
<div>
<h4 className="text-lg font-semibold text-gray-800 dark:text-white/90 lg:mb-6">
Personal Information
</h4>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:gap-7 2xl:gap-x-32">
<div>
<p className="mb-2 text-xs leading-normal text-gray-500 dark:text-gray-400">
First Name
</p>
<p className="text-sm font-medium text-gray-800 dark:text-white/90">
Musharof
</p>
</div>
<div>
<p className="mb-2 text-xs leading-normal text-gray-500 dark:text-gray-400">
Last Name
</p>
<p className="text-sm font-medium text-gray-800 dark:text-white/90">
Chowdhury
</p>
</div>
<div>
<p className="mb-2 text-xs leading-normal text-gray-500 dark:text-gray-400">
Email address
</p>
<p className="text-sm font-medium text-gray-800 dark:text-white/90">
randomuser@pimjo.com
</p>
</div>
<div>
<p className="mb-2 text-xs leading-normal text-gray-500 dark:text-gray-400">
Phone
</p>
<p className="text-sm font-medium text-gray-800 dark:text-white/90">
+09 363 398 46
</p>
</div>
<div>
<p className="mb-2 text-xs leading-normal text-gray-500 dark:text-gray-400">
Bio
</p>
<p className="text-sm font-medium text-gray-800 dark:text-white/90">
Team Manager
</p>
</div>
</div>
</div>
<button
onClick={openModal}
className="flex w-full items-center justify-center gap-2 rounded-full border border-gray-300 bg-white px-4 py-3 text-sm font-medium text-gray-700 shadow-theme-xs hover:bg-gray-50 hover:text-gray-800 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.03] dark:hover:text-gray-200 lg:inline-flex lg:w-auto"
>
<svg
className="fill-current"
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M15.0911 2.78206C14.2125 1.90338 12.7878 1.90338 11.9092 2.78206L4.57524 10.116C4.26682 10.4244 4.0547 10.8158 3.96468 11.2426L3.31231 14.3352C3.25997 14.5833 3.33653 14.841 3.51583 15.0203C3.69512 15.1996 3.95286 15.2761 4.20096 15.2238L7.29355 14.5714C7.72031 14.4814 8.11172 14.2693 8.42013 13.9609L15.7541 6.62695C16.6327 5.74827 16.6327 4.32365 15.7541 3.44497L15.0911 2.78206ZM12.9698 3.84272C13.2627 3.54982 13.7376 3.54982 14.0305 3.84272L14.6934 4.50563C14.9863 4.79852 14.9863 5.2734 14.6934 5.56629L14.044 6.21573L12.3204 4.49215L12.9698 3.84272ZM11.2597 5.55281L5.6359 11.1766C5.53309 11.2794 5.46238 11.4099 5.43238 11.5522L5.01758 13.5185L6.98394 13.1037C7.1262 13.0737 7.25666 13.003 7.35947 12.9002L12.9833 7.27639L11.2597 5.55281Z"
fill=""
/>
</svg>
Edit
</button>
</div>
<Modal isOpen={isOpen} onClose={closeModal} className="max-w-[700px] m-4">
<div className="no-scrollbar relative w-full max-w-[700px] overflow-y-auto rounded-3xl bg-white p-4 dark:bg-gray-900 lg:p-11">
<div className="px-2 pr-14">
<h4 className="mb-2 text-2xl font-semibold text-gray-800 dark:text-white/90">
Edit Personal Information
</h4>
<p className="mb-6 text-sm text-gray-500 dark:text-gray-400 lg:mb-7">
Update your details to keep your profile up-to-date.
</p>
</div>
<form className="flex flex-col">
<div className="custom-scrollbar h-[450px] overflow-y-auto px-2 pb-3">
<div>
<h5 className="mb-5 text-lg font-medium text-gray-800 dark:text-white/90 lg:mb-6">
Social Links
</h5>
<div className="grid grid-cols-1 gap-x-6 gap-y-5 lg:grid-cols-2">
<div>
<Label>Facebook</Label>
<Input
type="text"
value="https://www.facebook.com/PimjoHQ"
/>
</div>
<div>
<Label>X.com</Label>
<Input type="text" value="https://x.com/PimjoHQ" />
</div>
<div>
<Label>Linkedin</Label>
<Input
type="text"
value="https://www.linkedin.com/company/pimjo"
/>
</div>
<div>
<Label>Instagram</Label>
<Input type="text" value="https://instagram.com/PimjoHQ" />
</div>
</div>
</div>
<div className="mt-7">
<h5 className="mb-5 text-lg font-medium text-gray-800 dark:text-white/90 lg:mb-6">
Personal Information
</h5>
<div className="grid grid-cols-1 gap-x-6 gap-y-5 lg:grid-cols-2">
<div className="col-span-2 lg:col-span-1">
<Label>First Name</Label>
<Input type="text" value="Musharof" />
</div>
<div className="col-span-2 lg:col-span-1">
<Label>Last Name</Label>
<Input type="text" value="Chowdhury" />
</div>
<div className="col-span-2 lg:col-span-1">
<Label>Email Address</Label>
<Input type="text" value="randomuser@pimjo.com" />
</div>
<div className="col-span-2 lg:col-span-1">
<Label>Phone</Label>
<Input type="text" value="+09 363 398 46" />
</div>
<div className="col-span-2">
<Label>Bio</Label>
<Input type="text" value="Team Manager" />
</div>
</div>
</div>
</div>
<div className="flex items-center gap-3 px-2 mt-6 lg:justify-end">
<Button size="sm" variant="outline" onClick={closeModal}>
Close
</Button>
<Button size="sm" onClick={handleSave}>
Save Changes
</Button>
</div>
</form>
</div>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,236 @@
import { useModal } from "../../hooks/useModal";
import { Modal } from "../ui/modal";
import Button from "../ui/button/Button";
import Input from "../form/input/InputField";
import Label from "../form/Label";
export default function UserMetaCard() {
const { isOpen, openModal, closeModal } = useModal();
const handleSave = () => {
// Handle save logic here
console.log("Saving changes...");
closeModal();
};
return (
<>
<div className="p-5 border border-gray-200 rounded-2xl dark:border-gray-800 lg:p-6">
<div className="flex flex-col gap-5 xl:flex-row xl:items-center xl:justify-between">
<div className="flex flex-col items-center w-full gap-6 xl:flex-row">
<div className="w-20 h-20 overflow-hidden border border-gray-200 rounded-full dark:border-gray-800">
<img src="/images/user/owner.jpg" alt="user" />
</div>
<div className="order-3 xl:order-2">
<h4 className="mb-2 text-lg font-semibold text-center text-gray-800 dark:text-white/90 xl:text-left">
Musharof Chowdhury
</h4>
<div className="flex flex-col items-center gap-1 text-center xl:flex-row xl:gap-3 xl:text-left">
<p className="text-sm text-gray-500 dark:text-gray-400">
Team Manager
</p>
<div className="hidden h-3.5 w-px bg-gray-300 dark:bg-gray-700 xl:block"></div>
<p className="text-sm text-gray-500 dark:text-gray-400">
Arizona, United States
</p>
</div>
</div>
<div className="flex items-center order-2 gap-2 grow xl:order-3 xl:justify-end">
<a
href="https://www.facebook.com/PimjoHQ"
target="_blank"
rel="noopener"
className="flex h-11 w-11 items-center justify-center gap-2 rounded-full border border-gray-300 bg-white text-sm font-medium text-gray-700 shadow-theme-xs hover:bg-gray-50 hover:text-gray-800 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.03] dark:hover:text-gray-200"
>
<svg
className="fill-current"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11.6666 11.2503H13.7499L14.5833 7.91699H11.6666V6.25033C11.6666 5.39251 11.6666 4.58366 13.3333 4.58366H14.5833V1.78374C14.3118 1.7477 13.2858 1.66699 12.2023 1.66699C9.94025 1.66699 8.33325 3.04771 8.33325 5.58342V7.91699H5.83325V11.2503H8.33325V18.3337H11.6666V11.2503Z"
fill=""
/>
</svg>
</a>
<a
href="https://x.com/PimjoHQ"
target="_blank"
rel="noopener"
className="flex h-11 w-11 items-center justify-center gap-2 rounded-full border border-gray-300 bg-white text-sm font-medium text-gray-700 shadow-theme-xs hover:bg-gray-50 hover:text-gray-800 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.03] dark:hover:text-gray-200"
>
<svg
className="fill-current"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15.1708 1.875H17.9274L11.9049 8.75833L18.9899 18.125H13.4424L9.09742 12.4442L4.12578 18.125H1.36745L7.80912 10.7625L1.01245 1.875H6.70078L10.6283 7.0675L15.1708 1.875ZM14.2033 16.475H15.7308L5.87078 3.43833H4.23162L14.2033 16.475Z"
fill=""
/>
</svg>
</a>
<a
href="https://www.linkedin.com/company/pimjo"
target="_blank"
rel="noopener"
className="flex h-11 w-11 items-center justify-center gap-2 rounded-full border border-gray-300 bg-white text-sm font-medium text-gray-700 shadow-theme-xs hover:bg-gray-50 hover:text-gray-800 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.03] dark:hover:text-gray-200"
>
<svg
className="fill-current"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5.78381 4.16645C5.78351 4.84504 5.37181 5.45569 4.74286 5.71045C4.11391 5.96521 3.39331 5.81321 2.92083 5.32613C2.44836 4.83904 2.31837 4.11413 2.59216 3.49323C2.86596 2.87233 3.48886 2.47942 4.16715 2.49978C5.06804 2.52682 5.78422 3.26515 5.78381 4.16645ZM5.83381 7.06645H2.50048V17.4998H5.83381V7.06645ZM11.1005 7.06645H7.78381V17.4998H11.0672V12.0248C11.0672 8.97475 15.0422 8.69142 15.0422 12.0248V17.4998H18.3338V10.8914C18.3338 5.74978 12.4505 5.94145 11.0672 8.46642L11.1005 7.06645Z"
fill=""
/>
</svg>
</a>
<a
href="https://instagram.com/PimjoHQ"
target="_blank"
rel="noopener"
className="flex h-11 w-11 items-center justify-center gap-2 rounded-full border border-gray-300 bg-white text-sm font-medium text-gray-700 shadow-theme-xs hover:bg-gray-50 hover:text-gray-800 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.03] dark:hover:text-gray-200"
>
<svg
className="fill-current"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10.8567 1.66699C11.7946 1.66854 12.2698 1.67351 12.6805 1.68573L12.8422 1.69102C13.0291 1.69766 13.2134 1.70599 13.4357 1.71641C14.3224 1.75738 14.9273 1.89766 15.4586 2.10391C16.0078 2.31572 16.4717 2.60183 16.9349 3.06503C17.3974 3.52822 17.6836 3.99349 17.8961 4.54141C18.1016 5.07197 18.2419 5.67753 18.2836 6.56433C18.2935 6.78655 18.3015 6.97088 18.3081 7.15775L18.3133 7.31949C18.3255 7.73011 18.3311 8.20543 18.3328 9.1433L18.3335 9.76463C18.3336 9.84055 18.3336 9.91888 18.3336 9.99972L18.3335 10.2348L18.333 10.8562C18.3314 11.794 18.3265 12.2694 18.3142 12.68L18.3089 12.8417C18.3023 13.0286 18.294 13.213 18.2836 13.4351C18.2426 14.322 18.1016 14.9268 17.8961 15.458C17.6842 16.0074 17.3974 16.4713 16.9349 16.9345C16.4717 17.397 16.0057 17.6831 15.4586 17.8955C14.9273 18.1011 14.3224 18.2414 13.4357 18.2831C13.2134 18.293 13.0291 18.3011 12.8422 18.3076L12.6805 18.3128C12.2698 18.3251 11.7946 18.3306 10.8567 18.3324L10.2353 18.333C10.1594 18.333 10.0811 18.333 10.0002 18.333H9.76516L9.14375 18.3325C8.20591 18.331 7.7306 18.326 7.31997 18.3137L7.15824 18.3085C6.97136 18.3018 6.78703 18.2935 6.56481 18.2831C5.67801 18.2421 5.07384 18.1011 4.5419 17.8955C3.99328 17.6838 3.5287 17.397 3.06551 16.9345C2.60231 16.4713 2.3169 16.0053 2.1044 15.458C1.89815 14.9268 1.75856 14.322 1.7169 13.4351C1.707 13.213 1.69892 13.0286 1.69238 12.8417L1.68714 12.68C1.67495 12.2694 1.66939 11.794 1.66759 10.8562L1.66748 9.1433C1.66903 8.20543 1.67399 7.73011 1.68621 7.31949L1.69151 7.15775C1.69815 6.97088 1.70648 6.78655 1.7169 6.56433C1.75786 5.67683 1.89815 5.07266 2.1044 4.54141C2.3162 3.9928 2.60231 3.52822 3.06551 3.06503C3.5287 2.60183 3.99398 2.31641 4.5419 2.10391C5.07315 1.89766 5.67731 1.75808 6.56481 1.71641C6.78703 1.70652 6.97136 1.69844 7.15824 1.6919L7.31997 1.68666C7.7306 1.67446 8.20591 1.6689 9.14375 1.6671L10.8567 1.66699ZM10.0002 5.83308C7.69781 5.83308 5.83356 7.69935 5.83356 9.99972C5.83356 12.3021 7.69984 14.1664 10.0002 14.1664C12.3027 14.1664 14.1669 12.3001 14.1669 9.99972C14.1669 7.69732 12.3006 5.83308 10.0002 5.83308ZM10.0002 7.49974C11.381 7.49974 12.5002 8.61863 12.5002 9.99972C12.5002 11.3805 11.3813 12.4997 10.0002 12.4997C8.6195 12.4997 7.50023 11.3809 7.50023 9.99972C7.50023 8.61897 8.61908 7.49974 10.0002 7.49974ZM14.3752 4.58308C13.8008 4.58308 13.3336 5.04967 13.3336 5.62403C13.3336 6.19841 13.8002 6.66572 14.3752 6.66572C14.9496 6.66572 15.4169 6.19913 15.4169 5.62403C15.4169 5.04967 14.9488 4.58236 14.3752 4.58308Z"
fill=""
/>
</svg>
</a>
</div>
</div>
<button
onClick={openModal}
className="flex w-full items-center justify-center gap-2 rounded-full border border-gray-300 bg-white px-4 py-3 text-sm font-medium text-gray-700 shadow-theme-xs hover:bg-gray-50 hover:text-gray-800 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.03] dark:hover:text-gray-200 lg:inline-flex lg:w-auto"
>
<svg
className="fill-current"
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M15.0911 2.78206C14.2125 1.90338 12.7878 1.90338 11.9092 2.78206L4.57524 10.116C4.26682 10.4244 4.0547 10.8158 3.96468 11.2426L3.31231 14.3352C3.25997 14.5833 3.33653 14.841 3.51583 15.0203C3.69512 15.1996 3.95286 15.2761 4.20096 15.2238L7.29355 14.5714C7.72031 14.4814 8.11172 14.2693 8.42013 13.9609L15.7541 6.62695C16.6327 5.74827 16.6327 4.32365 15.7541 3.44497L15.0911 2.78206ZM12.9698 3.84272C13.2627 3.54982 13.7376 3.54982 14.0305 3.84272L14.6934 4.50563C14.9863 4.79852 14.9863 5.2734 14.6934 5.56629L14.044 6.21573L12.3204 4.49215L12.9698 3.84272ZM11.2597 5.55281L5.6359 11.1766C5.53309 11.2794 5.46238 11.4099 5.43238 11.5522L5.01758 13.5185L6.98394 13.1037C7.1262 13.0737 7.25666 13.003 7.35947 12.9002L12.9833 7.27639L11.2597 5.55281Z"
fill=""
/>
</svg>
Edit
</button>
</div>
</div>
<Modal isOpen={isOpen} onClose={closeModal} className="max-w-[700px] m-4">
<div className="no-scrollbar relative w-full max-w-[700px] overflow-y-auto rounded-3xl bg-white p-4 dark:bg-gray-900 lg:p-11">
<div className="px-2 pr-14">
<h4 className="mb-2 text-2xl font-semibold text-gray-800 dark:text-white/90">
Edit Personal Information
</h4>
<p className="mb-6 text-sm text-gray-500 dark:text-gray-400 lg:mb-7">
Update your details to keep your profile up-to-date.
</p>
</div>
<form className="flex flex-col">
<div className="custom-scrollbar h-[450px] overflow-y-auto px-2 pb-3">
<div>
<h5 className="mb-5 text-lg font-medium text-gray-800 dark:text-white/90 lg:mb-6">
Social Links
</h5>
<div className="grid grid-cols-1 gap-x-6 gap-y-5 lg:grid-cols-2">
<div>
<Label>Facebook</Label>
<Input
type="text"
value="https://www.facebook.com/PimjoHQ"
/>
</div>
<div>
<Label>X.com</Label>
<Input type="text" value="https://x.com/PimjoHQ" />
</div>
<div>
<Label>Linkedin</Label>
<Input
type="text"
value="https://www.linkedin.com/company/pimjo"
/>
</div>
<div>
<Label>Instagram</Label>
<Input type="text" value="https://instagram.com/PimjoHQ" />
</div>
</div>
</div>
<div className="mt-7">
<h5 className="mb-5 text-lg font-medium text-gray-800 dark:text-white/90 lg:mb-6">
Personal Information
</h5>
<div className="grid grid-cols-1 gap-x-6 gap-y-5 lg:grid-cols-2">
<div className="col-span-2 lg:col-span-1">
<Label>First Name</Label>
<Input type="text" value="Musharof" />
</div>
<div className="col-span-2 lg:col-span-1">
<Label>Last Name</Label>
<Input type="text" value="Chowdhury" />
</div>
<div className="col-span-2 lg:col-span-1">
<Label>Email Address</Label>
<Input type="text" value="randomuser@pimjo.com" />
</div>
<div className="col-span-2 lg:col-span-1">
<Label>Phone</Label>
<Input type="text" value="+09 363 398 46" />
</div>
<div className="col-span-2">
<Label>Bio</Label>
<Input type="text" value="Team Manager" />
</div>
</div>
</div>
</div>
<div className="flex items-center gap-3 px-2 mt-6 lg:justify-end">
<Button size="sm" variant="outline" onClick={closeModal}>
Close
</Button>
<Button size="sm" onClick={handleSave}>
Save Changes
</Button>
</div>
</form>
</div>
</Modal>
</>
);
}

View File

@@ -0,0 +1,96 @@
import { useEffect, ReactNode, useState } from "react";
import { Navigate, useLocation } from "react-router";
import { useAuthStore } from "../../store/authStore";
import { useErrorHandler } from "../../hooks/useErrorHandler";
import { trackLoading } from "../common/LoadingStateMonitor";
interface ProtectedRouteProps {
children: ReactNode;
}
/**
* ProtectedRoute component - guards routes requiring authentication
* Redirects to /signin if user is not authenticated
*/
export default function ProtectedRoute({ children }: ProtectedRouteProps) {
const { isAuthenticated, loading } = useAuthStore();
const location = useLocation();
const { addError } = useErrorHandler('ProtectedRoute');
const [showError, setShowError] = useState(false);
const [errorMessage, setErrorMessage] = useState<string>('');
// Track loading state
useEffect(() => {
trackLoading('auth-loading', loading);
}, [loading]);
// Immediate check on mount: if loading is true, reset it immediately
useEffect(() => {
if (loading) {
console.warn('ProtectedRoute: Loading state is true on mount, resetting immediately');
useAuthStore.setState({ loading: false });
}
}, []);
// Safety timeout: if loading becomes true and stays stuck, show error
useEffect(() => {
if (loading) {
const timeout1 = setTimeout(() => {
setErrorMessage('Authentication check is taking longer than expected. This may indicate a network or server issue.');
setShowError(true);
addError(new Error('Auth loading stuck for 3 seconds'), 'ProtectedRoute');
}, 3000);
const timeout2 = setTimeout(() => {
console.error('ProtectedRoute: Loading state stuck for 5 seconds, forcing reset');
useAuthStore.setState({ loading: false });
setShowError(false);
}, 5000);
return () => {
clearTimeout(timeout1);
clearTimeout(timeout2);
};
} else {
setShowError(false);
}
}, [loading, addError]);
// Show loading state while checking authentication
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-900">
<div className="text-center max-w-md px-4">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-brand-500 mb-4"></div>
<p className="text-lg font-medium text-gray-800 dark:text-white mb-2">Loading...</p>
{showError && (
<div className="mt-4 p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<p className="text-sm text-yellow-800 dark:text-yellow-200 mb-3">
{errorMessage}
</p>
<button
onClick={() => {
useAuthStore.setState({ loading: false });
setShowError(false);
window.location.reload();
}}
className="px-4 py-2 text-sm bg-yellow-600 text-white rounded hover:bg-yellow-700"
>
Retry or Reload Page
</button>
</div>
)}
</div>
</div>
);
}
// Redirect to signin if not authenticated
if (!isAuthenticated) {
return <Navigate to="/signin" state={{ from: location }} replace />;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,192 @@
import { useState } from "react";
import { Link, useNavigate, useLocation } from "react-router";
import { ChevronLeftIcon, EyeCloseIcon, EyeIcon } from "../../icons";
import Label from "../form/Label";
import Input from "../form/input/InputField";
import Checkbox from "../form/input/Checkbox";
import Button from "../ui/button/Button";
import { useAuthStore } from "../../store/authStore";
export default function SignInForm() {
const [showPassword, setShowPassword] = useState(false);
const [isChecked, setIsChecked] = useState(false);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const navigate = useNavigate();
const location = useLocation();
const { login, loading } = useAuthStore();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
if (!email || !password) {
setError("Please enter both email and password");
return;
}
try {
await login(email, password);
// Redirect to the page user was trying to access, or home
const from = (location.state as any)?.from?.pathname || "/";
navigate(from, { replace: true });
} catch (err: any) {
setError(err.message || "Login failed. Please check your credentials.");
}
};
return (
<div className="flex flex-col flex-1">
<div className="w-full max-w-md pt-10 mx-auto">
{/* Back link removed - users shouldn't go back to dashboard when not logged in */}
</div>
<div className="flex flex-col justify-center flex-1 w-full max-w-md mx-auto">
<div>
<div className="mb-5 sm:mb-8">
<h1 className="mb-2 font-semibold text-gray-800 text-title-sm dark:text-white/90 sm:text-title-md">
Sign In
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">
Enter your email and password to sign in!
</p>
</div>
<div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-5">
<button className="inline-flex items-center justify-center gap-3 py-3 text-sm font-normal text-gray-700 transition-colors bg-gray-100 rounded-lg px-7 hover:bg-gray-200 hover:text-gray-800 dark:bg-white/5 dark:text-white/90 dark:hover:bg-white/10">
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M18.7511 10.1944C18.7511 9.47495 18.6915 8.94995 18.5626 8.40552H10.1797V11.6527H15.1003C15.0011 12.4597 14.4654 13.675 13.2749 14.4916L13.2582 14.6003L15.9087 16.6126L16.0924 16.6305C17.7788 15.1041 18.7511 12.8583 18.7511 10.1944Z"
fill="#4285F4"
/>
<path
d="M10.1788 18.75C12.5895 18.75 14.6133 17.9722 16.0915 16.6305L13.274 14.4916C12.5201 15.0068 11.5081 15.3666 10.1788 15.3666C7.81773 15.3666 5.81379 13.8402 5.09944 11.7305L4.99473 11.7392L2.23868 13.8295L2.20264 13.9277C3.67087 16.786 6.68674 18.75 10.1788 18.75Z"
fill="#34A853"
/>
<path
d="M5.10014 11.7305C4.91165 11.186 4.80257 10.6027 4.80257 9.99992C4.80257 9.3971 4.91165 8.81379 5.09022 8.26935L5.08523 8.1534L2.29464 6.02954L2.20333 6.0721C1.5982 7.25823 1.25098 8.5902 1.25098 9.99992C1.25098 11.4096 1.5982 12.7415 2.20333 13.9277L5.10014 11.7305Z"
fill="#FBBC05"
/>
<path
d="M10.1789 4.63331C11.8554 4.63331 12.9864 5.34303 13.6312 5.93612L16.1511 3.525C14.6035 2.11528 12.5895 1.25 10.1789 1.25C6.68676 1.25 3.67088 3.21387 2.20264 6.07218L5.08953 8.26943C5.81381 6.15972 7.81776 4.63331 10.1789 4.63331Z"
fill="#EB4335"
/>
</svg>
Sign in with Google
</button>
<button className="inline-flex items-center justify-center gap-3 py-3 text-sm font-normal text-gray-700 transition-colors bg-gray-100 rounded-lg px-7 hover:bg-gray-200 hover:text-gray-800 dark:bg-white/5 dark:text-white/90 dark:hover:bg-white/10">
<svg
width="21"
className="fill-current"
height="20"
viewBox="0 0 21 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M15.6705 1.875H18.4272L12.4047 8.75833L19.4897 18.125H13.9422L9.59717 12.4442L4.62554 18.125H1.86721L8.30887 10.7625L1.51221 1.875H7.20054L11.128 7.0675L15.6705 1.875ZM14.703 16.475H16.2305L6.37054 3.43833H4.73137L14.703 16.475Z" />
</svg>
Sign in with X
</button>
</div>
<div className="relative py-3 sm:py-5">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-200 dark:border-gray-800"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="p-2 text-gray-400 bg-white dark:bg-gray-900 sm:px-5 sm:py-2">
Or
</span>
</div>
</div>
<form onSubmit={handleSubmit}>
<div className="space-y-6">
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg dark:bg-red-900/20 dark:text-red-400 dark:border-red-800">
{error}
</div>
)}
<div>
<Label>
Email <span className="text-error-500">*</span>{" "}
</Label>
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="info@gmail.com"
required
/>
</div>
<div>
<Label>
Password <span className="text-error-500">*</span>{" "}
</Label>
<div className="relative">
<Input
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your password"
required
/>
<span
onClick={() => setShowPassword(!showPassword)}
className="absolute z-30 -translate-y-1/2 cursor-pointer right-4 top-1/2"
>
{showPassword ? (
<EyeIcon className="fill-gray-500 dark:fill-gray-400 size-5" />
) : (
<EyeCloseIcon className="fill-gray-500 dark:fill-gray-400 size-5" />
)}
</span>
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Checkbox checked={isChecked} onChange={setIsChecked} />
<span className="block font-normal text-gray-700 text-theme-sm dark:text-gray-400">
Keep me logged in
</span>
</div>
<Link
to="/reset-password"
className="text-sm text-brand-500 hover:text-brand-600 dark:text-brand-400"
>
Forgot password?
</Link>
</div>
<div>
<Button
className="w-full"
size="sm"
type="submit"
disabled={loading}
>
{loading ? "Signing in..." : "Sign in"}
</Button>
</div>
</div>
</form>
<div className="mt-5">
<p className="text-sm font-normal text-center text-gray-700 dark:text-gray-400 sm:text-start">
Don&apos;t have an account? {""}
<Link
to="/signup"
className="text-brand-500 hover:text-brand-600 dark:text-brand-400"
>
Sign Up
</Link>
</p>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,262 @@
import { useState } from "react";
import { Link, useNavigate } from "react-router";
import { ChevronLeftIcon, EyeCloseIcon, EyeIcon } from "../../icons";
import Label from "../form/Label";
import Input from "../form/input/InputField";
import Checkbox from "../form/input/Checkbox";
import { useAuthStore } from "../../store/authStore";
export default function SignUpForm() {
const [showPassword, setShowPassword] = useState(false);
const [isChecked, setIsChecked] = useState(false);
const [formData, setFormData] = useState({
firstName: "",
lastName: "",
email: "",
password: "",
username: "",
});
const [error, setError] = useState("");
const navigate = useNavigate();
const { register, loading } = useAuthStore();
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
if (!formData.email || !formData.password || !formData.firstName || !formData.lastName) {
setError("Please fill in all required fields");
return;
}
if (!isChecked) {
setError("Please agree to the Terms and Conditions");
return;
}
try {
// Generate username from email if not provided
const username = formData.username || formData.email.split("@")[0];
await register({
email: formData.email,
password: formData.password,
username: username,
first_name: formData.firstName,
last_name: formData.lastName,
});
// Redirect to home after successful registration
navigate("/", { replace: true });
} catch (err: any) {
setError(err.message || "Registration failed. Please try again.");
}
};
return (
<div className="flex flex-col flex-1 w-full overflow-y-auto lg:w-1/2 no-scrollbar">
<div className="w-full max-w-md mx-auto mb-5 sm:pt-10">
<Link
to="/"
className="inline-flex items-center text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
>
<ChevronLeftIcon className="size-5" />
Back to dashboard
</Link>
</div>
<div className="flex flex-col justify-center flex-1 w-full max-w-md mx-auto">
<div>
<div className="mb-5 sm:mb-8">
<h1 className="mb-2 font-semibold text-gray-800 text-title-sm dark:text-white/90 sm:text-title-md">
Sign Up
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">
Enter your email and password to sign up!
</p>
</div>
<div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-5">
<button className="inline-flex items-center justify-center gap-3 py-3 text-sm font-normal text-gray-700 transition-colors bg-gray-100 rounded-lg px-7 hover:bg-gray-200 hover:text-gray-800 dark:bg-white/5 dark:text-white/90 dark:hover:bg-white/10">
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M18.7511 10.1944C18.7511 9.47495 18.6915 8.94995 18.5626 8.40552H10.1797V11.6527H15.1003C15.0011 12.4597 14.4654 13.675 13.2749 14.4916L13.2582 14.6003L15.9087 16.6126L16.0924 16.6305C17.7788 15.1041 18.7511 12.8583 18.7511 10.1944Z"
fill="#4285F4"
/>
<path
d="M10.1788 18.75C12.5895 18.75 14.6133 17.9722 16.0915 16.6305L13.274 14.4916C12.5201 15.0068 11.5081 15.3666 10.1788 15.3666C7.81773 15.3666 5.81379 13.8402 5.09944 11.7305L4.99473 11.7392L2.23868 13.8295L2.20264 13.9277C3.67087 16.786 6.68674 18.75 10.1788 18.75Z"
fill="#34A853"
/>
<path
d="M5.10014 11.7305C4.91165 11.186 4.80257 10.6027 4.80257 9.99992C4.80257 9.3971 4.91165 8.81379 5.09022 8.26935L5.08523 8.1534L2.29464 6.02954L2.20333 6.0721C1.5982 7.25823 1.25098 8.5902 1.25098 9.99992C1.25098 11.4096 1.5982 12.7415 2.20333 13.9277L5.10014 11.7305Z"
fill="#FBBC05"
/>
<path
d="M10.1789 4.63331C11.8554 4.63331 12.9864 5.34303 13.6312 5.93612L16.1511 3.525C14.6035 2.11528 12.5895 1.25 10.1789 1.25C6.68676 1.25 3.67088 3.21387 2.20264 6.07218L5.08953 8.26943C5.81381 6.15972 7.81776 4.63331 10.1789 4.63331Z"
fill="#EB4335"
/>
</svg>
Sign up with Google
</button>
<button className="inline-flex items-center justify-center gap-3 py-3 text-sm font-normal text-gray-700 transition-colors bg-gray-100 rounded-lg px-7 hover:bg-gray-200 hover:text-gray-800 dark:bg-white/5 dark:text-white/90 dark:hover:bg-white/10">
<svg
width="21"
className="fill-current"
height="20"
viewBox="0 0 21 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M15.6705 1.875H18.4272L12.4047 8.75833L19.4897 18.125H13.9422L9.59717 12.4442L4.62554 18.125H1.86721L8.30887 10.7625L1.51221 1.875H7.20054L11.128 7.0675L15.6705 1.875ZM14.703 16.475H16.2305L6.37054 3.43833H4.73137L14.703 16.475Z" />
</svg>
Sign up with X
</button>
</div>
<div className="relative py-3 sm:py-5">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-200 dark:border-gray-800"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="p-2 text-gray-400 bg-white dark:bg-gray-900 sm:px-5 sm:py-2">
Or
</span>
</div>
</div>
<form onSubmit={handleSubmit}>
<div className="space-y-5">
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg dark:bg-red-900/20 dark:text-red-400 dark:border-red-800">
{error}
</div>
)}
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
{/* <!-- First Name --> */}
<div className="sm:col-span-1">
<Label>
First Name<span className="text-error-500">*</span>
</Label>
<Input
type="text"
id="firstName"
name="firstName"
value={formData.firstName}
onChange={handleChange}
placeholder="Enter your first name"
required
/>
</div>
{/* <!-- Last Name --> */}
<div className="sm:col-span-1">
<Label>
Last Name<span className="text-error-500">*</span>
</Label>
<Input
type="text"
id="lastName"
name="lastName"
value={formData.lastName}
onChange={handleChange}
placeholder="Enter your last name"
required
/>
</div>
</div>
{/* <!-- Email --> */}
<div>
<Label>
Email<span className="text-error-500">*</span>
</Label>
<Input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
placeholder="Enter your email"
required
/>
</div>
{/* <!-- Password --> */}
<div>
<Label>
Password<span className="text-error-500">*</span>
</Label>
<div className="relative">
<Input
placeholder="Enter your password"
type={showPassword ? "text" : "password"}
id="password"
name="password"
value={formData.password}
onChange={handleChange}
required
/>
<span
onClick={() => setShowPassword(!showPassword)}
className="absolute z-30 -translate-y-1/2 cursor-pointer right-4 top-1/2"
>
{showPassword ? (
<EyeIcon className="fill-gray-500 dark:fill-gray-400 size-5" />
) : (
<EyeCloseIcon className="fill-gray-500 dark:fill-gray-400 size-5" />
)}
</span>
</div>
</div>
{/* <!-- Checkbox --> */}
<div className="flex items-center gap-3">
<Checkbox
className="w-5 h-5"
checked={isChecked}
onChange={setIsChecked}
/>
<p className="inline-block font-normal text-gray-500 dark:text-gray-400">
By creating an account means you agree to the{" "}
<span className="text-gray-800 dark:text-white/90">
Terms and Conditions,
</span>{" "}
and our{" "}
<span className="text-gray-800 dark:text-white">
Privacy Policy
</span>
</p>
</div>
{/* <!-- Button --> */}
<div>
<button
type="submit"
disabled={loading}
className="flex items-center justify-center w-full px-4 py-3 text-sm font-medium text-white transition rounded-lg bg-brand-500 shadow-theme-xs hover:bg-brand-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? "Signing up..." : "Sign Up"}
</button>
</div>
</div>
</form>
<div className="mt-5">
<p className="text-sm font-normal text-center text-gray-700 dark:text-gray-400 sm:text-start">
Already have an account? {""}
<Link
to="/signin"
className="text-brand-500 hover:text-brand-600 dark:text-brand-400"
>
Sign In
</Link>
</p>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,97 @@
import Chart from "react-apexcharts";
import { ApexOptions } from "apexcharts";
export default function BarChartOne() {
const options: ApexOptions = {
colors: ["#465fff"],
chart: {
fontFamily: "Outfit, sans-serif",
type: "bar",
height: 180,
toolbar: {
show: false,
},
},
plotOptions: {
bar: {
horizontal: false,
columnWidth: "39%",
borderRadius: 5,
borderRadiusApplication: "end",
},
},
dataLabels: {
enabled: false,
},
stroke: {
show: true,
width: 4,
colors: ["transparent"],
},
xaxis: {
categories: [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
],
axisBorder: {
show: false,
},
axisTicks: {
show: false,
},
},
legend: {
show: true,
position: "top",
horizontalAlign: "left",
fontFamily: "Outfit",
},
yaxis: {
title: {
text: undefined,
},
},
grid: {
yaxis: {
lines: {
show: true,
},
},
},
fill: {
opacity: 1,
},
tooltip: {
x: {
show: false,
},
y: {
formatter: (val: number) => `${val}`,
},
},
};
const series = [
{
name: "Sales",
data: [168, 385, 201, 298, 187, 195, 291, 110, 215, 390, 280, 112],
},
];
return (
<div className="max-w-full overflow-x-auto custom-scrollbar">
<div id="chartOne" className="min-w-[1000px]">
<Chart options={options} series={series} type="bar" height={180} />
</div>
</div>
);
}

View File

@@ -0,0 +1,120 @@
import Chart from "react-apexcharts";
import { ApexOptions } from "apexcharts";
export default function LineChartOne() {
const options: ApexOptions = {
legend: {
show: false, // Hide legend
position: "top",
horizontalAlign: "left",
},
colors: ["#465FFF", "#9CB9FF"], // Define line colors
chart: {
fontFamily: "Outfit, sans-serif",
height: 310,
type: "line", // Set the chart type to 'line'
toolbar: {
show: false, // Hide chart toolbar
},
},
stroke: {
curve: "straight", // Define the line style (straight, smooth, or step)
width: [2, 2], // Line width for each dataset
},
fill: {
type: "gradient",
gradient: {
opacityFrom: 0.55,
opacityTo: 0,
},
},
markers: {
size: 0, // Size of the marker points
strokeColors: "#fff", // Marker border color
strokeWidth: 2,
hover: {
size: 6, // Marker size on hover
},
},
grid: {
xaxis: {
lines: {
show: false, // Hide grid lines on x-axis
},
},
yaxis: {
lines: {
show: true, // Show grid lines on y-axis
},
},
},
dataLabels: {
enabled: false, // Disable data labels
},
tooltip: {
enabled: true, // Enable tooltip
x: {
format: "dd MMM yyyy", // Format for x-axis tooltip
},
},
xaxis: {
type: "category", // Category-based x-axis
categories: [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
],
axisBorder: {
show: false, // Hide x-axis border
},
axisTicks: {
show: false, // Hide x-axis ticks
},
tooltip: {
enabled: false, // Disable tooltip for x-axis points
},
},
yaxis: {
labels: {
style: {
fontSize: "12px", // Adjust font size for y-axis labels
colors: ["#6B7280"], // Color of the labels
},
},
title: {
text: "", // Remove y-axis title
style: {
fontSize: "0px",
},
},
},
};
const series = [
{
name: "Sales",
data: [180, 190, 170, 160, 175, 165, 170, 205, 230, 210, 240, 235],
},
{
name: "Revenue",
data: [40, 30, 50, 40, 55, 40, 70, 100, 110, 120, 150, 140],
},
];
return (
<div className="max-w-full overflow-x-auto custom-scrollbar">
<div id="chartEight" className="min-w-[1000px]">
<Chart options={options} series={series} type="area" height={310} />
</div>
</div>
);
}

View File

@@ -0,0 +1,73 @@
/**
* Bulk Export Confirmation Modal
* Reusable modal for confirming bulk export operations
* Used across all table pages (Keywords, Clusters, Ideas, Tasks, etc.)
*/
import { Modal } from '../ui/modal';
import Button from '../ui/button/Button';
import { InfoIcon } from '../../icons';
interface BulkExportModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void | Promise<void>;
title: string;
message: string;
confirmText?: string;
isLoading?: boolean;
}
export default function BulkExportModal({
isOpen,
onClose,
onConfirm,
title,
message,
confirmText = 'Export',
isLoading = false,
}: BulkExportModalProps) {
return (
<Modal
isOpen={isOpen}
onClose={onClose}
className="max-w-md"
>
<div className="p-6">
{/* Header with icon */}
<div className="flex items-center gap-3 mb-4">
<div className="flex items-center justify-center w-10 h-10 bg-blue-50 rounded-xl dark:bg-blue-500/10">
<InfoIcon className="w-5 h-5 text-blue-500" />
</div>
<h2 className="text-xl font-bold text-gray-800 dark:text-white">
{title}
</h2>
</div>
{/* Message */}
<p className="text-gray-600 dark:text-gray-400 mb-6">
{message}
</p>
{/* Actions */}
<div className="flex justify-end gap-4">
<Button
variant="outline"
onClick={onClose}
disabled={isLoading}
>
Cancel
</Button>
<Button
variant="primary"
onClick={onConfirm}
disabled={isLoading}
>
{isLoading ? 'Exporting...' : confirmText}
</Button>
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,106 @@
/**
* Bulk Status Update Modal
* Reusable modal for updating status of multiple selected records
* Used across all table pages (Keywords, Clusters, Ideas, Tasks, etc.)
*/
import { useState } from 'react';
import { Modal } from '../ui/modal';
import Button from '../ui/button/Button';
import SelectDropdown from '../form/SelectDropdown';
import Label from '../form/Label';
import { InfoIcon } from '../../icons';
interface BulkStatusUpdateModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: (status: string) => void | Promise<void>;
title: string;
message: string;
confirmText?: string;
statusOptions: Array<{ value: string; label: string }>;
isLoading?: boolean;
}
export default function BulkStatusUpdateModal({
isOpen,
onClose,
onConfirm,
title,
message,
confirmText = 'Update Status',
statusOptions,
isLoading = false,
}: BulkStatusUpdateModalProps) {
const [selectedStatus, setSelectedStatus] = useState<string>('');
const handleConfirm = async () => {
if (!selectedStatus) return;
await onConfirm(selectedStatus);
// Reset on success (onClose will be called by parent)
setSelectedStatus('');
};
const handleClose = () => {
setSelectedStatus('');
onClose();
};
return (
<Modal
isOpen={isOpen}
onClose={handleClose}
className="max-w-md"
>
<div className="p-6">
{/* Header with icon */}
<div className="flex items-center gap-3 mb-4">
<div className="flex items-center justify-center w-10 h-10 bg-blue-50 rounded-xl dark:bg-blue-500/10">
<InfoIcon className="w-5 h-5 text-blue-500" />
</div>
<h2 className="text-xl font-bold text-gray-800 dark:text-white">
{title}
</h2>
</div>
{/* Message */}
<p className="text-gray-600 dark:text-gray-400 mb-4">
{message}
</p>
{/* Status Selector */}
<div className="mb-6">
<Label className="mb-2">
New Status
</Label>
<SelectDropdown
options={statusOptions}
placeholder="Select status"
value={selectedStatus}
onChange={(value) => setSelectedStatus(value || '')}
className="w-full"
/>
</div>
{/* Actions */}
<div className="flex justify-end gap-4">
<Button
variant="outline"
onClick={handleClose}
disabled={isLoading}
>
Cancel
</Button>
<Button
variant="primary"
onClick={handleConfirm}
disabled={isLoading || !selectedStatus}
>
{isLoading ? 'Updating...' : confirmText}
</Button>
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,45 @@
import { useState } from "react";
const ChartTab: React.FC = () => {
const [selected, setSelected] = useState<
"optionOne" | "optionTwo" | "optionThree"
>("optionOne");
const getButtonClass = (option: "optionOne" | "optionTwo" | "optionThree") =>
selected === option
? "shadow-theme-xs text-gray-900 dark:text-white bg-white dark:bg-gray-800"
: "text-gray-500 dark:text-gray-400";
return (
<div className="flex items-center gap-0.5 rounded-lg bg-gray-100 p-0.5 dark:bg-gray-900">
<button
onClick={() => setSelected("optionOne")}
className={`px-3 py-2 font-medium w-full rounded-md text-theme-sm hover:text-gray-900 dark:hover:text-white ${getButtonClass(
"optionOne"
)}`}
>
Monthly
</button>
<button
onClick={() => setSelected("optionTwo")}
className={`px-3 py-2 font-medium w-full rounded-md text-theme-sm hover:text-gray-900 dark:hover:text-white ${getButtonClass(
"optionTwo"
)}`}
>
Quarterly
</button>
<button
onClick={() => setSelected("optionThree")}
className={`px-3 py-2 font-medium w-full rounded-md text-theme-sm hover:text-gray-900 dark:hover:text-white ${getButtonClass(
"optionThree"
)}`}
>
Annually
</button>
</div>
);
};
export default ChartTab;

View File

@@ -0,0 +1,38 @@
interface ComponentCardProps {
title: string;
children: React.ReactNode;
className?: string; // Additional custom classes for styling
desc?: string; // Description text
}
const ComponentCard: React.FC<ComponentCardProps> = ({
title,
children,
className = "",
desc = "",
}) => {
return (
<div
className={`rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03] ${className}`}
>
{/* Card Header */}
<div className="px-6 py-5">
<h3 className="text-base font-medium text-gray-800 dark:text-white/90">
{title}
</h3>
{desc && (
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{desc}
</p>
)}
</div>
{/* Card Body */}
<div className="p-4 border-t border-gray-100 dark:border-gray-800 sm:p-6">
<div className="space-y-6">{children}</div>
</div>
</div>
);
};
export default ComponentCard;

View File

@@ -0,0 +1,76 @@
import { Modal } from '../ui/modal';
import Button from '../ui/button/Button';
interface ConfirmDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
message: string;
confirmText?: string;
cancelText?: string;
variant?: 'danger' | 'warning' | 'info';
isLoading?: boolean;
}
export default function ConfirmDialog({
isOpen,
onClose,
onConfirm,
title,
message,
confirmText = 'Confirm',
cancelText = 'Cancel',
variant = 'danger',
isLoading = false,
}: ConfirmDialogProps) {
const variantStyles = {
danger: {
button: 'variant="primary"',
className: '',
},
warning: {
button: 'variant="primary"',
className: '',
},
info: {
button: 'variant="primary"',
className: '',
},
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
className="max-w-md"
>
<div className="p-6">
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-white">
{title}
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6">
{message}
</p>
<div className="flex justify-end gap-4">
<Button
variant="outline"
onClick={onClose}
disabled={isLoading}
>
{cancelText}
</Button>
<Button
variant="primary"
onClick={onConfirm}
disabled={isLoading}
>
{isLoading ? 'Processing...' : confirmText}
</Button>
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,82 @@
import React, { Component, ErrorInfo, ReactNode } from 'react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
errorInfo: ErrorInfo | null;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
hasError: false,
error: null,
errorInfo: null,
};
}
static getDerivedStateFromError(error: Error): State {
return {
hasError: true,
error,
errorInfo: null,
};
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('ErrorBoundary caught an error:', error, errorInfo);
this.setState({
error,
errorInfo,
});
}
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div className="flex items-center justify-center min-h-screen p-6">
<div className="text-center max-w-md">
<h2 className="text-2xl font-semibold text-gray-800 dark:text-white mb-4">
Something went wrong
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-4">
{this.state.error?.message || 'An unexpected error occurred'}
</p>
<button
onClick={() => {
this.setState({ hasError: false, error: null, errorInfo: null });
window.location.reload();
}}
className="px-4 py-2 bg-primary-500 text-white rounded-lg hover:bg-primary-600"
>
Reload Page
</button>
{import.meta.env.DEV && this.state.error && (
<details className="mt-4 text-left">
<summary className="cursor-pointer text-sm text-gray-500 dark:text-gray-400">
Error Details
</summary>
<pre className="mt-2 text-xs bg-gray-100 dark:bg-gray-800 p-2 rounded overflow-auto">
{this.state.error.stack}
</pre>
</details>
)}
</div>
</div>
);
}
return this.props.children;
}
}

View File

@@ -0,0 +1,224 @@
import { ReactNode } from 'react';
import { Modal } from '../ui/modal';
import Button from '../ui/button/Button';
import SelectDropdown from '../form/SelectDropdown';
import Label from '../form/Label';
export interface FormField {
key: string;
label: string;
type: 'text' | 'number' | 'email' | 'password' | 'select' | 'textarea';
placeholder?: string;
options?: Array<{ value: string; label: string }>;
value: any;
onChange: (value: any) => void;
required?: boolean;
min?: number;
max?: number;
rows?: number;
className?: string;
}
interface FormModalProps {
isOpen: boolean;
onClose: () => void;
onSubmit: () => void;
title: string;
fields?: FormField[];
submitLabel?: string;
cancelLabel?: string;
isLoading?: boolean;
className?: string;
customFooter?: ReactNode;
customBody?: ReactNode; // Custom body content that replaces fields
}
export default function FormModal({
isOpen,
onClose,
onSubmit,
title,
fields = [],
submitLabel = 'Create',
cancelLabel = 'Cancel',
isLoading = false,
className = 'max-w-2xl',
customFooter,
customBody,
}: FormModalProps) {
return (
<Modal
isOpen={isOpen}
onClose={onClose}
className={className}
>
<div className="p-6">
<h3 className="text-lg font-semibold mb-6 text-gray-800 dark:text-white">
{title}
</h3>
<form
onSubmit={(e) => {
e.preventDefault();
onSubmit();
}}
className="space-y-4"
>
{customBody ? (
customBody
) : (
<>
{fields.find(f => f.key === 'keyword') && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{fields.find(f => f.key === 'keyword')!.label}
{fields.find(f => f.key === 'keyword')!.required && <span className="text-error-500 ml-1">*</span>}
</label>
<input
type="text"
className="h-9 w-full rounded-lg border border-gray-300 bg-transparent px-3 py-2 text-sm shadow-theme-xs text-gray-800 placeholder:text-gray-400 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800"
value={fields.find(f => f.key === 'keyword')!.value || ''}
onChange={(e) => fields.find(f => f.key === 'keyword')!.onChange(e.target.value)}
placeholder={fields.find(f => f.key === 'keyword')!.placeholder}
required={fields.find(f => f.key === 'keyword')!.required}
/>
</div>
)}
{(fields.find(f => f.key === 'volume') || fields.find(f => f.key === 'difficulty')) && (
<div className="grid grid-cols-2 gap-4">
{fields.find(f => f.key === 'volume') && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{fields.find(f => f.key === 'volume')!.label}
{fields.find(f => f.key === 'volume')!.required && <span className="text-error-500 ml-1">*</span>}
</label>
<input
type="number"
className="h-9 w-full rounded-lg border border-gray-300 bg-transparent px-3 py-2 text-sm shadow-theme-xs text-gray-800 placeholder:text-gray-400 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800"
value={fields.find(f => f.key === 'volume')!.value || ''}
onChange={(e) => {
const value = e.target.value === '' ? '' : parseInt(e.target.value) || 0;
fields.find(f => f.key === 'volume')!.onChange(value);
}}
placeholder={fields.find(f => f.key === 'volume')!.placeholder}
required={fields.find(f => f.key === 'volume')!.required}
/>
</div>
)}
{fields.find(f => f.key === 'difficulty') && (() => {
const difficultyField = fields.find(f => f.key === 'difficulty')!;
return (
<div>
<Label className="mb-2">
{difficultyField.label}
{difficultyField.required && <span className="text-error-500 ml-1">*</span>}
</Label>
{difficultyField.type === 'select' ? (
<SelectDropdown
options={difficultyField.options || []}
placeholder={difficultyField.placeholder || difficultyField.label}
value={difficultyField.value || ''}
onChange={(value) => difficultyField.onChange(value)}
className="w-full"
/>
) : (
<input
type="number"
className="h-9 w-full rounded-lg border border-gray-300 bg-transparent px-3 py-2 text-sm shadow-theme-xs text-gray-800 placeholder:text-gray-400 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800"
value={difficultyField.value || ''}
onChange={(e) => {
const value = e.target.value === '' ? '' : parseInt(e.target.value) || 0;
difficultyField.onChange(value);
}}
placeholder={difficultyField.placeholder}
required={difficultyField.required}
min={difficultyField.min}
max={difficultyField.max}
/>
)}
</div>
);
})()}
</div>
)}
{fields.filter(f => f.key !== 'keyword' && f.key !== 'volume' && f.key !== 'difficulty').map((field) => {
if (field.type === 'select') {
return (
<div key={field.key}>
<Label className="mb-2">
{field.label}
{field.required && <span className="text-error-500 ml-1">*</span>}
</Label>
<SelectDropdown
options={field.options || []}
placeholder={field.placeholder || field.label}
value={field.value || ''}
onChange={(value) => field.onChange(value)}
className="w-full"
/>
</div>
);
}
if (field.type === 'textarea') {
return (
<div key={field.key}>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{field.label}
{field.required && <span className="text-error-500 ml-1">*</span>}
</label>
<textarea
rows={field.rows || 4}
className="w-full rounded-lg border border-gray-300 bg-transparent px-3 py-2 text-sm shadow-theme-xs text-gray-800 placeholder:text-gray-400 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800"
value={field.value || ''}
onChange={(e) => field.onChange(e.target.value)}
placeholder={field.placeholder}
required={field.required}
/>
</div>
);
}
return (
<div key={field.key}>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{field.label}
{field.required && <span className="text-error-500 ml-1">*</span>}
</label>
<input
type={field.type}
className="h-9 w-full rounded-lg border border-gray-300 bg-transparent px-3 py-2 text-sm shadow-theme-xs text-gray-800 placeholder:text-gray-400 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800"
value={field.value || ''}
onChange={(e) => field.onChange(e.target.value)}
placeholder={field.placeholder}
required={field.required}
min={field.min}
max={field.max}
/>
</div>
);
})}
</>
)}
{customFooter || (
<div className="flex justify-end gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={isLoading}
>
{cancelLabel}
</Button>
<Button
type="submit"
variant="primary"
disabled={isLoading}
>
{isLoading ? 'Processing...' : submitLabel}
</Button>
</div>
)}
</form>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,66 @@
import { useEffect, useState } from 'react';
import { useErrorHandler } from '../../hooks/useErrorHandler';
export default function GlobalErrorDisplay() {
const { errors, clearError, clearAllErrors } = useErrorHandler('GlobalErrorDisplay');
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
setIsVisible(errors.length > 0);
}, [errors.length]);
if (!isVisible || errors.length === 0) {
return null;
}
return (
<div className="fixed top-4 right-4 z-[9999] max-w-md space-y-2">
{errors.map((error, index) => (
<div
key={index}
className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg shadow-lg p-4 animate-in slide-in-from-right"
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="text-red-600 dark:text-red-400 text-lg"></span>
<span className="text-sm font-semibold text-red-800 dark:text-red-200">
{error.source}
</span>
</div>
<p className="text-sm text-red-700 dark:text-red-300 mb-2">
{error.message}
</p>
{error.stack && (
<details className="mt-2">
<summary className="text-xs text-red-600 dark:text-red-400 cursor-pointer hover:underline">
Show stack trace
</summary>
<pre className="mt-2 text-xs bg-red-100 dark:bg-red-900/40 p-2 rounded overflow-auto max-h-32">
{error.stack}
</pre>
</details>
)}
</div>
<button
onClick={() => clearError(index)}
className="text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-200 text-xl leading-none"
aria-label="Dismiss error"
>
×
</button>
</div>
</div>
))}
{errors.length > 1 && (
<button
onClick={clearAllErrors}
className="w-full px-3 py-2 text-xs bg-red-600 text-white rounded hover:bg-red-700"
>
Clear All Errors
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,12 @@
export default function GridShape() {
return (
<>
<div className="absolute right-0 top-0 -z-1 w-full max-w-[250px] xl:max-w-[450px]">
<img src="/images/shape/grid-01.svg" alt="grid" />
</div>
<div className="absolute bottom-0 left-0 -z-1 w-full max-w-[250px] rotate-180 xl:max-w-[450px]">
<img src="/images/shape/grid-01.svg" alt="grid" />
</div>
</>
);
}

View File

@@ -0,0 +1,293 @@
/**
* HTMLContentRenderer Component
* Safely renders HTML content with proper formatting and sanitization
*/
import React, { useMemo } from 'react';
import { sanitizeHTML, isHTML } from '../../utils/htmlSanitizer';
interface HTMLContentRendererProps {
content: string | null | undefined;
className?: string;
maxHeight?: string;
}
/**
* Parse and format content outline (JSON structure)
*/
function formatContentOutline(content: any): string {
if (!content) return '';
let html = '<div class="content-outline">';
// Handle introduction section - can be object or string
if (content.introduction) {
html += '<div class="outline-intro">';
if (typeof content.introduction === 'string') {
// Introduction is a simple string
html += `<div class="outline-paragraph">${escapeHTML(content.introduction)}</div>`;
} else if (typeof content.introduction === 'object') {
// Introduction is an object with hook and paragraphs
if (content.introduction.hook) {
html += `<div class="outline-hook"><strong>Hook:</strong> ${escapeHTML(content.introduction.hook)}</div>`;
}
if (content.introduction.paragraphs && Array.isArray(content.introduction.paragraphs)) {
content.introduction.paragraphs.forEach((para: any, index: number) => {
if (para.details) {
html += `<div class="outline-paragraph"><strong>Intro Paragraph ${index + 1}:</strong> ${escapeHTML(para.details)}</div>`;
}
});
}
}
html += '</div>';
}
// Handle sections array format (Format 3: nested structure)
if (content.sections && Array.isArray(content.sections)) {
content.sections.forEach((section: any) => {
if (!section) return;
html += '<div class="outline-section">';
// Handle section title (can be "H2: ..." or just text)
if (section.title) {
const titleText = section.title.replace(/^H2:\s*/i, '').trim();
if (titleText.toLowerCase() === 'conclusion') {
html += `<h3 class="section-heading">${escapeHTML(titleText)}</h3>`;
} else {
html += `<h3 class="section-heading">${escapeHTML(titleText)}</h3>`;
}
}
// Handle section content - can be array or string
if (section.content) {
if (Array.isArray(section.content)) {
// Content is an array of objects with title (H3) and content
section.content.forEach((item: any) => {
if (item.title) {
const subTitleText = item.title.replace(/^H3:\s*/i, '').trim();
html += `<h4 class="subsection-heading">${escapeHTML(subTitleText)}</h4>`;
}
if (item.content) {
html += `<div class="section-details">${escapeHTML(String(item.content))}</div>`;
}
});
} else if (typeof section.content === 'string') {
// Content is a simple string
html += `<div class="section-details">${escapeHTML(section.content)}</div>`;
}
}
html += '</div>';
});
}
// Handle H2 sections - can be array or simple key-value pairs
if (content.H2) {
if (Array.isArray(content.H2)) {
// Structured format: array of section objects
content.H2.forEach((section: any) => {
if (section.heading || typeof section === 'string') {
html += `<div class="outline-section">`;
const heading = section.heading || section;
html += `<h3 class="section-heading">${escapeHTML(heading)}</h3>`;
// Handle content type badge
if (section.content_type) {
html += `<div class="content-type-badge">${escapeHTML(section.content_type.replace('_', ' ').toUpperCase())}</div>`;
}
// Handle subsections (H3)
if (section.subsections && Array.isArray(section.subsections)) {
section.subsections.forEach((subsection: any) => {
const subheading = subsection.subheading || subsection.heading || subsection;
html += `<h4 class="subsection-heading">${escapeHTML(subheading)}</h4>`;
if (subsection.details) {
html += `<div class="section-details">${escapeHTML(subsection.details)}</div>`;
}
});
}
// Handle details
if (section.details) {
html += `<div class="section-details">${escapeHTML(section.details)}</div>`;
}
html += `</div>`;
}
});
} else if (typeof content.H2 === 'string') {
// Simple format: just a string (GPT-4o mini sometimes returns this)
html += `<div class="outline-section">`;
html += `<h3 class="section-heading">${escapeHTML(content.H2)}</h3>`;
html += `</div>`;
} else if (typeof content.H2 === 'object') {
// Simple key-value format (GPT-4o mini format)
Object.entries(content.H2).forEach(([key, value]: [string, any]) => {
html += `<div class="outline-section">`;
html += `<h3 class="section-heading">${escapeHTML(value)}</h3>`;
html += `</div>`;
});
}
}
// Handle H3 as a direct property (for GPT-4o mini simple format)
if (content.H3 && !content.H2) {
html += `<div class="outline-section">`;
if (typeof content.H3 === 'string') {
html += `<h4 class="subsection-heading">${escapeHTML(content.H3)}</h4>`;
} else if (typeof content.H3 === 'object') {
Object.entries(content.H3).forEach(([key, value]: [string, any]) => {
html += `<h4 class="subsection-heading">${escapeHTML(value)}</h4>`;
});
}
html += `</div>`;
}
html += '</div>';
return html;
}
/**
* Escape HTML to prevent XSS
*/
function escapeHTML(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
const HTMLContentRenderer: React.FC<HTMLContentRendererProps> = ({
content,
className = '',
maxHeight,
}) => {
const renderedContent = useMemo(() => {
if (!content) return '<div class="text-gray-400 italic">No content available</div>';
// If content is already an object (dict), use it directly
if (typeof content === 'object' && content !== null) {
// Check for any known structure format
if (content.H2 || content.H3 || content.introduction || content.sections) {
return formatContentOutline(content);
}
// If it's an object but not structured, try to format it
try {
// Check if it has any keys that suggest it's a structured outline
const keys = Object.keys(content);
if (keys.length > 0) {
// Try to format it as outline anyway
return formatContentOutline(content);
}
return escapeHTML(JSON.stringify(content, null, 2));
} catch {
return escapeHTML(JSON.stringify(content, null, 2));
}
}
// If content is a string, try to parse as JSON first
if (typeof content === 'string') {
// Try to parse as JSON (content outline from GPT-4o mini)
try {
const parsed = JSON.parse(content);
if (typeof parsed === 'object' && (parsed.H2 || parsed.H3 || parsed.introduction || parsed.sections)) {
return formatContentOutline(parsed);
}
} catch {
// Not JSON, continue with HTML/text processing
}
// Check if it's HTML (normalized content from backend)
if (isHTML(content)) {
// Content is already normalized HTML - sanitize and return
const sanitized = sanitizeHTML(content);
// Add wrapper classes for better styling in toggle row
// Check if content already has article or wrapper
if (sanitized.trim().startsWith('<article') || sanitized.trim().startsWith('<div')) {
return `<div class="normalized-html-content">${sanitized}</div>`;
}
return `<div class="normalized-html-content"><article>${sanitized}</article></div>`;
}
// Plain text (from GPT-4o) - format bullet points and line breaks
// Convert bullet points to HTML list
const lines = content.split('\n');
let html = '<div class="content-outline">';
let inList = false;
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) {
if (inList) {
html += '</ul>';
inList = false;
}
html += '<br>';
continue;
}
// Check for bullet points (- or *)
if (trimmed.match(/^[-*]\s+/)) {
if (!inList) {
html += '<ul class="outline-list">';
inList = true;
}
const text = trimmed.replace(/^[-*]\s+/, '');
// Check for nested bullets (indented)
if (trimmed.startsWith(' ') || trimmed.startsWith('\t')) {
html += `<li class="outline-item nested">${escapeHTML(text)}</li>`;
} else {
html += `<li class="outline-item">${escapeHTML(text)}</li>`;
}
}
// Check for H2 headings (starting with - H2:)
else if (trimmed.match(/^[-*]\s*H2[:]/i)) {
if (inList) {
html += '</ul>';
inList = false;
}
const heading = trimmed.replace(/^[-*]\s*H2[:]\s*/i, '');
html += `<h3 class="section-heading">${escapeHTML(heading)}</h3>`;
}
// Check for H3 headings (starting with - H3:)
else if (trimmed.match(/^[-*]\s*H3[:]/i)) {
if (inList) {
html += '</ul>';
inList = false;
}
const heading = trimmed.replace(/^[-*]\s*H3[:]\s*/i, '');
html += `<h4 class="subsection-heading">${escapeHTML(heading)}</h4>`;
}
// Regular paragraph
else {
if (inList) {
html += '</ul>';
inList = false;
}
html += `<p class="outline-paragraph">${escapeHTML(trimmed)}</p>`;
}
}
if (inList) {
html += '</ul>';
}
html += '</div>';
return html;
}
// Fallback: convert to string
return escapeHTML(String(content));
}, [content]);
return (
<div
className={`html-content-renderer ${className}`}
style={maxHeight ? { maxHeight, overflow: 'auto' } : undefined}
dangerouslySetInnerHTML={{ __html: renderedContent }}
/>
);
};
export default HTMLContentRenderer;

View File

@@ -0,0 +1,444 @@
import { ReactNode, useState, useEffect } from 'react';
import Button from '../ui/button/Button';
import { useToast } from '../ui/toast/ToastContainer';
import { fetchAPI } from '../../services/api';
interface ImageGenerationCardProps {
title: string;
description?: string;
integrationId: string;
icon?: ReactNode;
}
interface GeneratedImage {
url: string;
revised_prompt?: string;
model?: string;
provider?: string;
size?: string;
format?: string;
cost?: string;
}
interface ImageSettings {
service?: string;
model?: string;
runwareModel?: string;
}
/**
* Image Generation Testing Card Component
* Full implementation with form fields and image display
*/
export default function ImageGenerationCard({
title,
description,
integrationId,
icon,
}: ImageGenerationCardProps) {
const toast = useToast();
const [isGenerating, setIsGenerating] = useState(false);
const [prompt, setPrompt] = useState('');
const [negativePrompt, setNegativePrompt] = useState('text, watermark, logo, overlay, title, caption, writing on walls, writing on objects, UI, infographic elements, post title');
const [imageType, setImageType] = useState('realistic');
const [imageSize, setImageSize] = useState('1024x1024');
const [imageFormat, setImageFormat] = useState('webp');
const [imageSettings, setImageSettings] = useState<ImageSettings>({});
// Valid image sizes per model (from OpenAI official documentation)
const VALID_SIZES_BY_MODEL: Record<string, string[]> = {
'dall-e-3': ['1024x1024', '1024x1792', '1792x1024'],
'dall-e-2': ['256x256', '512x512', '1024x1024'],
};
// Get valid sizes for current model
const getValidSizes = (): string[] => {
const service = imageSettings.service || 'openai';
const model = service === 'openai'
? (imageSettings.model || 'dall-e-3')
: null;
if (model && VALID_SIZES_BY_MODEL[model]) {
return VALID_SIZES_BY_MODEL[model];
}
// Default to DALL-E 3 sizes if unknown
return VALID_SIZES_BY_MODEL['dall-e-3'];
};
// Update size if current size is invalid for the selected model
useEffect(() => {
const validSizes = getValidSizes();
if (validSizes.length > 0 && !validSizes.includes(imageSize)) {
// Reset to first valid size (usually the default)
setImageSize(validSizes[0]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [imageSettings.model, imageSettings.service]); // imageSize intentionally omitted to avoid infinite loop
const [generatedImage, setGeneratedImage] = useState<GeneratedImage | null>(null);
const [error, setError] = useState<string | null>(null);
const API_BASE_URL = import.meta.env.VITE_BACKEND_URL || 'https://api.igny8.com/api';
// Load default image generation settings on mount
useEffect(() => {
const loadImageSettings = async () => {
try {
const response = await fetch(
`${API_BASE_URL}/v1/system/settings/integrations/image_generation/`,
{ credentials: 'include' }
);
if (response.ok) {
const data = await response.json();
if (data.success && data.data) {
setImageSettings(data.data);
}
}
} catch (error) {
console.error('Error loading image settings:', error);
}
};
loadImageSettings();
}, [API_BASE_URL]);
const handleGenerate = async () => {
console.log('[ImageGenerationCard] handleGenerate called');
if (!prompt.trim()) {
toast.error('Please enter a prompt description');
return;
}
setIsGenerating(true);
setError(null);
setGeneratedImage(null);
try {
// Get the default service and model from settings
const service = imageSettings.service || 'openai';
const model = service === 'openai'
? (imageSettings.model || 'dall-e-3')
: (imageSettings.runwareModel || 'runware:97@1');
console.log('[ImageGenerationCard] Service and model:', { service, model, imageSettings });
// Build prompt with template (similar to reference plugin)
const fullPrompt = `Create a high-quality ${imageType} image. ${prompt}`;
console.log('[ImageGenerationCard] Full prompt:', fullPrompt.substring(0, 100) + '...');
const requestBody = {
prompt: fullPrompt,
negative_prompt: negativePrompt,
image_type: imageType,
image_size: imageSize,
image_format: imageFormat,
provider: service,
model: model,
};
console.log('[ImageGenerationCard] Making request to image generation endpoint');
console.log('[ImageGenerationCard] Request body:', requestBody);
const data = await fetchAPI('/v1/system/settings/integrations/image_generation/generate/', {
method: 'POST',
body: JSON.stringify(requestBody),
});
console.log('[ImageGenerationCard] Response data:', data);
if (!data.success) {
throw new Error(data.error || 'Failed to generate image');
}
const imageData = {
url: data.image_url,
revised_prompt: data.revised_prompt,
model: data.model || model,
provider: data.provider || service,
size: imageSize,
format: imageFormat.toUpperCase(),
cost: data.cost,
};
setGeneratedImage(imageData);
// Emit custom event for ImageResultCard to listen to
window.dispatchEvent(
new CustomEvent('imageGenerated', {
detail: imageData,
})
);
console.log('[ImageGenerationCard] Image generation successful:', imageData);
toast.success('Image generated successfully!');
} catch (err: any) {
console.error('[ImageGenerationCard] Error in handleGenerate:', {
error: err,
message: err.message,
stack: err.stack,
});
const errorMessage = err.message || 'Failed to generate image';
setError(errorMessage);
// Emit error event for ImageResultCard
window.dispatchEvent(
new CustomEvent('imageGenerationError', {
detail: errorMessage,
})
);
toast.error(errorMessage);
} finally {
console.log('[ImageGenerationCard] handleGenerate completed');
setIsGenerating(false);
}
};
// Get display name for provider and model
const getProviderDisplay = () => {
const service = imageSettings.service || 'openai';
if (service === 'openai') {
const model = imageSettings.model || 'dall-e-3';
const modelNames: Record<string, string> = {
'dall-e-3': 'DALL·E 3',
'dall-e-2': 'DALL·E 2',
'gpt-image-1': 'GPT Image 1 (Full)',
'gpt-image-1-mini': 'GPT Image 1 Mini',
};
return `OpenAI ${modelNames[model] || model}`;
} else {
return 'Runware';
}
};
// Image size options - dynamically generated based on selected model
const sizeLabels: Record<string, string> = {
'1024x1024': 'Square - 1024 x 1024',
'1024x1792': 'Portrait - 1024 x 1792',
'1792x1024': 'Landscape - 1792 x 1024',
'256x256': 'Small - 256 x 256',
'512x512': 'Medium - 512 x 512',
};
const sizeOptions = getValidSizes().map(size => ({
value: size,
label: sizeLabels[size] || size,
}));
// Image type options
const typeOptions = [
{ value: 'realistic', label: 'Realistic' },
{ value: 'illustration', label: 'Illustration' },
{ value: '3D render', label: '3D Render' },
{ value: 'minimalist', label: 'Minimalist' },
{ value: 'cartoon', label: 'Cartoon' },
];
// Format options
const formatOptions = [
{ value: 'webp', label: 'WEBP' },
{ value: 'jpg', label: 'JPG' },
{ value: 'png', label: 'PNG' },
];
return (
<article className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/3">
<div className="relative p-5 pb-6">
{icon && (
<div className="mb-4 inline-flex h-10 w-10 items-center justify-center">
{icon}
</div>
)}
<h3 className="mb-2 text-base font-semibold text-gray-800 dark:text-white/90">
{title}
</h3>
{description && (
<p className="text-sm text-gray-500 dark:text-gray-400">
{description}
</p>
)}
</div>
<div className="border-t border-gray-200 p-5 dark:border-gray-800">
<div className="space-y-5">
{/* API Provider and Model Display */}
<div className="flex items-center gap-3 rounded-lg bg-blue-50 px-4 py-3 dark:bg-blue-900/20">
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
className="text-blue-600 dark:text-blue-400"
>
<path
d="M10 2L3 7V17C3 17.5304 3.21071 18.0391 3.58579 18.4142C3.96086 18.7893 4.46957 19 5 19H15C15.5304 19 16.0391 18.7893 16.4142 18.4142C16.7893 18.0391 17 17.5304 17 17V7L10 2Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<div>
<p className="text-xs font-medium text-blue-600 dark:text-blue-400">Provider & Model</p>
<p className="text-sm font-semibold text-blue-900 dark:text-blue-200">
{getProviderDisplay()}
</p>
</div>
</div>
{/* Prompt Description - Full Width */}
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
Prompt Description *
</label>
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
rows={6}
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
placeholder="Describe the visual elements, style, mood, and composition you want in the image..."
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Describe the visual elements, style, mood, and composition you want in the image.
</p>
</div>
{/* Negative Prompt - Small */}
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
Negative Prompt
</label>
<textarea
value={negativePrompt}
onChange={(e) => setNegativePrompt(e.target.value)}
rows={2}
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
placeholder="Describe what you DON'T want in the image..."
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Specify elements to avoid in the generated image (text, watermarks, logos, etc.).
</p>
</div>
{/* 3 Column Dropdowns */}
<div className="grid grid-cols-3 gap-4">
{/* Image Type */}
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
Image Type
</label>
<select
value={imageType}
onChange={(e) => setImageType(e.target.value)}
className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
>
{typeOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
{/* Image Size */}
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
Image Size
</label>
<select
value={imageSize}
onChange={(e) => setImageSize(e.target.value)}
className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
>
{sizeOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
{/* Image Format */}
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
Image Format
</label>
<select
value={imageFormat}
onChange={(e) => setImageFormat(e.target.value)}
className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
>
{formatOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
</div>
{/* Generate Button - Bottom Right */}
<div className="flex justify-end">
<Button
onClick={handleGenerate}
disabled={isGenerating || !prompt.trim()}
className="inline-flex items-center gap-2 px-6 py-2.5"
>
{isGenerating ? (
<>
<svg
className="h-4 w-4 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-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>
Generating...
</>
) : (
<>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="9" cy="9" r="2" />
<path d="M21 15l-3.086-3.086a2 2 0 00-2.828 0L6 21" />
</svg>
Generate Image
</>
)}
</Button>
</div>
</div>
</div>
{/* Error display */}
{error && (
<div className="mt-4 rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-800 dark:bg-red-900/20">
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
)}
</article>
);
}

View File

@@ -0,0 +1,253 @@
import { ReactNode, useEffect, useState } from 'react';
interface ImageResultCardProps {
title: string;
description?: string;
icon?: ReactNode;
generatedImage?: {
url: string;
revised_prompt?: string;
model?: string;
provider?: string;
size?: string;
format?: string;
cost?: string;
} | null;
error?: string | null;
}
/**
* Image Result Display Card Component
* Displays the generated image with details
*/
export default function ImageResultCard({
title,
description,
icon,
generatedImage,
error,
}: ImageResultCardProps) {
const [imageData, setImageData] = useState(generatedImage);
const [errorState, setErrorState] = useState(error);
// Listen for image generation events from ImageGenerationCard
useEffect(() => {
const handleImageGenerated = (event: CustomEvent) => {
setImageData(event.detail);
setErrorState(null);
};
const handleImageError = (event: CustomEvent) => {
setErrorState(event.detail);
setImageData(null);
};
window.addEventListener('imageGenerated', handleImageGenerated as EventListener);
window.addEventListener('imageGenerationError', handleImageError as EventListener);
return () => {
window.removeEventListener('imageGenerated', handleImageGenerated as EventListener);
window.removeEventListener('imageGenerationError', handleImageError as EventListener);
};
}, []);
useEffect(() => {
setImageData(generatedImage);
}, [generatedImage]);
useEffect(() => {
setErrorState(error);
}, [error]);
return (
<article className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/3">
<div className="relative p-5 pb-6">
{icon && (
<div className="mb-4 inline-flex h-10 w-10 items-center justify-center">
{icon}
</div>
)}
<h3 className="mb-2 text-base font-semibold text-gray-800 dark:text-white/90">
{title}
</h3>
{description && (
<p className="text-sm text-gray-500 dark:text-gray-400">
{description}
</p>
)}
</div>
<div className="border-t border-gray-200 p-5 dark:border-gray-800">
{errorState ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="mb-4 rounded-full bg-red-100 p-4 dark:bg-red-900/20">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-red-600 dark:text-red-400"
>
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
</div>
<h4 className="mb-2 text-lg font-semibold text-gray-800 dark:text-white">
Generation Failed
</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">{errorState}</p>
</div>
) : imageData?.url ? (
<div className="space-y-5">
{/* Generated Image */}
<div className="overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700">
<img
src={imageData.url}
alt="Generated image"
className="w-full object-contain"
style={{ maxHeight: '400px' }}
/>
</div>
{/* Image Details */}
<div className="space-y-3 rounded-lg bg-gray-50 p-4 dark:bg-gray-800/50">
<h4 className="text-sm font-semibold text-gray-800 dark:text-white">
Image Details
</h4>
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<span className="font-medium text-gray-600 dark:text-gray-400">Size:</span>
<span className="ml-2 text-gray-800 dark:text-white">
{imageData.size || '1024x1024'} pixels
</span>
</div>
<div>
<span className="font-medium text-gray-600 dark:text-gray-400">Format:</span>
<span className="ml-2 text-gray-800 dark:text-white">
{imageData.format || 'WEBP'}
</span>
</div>
<div>
<span className="font-medium text-gray-600 dark:text-gray-400">Model:</span>
<span className="ml-2 text-gray-800 dark:text-white">
{imageData.model || 'DALL·E 3'}
</span>
</div>
{imageData.cost && (
<div>
<span className="font-medium text-gray-600 dark:text-gray-400">Cost:</span>
<span className="ml-2 text-gray-800 dark:text-white">{imageData.cost}</span>
</div>
)}
</div>
{/* Revised Prompt */}
{imageData.revised_prompt && (
<div className="mt-3 border-t border-gray-200 pt-3 dark:border-gray-700">
<p className="mb-2 text-xs font-medium text-gray-600 dark:text-gray-400">
Revised Prompt:
</p>
<p className="text-xs text-gray-700 dark:text-gray-300">
{imageData.revised_prompt}
</p>
</div>
)}
{/* Negative Prompt (if available) */}
{imageData.negative_prompt && (
<div className="mt-3 border-t border-gray-200 pt-3 dark:border-gray-700">
<p className="mb-2 text-xs font-medium text-gray-600 dark:text-gray-400">
Negative Prompt:
</p>
<p className="text-xs text-gray-700 dark:text-gray-300">
{imageData.negative_prompt}
</p>
</div>
)}
</div>
{/* Actions */}
<div className="flex gap-3">
<a
href={imageData.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
</svg>
View Original
</a>
<button
onClick={() => {
navigator.clipboard.writeText(imageData.url);
}}
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
Copy URL
</button>
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="mb-4 rounded-full bg-gray-100 p-4 dark:bg-gray-800">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-gray-400 dark:text-gray-500"
>
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="9" cy="9" r="2" />
<path d="M21 15l-3.086-3.086a2 2 0 00-2.828 0L6 21" />
</svg>
</div>
<p className="text-sm text-gray-400 dark:text-gray-500">
No image generated yet. Fill out the form and click "Generate Image" to create your
first AI image.
</p>
</div>
)}
</div>
</article>
);
}

View File

@@ -0,0 +1,187 @@
import { ReactNode, useState, useEffect } from 'react';
import Switch from '../form/switch/Switch';
import Button from '../ui/button/Button';
import { usePersistentToggle } from '../../hooks/usePersistentToggle';
import { useToast } from '../ui/toast/ToastContainer';
type ValidationStatus = 'not_configured' | 'pending' | 'success' | 'error';
interface ImageServiceCardProps {
icon: ReactNode;
title: string;
description: string;
validationStatus: ValidationStatus;
onSettings: () => void;
onDetails: () => void;
}
/**
* Image Generation Service Card Component
* Manages default image generation service and model selection app-wide
* This is separate from individual API integrations (OpenAI/Runware)
*/
export default function ImageServiceCard({
icon,
title,
description,
validationStatus,
onSettings,
onDetails,
}: ImageServiceCardProps) {
const toast = useToast();
// Use built-in persistent toggle for image generation service
const persistentToggle = usePersistentToggle({
resourceId: 'image_generation',
getEndpoint: '/v1/system/settings/integrations/{id}/',
saveEndpoint: '/v1/system/settings/integrations/{id}/save/',
initialEnabled: false,
onToggleSuccess: (enabled) => {
toast.success(`Image generation service ${enabled ? 'enabled' : 'disabled'}`);
},
onToggleError: (error) => {
toast.error(`Failed to update image generation service: ${error.message}`);
},
});
const enabled = persistentToggle.enabled;
const isToggling = persistentToggle.loading;
const [imageSettings, setImageSettings] = useState<{ service?: string; model?: string; runwareModel?: string }>({});
const API_BASE_URL = import.meta.env.VITE_BACKEND_URL || 'https://api.igny8.com/api';
// Load image settings to get provider and model
useEffect(() => {
const loadSettings = async () => {
try {
const response = await fetch(
`${API_BASE_URL}/v1/system/settings/integrations/image_generation/`,
{ credentials: 'include' }
);
if (response.ok) {
const data = await response.json();
if (data.success && data.data) {
setImageSettings(data.data);
}
}
} catch (error) {
console.error('Error loading image settings:', error);
}
};
loadSettings();
}, [API_BASE_URL, enabled]); // Reload when enabled changes
const handleToggle = (newEnabled: boolean) => {
persistentToggle.toggle(newEnabled);
};
// Get provider and model display text
const getProviderModelText = () => {
const service = imageSettings.service || 'openai';
if (service === 'openai') {
const model = imageSettings.model || 'dall-e-3';
const modelNames: Record<string, string> = {
'dall-e-3': 'DALL·E 3',
'dall-e-2': 'DALL·E 2',
'gpt-image-1': 'GPT Image 1 (Full)',
'gpt-image-1-mini': 'GPT Image 1 Mini',
};
return `OpenAI ${modelNames[model] || model}`;
} else if (service === 'runware') {
const model = imageSettings.runwareModel || 'runware:97@1';
// Map model ID to display name
const modelDisplayNames: Record<string, string> = {
'runware:97@1': 'HiDream-I1 Full',
'runware:gen3a_turbo': 'Gen3a Turbo',
'runware:gen3a': 'Gen3a',
};
const displayName = modelDisplayNames[model] || model;
return `Runware ${displayName}`;
}
return 'Not configured';
};
// Get text color based on provider and status
const getTextColor = () => {
const service = imageSettings.service || 'openai';
const isConfigured = service && (imageSettings.model || imageSettings.runwareModel);
// Grey if not configured or pending
if (!isConfigured || validationStatus === 'not_configured' || validationStatus === 'pending') {
return 'text-gray-400 dark:text-gray-500';
}
// Black for both OpenAI and Runware when configured
return 'text-black dark:text-white';
};
return (
<article className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/3">
<div className="relative p-5 pb-9">
<div className="mb-5 inline-flex h-10 w-10 items-center justify-center">
{icon}
</div>
<h3 className="mb-3 text-lg font-semibold text-gray-800 dark:text-white/90">
{title}
</h3>
<p className="max-w-xs text-sm text-gray-500 dark:text-gray-400">
{description}
</p>
{/* Provider + Model Text - Same size as heading */}
<div className="absolute top-5 right-5 h-fit">
<p className={`text-lg font-semibold ${getTextColor()} transition-colors duration-200`}>
{getProviderModelText()}
</p>
</div>
</div>
<div className="flex items-center justify-between border-t border-gray-200 p-5 dark:border-gray-800">
<div className="flex gap-3">
<Button
variant="outline"
size="md"
onClick={onSettings}
className="shadow-theme-xs inline-flex h-11 w-11 items-center justify-center rounded-lg border border-gray-300 text-gray-700 dark:border-gray-700 dark:text-gray-400"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
>
<path
d="M5.64615 4.59906C5.05459 4.25752 4.29808 4.46015 3.95654 5.05171L2.69321 7.23986C2.35175 7.83128 2.5544 8.58754 3.14582 8.92899C3.97016 9.40493 3.97017 10.5948 3.14583 11.0707C2.55441 11.4122 2.35178 12.1684 2.69323 12.7598L3.95657 14.948C4.2981 15.5395 5.05461 15.7422 5.64617 15.4006C6.4706 14.9247 7.50129 15.5196 7.50129 16.4715C7.50129 17.1545 8.05496 17.7082 8.73794 17.7082H11.2649C11.9478 17.7082 12.5013 17.1545 12.5013 16.4717C12.5013 15.5201 13.5315 14.9251 14.3556 15.401C14.9469 15.7423 15.7029 15.5397 16.0443 14.9485L17.3079 12.7598C17.6494 12.1684 17.4467 11.4121 16.8553 11.0707C16.031 10.5948 16.031 9.40494 16.8554 8.92902C17.4468 8.58757 17.6494 7.83133 17.3079 7.23992L16.0443 5.05123C15.7029 4.45996 14.9469 4.25737 14.3556 4.59874C13.5315 5.07456 12.5013 4.47961 12.5013 3.52798C12.5013 2.84515 11.9477 2.2915 11.2649 2.2915L8.73795 2.2915C8.05496 2.2915 7.50129 2.84518 7.50129 3.52816C7.50129 4.48015 6.47059 5.07505 5.64615 4.59906Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M12.5714 9.99977C12.5714 11.4196 11.4204 12.5706 10.0005 12.5706C8.58069 12.5706 7.42969 11.4196 7.42969 9.99977C7.42969 8.57994 8.58069 7.42894 10.0005 7.42894C11.4204 7.42894 12.5714 8.57994 12.5714 9.99977Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</Button>
<Button
variant="outline"
size="md"
onClick={onDetails}
className="shadow-theme-xs inline-flex h-11 items-center justify-center rounded-lg border border-gray-300 px-4 py-3 text-sm font-medium text-gray-700 dark:border-gray-700 dark:text-gray-400"
>
Details
</Button>
</div>
<Switch
label=""
checked={enabled}
disabled={isToggling}
onChange={handleToggle}
/>
</div>
</article>
);
}

View File

@@ -0,0 +1,216 @@
import { ReactNode } from 'react';
import Switch from '../form/switch/Switch';
import Button from '../ui/button/Button';
import { usePersistentToggle } from '../../hooks/usePersistentToggle';
import { useToast } from '../ui/toast/ToastContainer';
type ValidationStatus = 'not_configured' | 'pending' | 'success' | 'error';
interface IntegrationCardProps {
icon: ReactNode;
title: string;
description: string;
enabled?: boolean; // Optional - if not provided, will use persistent toggle hook
validationStatus: ValidationStatus; // 'not_configured' | 'pending' | 'success' | 'error'
onToggle?: (enabled: boolean) => void; // Optional - if not provided, will use persistent toggle hook
onSettings: () => void;
onDetails: () => void;
// Optional props for built-in persistence
integrationId?: string; // If provided, enables built-in persistence
getEndpoint?: string; // API endpoint pattern for loading (default if integrationId provided)
saveEndpoint?: string; // API endpoint pattern for saving (default if integrationId provided)
onToggleSuccess?: (enabled: boolean, data?: any) => void; // Callback when toggle succeeds - receives enabled state and full config data
onToggleError?: (error: Error) => void; // Callback when toggle fails
modelName?: string; // For Runware: display model name instead of status circle
}
export default function IntegrationCard({
icon,
title,
description,
enabled: externalEnabled,
validationStatus,
onToggle: externalOnToggle,
onSettings,
onDetails,
integrationId,
getEndpoint,
saveEndpoint,
onToggleSuccess: externalOnToggleSuccess,
onToggleError: externalOnToggleError,
modelName,
}: IntegrationCardProps) {
const toast = useToast();
// Use built-in persistent toggle if integrationId is provided
// This hook automatically loads state on mount and saves on toggle
// When using built-in persistence, we IGNORE external enabled prop to avoid conflicts
const persistentToggle = integrationId ? usePersistentToggle({
resourceId: integrationId,
getEndpoint: getEndpoint || '/v1/system/settings/integrations/{id}/',
saveEndpoint: saveEndpoint || '/v1/system/settings/integrations/{id}/save/',
initialEnabled: false, // Always start with false, let hook load from API
onToggleSuccess: (enabled, data) => {
// Show success toast
toast.success(`${integrationId} ${enabled ? 'enabled' : 'disabled'}`);
// Call external callbacks if provided - pass both enabled state and full config data
if (externalOnToggleSuccess) {
externalOnToggleSuccess(enabled, data);
}
// Don't call external onToggle when using built-in persistence
// The hook manages its own state, parent should not interfere
},
onToggleError: (error) => {
toast.error(`Failed to update ${integrationId}: ${error.message}`);
if (externalOnToggleError) {
externalOnToggleError(error);
}
},
}) : null;
// Determine which enabled state and toggle function to use
// When integrationId is provided, hook is the SINGLE source of truth
// When not provided, use external prop (backwards compatible)
const enabled = persistentToggle
? persistentToggle.enabled
: (externalEnabled ?? false);
const handleToggle = persistentToggle
? (newEnabled: boolean) => {
// Built-in persistence - automatically saves to backend
persistentToggle.toggle(newEnabled);
}
: (newEnabled: boolean) => {
// External handler mode - parent manages state
if (externalOnToggle) {
externalOnToggle(newEnabled);
}
};
const isToggling = persistentToggle ? persistentToggle.loading : false;
// Determine status circle color
const getStatusColor = () => {
if (!enabled || validationStatus === 'not_configured') {
return 'bg-gray-400 dark:bg-gray-500'; // Grey for disabled or not configured
}
if (validationStatus === 'pending') {
return 'bg-gray-400 dark:bg-gray-500 animate-pulse'; // Grey while validating (with pulse)
}
if (validationStatus === 'success') {
return 'bg-green-500 dark:bg-green-600'; // Green for success
}
if (validationStatus === 'error') {
return 'bg-red-500 dark:bg-red-600'; // Red for error
}
return 'bg-gray-400 dark:bg-gray-500'; // Default grey
};
// Get status text and color
const getStatusText = () => {
if (!enabled || validationStatus === 'not_configured') {
return { text: 'Disabled', color: 'text-gray-400 dark:text-gray-500', bold: false };
}
if (validationStatus === 'pending') {
return { text: 'Pending', color: 'text-gray-400 dark:text-gray-500', bold: false };
}
if (validationStatus === 'success') {
return { text: 'Enabled', color: 'text-gray-800 dark:text-white', bold: true };
}
if (validationStatus === 'error') {
return { text: 'Error', color: 'text-red-600 dark:text-red-400', bold: false };
}
return { text: 'Disabled', color: 'text-gray-400 dark:text-gray-500', bold: false };
};
const statusText = getStatusText();
return (
<article className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/3">
<div className="relative p-5 pb-9">
<div className="mb-5 inline-flex h-10 w-10 items-center justify-center">
{icon}
</div>
<h3 className="mb-3 text-lg font-semibold text-gray-800 dark:text-white/90">
{title}
</h3>
<p className="max-w-xs text-sm text-gray-500 dark:text-gray-400">
{description}
</p>
{/* Status Text and Circle - Same row */}
{/* For Runware: Show model name instead of circle */}
{integrationId === 'runware' ? (
<div className="absolute top-5 right-5">
<span className={`text-sm font-semibold ${modelName ? 'text-gray-800 dark:text-white' : 'text-gray-400 dark:text-gray-500'}`}>
{modelName || 'Disabled'}
</span>
</div>
) : (
<div className="absolute top-5 right-5 flex items-center gap-2">
<span className={`text-sm ${statusText.color} ${statusText.bold ? 'font-bold' : ''} transition-colors duration-200`}>
{statusText.text}
</span>
<div className={`w-[25px] h-[25px] rounded-full ${getStatusColor()} transition-colors duration-200`}
title={
validationStatus === 'not_configured' ? 'Not configured' :
validationStatus === 'pending' ? 'Validating...' :
validationStatus === 'success' ? 'Validated successfully' :
validationStatus === 'error' ? 'Validation failed' : 'Unknown status'
}
/>
</div>
)}
</div>
<div className="flex items-center justify-between border-t border-gray-200 p-5 dark:border-gray-800">
<div className="flex gap-3">
<Button
variant="outline"
size="md"
onClick={onSettings}
className="shadow-theme-xs inline-flex h-11 w-11 items-center justify-center rounded-lg border border-gray-300 text-gray-700 dark:border-gray-700 dark:text-gray-400"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
>
<path
d="M5.64615 4.59906C5.05459 4.25752 4.29808 4.46015 3.95654 5.05171L2.69321 7.23986C2.35175 7.83128 2.5544 8.58754 3.14582 8.92899C3.97016 9.40493 3.97017 10.5948 3.14583 11.0707C2.55441 11.4122 2.35178 12.1684 2.69323 12.7598L3.95657 14.948C4.2981 15.5395 5.05461 15.7422 5.64617 15.4006C6.4706 14.9247 7.50129 15.5196 7.50129 16.4715C7.50129 17.1545 8.05496 17.7082 8.73794 17.7082H11.2649C11.9478 17.7082 12.5013 17.1545 12.5013 16.4717C12.5013 15.5201 13.5315 14.9251 14.3556 15.401C14.9469 15.7423 15.7029 15.5397 16.0443 14.9485L17.3079 12.7598C17.6494 12.1684 17.4467 11.4121 16.8553 11.0707C16.031 10.5948 16.031 9.40494 16.8554 8.92902C17.4468 8.58757 17.6494 7.83133 17.3079 7.23992L16.0443 5.05123C15.7029 4.45996 14.9469 4.25737 14.3556 4.59874C13.5315 5.07456 12.5013 4.47961 12.5013 3.52798C12.5013 2.84515 11.9477 2.2915 11.2649 2.2915L8.73795 2.2915C8.05496 2.2915 7.50129 2.84518 7.50129 3.52816C7.50129 4.48015 6.47059 5.07505 5.64615 4.59906Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M12.5714 9.99977C12.5714 11.4196 11.4204 12.5706 10.0005 12.5706C8.58069 12.5706 7.42969 11.4196 7.42969 9.99977C7.42969 8.57994 8.58069 7.42894 10.0005 7.42894C11.4204 7.42894 12.5714 8.57994 12.5714 9.99977Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</Button>
<Button
variant="outline"
size="md"
onClick={onDetails}
className="shadow-theme-xs inline-flex h-11 items-center justify-center rounded-lg border border-gray-300 px-4 py-3 text-sm font-medium text-gray-700 dark:border-gray-700 dark:text-gray-400"
>
Details
</Button>
</div>
<Switch
label=""
checked={enabled}
disabled={isToggling}
onChange={handleToggle}
/>
</div>
</article>
);
}

View File

@@ -0,0 +1,108 @@
import { useEffect, useState, useRef } from 'react';
import { useErrorHandler } from '../../hooks/useErrorHandler';
interface LoadingState {
source: string;
startTime: number;
duration: number;
}
const loadingStates = new Map<string, LoadingState>();
const listeners = new Set<(states: LoadingState[]) => void>();
export function trackLoading(source: string, isLoading: boolean) {
if (isLoading) {
loadingStates.set(source, {
source,
startTime: Date.now(),
duration: 0,
});
} else {
loadingStates.delete(source);
}
listeners.forEach(listener => {
const states = Array.from(loadingStates.values()).map(s => ({
...s,
duration: Date.now() - s.startTime,
}));
listener(states);
});
}
export default function LoadingStateMonitor() {
const [localLoadingStates, setLocalLoadingStates] = useState<LoadingState[]>([]);
const { addError } = useErrorHandler('LoadingStateMonitor');
const reportedStuckStates = useRef<Set<string>>(new Set());
const addErrorRef = useRef(addError);
// Keep addError ref updated
useEffect(() => {
addErrorRef.current = addError;
}, [addError]);
useEffect(() => {
const updateStates = (statesFromListener: LoadingState[]) => {
// Use states from listener (always provided by trackLoading)
const states = statesFromListener.filter(s => s.duration < 60000); // Only show states less than 60 seconds old
setLocalLoadingStates(states);
// Detect stuck loading states (more than 5 seconds) - only report once per state
const stuck = states.filter(s => s.duration > 5000 && !reportedStuckStates.current.has(s.source));
if (stuck.length > 0) {
stuck.forEach(state => {
reportedStuckStates.current.add(state.source);
// Use ref to avoid dependency issues
addErrorRef.current(
new Error(`Loading state stuck: ${state.source} (${(state.duration / 1000).toFixed(1)}s)`),
'LoadingStateMonitor'
);
});
}
// Clean up reported states that are no longer stuck
const noLongerStuck = Array.from(reportedStuckStates.current).filter(
source => !states.find(s => s.source === source && s.duration > 5000)
);
noLongerStuck.forEach(source => reportedStuckStates.current.delete(source));
};
// Initial update from global Map
const initialStates = Array.from(loadingStates.values()).map(s => ({
...s,
duration: Date.now() - s.startTime,
}));
updateStates(initialStates);
listeners.add(updateStates);
// Periodic check (in case listener doesn't fire)
const interval = setInterval(() => {
const currentStates = Array.from(loadingStates.values()).map(s => ({
...s,
duration: Date.now() - s.startTime,
}));
updateStates(currentStates);
}, 1000);
return () => {
listeners.delete(updateStates);
clearInterval(interval);
};
}, []); // Empty deps - updateStates reads from global Map via listeners
// Auto-reset stuck loading states after 10 seconds
useEffect(() => {
const stuck = localLoadingStates.filter(s => s.duration > 10000);
if (stuck.length > 0) {
stuck.forEach(state => {
console.warn(`Auto-resetting stuck loading state: ${state.source}`);
trackLoading(state.source, false);
reportedStuckStates.current.delete(state.source);
});
}
}, [localLoadingStates]);
return null; // This component doesn't render anything visible
}

View File

@@ -0,0 +1,51 @@
import { Link } from "react-router";
interface BreadcrumbProps {
pageTitle: string;
}
const PageBreadcrumb: React.FC<BreadcrumbProps> = ({ pageTitle }) => {
return (
<div className="flex flex-wrap items-center justify-between gap-3 mb-6">
<h2
className="text-xl font-semibold text-gray-800 dark:text-white/90"
x-text="pageName"
>
{pageTitle}
</h2>
<nav>
<ol className="flex items-center gap-1.5">
<li>
<Link
className="inline-flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400"
to="/"
>
Home
<svg
className="stroke-current"
width="17"
height="16"
viewBox="0 0 17 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.0765 12.667L10.2432 8.50033L6.0765 4.33366"
stroke=""
strokeWidth="1.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</Link>
</li>
<li className="text-sm text-gray-800 dark:text-white/90">
{pageTitle}
</li>
</ol>
</nav>
</div>
);
};
export default PageBreadcrumb;

View File

@@ -0,0 +1,89 @@
import { Component, ErrorInfo, ReactNode } from 'react';
import { useErrorHandler } from '../../hooks/useErrorHandler';
interface Props {
children: ReactNode;
fallback?: ReactNode;
pageName?: string;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class PageErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
hasError: false,
error: null,
};
}
static getDerivedStateFromError(error: Error): State {
return {
hasError: true,
error,
};
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error(`[${this.props.pageName || 'Page'}] Error:`, error, errorInfo);
// Error will be caught by GlobalErrorDisplay via useErrorHandler
}
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div className="flex items-center justify-center min-h-[400px] p-6">
<div className="text-center max-w-md">
<div className="text-6xl mb-4"></div>
<h2 className="text-2xl font-semibold text-gray-800 dark:text-white mb-4">
Something went wrong
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-4">
{this.state.error?.message || 'An unexpected error occurred on this page'}
</p>
<div className="space-x-3">
<button
onClick={() => {
this.setState({ hasError: false, error: null });
window.location.reload();
}}
className="px-4 py-2 bg-brand-500 text-white rounded-lg hover:bg-brand-600"
>
Reload Page
</button>
<button
onClick={() => {
this.setState({ hasError: false, error: null });
}}
className="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-white rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600"
>
Try Again
</button>
</div>
{import.meta.env.DEV && this.state.error && (
<details className="mt-4 text-left">
<summary className="cursor-pointer text-sm text-gray-500 dark:text-gray-400">
Error Details (Dev Mode)
</summary>
<pre className="mt-2 text-xs bg-gray-100 dark:bg-gray-800 p-2 rounded overflow-auto max-h-64">
{this.state.error.stack}
</pre>
</details>
)}
</div>
</div>
);
}
return this.props.children;
}
}

View File

@@ -0,0 +1,20 @@
import { HelmetProvider, Helmet } from "react-helmet-async";
const PageMeta = ({
title,
description,
}: {
title: string;
description: string;
}) => (
<Helmet>
<title>{title}</title>
<meta name="description" content={description} />
</Helmet>
);
export const AppWrapper = ({ children }: { children: React.ReactNode }) => (
<HelmetProvider>{children}</HelmetProvider>
);
export default PageMeta;

View File

@@ -0,0 +1,201 @@
import React, { useEffect } from 'react';
import { Modal } from '../ui/modal';
import { ProgressBar } from '../ui/progress';
import Button from '../ui/button/Button';
export interface ProgressModalProps {
isOpen: boolean;
title: string;
percentage: number; // 0-100
status: 'pending' | 'processing' | 'completed' | 'error';
message: string;
details?: {
current: number;
total: number;
completed: number;
currentItem?: string;
phase?: string;
};
onClose?: () => void;
onCancel?: () => void;
taskId?: string;
}
export default function ProgressModal({
isOpen,
title,
percentage,
status,
message,
details,
onClose,
onCancel,
taskId,
}: ProgressModalProps) {
// Auto-close on completion after 2 seconds
// Don't auto-close on error - let user manually close to see error details
useEffect(() => {
if (status === 'completed' && onClose) {
const timer = setTimeout(() => {
onClose();
}, 2000);
return () => clearTimeout(timer);
}
// Don't auto-close on error - user should manually dismiss
}, [status, onClose]);
// Determine color based on status
const getProgressColor = (): 'primary' | 'success' | 'error' | 'warning' => {
if (status === 'error') return 'error';
if (status === 'completed') return 'success';
if (status === 'processing') return 'primary';
return 'primary';
};
// Get status icon
const getStatusIcon = () => {
if (status === 'completed') {
return (
<svg
className="w-6 h-6 text-success-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
);
}
if (status === 'error') {
return (
<svg
className="w-6 h-6 text-error-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
);
}
// Processing/Pending - spinner
return (
<svg
className="w-6 h-6 text-brand-500 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-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>
);
};
return (
<Modal
isOpen={isOpen}
onClose={onClose || (() => {})}
className="max-w-lg"
showCloseButton={status === 'completed' || status === 'error'}
>
<div className="p-6">
{/* Header */}
<div className="flex items-start gap-4 mb-6">
<div className="flex-shrink-0 mt-1">{getStatusIcon()}</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-1">
{title}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">{message}</p>
</div>
</div>
{/* Progress Bar */}
<div className="mb-6">
<ProgressBar
value={percentage}
color={getProgressColor()}
size="lg"
showLabel={true}
label={`${Math.round(percentage)}%`}
/>
</div>
{/* Details */}
{details && (
<div className="mb-6 space-y-2">
{details.currentItem && (
<div className="text-sm text-gray-700 dark:text-gray-300">
<span className="font-medium">Current:</span>{' '}
<span className="text-gray-600 dark:text-gray-400">
{details.currentItem}
</span>
</div>
)}
{details.total > 0 && (
<div className="text-sm text-gray-700 dark:text-gray-300">
<span className="font-medium">Progress:</span>{' '}
<span className="text-gray-600 dark:text-gray-400">
{details.current} of {details.total} completed
</span>
</div>
)}
{details.phase && (
<div className="text-xs text-gray-500 dark:text-gray-500">
Phase: {details.phase}
</div>
)}
</div>
)}
{/* Task ID (for debugging) */}
{taskId && import.meta.env.DEV && (
<div className="mb-4 text-xs text-gray-400 dark:text-gray-600">
Task ID: {taskId}
</div>
)}
{/* Footer */}
<div className="flex justify-end gap-3">
{onCancel && status !== 'completed' && status !== 'error' && (
<Button
variant="secondary"
size="sm"
onClick={onCancel}
disabled={status === 'processing'}
>
Cancel
</Button>
)}
{(status === 'completed' || status === 'error') && onClose && (
<Button variant="primary" size="sm" onClick={onClose}>
{status === 'completed' ? 'Close' : 'Dismiss'}
</Button>
)}
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,16 @@
import { useEffect } from "react";
import { useLocation } from "react-router";
export function ScrollToTop() {
const { pathname } = useLocation();
useEffect(() => {
window.scrollTo({
top: 0,
left: 0,
behavior: "smooth",
});
}, [pathname]);
return null;
}

View File

@@ -0,0 +1,149 @@
/**
* Sector Selector Component
* Displays a dropdown to select sectors for the active site
* Used in the header area of pages that need sector filtering
*/
import { useState, useEffect, useRef } from 'react';
import { Dropdown } from '../ui/dropdown/Dropdown';
import { DropdownItem } from '../ui/dropdown/DropdownItem';
import { useSectorStore } from '../../store/sectorStore';
import { useSiteStore } from '../../store/siteStore';
export default function SectorSelector() {
const { activeSite } = useSiteStore();
const { activeSector, sectors, setActiveSector, loading } = useSectorStore();
const [isOpen, setIsOpen] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(null);
// Don't render if no active site
if (!activeSite) {
return null;
}
// Don't render if no sectors available
if (!loading && sectors.length === 0) {
return (
<div className="flex items-center gap-2 px-3 py-2 text-sm text-gray-500 dark:text-gray-400">
<span>No sectors available</span>
</div>
);
}
const handleSectorSelect = (sectorId: number | null) => {
if (sectorId === null) {
// "All Sectors" option
setActiveSector(null);
setIsOpen(false);
} else {
const sector = sectors.find(s => s.id === sectorId);
if (sector) {
setActiveSector(sector);
setIsOpen(false);
}
}
};
return (
<div className="relative inline-block">
<button
ref={buttonRef}
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-700 dark:hover:bg-gray-700 dropdown-toggle"
aria-label="Select sector"
disabled={loading || sectors.length === 0}
>
<span className="flex items-center gap-2">
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
<span className="max-w-[150px] truncate">
{loading ? 'Loading...' : activeSector?.name || 'All Sectors'}
</span>
</span>
<svg
className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
<Dropdown
isOpen={isOpen}
onClose={() => setIsOpen(false)}
anchorRef={buttonRef}
placement="bottom-right"
className="w-64 p-2 overflow-y-auto max-h-[300px]"
>
{/* "All Sectors" option */}
<DropdownItem
onItemClick={() => handleSectorSelect(null)}
className={`flex items-center gap-3 px-3 py-2 font-medium rounded-lg text-sm text-left ${
!activeSector
? "bg-blue-50 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300"
: "text-gray-700 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300"
}`}
>
<span className="flex-1">All Sectors</span>
{!activeSector && (
<svg
className="w-4 h-4 text-blue-600 dark:text-blue-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
)}
</DropdownItem>
{sectors.map((sector) => (
<DropdownItem
key={sector.id}
onItemClick={() => handleSectorSelect(sector.id)}
className={`flex items-center gap-3 px-3 py-2 font-medium rounded-lg text-sm text-left ${
activeSector?.id === sector.id
? "bg-blue-50 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300"
: "text-gray-700 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300"
}`}
>
<span className="flex-1">{sector.name}</span>
{activeSector?.id === sector.id && (
<svg
className="w-4 h-4 text-blue-600 dark:text-blue-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
)}
</DropdownItem>
))}
</Dropdown>
</div>
);
}

View File

@@ -0,0 +1,137 @@
import { ReactNode } from 'react';
import Switch from '../form/switch/Switch';
import Button from '../ui/button/Button';
import Badge from '../ui/badge/Badge';
import { Site } from '../../services/api';
interface SiteCardProps {
site: Site;
icon: ReactNode;
onToggle: (siteId: number, enabled: boolean) => void;
onSettings: (site: Site) => void;
onDetails: (site: Site) => void;
isToggling?: boolean;
}
export default function SiteCard({
site,
icon,
onToggle,
onSettings,
onDetails,
isToggling = false,
}: SiteCardProps) {
const handleToggle = (enabled: boolean) => {
onToggle(site.id, enabled);
};
const getStatusColor = () => {
if (site.is_active) {
return 'bg-green-500 dark:bg-green-600';
}
return 'bg-gray-400 dark:bg-gray-500';
};
const getStatusText = () => {
if (site.is_active) {
return { text: 'Active', color: 'text-green-600 dark:text-green-400', bold: true };
}
return { text: 'Inactive', color: 'text-gray-400 dark:text-gray-500', bold: false };
};
const statusText = getStatusText();
return (
<article className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/3">
<div className="relative p-5 pb-9">
<div className="mb-5 inline-flex h-10 w-10 items-center justify-center">
{icon}
</div>
<h3 className="mb-3 text-lg font-semibold text-gray-800 dark:text-white/90">
{site.name}
</h3>
<p className="max-w-xs text-sm text-gray-500 dark:text-gray-400 mb-2">
{site.description || 'No description'}
</p>
{site.domain && (
<p className="text-xs text-gray-400 dark:text-gray-500 mb-2">
{site.domain}
</p>
)}
<div className="flex items-center gap-2 mb-2 flex-wrap">
{site.industry_name && (
<Badge variant="light" color="info" className="text-xs">
{site.industry_name}
</Badge>
)}
<Badge variant="light" color="info" className="text-xs">
{site.active_sectors_count} / 5 Sectors
</Badge>
{site.status && (
<Badge variant="light" color={site.status === 'active' ? 'success' : 'dark'} className="text-xs">
{site.status}
</Badge>
)}
</div>
{/* Status Text and Circle - Same row */}
<div className="absolute top-5 right-5 flex items-center gap-2">
<span className={`text-sm ${statusText.color} ${statusText.bold ? 'font-bold' : ''} transition-colors duration-200`}>
{statusText.text}
</span>
<div
className={`w-[25px] h-[25px] rounded-full ${getStatusColor()} transition-colors duration-200`}
title={site.is_active ? 'Active site' : 'Inactive site'}
/>
</div>
</div>
<div className="flex items-center justify-between border-t border-gray-200 p-5 dark:border-gray-800">
<div className="flex gap-3">
<Button
variant="outline"
size="md"
onClick={() => onSettings(site)}
className="shadow-theme-xs inline-flex h-11 w-11 items-center justify-center rounded-lg border border-gray-300 text-gray-700 dark:border-gray-700 dark:text-gray-400"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
>
<path
d="M5.64615 4.59906C5.05459 4.25752 4.29808 4.46015 3.95654 5.05171L2.69321 7.23986C2.35175 7.83128 2.5544 8.58754 3.14582 8.92899C3.97016 9.40493 3.97017 10.5948 3.14583 11.0707C2.55441 11.4122 2.35178 12.1684 2.69323 12.7598L3.95657 14.948C4.2981 15.5395 5.05461 15.7422 5.64617 15.4006C6.4706 14.9247 7.50129 15.5196 7.50129 16.4715C7.50129 17.1545 8.05496 17.7082 8.73794 17.7082H11.2649C11.9478 17.7082 12.5013 17.1545 12.5013 16.4717C12.5013 15.5201 13.5315 14.9251 14.3556 15.401C14.9469 15.7423 15.7029 15.5397 16.0443 14.9485L17.3079 12.7598C17.6494 12.1684 17.4467 11.4121 16.8553 11.0707C16.031 10.5948 16.031 9.40494 16.8554 8.92902C17.4468 8.58757 17.6494 7.83133 17.3079 7.23992L16.0443 5.05123C15.7029 4.45996 14.9469 4.25737 14.3556 4.59874C13.5315 5.07456 12.5013 4.47961 12.5013 3.52798C12.5013 2.84515 11.9477 2.2915 11.2649 2.2915L8.73795 2.2915C8.05496 2.2915 7.50129 2.84518 7.50129 3.52816C7.50129 4.48015 6.47059 5.07505 5.64615 4.59906Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M12.5714 9.99977C12.5714 11.4196 11.4204 12.5706 10.0005 12.5706C8.58069 12.5706 7.42969 11.4196 7.42969 9.99977C7.42969 8.57994 8.58069 7.42894 10.0005 7.42894C11.4204 7.42894 12.5714 8.57994 12.5714 9.99977Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</Button>
<Button
variant="outline"
size="md"
onClick={() => onDetails(site)}
className="shadow-theme-xs inline-flex h-11 items-center justify-center rounded-lg border border-gray-300 px-4 py-3 text-sm font-medium text-gray-700 dark:border-gray-700 dark:text-gray-400"
>
Details
</Button>
</div>
<Switch
label=""
checked={site.is_active}
disabled={isToggling}
onChange={handleToggle}
/>
</div>
</article>
);
}

View File

@@ -0,0 +1,41 @@
import { useTheme } from "../../context/ThemeContext";
export const ThemeToggleButton: React.FC = () => {
const { toggleTheme } = useTheme();
return (
<button
onClick={toggleTheme}
className="relative flex items-center justify-center text-gray-500 transition-colors bg-white border border-gray-200 rounded-full hover:text-dark-900 h-11 w-11 hover:bg-gray-100 hover:text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
>
<svg
className="hidden dark:block"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M9.99998 1.5415C10.4142 1.5415 10.75 1.87729 10.75 2.2915V3.5415C10.75 3.95572 10.4142 4.2915 9.99998 4.2915C9.58577 4.2915 9.24998 3.95572 9.24998 3.5415V2.2915C9.24998 1.87729 9.58577 1.5415 9.99998 1.5415ZM10.0009 6.79327C8.22978 6.79327 6.79402 8.22904 6.79402 10.0001C6.79402 11.7712 8.22978 13.207 10.0009 13.207C11.772 13.207 13.2078 11.7712 13.2078 10.0001C13.2078 8.22904 11.772 6.79327 10.0009 6.79327ZM5.29402 10.0001C5.29402 7.40061 7.40135 5.29327 10.0009 5.29327C12.6004 5.29327 14.7078 7.40061 14.7078 10.0001C14.7078 12.5997 12.6004 14.707 10.0009 14.707C7.40135 14.707 5.29402 12.5997 5.29402 10.0001ZM15.9813 5.08035C16.2742 4.78746 16.2742 4.31258 15.9813 4.01969C15.6884 3.7268 15.2135 3.7268 14.9207 4.01969L14.0368 4.90357C13.7439 5.19647 13.7439 5.67134 14.0368 5.96423C14.3297 6.25713 14.8045 6.25713 15.0974 5.96423L15.9813 5.08035ZM18.4577 10.0001C18.4577 10.4143 18.1219 10.7501 17.7077 10.7501H16.4577C16.0435 10.7501 15.7077 10.4143 15.7077 10.0001C15.7077 9.58592 16.0435 9.25013 16.4577 9.25013H17.7077C18.1219 9.25013 18.4577 9.58592 18.4577 10.0001ZM14.9207 15.9806C15.2135 16.2735 15.6884 16.2735 15.9813 15.9806C16.2742 15.6877 16.2742 15.2128 15.9813 14.9199L15.0974 14.036C14.8045 13.7431 14.3297 13.7431 14.0368 14.036C13.7439 14.3289 13.7439 14.8038 14.0368 15.0967L14.9207 15.9806ZM9.99998 15.7088C10.4142 15.7088 10.75 16.0445 10.75 16.4588V17.7088C10.75 18.123 10.4142 18.4588 9.99998 18.4588C9.58577 18.4588 9.24998 18.123 9.24998 17.7088V16.4588C9.24998 16.0445 9.58577 15.7088 9.99998 15.7088ZM5.96356 15.0972C6.25646 14.8043 6.25646 14.3295 5.96356 14.0366C5.67067 13.7437 5.1958 13.7437 4.9029 14.0366L4.01902 14.9204C3.72613 15.2133 3.72613 15.6882 4.01902 15.9811C4.31191 16.274 4.78679 16.274 5.07968 15.9811L5.96356 15.0972ZM4.29224 10.0001C4.29224 10.4143 3.95645 10.7501 3.54224 10.7501H2.29224C1.87802 10.7501 1.54224 10.4143 1.54224 10.0001C1.54224 9.58592 1.87802 9.25013 2.29224 9.25013H3.54224C3.95645 9.25013 4.29224 9.58592 4.29224 10.0001ZM4.9029 5.9637C5.1958 6.25659 5.67067 6.25659 5.96356 5.9637C6.25646 5.6708 6.25646 5.19593 5.96356 4.90303L5.07968 4.01915C4.78679 3.72626 4.31191 3.72626 4.01902 4.01915C3.72613 4.31204 3.72613 4.78692 4.01902 5.07981L4.9029 5.9637Z"
fill="currentColor"
/>
</svg>
<svg
className="dark:hidden"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M17.4547 11.97L18.1799 12.1611C18.265 11.8383 18.1265 11.4982 17.8401 11.3266C17.5538 11.1551 17.1885 11.1934 16.944 11.4207L17.4547 11.97ZM8.0306 2.5459L8.57989 3.05657C8.80718 2.81209 8.84554 2.44682 8.67398 2.16046C8.50243 1.8741 8.16227 1.73559 7.83948 1.82066L8.0306 2.5459ZM12.9154 13.0035C9.64678 13.0035 6.99707 10.3538 6.99707 7.08524H5.49707C5.49707 11.1823 8.81835 14.5035 12.9154 14.5035V13.0035ZM16.944 11.4207C15.8869 12.4035 14.4721 13.0035 12.9154 13.0035V14.5035C14.8657 14.5035 16.6418 13.7499 17.9654 12.5193L16.944 11.4207ZM16.7295 11.7789C15.9437 14.7607 13.2277 16.9586 10.0003 16.9586V18.4586C13.9257 18.4586 17.2249 15.7853 18.1799 12.1611L16.7295 11.7789ZM10.0003 16.9586C6.15734 16.9586 3.04199 13.8433 3.04199 10.0003H1.54199C1.54199 14.6717 5.32892 18.4586 10.0003 18.4586V16.9586ZM3.04199 10.0003C3.04199 6.77289 5.23988 4.05695 8.22173 3.27114L7.83948 1.82066C4.21532 2.77574 1.54199 6.07486 1.54199 10.0003H3.04199ZM6.99707 7.08524C6.99707 5.52854 7.5971 4.11366 8.57989 3.05657L7.48132 2.03522C6.25073 3.35885 5.49707 5.13487 5.49707 7.08524H6.99707Z"
fill="currentColor"
/>
</svg>
</button>
);
};

View File

@@ -0,0 +1,40 @@
import { useTheme } from "../../context/ThemeContext";
export default function ThemeTogglerTwo() {
const { toggleTheme } = useTheme();
return (
<button
onClick={toggleTheme}
className="inline-flex items-center justify-center text-white transition-colors rounded-full size-14 bg-brand-500 hover:bg-brand-600"
>
<svg
className="hidden dark:block"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M9.99998 1.5415C10.4142 1.5415 10.75 1.87729 10.75 2.2915V3.5415C10.75 3.95572 10.4142 4.2915 9.99998 4.2915C9.58577 4.2915 9.24998 3.95572 9.24998 3.5415V2.2915C9.24998 1.87729 9.58577 1.5415 9.99998 1.5415ZM10.0009 6.79327C8.22978 6.79327 6.79402 8.22904 6.79402 10.0001C6.79402 11.7712 8.22978 13.207 10.0009 13.207C11.772 13.207 13.2078 11.7712 13.2078 10.0001C13.2078 8.22904 11.772 6.79327 10.0009 6.79327ZM5.29402 10.0001C5.29402 7.40061 7.40135 5.29327 10.0009 5.29327C12.6004 5.29327 14.7078 7.40061 14.7078 10.0001C14.7078 12.5997 12.6004 14.707 10.0009 14.707C7.40135 14.707 5.29402 12.5997 5.29402 10.0001ZM15.9813 5.08035C16.2742 4.78746 16.2742 4.31258 15.9813 4.01969C15.6884 3.7268 15.2135 3.7268 14.9207 4.01969L14.0368 4.90357C13.7439 5.19647 13.7439 5.67134 14.0368 5.96423C14.3297 6.25713 14.8045 6.25713 15.0974 5.96423L15.9813 5.08035ZM18.4577 10.0001C18.4577 10.4143 18.1219 10.7501 17.7077 10.7501H16.4577C16.0435 10.7501 15.7077 10.4143 15.7077 10.0001C15.7077 9.58592 16.0435 9.25013 16.4577 9.25013H17.7077C18.1219 9.25013 18.4577 9.58592 18.4577 10.0001ZM14.9207 15.9806C15.2135 16.2735 15.6884 16.2735 15.9813 15.9806C16.2742 15.6877 16.2742 15.2128 15.9813 14.9199L15.0974 14.036C14.8045 13.7431 14.3297 13.7431 14.0368 14.036C13.7439 14.3289 13.7439 14.8038 14.0368 15.0967L14.9207 15.9806ZM9.99998 15.7088C10.4142 15.7088 10.75 16.0445 10.75 16.4588V17.7088C10.75 18.123 10.4142 18.4588 9.99998 18.4588C9.58577 18.4588 9.24998 18.123 9.24998 17.7088V16.4588C9.24998 16.0445 9.58577 15.7088 9.99998 15.7088ZM5.96356 15.0972C6.25646 14.8043 6.25646 14.3295 5.96356 14.0366C5.67067 13.7437 5.1958 13.7437 4.9029 14.0366L4.01902 14.9204C3.72613 15.2133 3.72613 15.6882 4.01902 15.9811C4.31191 16.274 4.78679 16.274 5.07968 15.9811L5.96356 15.0972ZM4.29224 10.0001C4.29224 10.4143 3.95645 10.7501 3.54224 10.7501H2.29224C1.87802 10.7501 1.54224 10.4143 1.54224 10.0001C1.54224 9.58592 1.87802 9.25013 2.29224 9.25013H3.54224C3.95645 9.25013 4.29224 9.58592 4.29224 10.0001ZM4.9029 5.9637C5.1958 6.25659 5.67067 6.25659 5.96356 5.9637C6.25646 5.6708 6.25646 5.19593 5.96356 4.90303L5.07968 4.01915C4.78679 3.72626 4.31191 3.72626 4.01902 4.01915C3.72613 4.31204 3.72613 4.78692 4.01902 5.07981L4.9029 5.9637Z"
fill="currentColor"
/>
</svg>
<svg
className="dark:hidden"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M17.4547 11.97L18.1799 12.1611C18.265 11.8383 18.1265 11.4982 17.8401 11.3266C17.5538 11.1551 17.1885 11.1934 16.944 11.4207L17.4547 11.97ZM8.0306 2.5459L8.57989 3.05657C8.80718 2.81209 8.84554 2.44682 8.67398 2.16046C8.50243 1.8741 8.16227 1.73559 7.83948 1.82066L8.0306 2.5459ZM12.9154 13.0035C9.64678 13.0035 6.99707 10.3538 6.99707 7.08524H5.49707C5.49707 11.1823 8.81835 14.5035 12.9154 14.5035V13.0035ZM16.944 11.4207C15.8869 12.4035 14.4721 13.0035 12.9154 13.0035V14.5035C14.8657 14.5035 16.6418 13.7499 17.9654 12.5193L16.944 11.4207ZM16.7295 11.7789C15.9437 14.7607 13.2277 16.9586 10.0003 16.9586V18.4586C13.9257 18.4586 17.2249 15.7853 18.1799 12.1611L16.7295 11.7789ZM10.0003 16.9586C6.15734 16.9586 3.04199 13.8433 3.04199 10.0003H1.54199C1.54199 14.6717 5.32892 18.4586 10.0003 18.4586V16.9586ZM3.04199 10.0003C3.04199 6.77289 5.23988 4.05695 8.22173 3.27114L7.83948 1.82066C4.21532 2.77574 1.54199 6.07486 1.54199 10.0003H3.04199ZM6.99707 7.08524C6.99707 5.52854 7.5971 4.11366 8.57989 3.05657L7.48132 2.03522C6.25073 3.35885 5.49707 5.13487 5.49707 7.08524H6.99707Z"
fill="currentColor"
/>
</svg>
</button>
);
}

View File

@@ -0,0 +1,173 @@
/**
* ToggleTableRow Component
* Reusable component for displaying long HTML content in table rows with expand/collapse functionality
*/
import React, { useState, useRef, useEffect } from 'react';
import { ChevronDownIcon, HorizontaLDots } from '../../icons';
import HTMLContentRenderer from './HTMLContentRenderer';
interface ToggleTableRowProps {
/** The row data */
row: any;
/** The column key that contains the toggleable content */
contentKey: string;
/** Custom label for the expanded content (e.g., "Content Outline", "Description") */
contentLabel?: string;
/** Column span for the expanded row (should match number of columns in table) */
colSpan: number;
/** Whether the row is expanded (controlled) */
isExpanded?: boolean;
/** Whether the row is initially expanded (uncontrolled) */
defaultExpanded?: boolean;
/** Callback when toggle state changes */
onToggle?: (expanded: boolean, rowId: string | number) => void;
/** Custom className */
className?: string;
}
const ToggleTableRow: React.FC<ToggleTableRowProps> = ({
row,
contentKey,
contentLabel = 'Content',
colSpan,
isExpanded: controlledExpanded,
defaultExpanded = false,
onToggle,
className = '',
}) => {
// Use controlled state if provided, otherwise use internal state
const [internalExpanded, setInternalExpanded] = useState(defaultExpanded);
const isExpanded = controlledExpanded !== undefined ? controlledExpanded : internalExpanded;
const [contentHeight, setContentHeight] = useState<number | 'auto'>('auto');
const contentRef = useRef<HTMLDivElement>(null);
// Get content - handle fallback to description if primary contentKey is empty
let content = row[contentKey];
if (!content || (typeof content === 'string' && content.trim().length === 0)) {
// Try fallback to description if primary content is empty
content = row.description || row.content_outline || null;
}
// Check if content exists - handle both strings and objects
const hasContent = content && (
typeof content === 'string'
? content.trim().length > 0
: typeof content === 'object' && content !== null && Object.keys(content).length > 0
);
useEffect(() => {
if (isExpanded && contentRef.current) {
// Measure content height for smooth animation
const height = contentRef.current.scrollHeight;
setContentHeight(height);
} else {
setContentHeight(0);
}
}, [isExpanded, content]);
const handleToggle = () => {
if (!hasContent) return;
const newExpanded = !isExpanded;
// Update internal state if uncontrolled
if (controlledExpanded === undefined) {
setInternalExpanded(newExpanded);
}
// Notify parent
if (onToggle) {
onToggle(newExpanded, row.id ?? row.id);
}
};
if (!hasContent) {
return null;
}
// Don't render anything if not expanded - no row HTML before toggle
if (!isExpanded) {
return null;
}
return (
<tr
className={`toggle-content-row expanded ${className}`}
aria-hidden={false}
>
<td
colSpan={colSpan}
className="px-5 py-0 bg-gray-50 dark:bg-gray-800/50 border-b border-gray-200 dark:border-white/[0.05]"
>
<div
ref={contentRef}
className="overflow-hidden"
>
<div className="py-4 px-2">
<div className="mb-2 text-xs font-semibold uppercase text-gray-500 dark:text-gray-400 tracking-wide">
{contentLabel}
</div>
<div className="html-content-wrapper">
<HTMLContentRenderer
content={content}
className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed"
/>
</div>
</div>
</div>
</td>
</tr>
);
};
/**
* Toggle Button Component - To be used in table cells
*/
interface ToggleButtonProps {
/** Whether the row is expanded */
isExpanded: boolean;
/** Click handler */
onClick: () => void;
/** Whether content exists */
hasContent: boolean;
/** Custom className */
className?: string;
}
export const ToggleButton: React.FC<ToggleButtonProps> = ({
isExpanded,
onClick,
hasContent,
className = '',
}) => {
if (!hasContent) {
return (
<span className={`inline-flex items-center justify-center w-8 h-8 text-gray-300 dark:text-gray-600 ${className}`}>
<HorizontaLDots className="w-4 h-4" />
</span>
);
}
return (
<button
type="button"
onClick={onClick}
className={`inline-flex items-center justify-center w-8 h-8 rounded-lg transition-all duration-200 ${
isExpanded
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'
} ${className}`}
aria-label={isExpanded ? 'Collapse content' : 'Expand content'}
aria-expanded={isExpanded}
>
<ChevronDownIcon
className={`w-4 h-4 transition-transform duration-200 ${
isExpanded ? 'rotate-180' : ''
}`}
/>
</button>
);
};
export default ToggleTableRow;

View File

@@ -0,0 +1,269 @@
import { ReactNode, useState } from 'react';
import Button from '../ui/button/Button';
import { fetchAPI } from '../../services/api';
interface ValidationCardProps {
title: string;
description?: string;
integrationId: string;
icon?: ReactNode;
}
interface TestResult {
success: boolean;
message: string;
model_used?: string;
response?: string;
tokens_used?: string;
total_tokens?: number;
cost?: string;
full_response?: any;
}
/**
* Validation Card Component
* Two-way response validation testing for OpenAI API
* Matches reference plugin implementation exactly
*/
export default function ValidationCard({
title,
description,
integrationId,
icon,
}: ValidationCardProps) {
const [isLoading, setIsLoading] = useState(false);
const [testResult, setTestResult] = useState<TestResult | null>(null);
const [withResponse, setWithResponse] = useState(false);
// Support OpenAI and Runware
if (integrationId !== 'openai' && integrationId !== 'runware') {
return null;
}
const testApiConnection = async (withResponseTest: boolean = false) => {
setIsLoading(true);
setWithResponse(withResponseTest);
setTestResult(null);
try {
// Get saved settings to get API key and model
const settingsData = await fetchAPI(`/v1/system/settings/integrations/${integrationId}/`);
let apiKey = '';
let model = 'gpt-4.1';
if (settingsData.success && settingsData.data) {
apiKey = settingsData.data.apiKey || '';
model = settingsData.data.model || 'gpt-4.1';
}
if (!apiKey) {
setTestResult({
success: false,
message: 'API key not configured. Please configure your API key in settings first.',
});
setIsLoading(false);
return;
}
// Call test endpoint
// For Runware, we don't need with_response or model config
const requestBody: any = {
apiKey: apiKey,
};
if (integrationId === 'openai') {
requestBody.config = {
model: model,
with_response: withResponseTest,
};
}
const data = await fetchAPI(`/v1/system/settings/integrations/${integrationId}/test/`, {
method: 'POST',
body: JSON.stringify(requestBody),
});
if (data.success) {
setTestResult({
success: true,
message: data.message || 'API connection successful!',
model_used: data.model_used || data.model,
response: data.response,
tokens_used: data.tokens_used,
total_tokens: data.total_tokens,
cost: data.cost,
full_response: data.full_response || {
image_url: data.image_url,
provider: data.provider,
size: data.size,
},
});
} else {
setTestResult({
success: false,
message: data.error || data.message || 'API connection failed',
});
}
} catch (error: any) {
setTestResult({
success: false,
message: `API connection failed: ${error.message || 'Unknown error'}`,
});
} finally {
setIsLoading(false);
}
};
return (
<article className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/3">
<div className="relative p-5 pb-6">
<h3 className="mb-2 text-base font-semibold text-gray-800 dark:text-white/90">
{title}
</h3>
{description && (
<p className="text-sm text-gray-500 dark:text-gray-400">
{description}
</p>
)}
</div>
<div className="border-t border-gray-200 p-5 dark:border-gray-800">
<div className="space-y-4">
{/* Test Buttons */}
<div className="flex gap-3">
{integrationId === 'openai' ? (
<>
<Button
variant="outline"
onClick={() => testApiConnection(false)}
disabled={isLoading}
className="flex-1"
>
{isLoading && !withResponse ? 'Testing...' : 'Test OpenAI Connection'}
</Button>
<Button
variant="outline"
onClick={() => testApiConnection(true)}
disabled={isLoading}
className="flex-1"
>
{isLoading && withResponse ? 'Testing...' : 'Test OpenAI Response (Ping)'}
</Button>
</>
) : (
// Runware: Single button for 128x128 image generation validation
<Button
variant="outline"
onClick={() => testApiConnection(false)}
disabled={isLoading}
className="flex-1"
>
{isLoading ? 'Testing...' : 'Test Runware Connection'}
</Button>
)}
</div>
{/* Test Results */}
{testResult && (
<div className="space-y-3">
{/* Success Message */}
{testResult.success && (
<div className="flex items-center gap-2 text-green-600 dark:text-green-400">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
<span className="text-sm font-medium">{testResult.message}</span>
</div>
)}
{/* Error Message */}
{!testResult.success && (
<div className="flex items-center gap-2 text-red-600 dark:text-red-400">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
<span className="text-sm font-medium">{testResult.message}</span>
</div>
)}
{/* Detailed Results Box */}
{testResult.success && (
<div className="bg-blue-50 dark:bg-blue-900/20 border-l-4 border-blue-500 p-4 rounded">
<div className="space-y-2 text-sm">
{integrationId === 'openai' && withResponse ? (
// OpenAI response test details
<>
<div>
<strong className="text-gray-700 dark:text-gray-300">Model Used:</strong>{' '}
<span className="text-gray-900 dark:text-white font-mono-custom">{testResult.model_used || 'N/A'}</span>
</div>
<div>
<strong className="text-gray-700 dark:text-gray-300">Expected:</strong>{' '}
<span className="text-gray-900 dark:text-white">"OK! Ping Received"</span>
</div>
<div>
<strong className="text-gray-700 dark:text-gray-300">Actual Response:</strong>{' '}
<span className="text-gray-900 dark:text-white">"{testResult.response || 'N/A'}"</span>
</div>
<div>
<strong className="text-gray-700 dark:text-gray-300">Token Limit Sent:</strong>{' '}
<span className="text-gray-900 dark:text-white">N/A (from your settings)</span>
</div>
<div>
<strong className="text-gray-700 dark:text-gray-300">Tokens Used:</strong>{' '}
<span className="text-gray-900 dark:text-white">{testResult.tokens_used || 'N/A'} (input/output)</span>
</div>
<div>
<strong className="text-gray-700 dark:text-gray-300">Total Tokens:</strong>{' '}
<span className="text-gray-900 dark:text-white">{testResult.total_tokens || 'N/A'}</span>
</div>
<div>
<strong className="text-gray-700 dark:text-gray-300">Cost:</strong>{' '}
<span className="text-gray-900 dark:text-white">{testResult.cost || '$0.0000'}</span>
</div>
</>
) : integrationId === 'runware' ? (
// Runware image generation test details
<>
<div>
<strong className="text-gray-700 dark:text-gray-300">Provider:</strong>{' '}
<span className="text-gray-900 dark:text-white">Runware</span>
</div>
<div>
<strong className="text-gray-700 dark:text-gray-300">Model:</strong>{' '}
<span className="text-gray-900 dark:text-white font-mono-custom">{testResult.model_used || 'runware:97@1'}</span>
</div>
<div>
<strong className="text-gray-700 dark:text-gray-300">Image Size:</strong>{' '}
<span className="text-gray-900 dark:text-white">128 x 128 (test image)</span>
</div>
<div>
<strong className="text-gray-700 dark:text-gray-300">Cost:</strong>{' '}
<span className="text-gray-900 dark:text-white">{testResult.cost || '$0.0360'}</span>
</div>
{testResult.full_response?.image_url && (
<div>
<strong className="text-gray-700 dark:text-gray-300">Test Image:</strong>{' '}
<a
href={testResult.full_response.image_url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 dark:text-blue-400 hover:underline"
>
View Image
</a>
</div>
)}
</>
) : null}
</div>
</div>
)}
</div>
)}
</div>
</div>
</article>
);
}

View File

@@ -0,0 +1,61 @@
import { useEffect } from 'react';
import { useBillingStore } from '../../store/billingStore';
import ComponentCard from '../common/ComponentCard';
export default function CreditBalanceWidget() {
const { balance, loading, loadBalance } = useBillingStore();
useEffect(() => {
loadBalance();
}, [loadBalance]);
if (loading && !balance) {
return (
<ComponentCard title="Credit Balance" desc="Loading...">
<div className="animate-pulse">Loading credit balance...</div>
</ComponentCard>
);
}
if (!balance) return null;
const usagePercentage = balance.plan_credits_per_month > 0
? (balance.credits_used_this_month / balance.plan_credits_per_month) * 100
: 0;
return (
<ComponentCard title="Credit Balance" desc="Current credit status and usage">
<div className="space-y-4">
<div>
<div className="flex justify-between items-center mb-2">
<span className="text-sm text-gray-600 dark:text-gray-400">Current Credits</span>
<span className="text-2xl font-bold text-primary">{balance.credits}</span>
</div>
</div>
<div>
<div className="flex justify-between items-center mb-2">
<span className="text-sm text-gray-600 dark:text-gray-400">Used This Month</span>
<span className="text-sm font-medium">
{balance.credits_used_this_month} / {balance.plan_credits_per_month}
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2.5 dark:bg-gray-700">
<div
className="bg-primary h-2.5 rounded-full transition-all"
style={{ width: `${Math.min(usagePercentage, 100)}%` }}
/>
</div>
</div>
<div className="pt-2 border-t border-gray-200 dark:border-gray-700">
<div className="flex justify-between items-center">
<span className="text-sm text-gray-600 dark:text-gray-400">Remaining</span>
<span className="text-sm font-medium text-success">{balance.credits_remaining}</span>
</div>
</div>
</div>
</ComponentCard>
);
}

View File

@@ -0,0 +1,83 @@
import { useEffect, useState } from 'react';
import { useBillingStore } from '../../store/billingStore';
import ComponentCard from '../common/ComponentCard';
export default function UsageChartWidget() {
const { usageSummary, loading, loadUsageSummary } = useBillingStore();
const [dateRange, setDateRange] = useState<'week' | 'month' | 'year'>('month');
useEffect(() => {
const now = new Date();
let startDate: string;
if (dateRange === 'week') {
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
startDate = weekAgo.toISOString().split('T')[0];
} else if (dateRange === 'month') {
startDate = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().split('T')[0];
} else {
startDate = new Date(now.getFullYear(), 0, 1).toISOString().split('T')[0];
}
const endDate = now.toISOString().split('T')[0];
loadUsageSummary(startDate, endDate);
}, [dateRange, loadUsageSummary]);
if (loading && !usageSummary) {
return (
<ComponentCard title="Usage Summary" desc="Loading...">
<div className="animate-pulse">Loading usage data...</div>
</ComponentCard>
);
}
if (!usageSummary) return null;
return (
<ComponentCard
title="Usage Summary"
desc="Credit usage breakdown by operation and model"
>
<div className="space-y-4">
<div className="flex justify-end items-center mb-4">
<select
className="h-9 rounded-lg border border-gray-300 bg-transparent px-3 py-2 text-sm shadow-theme-xs text-gray-800 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:focus:border-brand-800"
value={dateRange}
onChange={(e) => setDateRange(e.target.value as 'week' | 'month' | 'year')}
>
<option value="week">Last 7 Days</option>
<option value="month">This Month</option>
<option value="year">This Year</option>
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<div className="text-sm text-gray-600 dark:text-gray-400">Total Credits Used</div>
<div className="text-2xl font-bold">{usageSummary.total_credits_used}</div>
</div>
<div>
<div className="text-sm text-gray-600 dark:text-gray-400">Total Cost</div>
<div className="text-2xl font-bold">${(Number(usageSummary.total_cost_usd) || 0).toFixed(2)}</div>
</div>
</div>
<div>
<h4 className="text-sm font-semibold mb-2">By Operation</h4>
<div className="space-y-2">
{Object.entries(usageSummary.by_operation).map(([op, stats]) => (
<div key={op} className="flex justify-between items-center text-sm">
<span className="capitalize">{op.replace('_', ' ')}</span>
<span className="font-medium">{stats.credits} credits (${(Number(stats.cost) || 0).toFixed(2)})</span>
</div>
))}
{Object.keys(usageSummary.by_operation).length === 0 && (
<div className="text-sm text-gray-500 dark:text-gray-400">No usage data available</div>
)}
</div>
</div>
</div>
</ComponentCard>
);
}

View File

@@ -0,0 +1,390 @@
import { useState, useEffect, useRef } from 'react';
import { useAuthStore } from '../../store/authStore';
import { API_BASE_URL } from '../../services/api';
interface RequestMetrics {
request_id: string;
path: string;
method: string;
elapsed_time_ms: number;
cpu: {
user_time_ms: number;
system_time_ms: number;
total_time_ms: number;
system_percent: number;
};
memory: {
delta_bytes: number;
delta_mb: number;
final_rss_mb: number;
system_used_percent: number;
};
io: {
read_bytes: number;
read_mb: number;
write_bytes: number;
write_mb: number;
};
timestamp: number;
}
interface ResourceDebugOverlayProps {
enabled: boolean;
}
export default function ResourceDebugOverlay({ enabled }: ResourceDebugOverlayProps) {
const { user } = useAuthStore();
const [metrics, setMetrics] = useState<RequestMetrics[]>([]);
const [isVisible, setIsVisible] = useState(false);
const [pageLoadStart, setPageLoadStart] = useState<number | null>(null);
const requestIdRef = useRef<string | null>(null);
const metricsRef = useRef<RequestMetrics[]>([]);
const originalFetchRef = useRef<typeof fetch | null>(null);
const nativeFetchRef = useRef<typeof fetch | null>(null); // Store native fetch separately
// Check if user is admin/developer
const isAdminOrDeveloper = user?.role === 'admin' || user?.role === 'developer';
// Track page load start and intercept fetch requests
useEffect(() => {
if (!enabled || !isAdminOrDeveloper) {
// Restore native fetch if disabled
if (nativeFetchRef.current) {
window.fetch = nativeFetchRef.current;
nativeFetchRef.current = null;
originalFetchRef.current = null;
}
return;
}
setPageLoadStart(performance.now());
// Store native fetch and create bound version
if (!nativeFetchRef.current) {
nativeFetchRef.current = window.fetch; // Store actual native fetch
originalFetchRef.current = window.fetch.bind(window); // Create bound version for calling
}
// Intercept fetch requests to track API calls
window.fetch = async function(...args) {
const startTime = performance.now();
const [url, options = {}] = args;
// Don't intercept our own metrics fetch calls to avoid infinite loops
const urlString = typeof url === 'string' ? url : url.toString();
if (urlString.includes('/request-metrics/')) {
// Use native fetch directly for metrics calls
return nativeFetchRef.current!.apply(window, args as [RequestInfo | URL, RequestInit?]);
}
// Add debug header to enable tracking
const headers = new Headers(options.headers || {});
headers.set('X-Debug-Resource-Tracking', 'true');
// Use bound fetch to preserve context
const response = await originalFetchRef.current!(url, {
...options,
headers,
});
const endTime = performance.now();
// Get request ID from response header
const requestId = response.headers.get('X-Resource-Tracking-ID');
if (requestId) {
requestIdRef.current = requestId;
// Fetch metrics after a short delay to ensure backend has stored them
setTimeout(() => fetchRequestMetrics(requestId), 200);
}
return response;
};
return () => {
// Restore native fetch on cleanup
if (nativeFetchRef.current) {
window.fetch = nativeFetchRef.current;
nativeFetchRef.current = null;
originalFetchRef.current = null;
}
};
}, [enabled, isAdminOrDeveloper]);
// Fetch metrics for a request - use fetchAPI to get proper authentication handling
const fetchRequestMetrics = async (requestId: string) => {
try {
// Use fetchAPI which handles token refresh and authentication properly
// But we need to use native fetch to avoid interception loop
const nativeFetch = nativeFetchRef.current || window.fetch;
const { token } = useAuthStore.getState();
const headers: HeadersInit = {
'Content-Type': 'application/json',
};
// Add JWT token if available
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await nativeFetch.call(window, `${API_BASE_URL}/v1/system/request-metrics/${requestId}/`, {
method: 'GET',
headers,
credentials: 'include', // Include session cookies for authentication
});
if (response.ok) {
const data = await response.json();
console.log('Fetched metrics for request:', requestId, data); // Debug log
metricsRef.current = [...metricsRef.current, data];
setMetrics([...metricsRef.current]);
} else if (response.status === 401) {
// Token might be expired - try to refresh and retry once
try {
await useAuthStore.getState().refreshToken();
const newToken = useAuthStore.getState().token;
if (newToken) {
const retryHeaders: HeadersInit = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${newToken}`,
};
const retryResponse = await nativeFetch.call(window, `${API_BASE_URL}/v1/system/request-metrics/${requestId}/`, {
method: 'GET',
headers: retryHeaders,
credentials: 'include',
});
if (retryResponse.ok) {
const data = await retryResponse.json();
metricsRef.current = [...metricsRef.current, data];
setMetrics([...metricsRef.current]);
return;
}
}
} catch (refreshError) {
// Refresh failed - user needs to re-login
console.warn('Token refresh failed, user may need to re-authenticate');
}
// Silently ignore 401 errors - user might not be authenticated
} else if (response.status === 404) {
// Metrics not found or expired - this is expected, silently ignore
// Metrics expire after 5 minutes, so 404 is normal for older requests
return;
} else {
console.warn('Failed to fetch metrics:', response.status, response.statusText);
}
} catch (error) {
// Only log non-network errors
if (error instanceof TypeError && error.message.includes('fetch')) {
// Network error - silently ignore
return;
}
console.error('Failed to fetch request metrics:', error);
}
};
// Calculate page load time
const pageLoadTime = pageLoadStart ? performance.now() - pageLoadStart : null;
// Calculate totals
const totals = metrics.reduce((acc, m) => ({
elapsed_time_ms: acc.elapsed_time_ms + m.elapsed_time_ms,
cpu_total_ms: acc.cpu_total_ms + m.cpu.total_time_ms,
memory_delta_mb: acc.memory_delta_mb + m.memory.delta_mb,
io_read_mb: acc.io_read_mb + m.io.read_mb,
io_write_mb: acc.io_write_mb + m.io.write_mb,
}), {
elapsed_time_ms: 0,
cpu_total_ms: 0,
memory_delta_mb: 0,
io_read_mb: 0,
io_write_mb: 0,
});
// Find the slowest request
const slowestRequest = metrics.length > 0
? metrics.reduce((prev, current) =>
(current.elapsed_time_ms > prev.elapsed_time_ms) ? current : prev
)
: null;
// Find the request with highest CPU usage
const highestCpuRequest = metrics.length > 0
? metrics.reduce((prev, current) =>
(current.cpu.total_time_ms > prev.cpu.total_time_ms) ? current : prev
)
: null;
// Find the request with highest memory usage
const highestMemoryRequest = metrics.length > 0
? metrics.reduce((prev, current) =>
(current.memory.delta_mb > prev.memory.delta_mb) ? current : prev
)
: null;
if (!enabled || !isAdminOrDeveloper) return null;
return (
<>
{/* Toggle Button - Fixed position */}
<button
onClick={() => setIsVisible(!isVisible)}
className="fixed bottom-4 right-4 z-[99999] bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg shadow-lg text-sm font-medium flex items-center gap-2"
title="Toggle Resource Debug Overlay"
>
<span>🔍</span>
<span>Debug ({metrics.length})</span>
</button>
{/* Overlay */}
{isVisible && (
<div className="fixed bottom-20 right-4 z-[99998] bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded-lg shadow-2xl w-[500px] max-h-[85vh] overflow-auto">
<div className="sticky top-0 bg-gray-100 dark:bg-gray-800 px-4 py-3 border-b border-gray-300 dark:border-gray-700 flex justify-between items-center">
<h3 className="font-semibold text-gray-900 dark:text-white">Resource Debug</h3>
<button
onClick={() => setIsVisible(false)}
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
</button>
</div>
<div className="p-4 space-y-4">
{/* Page Load Summary */}
{pageLoadTime && (
<div className="bg-blue-50 dark:bg-blue-900/20 p-3 rounded border border-blue-200 dark:border-blue-800">
<h4 className="font-semibold text-blue-900 dark:text-blue-200 mb-2">Page Load Time</h4>
<div className="text-sm text-blue-800 dark:text-blue-300">
{pageLoadTime.toFixed(2)} ms
</div>
</div>
)}
{/* Performance Summary - Highlight Culprits */}
{metrics.length > 0 && (
<div className="bg-yellow-50 dark:bg-yellow-900/20 p-3 rounded border border-yellow-200 dark:border-yellow-800">
<h4 className="font-semibold text-yellow-900 dark:text-yellow-200 mb-2"> Performance Culprits</h4>
<div className="text-xs space-y-2 text-yellow-800 dark:text-yellow-300">
{slowestRequest && (
<div>
<span className="font-semibold">Slowest Request:</span> {slowestRequest.method} {slowestRequest.path}
<br />
<span className="ml-4">Time: {slowestRequest.elapsed_time_ms.toFixed(2)} ms</span>
</div>
)}
{highestCpuRequest && highestCpuRequest.cpu.total_time_ms > 100 && (
<div>
<span className="font-semibold">Highest CPU:</span> {highestCpuRequest.method} {highestCpuRequest.path}
<br />
<span className="ml-4">CPU: {highestCpuRequest.cpu.total_time_ms.toFixed(2)} ms (System: {highestCpuRequest.cpu.system_percent.toFixed(1)}%)</span>
</div>
)}
{highestMemoryRequest && highestMemoryRequest.memory.delta_mb > 1 && (
<div>
<span className="font-semibold">Highest Memory:</span> {highestMemoryRequest.method} {highestMemoryRequest.path}
<br />
<span className="ml-4">Memory: {highestMemoryRequest.memory.delta_mb > 0 ? '+' : ''}{highestMemoryRequest.memory.delta_mb.toFixed(2)} MB</span>
</div>
)}
</div>
</div>
)}
{/* Totals */}
{metrics.length > 0 && (
<div className="bg-gray-50 dark:bg-gray-800 p-3 rounded border border-gray-200 dark:border-gray-700">
<h4 className="font-semibold text-gray-900 dark:text-white mb-2">Request Totals</h4>
<div className="text-xs space-y-1 text-gray-700 dark:text-gray-300">
<div>Total Requests: {metrics.length}</div>
<div>Total Time: {totals.elapsed_time_ms.toFixed(2)} ms</div>
<div>Total CPU Time: {totals.cpu_total_ms.toFixed(2)} ms</div>
<div>Total Memory Delta: {totals.memory_delta_mb > 0 ? '+' : ''}{totals.memory_delta_mb.toFixed(2)} MB</div>
<div>Total I/O Read: {totals.io_read_mb.toFixed(2)} MB</div>
<div>Total I/O Write: {totals.io_write_mb.toFixed(2)} MB</div>
</div>
</div>
)}
{/* Individual Requests - Detailed View */}
<div className="space-y-2">
<h4 className="font-semibold text-gray-900 dark:text-white">All Requests (Detailed)</h4>
{metrics.length === 0 ? (
<div className="text-sm text-gray-500 dark:text-gray-400">
No requests tracked yet. Navigate to trigger API calls.
<br />
<span className="text-xs">Make sure debug toggle is enabled in header.</span>
</div>
) : (
metrics.map((m, idx) => {
const isSlow = m.elapsed_time_ms > 1000;
const isHighCpu = m.cpu.total_time_ms > 100;
const isHighMemory = m.memory.delta_mb > 1;
return (
<div
key={idx}
className={`p-3 rounded border text-xs ${
isSlow || isHighCpu || isHighMemory
? 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800'
: 'bg-gray-50 dark:bg-gray-800 border-gray-200 dark:border-gray-700'
}`}
>
<div className="font-semibold text-gray-900 dark:text-white mb-2 flex items-center gap-2">
<span>{m.method}</span>
<span className="text-gray-600 dark:text-gray-400 truncate">{m.path}</span>
{(isSlow || isHighCpu || isHighMemory) && (
<span className="text-red-600 dark:text-red-400 text-xs"></span>
)}
</div>
<div className="space-y-1 text-gray-700 dark:text-gray-300">
<div className={isSlow ? 'font-semibold text-red-700 dark:text-red-300' : ''}>
Time: {m.elapsed_time_ms.toFixed(2)} ms
</div>
<div className={isHighCpu ? 'font-semibold text-red-700 dark:text-red-300' : ''}>
🔥 CPU: {m.cpu.total_time_ms.toFixed(2)} ms
<span className="text-gray-500"> (User: {m.cpu.user_time_ms.toFixed(2)}ms, System: {m.cpu.system_time_ms.toFixed(2)}ms)</span>
<br />
<span className="ml-4 text-gray-500">System CPU: {m.cpu.system_percent.toFixed(1)}%</span>
</div>
<div className={isHighMemory ? 'font-semibold text-red-700 dark:text-red-300' : ''}>
💾 Memory: {m.memory.delta_mb > 0 ? '+' : ''}{m.memory.delta_mb.toFixed(2)} MB
<span className="text-gray-500"> (Final RSS: {m.memory.final_rss_mb.toFixed(2)} MB)</span>
<br />
<span className="ml-4 text-gray-500">System Memory: {m.memory.system_used_percent.toFixed(1)}%</span>
</div>
{m.io.read_mb > 0 && (
<div>
📖 I/O Read: {m.io.read_mb.toFixed(2)} MB ({m.io.read_bytes.toLocaleString()} bytes)
</div>
)}
{m.io.write_mb > 0 && (
<div>
📝 I/O Write: {m.io.write_mb.toFixed(2)} MB ({m.io.write_bytes.toLocaleString()} bytes)
</div>
)}
</div>
</div>
);
})
)}
</div>
{/* Clear Button */}
{metrics.length > 0 && (
<button
onClick={() => {
setMetrics([]);
metricsRef.current = [];
setPageLoadStart(performance.now());
}}
className="w-full bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-900 dark:text-white px-3 py-2 rounded text-sm"
>
Clear Metrics
</button>
)}
</div>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,42 @@
import { useState, useEffect } from 'react';
import { useAuthStore } from '../../store/authStore';
export default function ResourceDebugToggle() {
const { user } = useAuthStore();
const [enabled, setEnabled] = useState(false);
const isAdminOrDeveloper = user?.role === 'admin' || user?.role === 'developer';
// Load saved state from localStorage
useEffect(() => {
if (isAdminOrDeveloper) {
const saved = localStorage.getItem('debug_resource_tracking_enabled');
setEnabled(saved === 'true');
}
}, [isAdminOrDeveloper]);
const toggle = () => {
const newValue = !enabled;
setEnabled(newValue);
localStorage.setItem('debug_resource_tracking_enabled', String(newValue));
// Dispatch event for overlay component
window.dispatchEvent(new CustomEvent('debug-resource-tracking-toggle', { detail: newValue }));
};
if (!isAdminOrDeveloper) return null;
return (
<button
onClick={toggle}
className={`flex items-center justify-center w-10 h-10 rounded-lg transition-colors ${
enabled
? 'bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400'
: 'text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800'
}`}
title={enabled ? 'Disable Resource Debug' : 'Enable Resource Debug'}
>
🔍
</button>
);
}

View File

@@ -0,0 +1,94 @@
// react plugin for creating vector maps
import { VectorMap } from "@react-jvectormap/core";
import { worldMill } from "@react-jvectormap/world";
// Define the component props
interface CountryMapProps {
mapColor?: string;
}
const CountryMap: React.FC<CountryMapProps> = ({ mapColor }) => {
return (
<VectorMap
map={worldMill}
backgroundColor="transparent"
markerStyle={{
initial: {
fill: "#465FFF",
r: 4, // Custom radius for markers
} as any, // Type assertion to bypass strict CSS property checks
}}
markersSelectable={true}
markers={[
{
latLng: [37.2580397, -104.657039],
name: "United States",
style: {
fill: "#465FFF",
borderWidth: 1,
borderColor: "white",
stroke: "#383f47",
},
},
{
latLng: [20.7504374, 73.7276105],
name: "India",
style: { fill: "#465FFF", borderWidth: 1, borderColor: "white" },
},
{
latLng: [53.613, -11.6368],
name: "United Kingdom",
style: { fill: "#465FFF", borderWidth: 1, borderColor: "white" },
},
{
latLng: [-25.0304388, 115.2092761],
name: "Sweden",
style: {
fill: "#465FFF",
borderWidth: 1,
borderColor: "white",
strokeOpacity: 0,
},
},
]}
zoomOnScroll={false}
zoomMax={12}
zoomMin={1}
zoomAnimate={true}
zoomStep={1.5}
regionStyle={{
initial: {
fill: mapColor || "#D0D5DD",
fillOpacity: 1,
fontFamily: "Outfit",
stroke: "none",
strokeWidth: 0,
strokeOpacity: 0,
},
hover: {
fillOpacity: 0.7,
cursor: "pointer",
fill: "#465fff",
stroke: "none",
},
selected: {
fill: "#465FFF",
},
selectedHover: {},
}}
regionLabelStyle={{
initial: {
fill: "#35373e",
fontWeight: 500,
fontSize: "13px",
stroke: "none",
},
hover: {},
selected: {},
selectedHover: {},
}}
/>
);
};
export default CountryMap;

View File

@@ -0,0 +1,114 @@
import { useState } from "react";
import { Dropdown } from "../ui/dropdown/Dropdown";
import { DropdownItem } from "../ui/dropdown/DropdownItem";
import { MoreDotIcon } from "../../icons";
import CountryMap from "./CountryMap";
export default function DemographicCard() {
const [isOpen, setIsOpen] = useState(false);
function toggleDropdown() {
setIsOpen(!isOpen);
}
function closeDropdown() {
setIsOpen(false);
}
return (
<div className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] sm:p-6">
<div className="flex justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-800 dark:text-white/90">
Customers Demographic
</h3>
<p className="mt-1 text-gray-500 text-theme-sm dark:text-gray-400">
Number of customer based on country
</p>
</div>
<div className="relative inline-block">
<button className="dropdown-toggle" onClick={toggleDropdown}>
<MoreDotIcon className="text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 size-6" />
</button>
<Dropdown
isOpen={isOpen}
onClose={closeDropdown}
className="w-40 p-2"
>
<DropdownItem
onItemClick={closeDropdown}
className="flex w-full font-normal text-left text-gray-500 rounded-lg hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300"
>
View More
</DropdownItem>
<DropdownItem
onItemClick={closeDropdown}
className="flex w-full font-normal text-left text-gray-500 rounded-lg hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300"
>
Delete
</DropdownItem>
</Dropdown>
</div>
</div>
<div className="px-4 py-6 my-6 overflow-hidden border border-gary-200 rounded-2xl dark:border-gray-800 sm:px-6">
<div
id="mapOne"
className="mapOne map-btn -mx-4 -my-6 h-[212px] w-[252px] 2xsm:w-[307px] xsm:w-[358px] sm:-mx-6 md:w-[668px] lg:w-[634px] xl:w-[393px] 2xl:w-[554px]"
>
<CountryMap />
</div>
</div>
<div className="space-y-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="items-center w-full rounded-full max-w-8">
<img src="./images/country/country-01.svg" alt="usa" />
</div>
<div>
<p className="font-semibold text-gray-800 text-theme-sm dark:text-white/90">
USA
</p>
<span className="block text-gray-500 text-theme-xs dark:text-gray-400">
2,379 Customers
</span>
</div>
</div>
<div className="flex w-full max-w-[140px] items-center gap-3">
<div className="relative block h-2 w-full max-w-[100px] rounded-sm bg-gray-200 dark:bg-gray-800">
<div className="absolute left-0 top-0 flex h-full w-[79%] items-center justify-center rounded-sm bg-brand-500 text-xs font-medium text-white"></div>
</div>
<p className="font-medium text-gray-800 text-theme-sm dark:text-white/90">
79%
</p>
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="items-center w-full rounded-full max-w-8">
<img src="./images/country/country-02.svg" alt="france" />
</div>
<div>
<p className="font-semibold text-gray-800 text-theme-sm dark:text-white/90">
France
</p>
<span className="block text-gray-500 text-theme-xs dark:text-gray-400">
589 Customers
</span>
</div>
</div>
<div className="flex w-full max-w-[140px] items-center gap-3">
<div className="relative block h-2 w-full max-w-[100px] rounded-sm bg-gray-200 dark:bg-gray-800">
<div className="absolute left-0 top-0 flex h-full w-[23%] items-center justify-center rounded-sm bg-brand-500 text-xs font-medium text-white"></div>
</div>
<p className="font-medium text-gray-800 text-theme-sm dark:text-white/90">
23%
</p>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,59 @@
import {
ArrowDownIcon,
ArrowUpIcon,
BoxIconLine,
GroupIcon,
} from "../../icons";
import Badge from "../ui/badge/Badge";
export default function EcommerceMetrics() {
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:gap-6">
{/* <!-- Metric Item Start --> */}
<div className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] md:p-6">
<div className="flex items-center justify-center w-12 h-12 bg-gray-100 rounded-xl dark:bg-gray-800">
<GroupIcon className="text-gray-800 size-6 dark:text-white/90" />
</div>
<div className="flex items-end justify-between mt-5">
<div>
<span className="text-sm text-gray-500 dark:text-gray-400">
Customers
</span>
<h4 className="mt-2 font-bold text-gray-800 text-title-sm dark:text-white/90">
3,782
</h4>
</div>
<Badge color="success">
<ArrowUpIcon />
11.01%
</Badge>
</div>
</div>
{/* <!-- Metric Item End --> */}
{/* <!-- Metric Item Start --> */}
<div className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] md:p-6">
<div className="flex items-center justify-center w-12 h-12 bg-gray-100 rounded-xl dark:bg-gray-800">
<BoxIconLine className="text-gray-800 size-6 dark:text-white/90" />
</div>
<div className="flex items-end justify-between mt-5">
<div>
<span className="text-sm text-gray-500 dark:text-gray-400">
Orders
</span>
<h4 className="mt-2 font-bold text-gray-800 text-title-sm dark:text-white/90">
5,359
</h4>
</div>
<Badge color="error">
<ArrowDownIcon />
9.05%
</Badge>
</div>
</div>
{/* <!-- Metric Item End --> */}
</div>
);
}

View File

@@ -0,0 +1,141 @@
import Chart from "react-apexcharts";
import { ApexOptions } from "apexcharts";
import { Dropdown } from "../ui/dropdown/Dropdown";
import { DropdownItem } from "../ui/dropdown/DropdownItem";
import { MoreDotIcon } from "../../icons";
import { useState } from "react";
export default function MonthlySalesChart() {
const options: ApexOptions = {
colors: ["#465fff"],
chart: {
fontFamily: "Outfit, sans-serif",
type: "bar",
height: 180,
toolbar: {
show: false,
},
},
plotOptions: {
bar: {
horizontal: false,
columnWidth: "39%",
borderRadius: 5,
borderRadiusApplication: "end",
},
},
dataLabels: {
enabled: false,
},
stroke: {
show: true,
width: 4,
colors: ["transparent"],
},
xaxis: {
categories: [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
],
axisBorder: {
show: false,
},
axisTicks: {
show: false,
},
},
legend: {
show: true,
position: "top",
horizontalAlign: "left",
fontFamily: "Outfit",
},
yaxis: {
title: {
text: undefined,
},
},
grid: {
yaxis: {
lines: {
show: true,
},
},
},
fill: {
opacity: 1,
},
tooltip: {
x: {
show: false,
},
y: {
formatter: (val: number) => `${val}`,
},
},
};
const series = [
{
name: "Sales",
data: [168, 385, 201, 298, 187, 195, 291, 110, 215, 390, 280, 112],
},
];
const [isOpen, setIsOpen] = useState(false);
function toggleDropdown() {
setIsOpen(!isOpen);
}
function closeDropdown() {
setIsOpen(false);
}
return (
<div className="overflow-hidden rounded-2xl border border-gray-200 bg-white px-5 pt-5 dark:border-gray-800 dark:bg-white/[0.03] sm:px-6 sm:pt-6">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-800 dark:text-white/90">
Monthly Sales
</h3>
<div className="relative inline-block">
<button className="dropdown-toggle" onClick={toggleDropdown}>
<MoreDotIcon className="text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 size-6" />
</button>
<Dropdown
isOpen={isOpen}
onClose={closeDropdown}
className="w-40 p-2"
>
<DropdownItem
onItemClick={closeDropdown}
className="flex w-full font-normal text-left text-gray-500 rounded-lg hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300"
>
View More
</DropdownItem>
<DropdownItem
onItemClick={closeDropdown}
className="flex w-full font-normal text-left text-gray-500 rounded-lg hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300"
>
Delete
</DropdownItem>
</Dropdown>
</div>
</div>
<div className="max-w-full overflow-x-auto custom-scrollbar">
<div className="-ml-5 min-w-[650px] xl:min-w-full pl-2">
<Chart options={options} series={series} type="bar" height={180} />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,198 @@
import Chart from "react-apexcharts";
import { ApexOptions } from "apexcharts";
import { useState } from "react";
import { Dropdown } from "../ui/dropdown/Dropdown";
import { DropdownItem } from "../ui/dropdown/DropdownItem";
import { MoreDotIcon } from "../../icons";
export default function MonthlyTarget() {
const series = [75.55];
const options: ApexOptions = {
colors: ["#465FFF"],
chart: {
fontFamily: "Outfit, sans-serif",
type: "radialBar",
height: 330,
sparkline: {
enabled: true,
},
},
plotOptions: {
radialBar: {
startAngle: -85,
endAngle: 85,
hollow: {
size: "80%",
},
track: {
background: "#E4E7EC",
strokeWidth: "100%",
margin: 5, // margin is in pixels
},
dataLabels: {
name: {
show: false,
},
value: {
fontSize: "36px",
fontWeight: "600",
offsetY: -40,
color: "#1D2939",
formatter: function (val) {
return val + "%";
},
},
},
},
},
fill: {
type: "solid",
colors: ["#465FFF"],
},
stroke: {
lineCap: "round",
},
labels: ["Progress"],
};
const [isOpen, setIsOpen] = useState(false);
function toggleDropdown() {
setIsOpen(!isOpen);
}
function closeDropdown() {
setIsOpen(false);
}
return (
<div className="rounded-2xl border border-gray-200 bg-gray-100 dark:border-gray-800 dark:bg-white/[0.03]">
<div className="px-5 pt-5 bg-white shadow-default rounded-2xl pb-11 dark:bg-gray-900 sm:px-6 sm:pt-6">
<div className="flex justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-800 dark:text-white/90">
Monthly Target
</h3>
<p className="mt-1 text-gray-500 text-theme-sm dark:text-gray-400">
Target youve set for each month
</p>
</div>
<div className="relative inline-block">
<button className="dropdown-toggle" onClick={toggleDropdown}>
<MoreDotIcon className="text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 size-6" />
</button>
<Dropdown
isOpen={isOpen}
onClose={closeDropdown}
className="w-40 p-2"
>
<DropdownItem
onItemClick={closeDropdown}
className="flex w-full font-normal text-left text-gray-500 rounded-lg hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300"
>
View More
</DropdownItem>
<DropdownItem
onItemClick={closeDropdown}
className="flex w-full font-normal text-left text-gray-500 rounded-lg hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300"
>
Delete
</DropdownItem>
</Dropdown>
</div>
</div>
<div className="relative ">
<div className="max-h-[330px]" id="chartDarkStyle">
<Chart
options={options}
series={series}
type="radialBar"
height={330}
/>
</div>
<span className="absolute left-1/2 top-full -translate-x-1/2 -translate-y-[95%] rounded-full bg-success-50 px-3 py-1 text-xs font-medium text-success-600 dark:bg-success-500/15 dark:text-success-500">
+10%
</span>
</div>
<p className="mx-auto mt-10 w-full max-w-[380px] text-center text-sm text-gray-500 sm:text-base">
You earn $3287 today, it's higher than last month. Keep up your good
work!
</p>
</div>
<div className="flex items-center justify-center gap-5 px-6 py-3.5 sm:gap-8 sm:py-5">
<div>
<p className="mb-1 text-center text-gray-500 text-theme-xs dark:text-gray-400 sm:text-sm">
Target
</p>
<p className="flex items-center justify-center gap-1 text-base font-semibold text-gray-800 dark:text-white/90 sm:text-lg">
$20K
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M7.26816 13.6632C7.4056 13.8192 7.60686 13.9176 7.8311 13.9176C7.83148 13.9176 7.83187 13.9176 7.83226 13.9176C8.02445 13.9178 8.21671 13.8447 8.36339 13.6981L12.3635 9.70076C12.6565 9.40797 12.6567 8.9331 12.3639 8.6401C12.0711 8.34711 11.5962 8.34694 11.3032 8.63973L8.5811 11.36L8.5811 2.5C8.5811 2.08579 8.24531 1.75 7.8311 1.75C7.41688 1.75 7.0811 2.08579 7.0811 2.5L7.0811 11.3556L4.36354 8.63975C4.07055 8.34695 3.59568 8.3471 3.30288 8.64009C3.01008 8.93307 3.01023 9.40794 3.30321 9.70075L7.26816 13.6632Z"
fill="#D92D20"
/>
</svg>
</p>
</div>
<div className="w-px bg-gray-200 h-7 dark:bg-gray-800"></div>
<div>
<p className="mb-1 text-center text-gray-500 text-theme-xs dark:text-gray-400 sm:text-sm">
Revenue
</p>
<p className="flex items-center justify-center gap-1 text-base font-semibold text-gray-800 dark:text-white/90 sm:text-lg">
$20K
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M7.60141 2.33683C7.73885 2.18084 7.9401 2.08243 8.16435 2.08243C8.16475 2.08243 8.16516 2.08243 8.16556 2.08243C8.35773 2.08219 8.54998 2.15535 8.69664 2.30191L12.6968 6.29924C12.9898 6.59203 12.9899 7.0669 12.6971 7.3599C12.4044 7.6529 11.9295 7.65306 11.6365 7.36027L8.91435 4.64004L8.91435 13.5C8.91435 13.9142 8.57856 14.25 8.16435 14.25C7.75013 14.25 7.41435 13.9142 7.41435 13.5L7.41435 4.64442L4.69679 7.36025C4.4038 7.65305 3.92893 7.6529 3.63613 7.35992C3.34333 7.06693 3.34348 6.59206 3.63646 6.29926L7.60141 2.33683Z"
fill="#039855"
/>
</svg>
</p>
</div>
<div className="w-px bg-gray-200 h-7 dark:bg-gray-800"></div>
<div>
<p className="mb-1 text-center text-gray-500 text-theme-xs dark:text-gray-400 sm:text-sm">
Today
</p>
<p className="flex items-center justify-center gap-1 text-base font-semibold text-gray-800 dark:text-white/90 sm:text-lg">
$20K
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M7.60141 2.33683C7.73885 2.18084 7.9401 2.08243 8.16435 2.08243C8.16475 2.08243 8.16516 2.08243 8.16556 2.08243C8.35773 2.08219 8.54998 2.15535 8.69664 2.30191L12.6968 6.29924C12.9898 6.59203 12.9899 7.0669 12.6971 7.3599C12.4044 7.6529 11.9295 7.65306 11.6365 7.36027L8.91435 4.64004L8.91435 13.5C8.91435 13.9142 8.57856 14.25 8.16435 14.25C7.75013 14.25 7.41435 13.9142 7.41435 13.5L7.41435 4.64442L4.69679 7.36025C4.4038 7.65305 3.92893 7.6529 3.63613 7.35992C3.34333 7.06693 3.34348 6.59206 3.63646 6.29926L7.60141 2.33683Z"
fill="#039855"
/>
</svg>
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,208 @@
import {
Table,
TableBody,
TableCell,
TableHeader,
TableRow,
} from "../ui/table";
import Badge from "../ui/badge/Badge";
// Define the TypeScript interface for the table rows
interface Product {
id: number; // Unique identifier for each product
name: string; // Product name
variants: string; // Number of variants (e.g., "1 Variant", "2 Variants")
category: string; // Category of the product
price: string; // Price of the product (as a string with currency symbol)
// status: string; // Status of the product
image: string; // URL or path to the product image
status: "Delivered" | "Pending" | "Canceled"; // Status of the product
}
// Define the table data using the interface
const tableData: Product[] = [
{
id: 1,
name: "MacBook Pro 13”",
variants: "2 Variants",
category: "Laptop",
price: "$2399.00",
status: "Delivered",
image: "/images/product/product-01.jpg", // Replace with actual image URL
},
{
id: 2,
name: "Apple Watch Ultra",
variants: "1 Variant",
category: "Watch",
price: "$879.00",
status: "Pending",
image: "/images/product/product-02.jpg", // Replace with actual image URL
},
{
id: 3,
name: "iPhone 15 Pro Max",
variants: "2 Variants",
category: "SmartPhone",
price: "$1869.00",
status: "Delivered",
image: "/images/product/product-03.jpg", // Replace with actual image URL
},
{
id: 4,
name: "iPad Pro 3rd Gen",
variants: "2 Variants",
category: "Electronics",
price: "$1699.00",
status: "Canceled",
image: "/images/product/product-04.jpg", // Replace with actual image URL
},
{
id: 5,
name: "AirPods Pro 2nd Gen",
variants: "1 Variant",
category: "Accessories",
price: "$240.00",
status: "Delivered",
image: "/images/product/product-05.jpg", // Replace with actual image URL
},
];
export default function RecentOrders() {
return (
<div className="overflow-hidden rounded-2xl border border-gray-200 bg-white px-4 pb-3 pt-4 dark:border-gray-800 dark:bg-white/[0.03] sm:px-6">
<div className="flex flex-col gap-2 mb-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-800 dark:text-white/90">
Recent Orders
</h3>
</div>
<div className="flex items-center gap-3">
<button className="inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-4 py-2.5 text-theme-sm font-medium text-gray-700 shadow-theme-xs hover:bg-gray-50 hover:text-gray-800 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.03] dark:hover:text-gray-200">
<svg
className="stroke-current fill-white dark:fill-gray-800"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2.29004 5.90393H17.7067"
stroke=""
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M17.7075 14.0961H2.29085"
stroke=""
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M12.0826 3.33331C13.5024 3.33331 14.6534 4.48431 14.6534 5.90414C14.6534 7.32398 13.5024 8.47498 12.0826 8.47498C10.6627 8.47498 9.51172 7.32398 9.51172 5.90415C9.51172 4.48432 10.6627 3.33331 12.0826 3.33331Z"
fill=""
stroke=""
strokeWidth="1.5"
/>
<path
d="M7.91745 11.525C6.49762 11.525 5.34662 12.676 5.34662 14.0959C5.34661 15.5157 6.49762 16.6667 7.91745 16.6667C9.33728 16.6667 10.4883 15.5157 10.4883 14.0959C10.4883 12.676 9.33728 11.525 7.91745 11.525Z"
fill=""
stroke=""
strokeWidth="1.5"
/>
</svg>
Filter
</button>
<button className="inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-4 py-2.5 text-theme-sm font-medium text-gray-700 shadow-theme-xs hover:bg-gray-50 hover:text-gray-800 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.03] dark:hover:text-gray-200">
See all
</button>
</div>
</div>
<div className="max-w-full overflow-x-auto">
<Table>
{/* Table Header */}
<TableHeader className="border-gray-100 dark:border-gray-800 border-y">
<TableRow>
<TableCell
isHeader
className="py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400"
>
Products
</TableCell>
<TableCell
isHeader
className="py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400"
>
Category
</TableCell>
<TableCell
isHeader
className="py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400"
>
Price
</TableCell>
<TableCell
isHeader
className="py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400"
>
Status
</TableCell>
</TableRow>
</TableHeader>
{/* Table Body */}
<TableBody className="divide-y divide-gray-100 dark:divide-gray-800">
{tableData.map((product) => (
<TableRow key={product.id} className="">
<TableCell className="py-3">
<div className="flex items-center gap-3">
<div className="h-[50px] w-[50px] overflow-hidden rounded-md">
<img
src={product.image}
className="h-[50px] w-[50px]"
alt={product.name}
/>
</div>
<div>
<p className="font-medium text-gray-800 text-theme-sm dark:text-white/90">
{product.name}
</p>
<span className="text-gray-500 text-theme-xs dark:text-gray-400">
{product.variants}
</span>
</div>
</div>
</TableCell>
<TableCell className="py-3 text-gray-500 text-theme-sm dark:text-gray-400">
{product.price}
</TableCell>
<TableCell className="py-3 text-gray-500 text-theme-sm dark:text-gray-400">
{product.category}
</TableCell>
<TableCell className="py-3 text-gray-500 text-theme-sm dark:text-gray-400">
<Badge
size="sm"
color={
product.status === "Delivered"
? "success"
: product.status === "Pending"
? "warning"
: "error"
}
>
{product.status}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@@ -0,0 +1,137 @@
import Chart from "react-apexcharts";
import { ApexOptions } from "apexcharts";
import ChartTab from "../common/ChartTab";
export default function StatisticsChart() {
const options: ApexOptions = {
legend: {
show: false, // Hide legend
position: "top",
horizontalAlign: "left",
},
colors: ["#465FFF", "#9CB9FF"], // Define line colors
chart: {
fontFamily: "Outfit, sans-serif",
height: 310,
type: "line", // Set the chart type to 'line'
toolbar: {
show: false, // Hide chart toolbar
},
},
stroke: {
curve: "straight", // Define the line style (straight, smooth, or step)
width: [2, 2], // Line width for each dataset
},
fill: {
type: "gradient",
gradient: {
opacityFrom: 0.55,
opacityTo: 0,
},
},
markers: {
size: 0, // Size of the marker points
strokeColors: "#fff", // Marker border color
strokeWidth: 2,
hover: {
size: 6, // Marker size on hover
},
},
grid: {
xaxis: {
lines: {
show: false, // Hide grid lines on x-axis
},
},
yaxis: {
lines: {
show: true, // Show grid lines on y-axis
},
},
},
dataLabels: {
enabled: false, // Disable data labels
},
tooltip: {
enabled: true, // Enable tooltip
x: {
format: "dd MMM yyyy", // Format for x-axis tooltip
},
},
xaxis: {
type: "category", // Category-based x-axis
categories: [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
],
axisBorder: {
show: false, // Hide x-axis border
},
axisTicks: {
show: false, // Hide x-axis ticks
},
tooltip: {
enabled: false, // Disable tooltip for x-axis points
},
},
yaxis: {
labels: {
style: {
fontSize: "12px", // Adjust font size for y-axis labels
colors: ["#6B7280"], // Color of the labels
},
},
title: {
text: "", // Remove y-axis title
style: {
fontSize: "0px",
},
},
},
};
const series = [
{
name: "Sales",
data: [180, 190, 170, 160, 175, 165, 170, 205, 230, 210, 240, 235],
},
{
name: "Revenue",
data: [40, 30, 50, 40, 55, 40, 70, 100, 110, 120, 150, 140],
},
];
return (
<div className="rounded-2xl border border-gray-200 bg-white px-5 pb-5 pt-5 dark:border-gray-800 dark:bg-white/[0.03] sm:px-6 sm:pt-6">
<div className="flex flex-col gap-5 mb-6 sm:flex-row sm:justify-between">
<div className="w-full">
<h3 className="text-lg font-semibold text-gray-800 dark:text-white/90">
Statistics
</h3>
<p className="mt-1 text-gray-500 text-theme-sm dark:text-gray-400">
Target youve set for each month
</p>
</div>
<div className="flex items-start w-full gap-3 sm:justify-end">
<ChartTab />
</div>
</div>
<div className="max-w-full overflow-x-auto custom-scrollbar">
<div className="min-w-[1000px] xl:min-w-full">
<Chart options={options} series={series} type="area" height={310} />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,23 @@
import { FC, ReactNode, FormEvent } from "react";
interface FormProps {
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
children: ReactNode;
className?: string;
}
const Form: FC<FormProps> = ({ onSubmit, children, className }) => {
return (
<form
onSubmit={(event) => {
event.preventDefault(); // Prevent default form submission
onSubmit(event);
}}
className={` ${className}`} // Default spacing between form fields
>
{children}
</form>
);
};
export default Form;

View File

@@ -0,0 +1,167 @@
/**
* FormFieldRenderer - Dynamic form field renderer
* Renders form fields based on configuration objects
*
* Usage:
* ```typescript
* <FormFieldRenderer
* fields={formFieldsConfig}
* values={formValues}
* onChange={handleFieldChange}
* />
* ```
*/
import React from 'react';
import Input from './input/InputField';
import SelectDropdown from './SelectDropdown';
import Label from './Label';
export interface FormFieldConfig {
key: string;
label: string;
type: 'text' | 'number' | 'email' | 'password' | 'select' | 'textarea';
placeholder?: string;
options?: Array<{ value: string; label: string }>;
required?: boolean;
min?: number;
max?: number;
rows?: number;
className?: string;
gridCols?: 1 | 2; // For inline fields (e.g., Volume & Difficulty)
}
interface FormFieldRendererProps {
fields: FormFieldConfig[];
values: Record<string, any>;
onChange: (key: string, value: any) => void;
errors?: Record<string, string>;
disabled?: boolean;
}
export default function FormFieldRenderer({
fields,
values,
onChange,
errors = {},
disabled = false,
}: FormFieldRendererProps) {
// Group fields by grid layout
const fieldGroups: FormFieldConfig[][] = [];
let currentGroup: FormFieldConfig[] = [];
fields.forEach((field) => {
if (field.gridCols === 2) {
// Start new group for inline fields
if (currentGroup.length > 0) {
fieldGroups.push(currentGroup);
currentGroup = [];
}
currentGroup.push(field);
if (currentGroup.length === 2) {
fieldGroups.push(currentGroup);
currentGroup = [];
}
} else {
// Full-width field
if (currentGroup.length > 0) {
fieldGroups.push(currentGroup);
currentGroup = [];
}
fieldGroups.push([field]);
}
});
if (currentGroup.length > 0) {
fieldGroups.push(currentGroup);
}
const renderField = (field: FormFieldConfig) => {
const value = values[field.key] || '';
const error = errors[field.key];
const fieldId = `field-${field.key}`;
return (
<div key={field.key} className={field.className}>
<Label htmlFor={fieldId} className="mb-2">
{field.label}
{field.required && <span className="text-error-500 ml-1">*</span>}
</Label>
{field.type === 'select' ? (
<SelectDropdown
options={field.options || []}
placeholder={field.placeholder || field.label}
value={value}
onChange={(val) => onChange(field.key, val)}
className="w-full"
/>
) : field.type === 'textarea' ? (
<textarea
id={fieldId}
className={`w-full rounded-lg border ${
error
? 'border-error-500'
: 'border-gray-300 dark:border-gray-700'
} bg-transparent px-3 py-2 text-sm shadow-theme-xs text-gray-800 placeholder:text-gray-400 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800`}
value={value}
onChange={(e) => onChange(field.key, e.target.value)}
placeholder={field.placeholder}
required={field.required}
rows={field.rows || 4}
disabled={disabled}
/>
) : (
<Input
id={fieldId}
type={field.type}
value={value}
onChange={(e) => {
const newValue =
field.type === 'number'
? e.target.value === ''
? ''
: parseInt(e.target.value) || 0
: e.target.value;
onChange(field.key, newValue);
}}
placeholder={field.placeholder}
required={field.required}
min={field.min}
max={field.max}
error={!!error}
disabled={disabled}
className="w-full"
/>
)}
{error && (
<p className="mt-1 text-sm text-error-500">{error}</p>
)}
</div>
);
};
return (
<div className="space-y-4">
{fieldGroups.map((group, groupIndex) => {
if (group.length === 2 && group[0].gridCols === 2) {
// Render inline fields
return (
<div key={`group-${groupIndex}`} className="grid grid-cols-2 gap-4">
{group.map((field) => renderField(field))}
</div>
);
} else {
// Render full-width field
return (
<div key={`group-${groupIndex}`}>
{group.map((field) => renderField(field))}
</div>
);
}
})}
</div>
);
}

View File

@@ -0,0 +1,27 @@
import { FC, ReactNode } from "react";
import { twMerge } from "tailwind-merge";
import { clsx } from "clsx";
interface LabelProps {
htmlFor?: string;
children: ReactNode;
className?: string;
}
const Label: FC<LabelProps> = ({ htmlFor, children, className }) => {
return (
<label
htmlFor={htmlFor}
className={clsx(
twMerge(
"mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-400",
className,
),
)}
>
{children}
</label>
);
};
export default Label;

View File

@@ -0,0 +1,249 @@
import type React from "react";
import { useState, useEffect, useRef } from "react";
interface Option {
value: string;
text: string;
}
interface MultiSelectProps {
label: string;
options: Option[];
defaultSelected?: string[];
value?: string[];
onChange?: (selected: string[]) => void;
disabled?: boolean;
placeholder?: string;
}
const MultiSelect: React.FC<MultiSelectProps> = ({
label,
options,
defaultSelected = [],
value,
onChange,
disabled = false,
placeholder = "Select options",
}) => {
const isControlled = value !== undefined;
const [internalSelected, setInternalSelected] =
useState<string[]>(defaultSelected);
const selectedOptions = isControlled ? value : internalSelected;
const [isOpen, setIsOpen] = useState(false);
const [focusedIndex, setFocusedIndex] = useState(-1);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
return () =>
document.removeEventListener("mousedown", handleClickOutside);
}
}, [isOpen]);
const updateSelection = (newSelected: string[]) => {
if (!isControlled) setInternalSelected(newSelected);
onChange?.(newSelected);
};
const toggleDropdown = () => {
if (!disabled) {
setIsOpen((prev) => !prev);
setFocusedIndex(-1);
}
};
const handleSelect = (optionValue: string) => {
const newSelected = selectedOptions.includes(optionValue)
? selectedOptions.filter((v) => v !== optionValue)
: [...selectedOptions, optionValue];
updateSelection(newSelected);
};
const removeOption = (optionValue: string) => {
updateSelection(selectedOptions.filter((v) => v !== optionValue));
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (disabled) return;
e.preventDefault();
switch (e.key) {
case "Enter":
if (!isOpen) {
setIsOpen(true);
} else if (focusedIndex >= 0) {
handleSelect(options[focusedIndex].value);
}
break;
case "Escape":
setIsOpen(false);
break;
case "ArrowDown":
if (!isOpen) {
setIsOpen(true);
} else {
setFocusedIndex((prev) => (prev < options.length - 1 ? prev + 1 : 0));
}
break;
case "ArrowUp":
if (isOpen) {
setFocusedIndex((prev) => (prev > 0 ? prev - 1 : options.length - 1));
}
break;
}
};
return (
<div className="w-full" ref={dropdownRef}>
<label
className="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-400"
id={`${label}-label`}
>
{label}
</label>
<div className="relative z-20 inline-block w-full">
<div className="relative flex flex-col items-center">
<div
onClick={toggleDropdown}
onKeyDown={handleKeyDown}
className="w-full"
role="combobox"
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-labelledby={`${label}-label`}
aria-disabled={disabled}
tabIndex={disabled ? -1 : 0}
>
<div
className={`mb-2 flex min-h-11 rounded-lg border border-gray-300 py-1.5 pl-3 pr-3 shadow-theme-xs outline-hidden transition focus:border-brand-300 focus:shadow-focus-ring dark:border-gray-700 dark:bg-gray-900 dark:focus:border-brand-300 ${
disabled
? "opacity-50 cursor-not-allowed bg-gray-50 dark:bg-gray-800"
: "cursor-pointer"
}`}
>
<div className="flex flex-wrap flex-auto gap-2">
{selectedOptions.length > 0 ? (
selectedOptions.map((value) => {
const text =
options.find((opt) => opt.value === value)?.text || value;
return (
<div
key={value}
className="group flex items-center justify-center rounded-full border-[0.7px] border-transparent bg-gray-100 py-1 pl-2.5 pr-2 text-sm text-gray-800 hover:border-gray-200 dark:bg-gray-800 dark:text-white/90 dark:hover:border-gray-800"
>
<span className="flex-initial max-w-full">{text}</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
if (!disabled) removeOption(value);
}}
disabled={disabled}
className="pl-2 text-gray-500 cursor-pointer group-hover:text-gray-400 dark:text-gray-400 disabled:cursor-not-allowed"
aria-label={`Remove ${text}`}
>
<svg
className="fill-current"
width="14"
height="14"
viewBox="0 0 14 14"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M3.40717 4.46881C3.11428 4.17591 3.11428 3.70104 3.40717 3.40815C3.70006 3.11525 4.17494 3.11525 4.46783 3.40815L6.99943 5.93975L9.53095 3.40822C9.82385 3.11533 10.2987 3.11533 10.5916 3.40822C10.8845 3.70112 10.8845 4.17599 10.5916 4.46888L8.06009 7.00041L10.5916 9.53193C10.8845 9.82482 10.8845 10.2997 10.5916 10.5926C10.2987 10.8855 9.82385 10.8855 9.53095 10.5926L6.99943 8.06107L4.46783 10.5927C4.17494 10.8856 3.70006 10.8856 3.40717 10.5927C3.11428 10.2998 3.11428 9.8249 3.40717 9.53201L5.93877 7.00041L3.40717 4.46881Z"
/>
</svg>
</button>
</div>
);
})
) : (
<div className="w-full h-full p-1 pr-2 text-sm text-gray-400 dark:text-gray-500 pointer-events-none">
{placeholder}
</div>
)}
</div>
<div className="flex items-center self-start py-1 pl-1 pr-1 w-7">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
toggleDropdown();
}}
disabled={disabled}
className="w-5 h-5 text-gray-700 outline-hidden cursor-pointer focus:outline-hidden dark:text-gray-400 disabled:cursor-not-allowed"
>
<svg
className={`stroke-current transition-transform ${
isOpen ? "rotate-180" : ""
}`}
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4.79175 7.39551L10.0001 12.6038L15.2084 7.39551"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>
</div>
</div>
{isOpen && (
<div
className="absolute left-0 z-40 w-full overflow-y-auto bg-white rounded-lg shadow-sm top-full max-h-select dark:bg-gray-900"
onClick={(e) => e.stopPropagation()}
role="listbox"
aria-label={label}
>
{options.map((option, index) => {
const isSelected = selectedOptions.includes(option.value);
const isFocused = index === focusedIndex;
return (
<div
key={option.value}
className={`hover:bg-primary/5 w-full cursor-pointer rounded-t border-b border-gray-200 dark:border-gray-800 ${
isFocused ? "bg-primary/5" : ""
} ${isSelected ? "bg-primary/10" : ""}`}
onClick={() => handleSelect(option.value)}
role="option"
aria-selected={isSelected}
>
<div className="relative flex w-full items-center p-2 pl-2">
<div className="mx-2 leading-6 text-gray-800 dark:text-white/90">
{option.text}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
);
};
export default MultiSelect;

View File

@@ -0,0 +1,64 @@
import { useState } from "react";
interface Option {
value: string;
label: string;
}
interface SelectProps {
options: Option[];
placeholder?: string;
onChange: (value: string) => void;
className?: string;
defaultValue?: string;
}
const Select: React.FC<SelectProps> = ({
options,
placeholder = "Select an option",
onChange,
className = "",
defaultValue = "",
}) => {
// Manage the selected value
const [selectedValue, setSelectedValue] = useState<string>(defaultValue);
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const value = e.target.value;
setSelectedValue(value);
onChange(value); // Trigger parent handler
};
return (
<select
className={`igny8-select-styled h-9 w-full appearance-none rounded-lg border border-gray-300 bg-transparent px-3 py-2 pr-10 text-sm shadow-theme-xs placeholder:text-gray-400 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800 ${
selectedValue
? "text-gray-800 dark:text-white/90"
: "text-gray-400 dark:text-gray-400"
} ${className}`}
value={selectedValue}
onChange={handleChange}
>
{/* Placeholder option */}
<option
value=""
disabled
className="text-gray-700 dark:bg-gray-900 dark:text-gray-400"
>
{placeholder}
</option>
{/* Map over options */}
{options.map((option) => (
<option
key={option.value}
value={option.value}
className="text-gray-700 dark:bg-gray-900 dark:text-gray-400"
>
{option.label}
</option>
))}
</select>
);
};
export default Select;

View File

@@ -0,0 +1,162 @@
import { useState, useEffect, useRef } from "react";
import { ChevronDownIcon } from "../../icons";
interface Option {
value: string;
label: string;
icon?: React.ReactNode;
}
interface SelectDropdownProps {
options: Option[];
placeholder?: string;
onChange: (value: string) => void;
className?: string;
defaultValue?: string;
value?: string; // Controlled value
disabled?: boolean;
}
const SelectDropdown: React.FC<SelectDropdownProps> = ({
options,
placeholder = "Select an option",
onChange,
className = "",
defaultValue = "",
value,
disabled = false,
}) => {
const isControlled = value !== undefined;
const [isOpen, setIsOpen] = useState(false);
const [internalValue, setInternalValue] = useState<string>(defaultValue);
// For controlled components, always use the value prop; for uncontrolled, use internal state
const selectedValue = isControlled ? (value || '') : internalValue;
const dropdownRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
// Sync internalValue with defaultValue when it changes externally (only for uncontrolled)
useEffect(() => {
if (!isControlled) {
setInternalValue(defaultValue);
}
}, [defaultValue, isControlled]);
// When controlled, ensure selectedValue updates when value prop changes
// selectedValue is computed directly from value prop, no effect needed
// Get selected label - normalize values for comparison
const normalizedSelectedValue = String(selectedValue || '');
const selectedOption = options.find((opt) => String(opt.value || '') === normalizedSelectedValue);
const displayText = selectedOption ? selectedOption.label : placeholder;
const isPlaceholder = !selectedOption;
// Handle click outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
buttonRef.current &&
!buttonRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}
}, [isOpen]);
// Handle option selection
const handleSelect = (val: string | undefined) => {
// Normalize: undefined/null becomes empty string for "All" options
const selectedVal = val === null || val === undefined ? '' : String(val);
if (!isControlled) {
setInternalValue(selectedVal);
}
// Always call onChange - this should trigger parent state update
onChange(selectedVal);
setIsOpen(false);
};
// Handle keyboard navigation
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
setIsOpen(false);
} else if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setIsOpen(!isOpen);
}
};
return (
<div className={`relative ${className}`}>
{/* Trigger Button - styled like igny8-select-styled */}
<button
ref={buttonRef}
type="button"
onClick={() => !disabled && setIsOpen(!isOpen)}
disabled={disabled}
onKeyDown={handleKeyDown}
className={`igny8-select-styled h-9 w-full appearance-none rounded-lg border border-gray-300 bg-transparent px-3 py-2 pr-10 text-sm shadow-theme-xs focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-700 dark:bg-gray-900 dark:focus:border-brand-800 ${
isPlaceholder
? "text-gray-400 dark:text-gray-400"
: "text-gray-800 dark:text-white/90"
} ${
isOpen
? "border-brand-300 ring-3 ring-brand-500/10 dark:border-brand-800"
: ""
} ${disabled ? "opacity-50 cursor-not-allowed" : ""}`}
>
<span className="block text-left truncate">{displayText}</span>
<span className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
<ChevronDownIcon className={`h-4 w-4 text-gray-400 transition-transform ${isOpen ? 'transform rotate-180' : ''}`} />
</span>
</button>
{/* Dropdown Menu - styled like Dropdown component but compact */}
{isOpen && (
<div
ref={dropdownRef}
className="absolute z-50 left-0 right-0 mt-1 rounded-lg border border-gray-200 bg-white shadow-theme-lg dark:border-gray-800 dark:bg-gray-dark overflow-hidden max-h-60 overflow-y-auto"
>
<div className="py-1">
{options.map((option) => {
const optionValue = String(option.value || ''); // Normalize option value
const normalizedSelected = String(selectedValue || '');
// Compare values strictly
const isSelected = normalizedSelected === optionValue;
return (
<button
key={`option-${option.value || 'empty'}-${option.label}`}
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
// Pass the normalized optionValue to ensure consistency
handleSelect(optionValue);
}}
className={`w-full text-left px-3 py-2 text-sm transition-colors flex items-center gap-2 ${
isSelected
? "bg-brand-500 text-white"
: "text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
}`}
>
{option.icon && <span className="flex-shrink-0">{option.icon}</span>}
<span>{option.label}</span>
</button>
);
})}
</div>
</div>
)}
</div>
);
};
export default SelectDropdown;

View File

@@ -0,0 +1,60 @@
import { useEffect } from "react";
import flatpickr from "flatpickr";
import "flatpickr/dist/flatpickr.css";
import Label from "./Label";
import { CalenderIcon } from "../../icons";
import Hook = flatpickr.Options.Hook;
import DateOption = flatpickr.Options.DateOption;
type PropsType = {
id: string;
mode?: "single" | "multiple" | "range" | "time";
onChange?: Hook | Hook[];
defaultDate?: DateOption;
label?: string;
placeholder?: string;
};
export default function DatePicker({
id,
mode,
onChange,
label,
defaultDate,
placeholder,
}: PropsType) {
useEffect(() => {
const flatPickr = flatpickr(`#${id}`, {
mode: mode || "single",
static: true,
monthSelectorType: "static",
dateFormat: "Y-m-d",
defaultDate,
onChange,
});
return () => {
if (!Array.isArray(flatPickr)) {
flatPickr.destroy();
}
};
}, [mode, onChange, id, defaultDate]);
return (
<div>
{label && <Label htmlFor={id}>{label}</Label>}
<div className="relative">
<input
id={id}
placeholder={placeholder}
className="h-11 w-full rounded-lg border appearance-none px-4 py-2.5 text-sm shadow-theme-xs placeholder:text-gray-400 focus:outline-hidden focus:ring-3 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 bg-transparent text-gray-800 border-gray-300 focus:border-brand-300 focus:ring-brand-500/20 dark:border-gray-700 dark:focus:border-brand-800"
/>
<span className="absolute text-gray-500 -translate-y-1/2 pointer-events-none right-3 top-1/2 dark:text-gray-400">
<CalenderIcon className="size-6" />
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,36 @@
import { useState } from "react";
import ComponentCard from "../../common/ComponentCard";
import Checkbox from "../input/Checkbox";
export default function CheckboxComponents() {
const [isChecked, setIsChecked] = useState(false);
const [isCheckedTwo, setIsCheckedTwo] = useState(true);
const [isCheckedDisabled, setIsCheckedDisabled] = useState(false);
return (
<ComponentCard title="Checkbox">
<div className="flex items-center gap-4">
<div className="flex items-center gap-3">
<Checkbox checked={isChecked} onChange={setIsChecked} />
<span className="block text-sm font-medium text-gray-700 dark:text-gray-400">
Default
</span>
</div>
<div className="flex items-center gap-3">
<Checkbox
checked={isCheckedTwo}
onChange={setIsCheckedTwo}
label="Checked"
/>
</div>
<div className="flex items-center gap-3">
<Checkbox
checked={isCheckedDisabled}
onChange={setIsCheckedDisabled}
disabled
label="Disabled"
/>
</div>
</div>
</ComponentCard>
);
}

View File

@@ -0,0 +1,115 @@
import { useState } from "react";
import ComponentCard from "../../common/ComponentCard";
import Label from "../Label";
import Input from "../input/InputField";
import Select from "../Select";
import { EyeCloseIcon, EyeIcon, TimeIcon } from "../../../icons";
import DatePicker from "../date-picker.tsx";
export default function DefaultInputs() {
const [showPassword, setShowPassword] = useState(false);
const options = [
{ value: "marketing", label: "Marketing" },
{ value: "template", label: "Template" },
{ value: "development", label: "Development" },
];
const handleSelectChange = (value: string) => {
console.log("Selected value:", value);
};
return (
<ComponentCard title="Default Inputs">
<div className="space-y-6">
<div>
<Label htmlFor="input">Input</Label>
<Input type="text" id="input" />
</div>
<div>
<Label htmlFor="inputTwo">Input with Placeholder</Label>
<Input type="text" id="inputTwo" placeholder="info@gmail.com" />
</div>
<div>
<Label>Select Input</Label>
<Select
options={options}
placeholder="Select an option"
onChange={handleSelectChange}
className="dark:bg-dark-900"
/>
</div>
<div>
<Label>Password Input</Label>
<div className="relative">
<Input
type={showPassword ? "text" : "password"}
placeholder="Enter your password"
/>
<button
onClick={() => setShowPassword(!showPassword)}
className="absolute z-30 -translate-y-1/2 cursor-pointer right-4 top-1/2"
>
{showPassword ? (
<EyeIcon className="fill-gray-500 dark:fill-gray-400 size-5" />
) : (
<EyeCloseIcon className="fill-gray-500 dark:fill-gray-400 size-5" />
)}
</button>
</div>
</div>
<div>
<DatePicker
id="date-picker"
label="Date Picker Input"
placeholder="Select a date"
onChange={(dates, currentDateString) => {
// Handle your logic
console.log({ dates, currentDateString });
}}
/>
</div>
<div>
<Label htmlFor="tm">Time Picker Input</Label>
<div className="relative">
<Input
type="time"
id="tm"
name="tm"
onChange={(e) => console.log(e.target.value)}
/>
<span className="absolute text-gray-500 -translate-y-1/2 pointer-events-none right-3 top-1/2 dark:text-gray-400">
<TimeIcon className="size-6" />
</span>
</div>
</div>
<div>
<Label htmlFor="tm">Input with Payment</Label>
<div className="relative">
<Input
type="text"
placeholder="Card number"
className="pl-[62px]"
/>
<span className="absolute left-0 top-1/2 flex h-11 w-[46px] -translate-y-1/2 items-center justify-center border-r border-gray-200 dark:border-gray-800">
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="6.25" cy="10" r="5.625" fill="#E80B26" />
<circle cx="13.75" cy="10" r="5.625" fill="#F59D31" />
<path
d="M10 14.1924C11.1508 13.1625 11.875 11.6657 11.875 9.99979C11.875 8.33383 11.1508 6.8371 10 5.80713C8.84918 6.8371 8.125 8.33383 8.125 9.99979C8.125 11.6657 8.84918 13.1625 10 14.1924Z"
fill="#FC6020"
/>
</svg>
</span>
</div>
</div>
</div>
</ComponentCard>
);
}

View File

@@ -0,0 +1,76 @@
import ComponentCard from "../../common/ComponentCard";
import { useDropzone } from "react-dropzone";
// import Dropzone from "react-dropzone";
const DropzoneComponent: React.FC = () => {
const onDrop = (acceptedFiles: File[]) => {
console.log("Files dropped:", acceptedFiles);
// Handle file uploads here
};
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
"image/png": [],
"image/jpeg": [],
"image/webp": [],
"image/svg+xml": [],
},
});
return (
<ComponentCard title="Dropzone">
<div className="transition border border-gray-300 border-dashed cursor-pointer dark:hover:border-brand-500 dark:border-gray-700 rounded-xl hover:border-brand-500">
<form
{...getRootProps()}
className={`dropzone rounded-xl border-dashed border-gray-300 p-7 lg:p-10
${
isDragActive
? "border-brand-500 bg-gray-100 dark:bg-gray-800"
: "border-gray-300 bg-gray-50 dark:border-gray-700 dark:bg-gray-900"
}
`}
id="demo-upload"
>
{/* Hidden Input */}
<input {...getInputProps()} />
<div className="dz-message flex flex-col items-center m-0!">
{/* Icon Container */}
<div className="mb-[22px] flex justify-center">
<div className="flex h-[68px] w-[68px] items-center justify-center rounded-full bg-gray-200 text-gray-700 dark:bg-gray-800 dark:text-gray-400">
<svg
className="fill-current"
width="29"
height="28"
viewBox="0 0 29 28"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M14.5019 3.91699C14.2852 3.91699 14.0899 4.00891 13.953 4.15589L8.57363 9.53186C8.28065 9.82466 8.2805 10.2995 8.5733 10.5925C8.8661 10.8855 9.34097 10.8857 9.63396 10.5929L13.7519 6.47752V18.667C13.7519 19.0812 14.0877 19.417 14.5019 19.417C14.9161 19.417 15.2519 19.0812 15.2519 18.667V6.48234L19.3653 10.5929C19.6583 10.8857 20.1332 10.8855 20.426 10.5925C20.7188 10.2995 20.7186 9.82463 20.4256 9.53184L15.0838 4.19378C14.9463 4.02488 14.7367 3.91699 14.5019 3.91699ZM5.91626 18.667C5.91626 18.2528 5.58047 17.917 5.16626 17.917C4.75205 17.917 4.41626 18.2528 4.41626 18.667V21.8337C4.41626 23.0763 5.42362 24.0837 6.66626 24.0837H22.3339C23.5766 24.0837 24.5839 23.0763 24.5839 21.8337V18.667C24.5839 18.2528 24.2482 17.917 23.8339 17.917C23.4197 17.917 23.0839 18.2528 23.0839 18.667V21.8337C23.0839 22.2479 22.7482 22.5837 22.3339 22.5837H6.66626C6.25205 22.5837 5.91626 22.2479 5.91626 21.8337V18.667Z"
/>
</svg>
</div>
</div>
{/* Text Content */}
<h4 className="mb-3 font-semibold text-gray-800 text-theme-xl dark:text-white/90">
{isDragActive ? "Drop Files Here" : "Drag & Drop Files Here"}
</h4>
<span className=" text-center mb-5 block w-full max-w-[290px] text-sm text-gray-700 dark:text-gray-400">
Drag and drop your PNG, JPG, WebP, SVG images here or browse
</span>
<span className="font-medium underline text-theme-sm text-brand-500">
Browse File
</span>
</div>
</form>
</div>
</ComponentCard>
);
};
export default DropzoneComponent;

View File

@@ -0,0 +1,21 @@
import ComponentCard from "../../common/ComponentCard";
import FileInput from "../input/FileInput";
import Label from "../Label";
export default function FileInputExample() {
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
console.log("Selected file:", file.name);
}
};
return (
<ComponentCard title="File Input">
<div>
<Label>Upload file</Label>
<FileInput onChange={handleFileChange} className="custom-class" />
</div>
</ComponentCard>
);
}

View File

@@ -0,0 +1,54 @@
import ComponentCard from "../../common/ComponentCard";
import Label from "../Label";
import Input from "../input/InputField";
import { EnvelopeIcon } from "../../../icons";
import PhoneInput from "../group-input/PhoneInput";
export default function InputGroup() {
const countries = [
{ code: "US", label: "+1" },
{ code: "GB", label: "+44" },
{ code: "CA", label: "+1" },
{ code: "AU", label: "+61" },
];
const handlePhoneNumberChange = (phoneNumber: string) => {
console.log("Updated phone number:", phoneNumber);
};
return (
<ComponentCard title="Input Group">
<div className="space-y-6">
<div>
<Label>Email</Label>
<div className="relative">
<Input
placeholder="info@gmail.com"
type="text"
className="pl-[62px]"
/>
<span className="absolute left-0 top-1/2 -translate-y-1/2 border-r border-gray-200 px-3.5 py-3 text-gray-500 dark:border-gray-800 dark:text-gray-400">
<EnvelopeIcon className="size-6" />
</span>
</div>
</div>
<div>
<Label>Phone</Label>
<PhoneInput
selectPosition="start"
countries={countries}
placeholder="+1 (555) 000-0000"
onChange={handlePhoneNumberChange}
/>
</div>{" "}
<div>
<Label>Phone</Label>
<PhoneInput
selectPosition="end"
countries={countries}
placeholder="+1 (555) 000-0000"
onChange={handlePhoneNumberChange}
/>
</div>
</div>
</ComponentCard>
);
}

View File

@@ -0,0 +1,73 @@
import { useState } from "react";
import ComponentCard from "../../common/ComponentCard";
import Input from "../input/InputField";
import Label from "../Label";
export default function InputStates() {
const [email, setEmail] = useState("");
const [emailTwo, setEmailTwo] = useState("");
const [error, setError] = useState(false);
// Simulate a validation check
const validateEmail = (value: string) => {
const isValidEmail =
/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(value);
setError(!isValidEmail);
return isValidEmail;
};
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setEmail(value);
validateEmail(value);
};
const handleEmailTwoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setEmailTwo(value);
validateEmail(value);
};
return (
<ComponentCard
title="Input States"
desc="Validation styles for error, success and disabled states on form controls."
>
<div className="space-y-5 sm:space-y-6">
{/* Error Input */}
<div>
<Label>Email</Label>
<Input
type="email"
value={email}
error={error}
onChange={handleEmailChange}
placeholder="Enter your email"
hint={error ? "This is an invalid email address." : ""}
/>
</div>
{/* Success Input */}
<div>
<Label>Email</Label>
<Input
type="email"
value={emailTwo}
success={!error}
onChange={handleEmailTwoChange}
placeholder="Enter your email"
hint={!error ? "This is an success message." : ""}
/>
</div>
{/* Disabled Input */}
<div>
<Label>Email</Label>
<Input
type="text"
value="disabled@example.com"
disabled={true}
placeholder="Disabled email"
/>
</div>
</div>
</ComponentCard>
);
}

View File

@@ -0,0 +1,42 @@
import { useState } from "react";
import ComponentCard from "../../common/ComponentCard";
import Radio from "../input/Radio";
export default function RadioButtons() {
const [selectedValue, setSelectedValue] = useState<string>("option2");
const handleRadioChange = (value: string) => {
setSelectedValue(value);
};
return (
<ComponentCard title="Radio Buttons">
<div className="flex flex-wrap items-center gap-8">
<Radio
id="radio1"
name="group1"
value="option1"
checked={selectedValue === "option1"}
onChange={handleRadioChange}
label="Default"
/>
<Radio
id="radio2"
name="group1"
value="option2"
checked={selectedValue === "option2"}
onChange={handleRadioChange}
label="Selected"
/>
<Radio
id="radio3"
name="group1"
value="option3"
checked={selectedValue === "option3"}
onChange={handleRadioChange}
label="Disabled"
disabled={true}
/>
</div>
</ComponentCard>
);
}

View File

@@ -0,0 +1,51 @@
import { useState } from "react";
import ComponentCard from "../../common/ComponentCard";
import Label from "../Label";
import Select from "../Select";
import MultiSelect from "../MultiSelect";
export default function SelectInputs() {
const options = [
{ value: "marketing", label: "Marketing" },
{ value: "template", label: "Template" },
{ value: "development", label: "Development" },
];
const handleSelectChange = (value: string) => {
console.log("Selected value:", value);
};
const [selectedValues, setSelectedValues] = useState<string[]>([]);
const multiOptions = [
{ value: "1", text: "Option 1", selected: false },
{ value: "2", text: "Option 2", selected: false },
{ value: "3", text: "Option 3", selected: false },
{ value: "4", text: "Option 4", selected: false },
{ value: "5", text: "Option 5", selected: false },
];
return (
<ComponentCard title="Select Inputs">
<div className="space-y-6">
<div>
<Label>Select Input</Label>
<Select
options={options}
placeholder="Select Option"
onChange={handleSelectChange}
className="dark:bg-dark-900"
/>
</div>
<div>
<MultiSelect
label="Multiple Select Options"
options={multiOptions}
defaultSelected={["1", "3"]}
onChange={(values) => setSelectedValues(values)}
/>
<p className="sr-only">
Selected Values: {selectedValues.join(", ")}
</p>
</div>
</div>
</ComponentCard>
);
}

View File

@@ -0,0 +1,42 @@
import { useState } from "react";
import ComponentCard from "../../common/ComponentCard";
import TextArea from "../input/TextArea";
import Label from "../Label";
export default function TextAreaInput() {
const [message, setMessage] = useState("");
const [messageTwo, setMessageTwo] = useState("");
return (
<ComponentCard title="Textarea input field">
<div className="space-y-6">
{/* Default TextArea */}
<div>
<Label>Description</Label>
<TextArea
value={message}
onChange={(value) => setMessage(value)}
rows={6}
/>
</div>
{/* Disabled TextArea */}
<div>
<Label>Description</Label>
<TextArea rows={6} disabled />
</div>
{/* Error TextArea */}
<div>
<Label>Description</Label>
<TextArea
rows={6}
value={messageTwo}
error
onChange={(value) => setMessageTwo(value)}
hint="Please enter a valid message."
/>
</div>
</div>
</ComponentCard>
);
}

View File

@@ -0,0 +1,40 @@
import ComponentCard from "../../common/ComponentCard";
import Switch from "../switch/Switch";
export default function ToggleSwitch() {
const handleSwitchChange = (checked: boolean) => {
console.log("Switch is now:", checked ? "ON" : "OFF");
};
return (
<ComponentCard title="Toggle switch input">
<div className="flex gap-4">
<Switch
label="Default"
defaultChecked={true}
onChange={handleSwitchChange}
/>
<Switch
label="Checked"
defaultChecked={true}
onChange={handleSwitchChange}
/>
<Switch label="Disabled" disabled={true} />
</div>{" "}
<div className="flex gap-4">
<Switch
label="Default"
defaultChecked={true}
onChange={handleSwitchChange}
color="gray"
/>
<Switch
label="Checked"
defaultChecked={true}
onChange={handleSwitchChange}
color="gray"
/>
<Switch label="Disabled" disabled={true} color="gray" />
</div>
</ComponentCard>
);
}

View File

@@ -0,0 +1,140 @@
import { useState } from "react";
interface CountryCode {
code: string;
label: string;
}
interface PhoneInputProps {
countries: CountryCode[];
placeholder?: string;
onChange?: (phoneNumber: string) => void;
selectPosition?: "start" | "end"; // New prop for dropdown position
}
const PhoneInput: React.FC<PhoneInputProps> = ({
countries,
placeholder = "+1 (555) 000-0000",
onChange,
selectPosition = "start", // Default position is 'start'
}) => {
const [selectedCountry, setSelectedCountry] = useState<string>("US");
const [phoneNumber, setPhoneNumber] = useState<string>("+1");
const countryCodes: Record<string, string> = countries.reduce(
(acc, { code, label }) => ({ ...acc, [code]: label }),
{}
);
const handleCountryChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newCountry = e.target.value;
setSelectedCountry(newCountry);
setPhoneNumber(countryCodes[newCountry]);
if (onChange) {
onChange(countryCodes[newCountry]);
}
};
const handlePhoneNumberChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newPhoneNumber = e.target.value;
setPhoneNumber(newPhoneNumber);
if (onChange) {
onChange(newPhoneNumber);
}
};
return (
<div className="relative flex">
{/* Dropdown position: Start */}
{selectPosition === "start" && (
<div className="absolute">
<select
value={selectedCountry}
onChange={handleCountryChange}
className="appearance-none bg-none rounded-l-lg border-0 border-r border-gray-200 bg-transparent py-3 pl-3.5 pr-8 leading-tight text-gray-700 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:text-gray-400"
>
{countries.map((country) => (
<option
key={country.code}
value={country.code}
className="text-gray-700 dark:bg-gray-900 dark:text-gray-400"
>
{country.code}
</option>
))}
</select>
<div className="absolute inset-y-0 flex items-center text-gray-700 pointer-events-none bg-none right-3 dark:text-gray-400">
<svg
className="stroke-current"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4.79175 7.396L10.0001 12.6043L15.2084 7.396"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
</div>
)}
{/* Input field */}
<input
type="tel"
value={phoneNumber}
onChange={handlePhoneNumberChange}
placeholder={placeholder}
className={`dark:bg-dark-900 h-11 w-full ${
selectPosition === "start" ? "pl-[84px]" : "pr-[84px]"
} rounded-lg border border-gray-300 bg-transparent py-3 px-4 text-sm text-gray-800 shadow-theme-xs placeholder:text-gray-400 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800`}
/>
{/* Dropdown position: End */}
{selectPosition === "end" && (
<div className="absolute right-0">
<select
value={selectedCountry}
onChange={handleCountryChange}
className="appearance-none bg-none rounded-r-lg border-0 border-l border-gray-200 bg-transparent py-3 pl-3.5 pr-8 leading-tight text-gray-700 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:text-gray-400"
>
{countries.map((country) => (
<option
key={country.code}
value={country.code}
className="text-gray-700 dark:bg-gray-900 dark:text-gray-400"
>
{country.code}
</option>
))}
</select>
<div className="absolute inset-y-0 flex items-center text-gray-700 pointer-events-none right-3 dark:text-gray-400">
<svg
className="stroke-current"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4.79175 7.396L10.0001 12.6043L15.2084 7.396"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
</div>
)}
</div>
);
};
export default PhoneInput;

View File

@@ -0,0 +1,82 @@
import type React from "react";
interface CheckboxProps {
label?: string;
checked: boolean;
className?: string;
id?: string;
onChange: (checked: boolean) => void;
disabled?: boolean;
}
const Checkbox: React.FC<CheckboxProps> = ({
label,
checked,
id,
onChange,
className = "",
disabled = false,
}) => {
return (
<label
className={`flex items-center space-x-3 group cursor-pointer ${
disabled ? "cursor-not-allowed opacity-60" : ""
}`}
>
<div className="relative w-4 h-4">
<input
id={id}
type="checkbox"
className={`w-4 h-4 appearance-none cursor-pointer dark:border-gray-700 border border-gray-300 checked:border-transparent rounded-md checked:bg-brand-500 disabled:opacity-60
${className}`}
checked={checked}
onChange={(e) => onChange(e.target.checked)}
disabled={disabled}
/>
{checked && (
<svg
className="absolute transform -translate-x-1/2 -translate-y-1/2 pointer-events-none top-1/2 left-1/2"
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
>
<path
d="M11.6666 3.5L5.24992 9.91667L2.33325 7"
stroke="white"
strokeWidth="1.94437"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
{disabled && (
<svg
className="absolute transform -translate-x-1/2 -translate-y-1/2 pointer-events-none top-1/2 left-1/2"
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
>
<path
d="M11.6666 3.5L5.24992 9.91667L2.33325 7"
stroke="#E4E7EC"
strokeWidth="2.33333"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</div>
{label && (
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">
{label}
</span>
)}
</label>
);
};
export default Checkbox;

View File

@@ -0,0 +1,22 @@
import { FC } from "react";
interface FileInputProps {
className?: string;
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void;
accept?: string;
disabled?: boolean;
}
const FileInput: FC<FileInputProps> = ({ className, onChange, accept, disabled = false }) => {
return (
<input
type="file"
accept={accept}
disabled={disabled}
className={`focus:border-ring-brand-300 h-11 w-full overflow-hidden rounded-lg border border-gray-300 bg-transparent text-sm text-gray-500 shadow-theme-xs transition-colors file:mr-5 file:border-collapse file:cursor-pointer file:rounded-l-lg file:border-0 file:border-r file:border-solid file:border-gray-200 file:bg-gray-50 file:py-3 file:pl-3.5 file:pr-3 file:text-sm file:text-gray-700 placeholder:text-gray-400 hover:file:bg-gray-100 focus:outline-hidden focus:file:ring-brand-300 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-400 dark:text-white/90 dark:file:border-gray-800 dark:file:bg-white/[0.03] dark:file:text-gray-400 dark:placeholder:text-gray-400 disabled:opacity-50 disabled:cursor-not-allowed ${className}`}
onChange={onChange}
/>
);
};
export default FileInput;

View File

@@ -0,0 +1,82 @@
import type React from "react";
import type { FC } from "react";
interface InputProps {
type?: "text" | "number" | "email" | "password" | "date" | "time" | string;
id?: string;
name?: string;
placeholder?: string;
value?: string | number;
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
className?: string;
min?: string;
max?: string;
step?: number;
disabled?: boolean;
success?: boolean;
error?: boolean;
hint?: string;
}
const Input: FC<InputProps> = ({
type = "text",
id,
name,
placeholder,
value,
onChange,
className = "",
min,
max,
step,
disabled = false,
success = false,
error = false,
hint,
}) => {
let inputClasses = ` h-9 w-full rounded-lg border appearance-none px-3 py-2 text-sm shadow-theme-xs placeholder:text-gray-400 focus:outline-hidden focus:ring-3 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 ${className}`;
if (disabled) {
inputClasses += ` text-gray-500 border-gray-300 opacity-40 bg-gray-100 cursor-not-allowed dark:bg-gray-800 dark:text-gray-400 dark:border-gray-700 opacity-40`;
} else if (error) {
inputClasses += ` border-error-500 focus:border-error-300 focus:ring-error-500/20 dark:text-error-400 dark:border-error-500 dark:focus:border-error-800`;
} else if (success) {
inputClasses += ` border-success-500 focus:border-success-300 focus:ring-success-500/20 dark:text-success-400 dark:border-success-500 dark:focus:border-success-800`;
} else {
inputClasses += ` bg-transparent text-gray-800 border-gray-300 focus:border-brand-300 focus:ring-brand-500/20 dark:border-gray-700 dark:text-white/90 dark:focus:border-brand-800`;
}
return (
<div className="relative">
<input
type={type}
id={id}
name={name}
placeholder={placeholder}
value={value}
onChange={onChange}
min={min}
max={max}
step={step}
disabled={disabled}
className={inputClasses}
/>
{hint && (
<p
className={`mt-1.5 text-xs ${
error
? "text-error-500"
: success
? "text-success-500"
: "text-gray-500"
}`}
>
{hint}
</p>
)}
</div>
);
};
export default Input;

View File

@@ -0,0 +1,63 @@
interface RadioProps {
id: string; // Unique ID for the radio button
name: string; // Radio group name
value: string; // Value of the radio button
checked: boolean; // Whether the radio button is checked
label: string; // Label for the radio button
onChange: (value: string) => void; // Handler for value change
className?: string; // Optional additional classes
disabled?: boolean; // Optional disabled state for the radio button
}
const Radio: React.FC<RadioProps> = ({
id,
name,
value,
checked,
label,
onChange,
className = "",
disabled = false,
}) => {
return (
<label
htmlFor={id}
className={`relative flex cursor-pointer select-none items-center gap-3 text-sm font-medium ${
disabled
? "text-gray-300 dark:text-gray-600 cursor-not-allowed"
: "text-gray-700 dark:text-gray-400"
} ${className}`}
>
<input
id={id}
name={name}
type="radio"
value={value}
checked={checked}
onChange={() => !disabled && onChange(value)} // Prevent onChange when disabled
className="sr-only"
disabled={disabled} // Disable input
/>
<span
className={`flex h-5 w-5 items-center justify-center rounded-full border-[1.25px] ${
checked
? "border-brand-500 bg-brand-500"
: "bg-transparent border-gray-300 dark:border-gray-700"
} ${
disabled
? "bg-gray-100 dark:bg-gray-700 border-gray-200 dark:border-gray-700"
: ""
}`}
>
<span
className={`h-2 w-2 rounded-full bg-white ${
checked ? "block" : "hidden"
}`}
></span>
</span>
{label}
</label>
);
};
export default Radio;

View File

@@ -0,0 +1,57 @@
interface RadioProps {
id: string; // Unique ID for the radio button
name: string; // Group name for the radio button
value: string; // Value of the radio button
checked: boolean; // Whether the radio button is checked
label: string; // Label text for the radio button
onChange: (value: string) => void; // Handler for when the radio button is toggled
className?: string; // Optional custom classes for styling
}
const RadioSm: React.FC<RadioProps> = ({
id,
name,
value,
checked,
label,
onChange,
className = "",
}) => {
return (
<label
htmlFor={id}
className={`flex cursor-pointer select-none items-center text-sm text-gray-500 dark:text-gray-400 ${className}`}
>
<span className="relative">
{/* Hidden Input */}
<input
type="radio"
id={id}
name={name}
value={value}
checked={checked}
onChange={() => onChange(value)}
className="sr-only"
/>
{/* Styled Radio Circle */}
<span
className={`mr-2 flex h-4 w-4 items-center justify-center rounded-full border ${
checked
? "border-brand-500 bg-brand-500"
: "bg-transparent border-gray-300 dark:border-gray-700"
}`}
>
{/* Inner Dot */}
<span
className={`h-1.5 w-1.5 rounded-full ${
checked ? "bg-white" : "bg-white dark:bg-[#1e2636]"
}`}
></span>
</span>
</span>
{label}
</label>
);
};
export default RadioSm;

View File

@@ -0,0 +1,63 @@
import React from "react";
interface TextareaProps {
placeholder?: string; // Placeholder text
rows?: number; // Number of rows
value?: string; // Current value
onChange?: (value: string) => void; // Change handler
className?: string; // Additional CSS classes
disabled?: boolean; // Disabled state
error?: boolean; // Error state
hint?: string; // Hint text to display
}
const TextArea: React.FC<TextareaProps> = ({
placeholder = "Enter your message", // Default placeholder
rows = 3, // Default number of rows
value = "", // Default value
onChange, // Callback for changes
className = "", // Additional custom styles
disabled = false, // Disabled state
error = false, // Error state
hint = "", // Default hint text
}) => {
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
if (onChange) {
onChange(e.target.value);
}
};
let textareaClasses = `w-full rounded-lg border px-4 py-2.5 text-sm shadow-theme-xs focus:outline-hidden ${className} `;
if (disabled) {
textareaClasses += ` bg-gray-100 opacity-50 text-gray-500 border-gray-300 cursor-not-allowed opacity40 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-700`;
} else if (error) {
textareaClasses += ` bg-transparent border-gray-300 focus:border-error-300 focus:ring-3 focus:ring-error-500/10 dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:focus:border-error-800`;
} else {
textareaClasses += ` bg-transparent text-gray-900 dark:text-gray-300 text-gray-900 border-gray-300 focus:border-brand-300 focus:ring-3 focus:ring-brand-500/10 dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:focus:border-brand-800`;
}
return (
<div className="relative">
<textarea
placeholder={placeholder}
rows={rows}
value={value}
onChange={handleChange}
disabled={disabled}
className={textareaClasses}
/>
{hint && (
<p
className={`mt-2 text-sm ${
error ? "text-error-500" : "text-gray-500 dark:text-gray-400"
}`}
>
{hint}
</p>
)}
</div>
);
};
export default TextArea;

View File

@@ -0,0 +1,99 @@
import { useState, useEffect } from "react";
interface SwitchProps {
label: string;
defaultChecked?: boolean;
checked?: boolean; // Controlled mode - when provided, component is controlled
disabled?: boolean;
onChange?: (checked: boolean) => void;
color?: "blue" | "gray"; // Added prop to toggle color theme
}
const Switch: React.FC<SwitchProps> = ({
label,
defaultChecked = false,
checked, // Controlled mode
disabled = false,
onChange,
color = "blue", // Default to blue color
}) => {
// Use controlled mode if 'checked' prop is provided, otherwise use uncontrolled
const isControlled = checked !== undefined;
const [internalChecked, setInternalChecked] = useState(defaultChecked);
// Sync internal state with controlled prop - only update when checked actually changes
useEffect(() => {
if (isControlled && checked !== internalChecked) {
setInternalChecked(checked);
}
}, [checked, isControlled, internalChecked]);
// Sync internal state with defaultChecked prop changes (for uncontrolled mode)
useEffect(() => {
if (!isControlled && defaultChecked !== internalChecked) {
setInternalChecked(defaultChecked);
}
}, [defaultChecked, isControlled, internalChecked]);
// Use controlled value directly, or fallback to internal state
const isChecked = isControlled ? (checked ?? false) : internalChecked;
const handleToggle = () => {
if (disabled) return;
const newCheckedState = !isChecked;
// Only update internal state if uncontrolled
if (!isControlled) {
setInternalChecked(newCheckedState);
}
// Always call onChange callback
if (onChange) {
onChange(newCheckedState);
}
};
const switchColors =
color === "blue"
? {
background: isChecked
? "bg-brand-500 "
: "bg-gray-200 dark:bg-white/10", // Blue version
knob: isChecked
? "translate-x-full bg-white"
: "translate-x-0 bg-white",
}
: {
background: isChecked
? "bg-gray-800 dark:bg-white/10"
: "bg-gray-200 dark:bg-white/10", // Gray version
knob: isChecked
? "translate-x-full bg-white"
: "translate-x-0 bg-white",
};
return (
<label
className={`flex cursor-pointer select-none items-center gap-3 text-sm font-medium ${
disabled ? "text-gray-400" : "text-gray-700 dark:text-gray-400"
}`}
onClick={handleToggle} // Toggle when the label itself is clicked
>
<div className="relative">
<div
className={`block transition duration-150 ease-linear h-6 w-11 rounded-full ${
disabled
? "bg-gray-100 pointer-events-none dark:bg-gray-800"
: switchColors.background
}`}
></div>
<div
className={`absolute left-0.5 top-0.5 h-5 w-5 rounded-full shadow-theme-sm duration-150 ease-linear transform ${switchColors.knob}`}
></div>
</div>
{label}
</label>
);
};
export default Switch;

View File

@@ -0,0 +1,168 @@
import { useState } from "react";
import { ThemeToggleButton } from "../common/ThemeToggleButton";
import NotificationDropdown from "./NotificationDropdown";
import UserDropdown from "./UserDropdown";
import { Link } from "react-router";
// Define the interface for the props
interface HeaderProps {
onClick?: () => void; // Optional function that takes no arguments and returns void
onToggle: () => void;
}
const Header: React.FC<HeaderProps> = ({ onClick, onToggle }) => {
const [isApplicationMenuOpen, setApplicationMenuOpen] = useState(false);
const toggleApplicationMenu = () => {
setApplicationMenuOpen(!isApplicationMenuOpen);
};
return (
<header className="sticky top-0 flex w-full bg-white border-gray-200 z-99999 dark:border-gray-800 dark:bg-gray-900 lg:border-b">
<div className="flex flex-col items-center justify-between grow lg:flex-row lg:px-6">
<div className="flex items-center justify-between w-full gap-2 px-3 py-3 border-b border-gray-200 dark:border-gray-800 sm:gap-4 lg:justify-normal lg:border-b-0 lg:px-0 lg:py-4">
<button
className="block w-10 h-10 text-gray-500 lg:hidden dark:text-gray-400"
onClick={onToggle}
>
{/* Hamburger Icon */}
<svg
className={`block`}
width="16"
height="12"
viewBox="0 0 16 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M0.583252 1C0.583252 0.585788 0.919038 0.25 1.33325 0.25H14.6666C15.0808 0.25 15.4166 0.585786 15.4166 1C15.4166 1.41421 15.0808 1.75 14.6666 1.75L1.33325 1.75C0.919038 1.75 0.583252 1.41422 0.583252 1ZM0.583252 11C0.583252 10.5858 0.919038 10.25 1.33325 10.25L14.6666 10.25C15.0808 10.25 15.4166 10.5858 15.4166 11C15.4166 11.4142 15.0808 11.75 14.6666 11.75L1.33325 11.75C0.919038 11.75 0.583252 11.4142 0.583252 11ZM1.33325 5.25C0.919038 5.25 0.583252 5.58579 0.583252 6C0.583252 6.41421 0.919038 6.75 1.33325 6.75L7.99992 6.75C8.41413 6.75 8.74992 6.41421 8.74992 6C8.74992 5.58579 8.41413 5.25 7.99992 5.25L1.33325 5.25Z"
fill="currentColor"
/>
</svg>
<svg
className="hidden"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6.21967 7.28131C5.92678 6.98841 5.92678 6.51354 6.21967 6.22065C6.51256 5.92775 6.98744 5.92775 7.28033 6.22065L11.999 10.9393L16.7176 6.22078C17.0105 5.92789 17.4854 5.92788 17.7782 6.22078C18.0711 6.51367 18.0711 6.98855 17.7782 7.28144L13.0597 12L17.7782 16.7186C18.0711 17.0115 18.0711 17.4863 17.7782 17.7792C17.4854 18.0721 17.0105 18.0721 16.7176 17.7792L11.999 13.0607L7.28033 17.7794C6.98744 18.0722 6.51256 18.0722 6.21967 17.7794C5.92678 17.4865 5.92678 17.0116 6.21967 16.7187L10.9384 12L6.21967 7.28131Z"
fill="currentColor"
/>
</svg>
{/* Cross Icon */}
</button>
<button
onClick={onClick}
className="items-center justify-center hidden w-10 h-10 text-gray-500 border-gray-200 rounded-lg z-99999 dark:border-gray-800 lg:flex dark:text-gray-400 lg:h-11 lg:w-11 lg:border"
>
<svg
className="hidden fill-current lg:block"
width="16"
height="12"
viewBox="0 0 16 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M0.583252 1C0.583252 0.585788 0.919038 0.25 1.33325 0.25H14.6666C15.0808 0.25 15.4166 0.585786 15.4166 1C15.4166 1.41421 15.0808 1.75 14.6666 1.75L1.33325 1.75C0.919038 1.75 0.583252 1.41422 0.583252 1ZM0.583252 11C0.583252 10.5858 0.919038 10.25 1.33325 10.25L14.6666 10.25C15.0808 10.25 15.4166 10.5858 15.4166 11C15.4166 11.4142 15.0808 11.75 14.6666 11.75L1.33325 11.75C0.919038 11.75 0.583252 11.4142 0.583252 11ZM1.33325 5.25C0.919038 5.25 0.583252 5.58579 0.583252 6C0.583252 6.41421 0.919038 6.75 1.33325 6.75L7.99992 6.75C8.41413 6.75 8.74992 6.41421 8.74992 6C8.74992 5.58579 8.41413 5.25 7.99992 5.25L1.33325 5.25Z"
fill=""
/>
</svg>
</button>
<Link to="/" className="lg:hidden">
<img
className="dark:hidden"
src="./images/logo/logo.svg"
alt="Logo"
/>
<img
className="hidden dark:block"
src="./images/logo/logo-dark.svg"
alt="Logo"
/>
</Link>
<button
onClick={toggleApplicationMenu}
className="flex items-center justify-center w-10 h-10 text-gray-700 rounded-lg z-99999 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800 lg:hidden"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M5.99902 10.4951C6.82745 10.4951 7.49902 11.1667 7.49902 11.9951V12.0051C7.49902 12.8335 6.82745 13.5051 5.99902 13.5051C5.1706 13.5051 4.49902 12.8335 4.49902 12.0051V11.9951C4.49902 11.1667 5.1706 10.4951 5.99902 10.4951ZM17.999 10.4951C18.8275 10.4951 19.499 11.1667 19.499 11.9951V12.0051C19.499 12.8335 18.8275 13.5051 17.999 13.5051C17.1706 13.5051 16.499 12.8335 16.499 12.0051V11.9951C16.499 11.1667 17.1706 10.4951 17.999 10.4951ZM13.499 11.9951C13.499 11.1667 12.8275 10.4951 11.999 10.4951C11.1706 10.4951 10.499 11.1667 10.499 11.9951V12.0051C10.499 12.8335 11.1706 13.5051 11.999 13.5051C12.8275 13.5051 13.499 12.8335 13.499 12.0051V11.9951Z"
fill="currentColor"
/>
</svg>
</button>
<div className="hidden lg:block">
<form action="https://formbold.com/s/unique_form_id" method="POST">
<div className="relative">
<button className="absolute -translate-y-1/2 left-4 top-1/2">
<svg
className="fill-gray-500 dark:fill-gray-400"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M3.04175 9.37363C3.04175 5.87693 5.87711 3.04199 9.37508 3.04199C12.8731 3.04199 15.7084 5.87693 15.7084 9.37363C15.7084 12.8703 12.8731 15.7053 9.37508 15.7053C5.87711 15.7053 3.04175 12.8703 3.04175 9.37363ZM9.37508 1.54199C5.04902 1.54199 1.54175 5.04817 1.54175 9.37363C1.54175 13.6991 5.04902 17.2053 9.37508 17.2053C11.2674 17.2053 13.003 16.5344 14.357 15.4176L17.177 18.238C17.4699 18.5309 17.9448 18.5309 18.2377 18.238C18.5306 17.9451 18.5306 17.4703 18.2377 17.1774L15.418 14.3573C16.5365 13.0033 17.2084 11.2669 17.2084 9.37363C17.2084 5.04817 13.7011 1.54199 9.37508 1.54199Z"
fill=""
/>
</svg>
</button>
<input
type="text"
placeholder="Search or type command..."
className="dark:bg-dark-900 h-11 w-full rounded-lg border border-gray-200 bg-transparent py-2.5 pl-12 pr-14 text-sm text-gray-800 shadow-theme-xs placeholder:text-gray-400 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:bg-gray-900 dark:bg-white/[0.03] dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800 xl:w-[430px]"
/>
<button className="absolute right-2.5 top-1/2 inline-flex -translate-y-1/2 items-center gap-0.5 rounded-lg border border-gray-200 bg-gray-50 px-[7px] py-[4.5px] text-xs -tracking-[0.2px] text-gray-500 dark:border-gray-800 dark:bg-white/[0.03] dark:text-gray-400">
<span> </span>
<span> K </span>
</button>
</div>
</form>
</div>
</div>
<div
className={`${
isApplicationMenuOpen ? "flex" : "hidden"
} items-center justify-between w-full gap-4 px-5 py-4 lg:flex shadow-theme-md lg:justify-end lg:px-0 lg:shadow-none`}
>
<div className="flex items-center gap-2 2xsm:gap-3">
{/* <!-- Dark Mode Toggler --> */}
<ThemeToggleButton />
{/* <!-- Dark Mode Toggler --> */}
<NotificationDropdown />
{/* <!-- Notification Menu Area --> */}
</div>
{/* <!-- User Area --> */}
<UserDropdown />
</div>
</div>
</header>
);
};
export default Header;

View File

@@ -0,0 +1,26 @@
import React from 'react';
import { useHeaderMetrics } from '../../context/HeaderMetricsContext';
export const HeaderMetrics: React.FC = () => {
const { metrics } = useHeaderMetrics();
if (!metrics || metrics.length === 0) return null;
return (
<div className="igny8-header-metrics hidden lg:flex">
{metrics.map((metric, index) => (
<React.Fragment key={index}>
<div className="igny8-header-metric">
<div className={`igny8-header-metric-accent ${metric.accentColor}`}></div>
<span className="igny8-header-metric-label">{metric.label}</span>
<span className="igny8-header-metric-value">
{typeof metric.value === 'number' ? metric.value.toLocaleString() : metric.value}
</span>
</div>
{index < metrics.length - 1 && <div className="igny8-header-metric-separator"></div>}
</React.Fragment>
))}
</div>
);
};

View File

@@ -0,0 +1,380 @@
import { useState } from "react";
import { Dropdown } from "../ui/dropdown/Dropdown";
import { DropdownItem } from "../ui/dropdown/DropdownItem";
import { Link } from "react-router";
export default function NotificationDropdown() {
const [isOpen, setIsOpen] = useState(false);
const [notifying, setNotifying] = useState(true);
function toggleDropdown() {
setIsOpen(!isOpen);
}
function closeDropdown() {
setIsOpen(false);
}
const handleClick = () => {
toggleDropdown();
setNotifying(false);
};
return (
<div className="relative">
<button
className="relative flex items-center justify-center text-gray-500 transition-colors bg-white border border-gray-200 rounded-full dropdown-toggle hover:text-gray-700 h-11 w-11 hover:bg-gray-100 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
onClick={handleClick}
>
<span
className={`absolute right-0 top-0.5 z-10 h-2 w-2 rounded-full bg-orange-400 ${
!notifying ? "hidden" : "flex"
}`}
>
<span className="absolute inline-flex w-full h-full bg-orange-400 rounded-full opacity-75 animate-ping"></span>
</span>
<svg
className="fill-current"
width="20"
height="20"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M10.75 2.29248C10.75 1.87827 10.4143 1.54248 10 1.54248C9.58583 1.54248 9.25004 1.87827 9.25004 2.29248V2.83613C6.08266 3.20733 3.62504 5.9004 3.62504 9.16748V14.4591H3.33337C2.91916 14.4591 2.58337 14.7949 2.58337 15.2091C2.58337 15.6234 2.91916 15.9591 3.33337 15.9591H4.37504H15.625H16.6667C17.0809 15.9591 17.4167 15.6234 17.4167 15.2091C17.4167 14.7949 17.0809 14.4591 16.6667 14.4591H16.375V9.16748C16.375 5.9004 13.9174 3.20733 10.75 2.83613V2.29248ZM14.875 14.4591V9.16748C14.875 6.47509 12.6924 4.29248 10 4.29248C7.30765 4.29248 5.12504 6.47509 5.12504 9.16748V14.4591H14.875ZM8.00004 17.7085C8.00004 18.1228 8.33583 18.4585 8.75004 18.4585H11.25C11.6643 18.4585 12 18.1228 12 17.7085C12 17.2943 11.6643 16.9585 11.25 16.9585H8.75004C8.33583 16.9585 8.00004 17.2943 8.00004 17.7085Z"
fill="currentColor"
/>
</svg>
</button>
<Dropdown
isOpen={isOpen}
onClose={closeDropdown}
className="absolute -right-[240px] mt-[17px] flex h-[480px] w-[350px] flex-col rounded-2xl border border-gray-200 bg-white p-3 shadow-theme-lg dark:border-gray-800 dark:bg-gray-dark sm:w-[361px] lg:right-0"
>
<div className="flex items-center justify-between pb-3 mb-3 border-b border-gray-100 dark:border-gray-700">
<h5 className="text-lg font-semibold text-gray-800 dark:text-gray-200">
Notification
</h5>
<button
onClick={toggleDropdown}
className="text-gray-500 transition dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
>
<svg
className="fill-current"
width="24"
height="24"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6.21967 7.28131C5.92678 6.98841 5.92678 6.51354 6.21967 6.22065C6.51256 5.92775 6.98744 5.92775 7.28033 6.22065L11.999 10.9393L16.7176 6.22078C17.0105 5.92789 17.4854 5.92788 17.7782 6.22078C18.0711 6.51367 18.0711 6.98855 17.7782 7.28144L13.0597 12L17.7782 16.7186C18.0711 17.0115 18.0711 17.4863 17.7782 17.7792C17.4854 18.0721 17.0105 18.0721 16.7176 17.7792L11.999 13.0607L7.28033 17.7794C6.98744 18.0722 6.51256 18.0722 6.21967 17.7794C5.92678 17.4865 5.92678 17.0116 6.21967 16.7187L10.9384 12L6.21967 7.28131Z"
fill="currentColor"
/>
</svg>
</button>
</div>
<ul className="flex flex-col h-auto overflow-y-auto custom-scrollbar">
{/* Example notification items */}
<li>
<DropdownItem
onItemClick={closeDropdown}
className="flex gap-3 rounded-lg border-b border-gray-100 p-3 px-4.5 py-3 hover:bg-gray-100 dark:border-gray-800 dark:hover:bg-white/5"
>
<span className="relative block w-full h-10 rounded-full z-1 max-w-10">
<img
width={40}
height={40}
src="/images/user/user-02.jpg"
alt="User"
className="w-full overflow-hidden rounded-full"
/>
<span className="absolute bottom-0 right-0 z-10 h-2.5 w-full max-w-2.5 rounded-full border-[1.5px] border-white bg-success-500 dark:border-gray-900"></span>
</span>
<span className="block">
<span className="mb-1.5 block text-theme-sm text-gray-500 dark:text-gray-400 space-x-1">
<span className="font-medium text-gray-800 dark:text-white/90">
Terry Franci
</span>
<span> requests permission to change</span>
<span className="font-medium text-gray-800 dark:text-white/90">
Project - Nganter App
</span>
</span>
<span className="flex items-center gap-2 text-gray-500 text-theme-xs dark:text-gray-400">
<span>Project</span>
<span className="w-1 h-1 bg-gray-400 rounded-full"></span>
<span>5 min ago</span>
</span>
</span>
</DropdownItem>
</li>
<li>
<DropdownItem
onItemClick={closeDropdown}
className="flex gap-3 rounded-lg border-b border-gray-100 p-3 px-4.5 py-3 hover:bg-gray-100 dark:border-gray-800 dark:hover:bg-white/5"
>
<span className="relative block w-full h-10 rounded-full z-1 max-w-10">
<img
width={40}
height={40}
src="/images/user/user-03.jpg"
alt="User"
className="w-full overflow-hidden rounded-full"
/>
<span className="absolute bottom-0 right-0 z-10 h-2.5 w-full max-w-2.5 rounded-full border-[1.5px] border-white bg-success-500 dark:border-gray-900"></span>
</span>
<span className="block">
<span className="mb-1.5 block space-x-1 text-theme-sm text-gray-500 dark:text-gray-400">
<span className="font-medium text-gray-800 dark:text-white/90">
Alena Franci
</span>
<span>requests permission to change</span>
<span className="font-medium text-gray-800 dark:text-white/90">
Project - Nganter App
</span>
</span>
<span className="flex items-center gap-2 text-gray-500 text-theme-xs dark:text-gray-400">
<span>Project</span>
<span className="w-1 h-1 bg-gray-400 rounded-full"></span>
<span>8 min ago</span>
</span>
</span>
</DropdownItem>
</li>
<li>
<DropdownItem
onItemClick={closeDropdown}
className="flex gap-3 rounded-lg border-b border-gray-100 p-3 px-4.5 py-3 hover:bg-gray-100 dark:border-gray-800 dark:hover:bg-white/5"
>
<span className="relative block w-full h-10 rounded-full z-1 max-w-10">
<img
width={40}
height={40}
src="/images/user/user-04.jpg"
alt="User"
className="w-full overflow-hidden rounded-full"
/>
<span className="absolute bottom-0 right-0 z-10 h-2.5 w-full max-w-2.5 rounded-full border-[1.5px] border-white bg-success-500 dark:border-gray-900"></span>
</span>
<span className="block">
<span className="mb-1.5 block space-x-1 text-theme-sm text-gray-500 dark:text-gray-400">
<span className="font-medium text-gray-800 dark:text-white/90">
Jocelyn Kenter
</span>
<span> requests permission to change</span>
<span className="font-medium text-gray-800 dark:text-white/90">
Project - Nganter App
</span>
</span>
<span className="flex items-center gap-2 text-gray-500 text-theme-xs dark:text-gray-400">
<span>Project</span>
<span className="w-1 h-1 bg-gray-400 rounded-full"></span>
<span>15 min ago</span>
</span>
</span>
</DropdownItem>
</li>
<li>
<DropdownItem
onItemClick={closeDropdown}
className="flex gap-3 rounded-lg border-b border-gray-100 p-3 px-4.5 py-3 hover:bg-gray-100 dark:border-gray-800 dark:hover:bg-white/5"
to="/"
>
<span className="relative block w-full h-10 rounded-full z-1 max-w-10">
<img
width={40}
height={40}
src="/images/user/user-05.jpg"
alt="User"
className="w-full overflow-hidden rounded-full"
/>
<span className="absolute bottom-0 right-0 z-10 h-2.5 w-full max-w-2.5 rounded-full border-[1.5px] border-white bg-error-500 dark:border-gray-900"></span>
</span>
<span className="block">
<span className="mb-1.5 space-x-1 block text-theme-sm text-gray-500 dark:text-gray-400">
<span className="font-medium text-gray-800 dark:text-white/90">
Brandon Philips
</span>
<span>requests permission to change</span>
<span className="font-medium text-gray-800 dark:text-white/90">
Project - Nganter App
</span>
</span>
<span className="flex items-center gap-2 text-gray-500 text-theme-xs dark:text-gray-400">
<span>Project</span>
<span className="w-1 h-1 bg-gray-400 rounded-full"></span>
<span>1 hr ago</span>
</span>
</span>
</DropdownItem>
</li>
<li>
<DropdownItem
className="flex gap-3 rounded-lg border-b border-gray-100 p-3 px-4.5 py-3 hover:bg-gray-100 dark:border-gray-800 dark:hover:bg-white/5"
onItemClick={closeDropdown}
>
<span className="relative block w-full h-10 rounded-full z-1 max-w-10">
<img
width={40}
height={40}
src="/images/user/user-02.jpg"
alt="User"
className="w-full overflow-hidden rounded-full"
/>
<span className="absolute bottom-0 right-0 z-10 h-2.5 w-full max-w-2.5 rounded-full border-[1.5px] border-white bg-success-500 dark:border-gray-900"></span>
</span>
<span className="block">
<span className="mb-1.5 block space-x-1 text-theme-sm text-gray-500 dark:text-gray-400">
<span className="font-medium text-gray-800 dark:text-white/90">
Terry Franci
</span>
<span> requests permission to change</span>
<span className="font-medium text-gray-800 dark:text-white/90">
Project - Nganter App
</span>
</span>
<span className="flex items-center gap-2 text-gray-500 text-theme-xs dark:text-gray-400">
<span>Project</span>
<span className="w-1 h-1 bg-gray-400 rounded-full"></span>
<span>5 min ago</span>
</span>
</span>
</DropdownItem>
</li>
<li>
<DropdownItem
onItemClick={closeDropdown}
className="flex gap-3 rounded-lg border-b border-gray-100 p-3 px-4.5 py-3 hover:bg-gray-100 dark:border-gray-800 dark:hover:bg-white/5"
>
<span className="relative block w-full h-10 rounded-full z-1 max-w-10">
<img
width={40}
height={40}
src="/images/user/user-03.jpg"
alt="User"
className="w-full overflow-hidden rounded-full"
/>
<span className="absolute bottom-0 right-0 z-10 h-2.5 w-full max-w-2.5 rounded-full border-[1.5px] border-white bg-success-500 dark:border-gray-900"></span>
</span>
<span className="block">
<span className="mb-1.5 block space-x-1 text-theme-sm text-gray-500 dark:text-gray-400">
<span className="font-medium text-gray-800 dark:text-white/90">
Alena Franci
</span>
<span> requests permission to change</span>
<span className="font-medium text-gray-800 dark:text-white/90">
Project - Nganter App
</span>
</span>
<span className="flex items-center gap-2 text-gray-500 text-theme-xs dark:text-gray-400">
<span>Project</span>
<span className="w-1 h-1 bg-gray-400 rounded-full"></span>
<span>8 min ago</span>
</span>
</span>
</DropdownItem>
</li>
<li>
<DropdownItem
onItemClick={closeDropdown}
className="flex gap-3 rounded-lg border-b border-gray-100 p-3 px-4.5 py-3 hover:bg-gray-100 dark:border-gray-800 dark:hover:bg-white/5"
>
<span className="relative block w-full h-10 rounded-full z-1 max-w-10">
<img
width={40}
height={40}
src="/images/user/user-04.jpg"
alt="User"
className="w-full overflow-hidden rounded-full"
/>
<span className="absolute bottom-0 right-0 z-10 h-2.5 w-full max-w-2.5 rounded-full border-[1.5px] border-white bg-success-500 dark:border-gray-900"></span>
</span>
<span className="block">
<span className="mb-1.5 block space-x-1 text-theme-sm text-gray-500 dark:text-gray-400">
<span className="font-medium text-gray-800 dark:text-white/90">
Jocelyn Kenter
</span>
<span> requests permission to change</span>
<span className="font-medium text-gray-800 dark:text-white/90">
Project - Nganter App
</span>
</span>
<span className="flex items-center gap-2 text-gray-500 text-theme-xs dark:text-gray-400">
<span>Project</span>
<span className="w-1 h-1 bg-gray-400 rounded-full"></span>
<span>15 min ago</span>
</span>
</span>
</DropdownItem>
</li>
<li>
<DropdownItem
onItemClick={closeDropdown}
className="flex gap-3 rounded-lg border-b border-gray-100 p-3 px-4.5 py-3 hover:bg-gray-100 dark:border-gray-800 dark:hover:bg-white/5"
>
<span className="relative block w-full h-10 rounded-full z-1 max-w-10">
<img
width={40}
height={40}
src="/images/user/user-05.jpg"
alt="User"
className="overflow-hidden rounded-full"
/>
<span className="absolute bottom-0 right-0 z-10 h-2.5 w-full max-w-2.5 rounded-full border-[1.5px] border-white bg-error-500 dark:border-gray-900"></span>
</span>
<span className="block">
<span className="mb-1.5 block space-x-1 text-theme-sm text-gray-500 dark:text-gray-400">
<span className="font-medium text-gray-800 dark:text-white/90">
Brandon Philips
</span>
<span>requests permission to change</span>
<span className="font-medium text-gray-800 dark:text-white/90">
Project - Nganter App
</span>
</span>
<span className="flex items-center gap-2 text-gray-500 text-theme-xs dark:text-gray-400">
<span>Project</span>
<span className="w-1 h-1 bg-gray-400 rounded-full"></span>
<span>1 hr ago</span>
</span>
</span>
</DropdownItem>
</li>
{/* Add more items as needed */}
</ul>
<Link
to="/"
className="block px-4 py-2 mt-3 text-sm font-medium text-center text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700"
>
View All Notifications
</Link>
</Dropdown>
</div>
);
}

View File

@@ -0,0 +1,219 @@
import { useState, useEffect, useRef } from "react";
import { useLocation } from "react-router";
import { Dropdown } from "../ui/dropdown/Dropdown";
import { DropdownItem } from "../ui/dropdown/DropdownItem";
import { fetchSites, Site, setActiveSite as apiSetActiveSite } from "../../services/api";
import { useToast } from "../ui/toast/ToastContainer";
import { useSiteStore } from "../../store/siteStore";
import { useAuthStore } from "../../store/authStore";
/**
* SiteSwitcher Component
*
* Shows a dropdown menu with all active sites for the user.
* Should only be visible on planner and writer pages (and their submodules).
*
* Paths where switcher should be shown:
* - /planner (dashboard and all subpages)
* - /writer (dashboard and all subpages)
*
* Paths where switcher should be hidden:
* - /settings (including /settings/sites)
* - /dashboard
* - /analytics
* - /schedules
* - /thinker
* - /signin, /signup
*/
// Paths where switcher should be shown (planner and writer modules)
const SITE_SWITCHER_SHOWN_PATHS = [
'/planner',
'/writer',
];
// Paths where switcher should be hidden (override shown paths)
const SITE_SWITCHER_HIDDEN_PATHS = [
'/settings',
'/dashboard',
'/analytics',
'/schedules',
'/thinker',
];
interface SiteSwitcherProps {
hiddenPaths?: string[]; // Optional override for hidden paths
}
export default function SiteSwitcher({ hiddenPaths }: SiteSwitcherProps) {
const location = useLocation();
const toast = useToast();
const { activeSite, setActiveSite, loadActiveSite } = useSiteStore();
const { user, refreshUser, isAuthenticated } = useAuthStore();
const [isOpen, setIsOpen] = useState(false);
const [sites, setSites] = useState<Site[]>([]);
const [loading, setLoading] = useState(true);
const buttonRef = useRef<HTMLButtonElement>(null);
// Determine if switcher should be shown
const hiddenPathsToUse = hiddenPaths || SITE_SWITCHER_HIDDEN_PATHS;
const shownPaths = SITE_SWITCHER_SHOWN_PATHS;
let shouldHide = false;
if (shownPaths.length > 0) {
// If shown paths are defined, only show on those paths
shouldHide = !shownPaths.some(path => location.pathname.startsWith(path));
} else {
// Otherwise, hide on explicitly hidden paths
shouldHide = hiddenPathsToUse.some(path => location.pathname.startsWith(path));
}
// Refresh user data when component mounts or user changes
// This ensures we have latest account/plan info for proper site filtering
useEffect(() => {
if (isAuthenticated && user) {
// Refresh user data to get latest account/plan changes
// This is important so site filtering works correctly
refreshUser().catch((error) => {
// Silently fail - user might still be valid, just couldn't refresh
console.debug('SiteSwitcher: Failed to refresh user (non-critical):', error);
});
}
}, [isAuthenticated]); // Only refresh when auth state changes, not on every render
useEffect(() => {
if (shouldHide) {
setLoading(false);
return;
}
loadSites();
// Load active site from store (only once, not on every render)
if (!activeSite) {
loadActiveSite();
}
}, [shouldHide, location.pathname, user?.account?.id]); // Also reload when user's account changes
const loadSites = async () => {
try {
setLoading(true);
const response = await fetchSites();
// Filter to only show active sites in the switcher
const activeSites = (response.results || []).filter(site => site.is_active);
setSites(activeSites);
} catch (error: any) {
console.error('Failed to load sites:', error);
toast.error(`Failed to load sites: ${error.message}`);
} finally {
setLoading(false);
}
};
const handleSiteSelect = async (siteId: number) => {
try {
await apiSetActiveSite(siteId);
const selectedSite = sites.find(s => s.id === siteId);
if (selectedSite) {
// Update the global site store - this will dispatch siteChanged event
setActiveSite(selectedSite);
toast.success(`Switched to "${selectedSite.name}"`);
}
setIsOpen(false);
} catch (error: any) {
toast.error(`Failed to switch site: ${error.message}`);
}
};
function toggleDropdown() {
setIsOpen(!isOpen);
}
// Don't render if should be hidden
if (shouldHide) {
return null;
}
// Don't render if loading or no sites
if (loading || sites.length === 0) {
return null;
}
return (
<div className="relative inline-block">
<button
ref={buttonRef}
onClick={toggleDropdown}
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-700 dark:hover:bg-gray-700 dropdown-toggle"
aria-label="Switch site"
>
<span className="flex items-center gap-2">
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
/>
</svg>
<span className="max-w-[150px] truncate">
{activeSite?.name || 'Select Site'}
</span>
</span>
<svg
className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
<Dropdown
isOpen={isOpen}
onClose={() => setIsOpen(false)}
anchorRef={buttonRef}
placement="bottom-left"
className="w-64 p-2"
>
{sites.map((site) => (
<DropdownItem
key={site.id}
onItemClick={() => handleSiteSelect(site.id)}
className={`flex items-center gap-3 px-3 py-2 font-medium rounded-lg text-sm text-left ${
activeSite?.id === site.id
? "bg-blue-50 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300"
: "text-gray-700 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300"
}`}
>
<span className="flex-1">{site.name}</span>
{activeSite?.id === site.id && (
<svg
className="w-4 h-4 text-blue-600 dark:text-blue-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
)}
</DropdownItem>
))}
</Dropdown>
</div>
);
}

View File

@@ -0,0 +1,185 @@
import { useState } from "react";
import { useNavigate } from "react-router";
import { DropdownItem } from "../ui/dropdown/DropdownItem";
import { Dropdown } from "../ui/dropdown/Dropdown";
import { Link } from "react-router";
import { useAuthStore } from "../../store/authStore";
export default function UserDropdown() {
const [isOpen, setIsOpen] = useState(false);
const navigate = useNavigate();
const { user, logout } = useAuthStore();
function toggleDropdown() {
setIsOpen(!isOpen);
}
function closeDropdown() {
setIsOpen(false);
}
const handleLogout = () => {
logout();
navigate("/signin", { replace: true });
closeDropdown();
};
return (
<div className="relative">
<button
onClick={toggleDropdown}
className="flex items-center text-gray-700 dropdown-toggle dark:text-gray-400"
>
<span className="mr-3 overflow-hidden rounded-full h-11 w-11 bg-brand-500 flex items-center justify-center">
{user?.email ? (
<span className="text-white font-semibold text-sm">
{user.email.charAt(0).toUpperCase()}
</span>
) : (
<img src="/images/user/owner.jpg" alt="User" />
)}
</span>
<span className="block mr-1 font-medium text-theme-sm">
{user?.username || user?.email?.split("@")[0] || "User"}
</span>
<svg
className={`stroke-gray-500 dark:stroke-gray-400 transition-transform duration-200 ${
isOpen ? "rotate-180" : ""
}`}
width="18"
height="20"
viewBox="0 0 18 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4.3125 8.65625L9 13.3437L13.6875 8.65625"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
<Dropdown
isOpen={isOpen}
onClose={closeDropdown}
className="absolute right-0 mt-[17px] flex w-[260px] flex-col rounded-2xl border border-gray-200 bg-white p-3 shadow-theme-lg dark:border-gray-800 dark:bg-gray-dark"
>
<div>
<span className="block font-medium text-gray-700 text-theme-sm dark:text-gray-400">
{user?.username || "User"}
</span>
<span className="mt-0.5 block text-theme-xs text-gray-500 dark:text-gray-400">
{user?.email || "No email"}
</span>
{user?.role && (
<span className="mt-1 inline-block px-2 py-0.5 text-xs font-medium text-brand-600 bg-brand-50 rounded dark:text-brand-400 dark:bg-brand-900/20">
{user.role}
</span>
)}
</div>
<ul className="flex flex-col gap-1 pt-4 pb-3 border-b border-gray-200 dark:border-gray-800">
<li>
<DropdownItem
onItemClick={closeDropdown}
tag="a"
to="/profile"
className="flex items-center gap-3 px-3 py-2 font-medium text-gray-700 rounded-lg group text-theme-sm hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300"
>
<svg
className="fill-gray-500 group-hover:fill-gray-700 dark:fill-gray-400 dark:group-hover:fill-gray-300"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12 3.5C7.30558 3.5 3.5 7.30558 3.5 12C3.5 14.1526 4.3002 16.1184 5.61936 17.616C6.17279 15.3096 8.24852 13.5955 10.7246 13.5955H13.2746C15.7509 13.5955 17.8268 15.31 18.38 17.6167C19.6996 16.119 20.5 14.153 20.5 12C20.5 7.30558 16.6944 3.5 12 3.5ZM17.0246 18.8566V18.8455C17.0246 16.7744 15.3457 15.0955 13.2746 15.0955H10.7246C8.65354 15.0955 6.97461 16.7744 6.97461 18.8455V18.856C8.38223 19.8895 10.1198 20.5 12 20.5C13.8798 20.5 15.6171 19.8898 17.0246 18.8566ZM2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM11.9991 7.25C10.8847 7.25 9.98126 8.15342 9.98126 9.26784C9.98126 10.3823 10.8847 11.2857 11.9991 11.2857C13.1135 11.2857 14.0169 10.3823 14.0169 9.26784C14.0169 8.15342 13.1135 7.25 11.9991 7.25ZM8.48126 9.26784C8.48126 7.32499 10.0563 5.75 11.9991 5.75C13.9419 5.75 15.5169 7.32499 15.5169 9.26784C15.5169 11.2107 13.9419 12.7857 11.9991 12.7857C10.0563 12.7857 8.48126 11.2107 8.48126 9.26784Z"
fill=""
/>
</svg>
Edit profile
</DropdownItem>
</li>
<li>
<DropdownItem
onItemClick={closeDropdown}
tag="a"
to="/profile"
className="flex items-center gap-3 px-3 py-2 font-medium text-gray-700 rounded-lg group text-theme-sm hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300"
>
<svg
className="fill-gray-500 group-hover:fill-gray-700 dark:fill-gray-400 dark:group-hover:fill-gray-300"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M10.4858 3.5L13.5182 3.5C13.9233 3.5 14.2518 3.82851 14.2518 4.23377C14.2518 5.9529 16.1129 7.02795 17.602 6.1682C17.9528 5.96567 18.4014 6.08586 18.6039 6.43667L20.1203 9.0631C20.3229 9.41407 20.2027 9.86286 19.8517 10.0655C18.3625 10.9253 18.3625 13.0747 19.8517 13.9345C20.2026 14.1372 20.3229 14.5859 20.1203 14.9369L18.6039 17.5634C18.4013 17.9142 17.9528 18.0344 17.602 17.8318C16.1129 16.9721 14.2518 18.0471 14.2518 19.7663C14.2518 20.1715 13.9233 20.5 13.5182 20.5H10.4858C10.0804 20.5 9.75182 20.1714 9.75182 19.766C9.75182 18.0461 7.88983 16.9717 6.40067 17.8314C6.04945 18.0342 5.60037 17.9139 5.39767 17.5628L3.88167 14.937C3.67903 14.586 3.79928 14.1372 4.15026 13.9346C5.63949 13.0748 5.63946 10.9253 4.15025 10.0655C3.79926 9.86282 3.67901 9.41401 3.88165 9.06303L5.39764 6.43725C5.60034 6.08617 6.04943 5.96581 6.40065 6.16858C7.88982 7.02836 9.75182 5.9539 9.75182 4.23399C9.75182 3.82862 10.0804 3.5 10.4858 3.5ZM13.5182 2L10.4858 2C9.25201 2 8.25182 3.00019 8.25182 4.23399C8.25182 4.79884 7.64013 5.15215 7.15065 4.86955C6.08213 4.25263 4.71559 4.61859 4.0986 5.68725L2.58261 8.31303C1.96575 9.38146 2.33183 10.7477 3.40025 11.3645C3.88948 11.647 3.88947 12.3531 3.40026 12.6355C2.33184 13.2524 1.96578 14.6186 2.58263 15.687L4.09863 18.3128C4.71562 19.3814 6.08215 19.7474 7.15067 19.1305C7.64015 18.8479 8.25182 19.2012 8.25182 19.766C8.25182 20.9998 9.25201 22 10.4858 22H13.5182C14.7519 22 15.7518 20.9998 15.7518 19.7663C15.7518 19.2015 16.3632 18.8487 16.852 19.1309C17.9202 19.7476 19.2862 19.3816 19.9029 18.3134L21.4193 15.6869C22.0361 14.6185 21.6701 13.2523 20.6017 12.6355C20.1125 12.3531 20.1125 11.647 20.6017 11.3645C21.6701 10.7477 22.0362 9.38152 21.4193 8.3131L19.903 5.68667C19.2862 4.61842 17.9202 4.25241 16.852 4.86917C16.3632 5.15138 15.7518 4.79856 15.7518 4.23377C15.7518 3.00024 14.7519 2 13.5182 2ZM9.6659 11.9999C9.6659 10.7103 10.7113 9.66493 12.0009 9.66493C13.2905 9.66493 14.3359 10.7103 14.3359 11.9999C14.3359 13.2895 13.2905 14.3349 12.0009 14.3349C10.7113 14.3349 9.6659 13.2895 9.6659 11.9999ZM12.0009 8.16493C9.88289 8.16493 8.1659 9.88191 8.1659 11.9999C8.1659 14.1179 9.88289 15.8349 12.0009 15.8349C14.1189 15.8349 15.8359 14.1179 15.8359 11.9999C15.8359 9.88191 14.1189 8.16493 12.0009 8.16493Z"
fill=""
/>
</svg>
Account settings
</DropdownItem>
</li>
<li>
<DropdownItem
onItemClick={closeDropdown}
tag="a"
to="/profile"
className="flex items-center gap-3 px-3 py-2 font-medium text-gray-700 rounded-lg group text-theme-sm hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300"
>
<svg
className="fill-gray-500 group-hover:fill-gray-700 dark:fill-gray-400 dark:group-hover:fill-gray-300"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M3.5 12C3.5 7.30558 7.30558 3.5 12 3.5C16.6944 3.5 20.5 7.30558 20.5 12C20.5 16.6944 16.6944 20.5 12 20.5C7.30558 20.5 3.5 16.6944 3.5 12ZM12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2ZM11.0991 7.52507C11.0991 8.02213 11.5021 8.42507 11.9991 8.42507H12.0001C12.4972 8.42507 12.9001 8.02213 12.9001 7.52507C12.9001 7.02802 12.4972 6.62507 12.0001 6.62507H11.9991C11.5021 6.62507 11.0991 7.02802 11.0991 7.52507ZM12.0001 17.3714C11.5859 17.3714 11.2501 17.0356 11.2501 16.6214V10.9449C11.2501 10.5307 11.5859 10.1949 12.0001 10.1949C12.4143 10.1949 12.7501 10.5307 12.7501 10.9449V16.6214C12.7501 17.0356 12.4143 17.3714 12.0001 17.3714Z"
fill=""
/>
</svg>
Support
</DropdownItem>
</li>
</ul>
<button
onClick={handleLogout}
className="flex items-center gap-3 px-3 py-2 mt-3 font-medium text-gray-700 rounded-lg group text-theme-sm hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300 w-full text-left"
>
<svg
className="fill-gray-500 group-hover:fill-gray-700 dark:group-hover:fill-gray-300"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M15.1007 19.247C14.6865 19.247 14.3507 18.9112 14.3507 18.497L14.3507 14.245H12.8507V18.497C12.8507 19.7396 13.8581 20.747 15.1007 20.747H18.5007C19.7434 20.747 20.7507 19.7396 20.7507 18.497L20.7507 5.49609C20.7507 4.25345 19.7433 3.24609 18.5007 3.24609H15.1007C13.8581 3.24609 12.8507 4.25345 12.8507 5.49609V9.74501L14.3507 9.74501V5.49609C14.3507 5.08188 14.6865 4.74609 15.1007 4.74609L18.5007 4.74609C18.9149 4.74609 19.2507 5.08188 19.2507 5.49609L19.2507 18.497C19.2507 18.9112 18.9149 19.247 18.5007 19.247H15.1007ZM3.25073 11.9984C3.25073 12.2144 3.34204 12.4091 3.48817 12.546L8.09483 17.1556C8.38763 17.4485 8.86251 17.4487 9.15549 17.1559C9.44848 16.8631 9.44863 16.3882 9.15583 16.0952L5.81116 12.7484L16.0007 12.7484C16.4149 12.7484 16.7507 12.4127 16.7507 11.9984C16.7507 11.5842 16.4149 11.2484 16.0007 11.2484L5.81528 11.2484L9.15585 7.90554C9.44864 7.61255 9.44847 7.13767 9.15547 6.84488C8.86248 6.55209 8.3876 6.55226 8.09481 6.84525L3.52309 11.4202C3.35673 11.5577 3.25073 11.7657 3.25073 11.9984Z"
fill=""
/>
</svg>
Sign out
</button>
</Dropdown>
</div>
);
}

View File

@@ -0,0 +1,222 @@
import {
Table,
TableBody,
TableCell,
TableHeader,
TableRow,
} from "../../ui/table";
import Badge from "../../ui/badge/Badge";
interface Order {
id: number;
user: {
image: string;
name: string;
role: string;
};
projectName: string;
team: {
images: string[];
};
status: string;
budget: string;
}
// Define the table data using the interface
const tableData: Order[] = [
{
id: 1,
user: {
image: "/images/user/user-17.jpg",
name: "Lindsey Curtis",
role: "Web Designer",
},
projectName: "Agency Website",
team: {
images: [
"/images/user/user-22.jpg",
"/images/user/user-23.jpg",
"/images/user/user-24.jpg",
],
},
budget: "3.9K",
status: "Active",
},
{
id: 2,
user: {
image: "/images/user/user-18.jpg",
name: "Kaiya George",
role: "Project Manager",
},
projectName: "Technology",
team: {
images: ["/images/user/user-25.jpg", "/images/user/user-26.jpg"],
},
budget: "24.9K",
status: "Pending",
},
{
id: 3,
user: {
image: "/images/user/user-17.jpg",
name: "Zain Geidt",
role: "Content Writing",
},
projectName: "Blog Writing",
team: {
images: ["/images/user/user-27.jpg"],
},
budget: "12.7K",
status: "Active",
},
{
id: 4,
user: {
image: "/images/user/user-20.jpg",
name: "Abram Schleifer",
role: "Digital Marketer",
},
projectName: "Social Media",
team: {
images: [
"/images/user/user-28.jpg",
"/images/user/user-29.jpg",
"/images/user/user-30.jpg",
],
},
budget: "2.8K",
status: "Cancel",
},
{
id: 5,
user: {
image: "/images/user/user-21.jpg",
name: "Carla George",
role: "Front-end Developer",
},
projectName: "Website",
team: {
images: [
"/images/user/user-31.jpg",
"/images/user/user-32.jpg",
"/images/user/user-33.jpg",
],
},
budget: "4.5K",
status: "Active",
},
];
export default function BasicTableOne() {
return (
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-white/[0.05] dark:bg-white/[0.03]">
<div className="max-w-full overflow-x-auto">
<Table>
{/* Table Header */}
<TableHeader className="border-b border-gray-100 dark:border-white/[0.05]">
<TableRow>
<TableCell
isHeader
className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400"
>
User
</TableCell>
<TableCell
isHeader
className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400"
>
Project Name
</TableCell>
<TableCell
isHeader
className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400"
>
Team
</TableCell>
<TableCell
isHeader
className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400"
>
Status
</TableCell>
<TableCell
isHeader
className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400"
>
Budget
</TableCell>
</TableRow>
</TableHeader>
{/* Table Body */}
<TableBody className="divide-y divide-gray-100 dark:divide-white/[0.05]">
{tableData.map((order) => (
<TableRow key={order.id}>
<TableCell className="px-5 py-4 sm:px-6 text-start">
<div className="flex items-center gap-3">
<div className="w-10 h-10 overflow-hidden rounded-full">
<img
width={40}
height={40}
src={order.user.image}
alt={order.user.name}
/>
</div>
<div>
<span className="block font-medium text-gray-800 text-theme-sm dark:text-white/90">
{order.user.name}
</span>
<span className="block text-gray-500 text-theme-xs dark:text-gray-400">
{order.user.role}
</span>
</div>
</div>
</TableCell>
<TableCell className="px-4 py-3 text-gray-500 text-start text-theme-sm dark:text-gray-400">
{order.projectName}
</TableCell>
<TableCell className="px-4 py-3 text-gray-500 text-start text-theme-sm dark:text-gray-400">
<div className="flex -space-x-2">
{order.team.images.map((teamImage, index) => (
<div
key={index}
className="w-6 h-6 overflow-hidden border-2 border-white rounded-full dark:border-gray-900"
>
<img
width={24}
height={24}
src={teamImage}
alt={`Team member ${index + 1}`}
className="w-full size-6"
/>
</div>
))}
</div>
</TableCell>
<TableCell className="px-4 py-3 text-gray-500 text-start text-theme-sm dark:text-gray-400">
<Badge
size="sm"
color={
order.status === "Active"
? "success"
: order.status === "Pending"
? "warning"
: "error"
}
>
{order.status}
</Badge>
</TableCell>
<TableCell className="px-4 py-3 text-gray-500 text-theme-sm dark:text-gray-400">
{order.budget}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@@ -0,0 +1,146 @@
import { Link } from "react-router";
interface AlertProps {
variant: "success" | "error" | "warning" | "info"; // Alert type
title: string; // Title of the alert
message: string | React.ReactNode; // Message of the alert (supports string with \n or JSX)
showLink?: boolean; // Whether to show the "Learn More" link
linkHref?: string; // Link URL
linkText?: string; // Link text
}
const Alert: React.FC<AlertProps> = ({
variant,
title,
message,
showLink = false,
linkHref = "#",
linkText = "Learn more",
}) => {
// Tailwind classes for each variant - matching notification style from image
const variantClasses = {
success: {
container:
"border-b-2 border-success-500 bg-success-50 dark:border-success-500/30 dark:bg-success-500/15",
icon: "text-success-500",
},
error: {
container:
"border-b-2 border-error-500 bg-error-50 dark:border-error-500/30 dark:bg-error-500/15",
icon: "text-error-500",
},
warning: {
container:
"border-b-2 border-warning-500 bg-warning-50 dark:border-warning-500/30 dark:bg-warning-500/15",
icon: "text-warning-500",
},
info: {
container:
"border-b-2 border-blue-light-500 bg-blue-light-50 dark:border-blue-light-500/30 dark:bg-blue-light-500/15",
icon: "text-blue-light-500",
},
};
// Icon for each variant
const icons = {
success: (
<svg
className="fill-current"
width="24"
height="24"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M3.70186 12.0001C3.70186 7.41711 7.41711 3.70186 12.0001 3.70186C16.5831 3.70186 20.2984 7.41711 20.2984 12.0001C20.2984 16.5831 16.5831 20.2984 12.0001 20.2984C7.41711 20.2984 3.70186 16.5831 3.70186 12.0001ZM12.0001 1.90186C6.423 1.90186 1.90186 6.423 1.90186 12.0001C1.90186 17.5772 6.423 22.0984 12.0001 22.0984C17.5772 22.0984 22.0984 17.5772 22.0984 12.0001C22.0984 6.423 17.5772 1.90186 12.0001 1.90186ZM15.6197 10.7395C15.9712 10.388 15.9712 9.81819 15.6197 9.46672C15.2683 9.11525 14.6984 9.11525 14.347 9.46672L11.1894 12.6243L9.6533 11.0883C9.30183 10.7368 8.73198 10.7368 8.38051 11.0883C8.02904 11.4397 8.02904 12.0096 8.38051 12.3611L10.553 14.5335C10.7217 14.7023 10.9507 14.7971 11.1894 14.7971C11.428 14.7971 11.657 14.7023 11.8257 14.5335L15.6197 10.7395Z"
/>
</svg>
),
error: (
<svg
className="fill-current"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M20.3499 12.0004C20.3499 16.612 16.6115 20.3504 11.9999 20.3504C7.38832 20.3504 3.6499 16.612 3.6499 12.0004C3.6499 7.38881 7.38833 3.65039 11.9999 3.65039C16.6115 3.65039 20.3499 7.38881 20.3499 12.0004ZM11.9999 22.1504C17.6056 22.1504 22.1499 17.6061 22.1499 12.0004C22.1499 6.3947 17.6056 1.85039 11.9999 1.85039C6.39421 1.85039 1.8499 6.3947 1.8499 12.0004C1.8499 17.6061 6.39421 22.1504 11.9999 22.1504ZM13.0008 16.4753C13.0008 15.923 12.5531 15.4753 12.0008 15.4753L11.9998 15.4753C11.4475 15.4753 10.9998 15.923 10.9998 16.4753C10.9998 17.0276 11.4475 17.4753 11.9998 17.4753L12.0008 17.4753C12.5531 17.4753 13.0008 17.0276 13.0008 16.4753ZM11.9998 6.62898C12.414 6.62898 12.7498 6.96476 12.7498 7.37898L12.7498 13.0555C12.7498 13.4697 12.414 13.8055 11.9998 13.8055C11.5856 13.8055 11.2498 13.4697 11.2498 13.0555L11.2498 7.37898C11.2498 6.96476 11.5856 6.62898 11.9998 6.62898Z"
fill="#F04438"
/>
</svg>
),
warning: (
<svg
className="fill-current"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M3.6501 12.0001C3.6501 7.38852 7.38852 3.6501 12.0001 3.6501C16.6117 3.6501 20.3501 7.38852 20.3501 12.0001C20.3501 16.6117 16.6117 20.3501 12.0001 20.3501C7.38852 20.3501 3.6501 16.6117 3.6501 12.0001ZM12.0001 1.8501C6.39441 1.8501 1.8501 6.39441 1.8501 12.0001C1.8501 17.6058 6.39441 22.1501 12.0001 22.1501C17.6058 22.1501 22.1501 17.6058 22.1501 12.0001C22.1501 6.39441 17.6058 1.8501 12.0001 1.8501ZM10.9992 7.52517C10.9992 8.07746 11.4469 8.52517 11.9992 8.52517H12.0002C12.5525 8.52517 13.0002 8.07746 13.0002 7.52517C13.0002 6.97289 12.5525 6.52517 12.0002 6.52517H11.9992C11.4469 6.52517 10.9992 6.97289 10.9992 7.52517ZM12.0002 17.3715C11.586 17.3715 11.2502 17.0357 11.2502 16.6215V10.945C11.2502 10.5308 11.586 10.195 12.0002 10.195C12.4144 10.195 12.7502 10.5308 12.7502 10.945V16.6215C12.7502 17.0357 12.4144 17.3715 12.0002 17.3715Z"
fill=""
/>
</svg>
),
info: (
<svg
className="fill-current"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M3.6501 11.9996C3.6501 7.38803 7.38852 3.64961 12.0001 3.64961C16.6117 3.64961 20.3501 7.38803 20.3501 11.9996C20.3501 16.6112 16.6117 20.3496 12.0001 20.3496C7.38852 20.3496 3.6501 16.6112 3.6501 11.9996ZM12.0001 1.84961C6.39441 1.84961 1.8501 6.39392 1.8501 11.9996C1.8501 17.6053 6.39441 22.1496 12.0001 22.1496C17.6058 22.1496 22.1501 17.6053 22.1501 11.9996C22.1501 6.39392 17.6058 1.84961 12.0001 1.84961ZM10.9992 7.52468C10.9992 8.07697 11.4469 8.52468 11.9992 8.52468H12.0002C12.5525 8.52468 13.0002 8.07697 13.0002 7.52468C13.0002 6.9724 12.5525 6.52468 12.0002 6.52468H11.9992C11.4469 6.52468 10.9992 6.9724 10.9992 7.52468ZM12.0002 17.371C11.586 17.371 11.2502 17.0352 11.2502 16.621V10.9445C11.2502 10.5303 11.586 10.1945 12.0002 10.1945C12.4144 10.1945 12.7502 10.5303 12.7502 10.9445V16.621C12.7502 17.0352 12.4144 17.371 12.0002 17.371Z"
fill=""
/>
</svg>
),
};
return (
<div
className={`rounded-xl p-4 ${variantClasses[variant].container}`}
>
<div className="flex items-start gap-3">
<div className={`-mt-0.5 ${variantClasses[variant].icon}`}>
{icons[variant]}
</div>
<div>
<h4 className="mb-1 text-sm font-semibold text-gray-800 dark:text-white/90">
{title}
</h4>
<p className="text-sm text-gray-500 dark:text-gray-400 whitespace-pre-line">
{message}
</p>
{showLink && (
<Link
to={linkHref}
className="inline-block mt-3 text-sm font-medium text-gray-500 underline dark:text-gray-400"
>
{linkText}
</Link>
)}
</div>
</div>
</div>
);
};
export default Alert;

View File

@@ -0,0 +1,200 @@
import React from 'react';
import { Modal } from '../modal';
import Button from '../button/Button';
export type AlertModalVariant = 'success' | 'info' | 'warning' | 'danger';
interface AlertModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
message: string;
variant?: AlertModalVariant;
buttonText?: string;
// Confirmation mode props
isConfirmation?: boolean;
onConfirm?: () => void;
confirmText?: string;
cancelText?: string;
isLoading?: boolean;
itemsList?: string[]; // For showing items being deleted (max 5)
}
export default function AlertModal({
isOpen,
onClose,
title,
message,
variant = 'info',
buttonText = 'Okay, Got It',
isConfirmation = false,
onConfirm,
confirmText = 'Okay, Got It',
cancelText = 'Cancel',
isLoading = false,
itemsList = [],
}: AlertModalProps) {
// Icon configurations for each variant - matching design from images
const iconConfig = {
success: (
<div className="relative flex items-center justify-center w-24 h-24 mx-auto mb-6">
{/* Light green flower-like outer shape with rounded petals */}
<div className="absolute inset-0 bg-success-100 rounded-full" style={{
clipPath: 'polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%)',
width: '80px',
height: '80px'
}}></div>
{/* Dark green inner circle */}
<div className="relative bg-success-600 rounded-full w-16 h-16 flex items-center justify-center">
<svg
className="w-8 h-8 text-white"
fill="none"
stroke="currentColor"
strokeWidth={3}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M5 13l4 4L19 7"
/>
</svg>
</div>
</div>
),
info: (
<div className="relative flex items-center justify-center w-24 h-24 mx-auto mb-6">
{/* Light blue cloud-like background */}
<div className="absolute inset-0 bg-blue-light-100 rounded-full blur-2xl opacity-50" style={{
width: '90px',
height: '90px',
transform: 'scale(1.1)'
}}></div>
{/* Blue circle with 'i' */}
<div className="relative bg-blue-light-500 rounded-full w-16 h-16 flex items-center justify-center shadow-lg">
<span className="text-white text-4xl font-bold leading-none">i</span>
</div>
</div>
),
warning: (
<div className="relative flex items-center justify-center w-24 h-24 mx-auto mb-6">
{/* Light orange cloud-like background */}
<div className="absolute inset-0 bg-warning-100 rounded-full blur-2xl opacity-50" style={{
width: '90px',
height: '90px',
transform: 'scale(1.1)'
}}></div>
{/* White circle */}
<div className="relative bg-white rounded-full w-14 h-14 flex items-center justify-center shadow-lg">
{/* Orange circle with exclamation */}
<div className="bg-warning-500 rounded-full w-16 h-16 flex items-center justify-center absolute -inset-1">
<span className="text-white text-4xl font-bold leading-none">!</span>
</div>
</div>
</div>
),
danger: (
<div className="relative flex items-center justify-center w-24 h-24 mx-auto mb-6">
{/* Light red cloud-like background */}
<div className="absolute inset-0 bg-error-100 rounded-full blur-2xl opacity-50" style={{
width: '90px',
height: '90px',
transform: 'scale(1.1)'
}}></div>
{/* Light red circle with red X */}
<div className="relative bg-error-100 rounded-full w-16 h-16 flex items-center justify-center">
<svg
className="w-10 h-10 text-error-500"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
fillRule="evenodd"
d="M18.364 5.636a1 1 0 010 1.414L13.414 12l4.95 4.95a1 1 0 11-1.414 1.414L12 13.414l-4.95 4.95a1 1 0 01-1.414-1.414L10.586 12 5.636 7.05a1 1 0 011.414-1.414L12 10.586l4.95-4.95a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</div>
</div>
),
};
// Button color configurations
const buttonConfig = {
success: 'bg-success-500 hover:bg-success-600 text-white',
info: 'bg-blue-light-500 hover:bg-blue-light-600 text-white',
warning: 'bg-warning-500 hover:bg-warning-600 text-white',
danger: 'bg-error-500 hover:bg-error-600 text-white',
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
className="max-w-md"
>
<div className="px-8 py-10 text-center">
{/* Icon */}
{iconConfig[variant]}
{/* Title */}
<h2 className="text-2xl font-bold text-gray-800 dark:text-white mb-4">
{title}
</h2>
{/* Items List (for delete confirmations) */}
{itemsList.length > 0 && (
<div className="mb-6">
<ul className="text-left text-gray-700 dark:text-gray-300 text-sm space-y-1 max-w-md mx-auto">
{itemsList.slice(0, 5).map((item, index) => (
<li key={index} className="italic">
{item}
</li>
))}
{itemsList.length > 5 && (
<li className="text-gray-500 dark:text-gray-400 italic">
... and {itemsList.length - 5} more
</li>
)}
</ul>
</div>
)}
{/* Message */}
<p className="text-gray-600 dark:text-gray-400 mb-8 text-sm leading-relaxed">
{message}
</p>
{/* Buttons */}
{isConfirmation ? (
<div className="flex justify-center gap-3">
<button
onClick={onClose}
disabled={isLoading}
className="px-6 py-3 rounded-lg font-medium text-sm transition-colors shadow-sm bg-gray-200 hover:bg-gray-300 text-gray-700 dark:bg-gray-700 dark:hover:bg-gray-600 dark:text-gray-300 disabled:opacity-50 disabled:cursor-not-allowed"
>
{cancelText}
</button>
<button
onClick={onConfirm}
disabled={isLoading}
className={`px-6 py-3 rounded-lg font-medium text-sm transition-colors shadow-sm ${buttonConfig[variant]} disabled:opacity-50 disabled:cursor-not-allowed`}
>
{isLoading ? 'Processing...' : confirmText}
</button>
</div>
) : (
<div className="flex justify-center">
<button
onClick={onClose}
className={`px-6 py-3 rounded-lg font-medium text-sm transition-colors shadow-sm ${buttonConfig[variant]}`}
>
{buttonText}
</button>
</div>
)}
</div>
</Modal>
);
}

View File

@@ -0,0 +1,55 @@
interface AvatarProps {
src: string; // URL of the avatar image
alt?: string; // Alt text for the avatar
size?: "xsmall" | "small" | "medium" | "large" | "xlarge" | "xxlarge"; // Avatar size
status?: "online" | "offline" | "busy" | "none"; // Status indicator
}
const sizeClasses = {
xsmall: "h-6 w-6 max-w-6",
small: "h-8 w-8 max-w-8",
medium: "h-10 w-10 max-w-10",
large: "h-12 w-12 max-w-12",
xlarge: "h-14 w-14 max-w-14",
xxlarge: "h-16 w-16 max-w-16",
};
const statusSizeClasses = {
xsmall: "h-1.5 w-1.5 max-w-1.5",
small: "h-2 w-2 max-w-2",
medium: "h-2.5 w-2.5 max-w-2.5",
large: "h-3 w-3 max-w-3",
xlarge: "h-3.5 w-3.5 max-w-3.5",
xxlarge: "h-4 w-4 max-w-4",
};
const statusColorClasses = {
online: "bg-success-500",
offline: "bg-error-400",
busy: "bg-warning-500",
};
const Avatar: React.FC<AvatarProps> = ({
src,
alt = "User Avatar",
size = "medium",
status = "none",
}) => {
return (
<div className={`relative rounded-full ${sizeClasses[size]}`}>
{/* Avatar Image */}
<img src={src} alt={alt} className="object-cover rounded-full" />
{/* Status Indicator */}
{status !== "none" && (
<span
className={`absolute bottom-0 right-0 rounded-full border-[1.5px] border-white dark:border-gray-900 ${
statusSizeClasses[size]
} ${statusColorClasses[status] || ""}`}
></span>
)}
</div>
);
};
export default Avatar;

View File

@@ -0,0 +1,85 @@
/**
* Badge Component
*
* 🔒 STYLE LOCKED - See DESIGN_SYSTEM.md for available colors and variants.
* Do not modify colors or add new ones without updating DESIGN_SYSTEM.md first.
*/
type BadgeVariant = "light" | "solid";
type BadgeSize = "sm" | "md";
type BadgeColor =
| "primary"
| "success"
| "error"
| "warning"
| "info"
| "light"
| "dark";
interface BadgeProps {
variant?: BadgeVariant; // Light or solid variant
size?: BadgeSize; // Badge size
color?: BadgeColor; // Badge color
startIcon?: React.ReactNode; // Icon at the start
endIcon?: React.ReactNode; // Icon at the end
children: React.ReactNode; // Badge content
className?: string; // Additional classes
}
const Badge: React.FC<BadgeProps> = ({
variant = "light",
color = "primary",
size = "md",
startIcon,
endIcon,
children,
className = "",
}) => {
const baseStyles =
"inline-flex items-center px-2.5 py-0.5 justify-center gap-1 rounded-full font-medium";
// Define size styles
const sizeStyles = {
sm: "text-theme-xs", // Smaller padding and font size
md: "text-sm", // Default padding and font size
};
// Define color styles for variants
const variants = {
light: {
primary:
"bg-brand-50 text-brand-500 dark:bg-brand-500/15 dark:text-brand-400",
success:
"bg-success-50 text-success-600 dark:bg-success-500/15 dark:text-success-500",
error:
"bg-error-50 text-error-600 dark:bg-error-500/15 dark:text-error-500",
warning:
"bg-warning-50 text-warning-600 dark:bg-warning-500/15 dark:text-orange-400",
info: "bg-blue-light-50 text-blue-light-500 dark:bg-blue-light-500/15 dark:text-blue-light-500",
light: "bg-gray-100 text-gray-700 dark:bg-white/5 dark:text-white/80",
dark: "bg-gray-500 text-white dark:bg-white/5 dark:text-white",
},
solid: {
primary: "bg-brand-500 text-white dark:text-white",
success: "bg-success-500 text-white dark:text-white",
error: "bg-error-500 text-white dark:text-white",
warning: "bg-warning-500 text-white dark:text-white",
info: "bg-blue-light-500 text-white dark:text-white",
light: "bg-gray-400 dark:bg-white/5 text-white dark:text-white/80",
dark: "bg-gray-700 text-white dark:text-white",
},
};
// Get styles based on size and color variant
const sizeClass = sizeStyles[size];
const colorStyles = variants[variant][color];
return (
<span className={`${baseStyles} ${sizeClass} ${colorStyles} ${className}`}>
{startIcon && <span className="mr-1">{startIcon}</span>}
{children}
{endIcon && <span className="ml-1">{endIcon}</span>}
</span>
);
};
export default Badge;

View File

@@ -0,0 +1,59 @@
import { ReactNode } from "react";
import { Link } from "react-router";
interface BreadcrumbProps {
items: Array<{
label: string;
path?: string;
icon?: ReactNode;
}>;
className?: string;
}
export const Breadcrumb: React.FC<BreadcrumbProps> = ({
items,
className = "",
}) => {
return (
<nav className={className}>
<ol className="flex items-center gap-1.5">
{items.map((item, index) => (
<li key={index} className="flex items-center gap-1.5">
{index > 0 && (
<svg
className="stroke-current text-gray-400"
width="17"
height="16"
viewBox="0 0 17 16"
fill="none"
>
<path
d="M6.0765 12.667L10.2432 8.50033L6.0765 4.33366"
stroke=""
strokeWidth="1.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
{item.path && index < items.length - 1 ? (
<Link
to={item.path}
className="inline-flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
>
{item.icon && <span>{item.icon}</span>}
{item.label}
</Link>
) : (
<span className="text-sm text-gray-800 dark:text-white/90">
{item.icon && <span className="mr-1.5">{item.icon}</span>}
{item.label}
</span>
)}
</li>
))}
</ol>
</nav>
);
};

View File

@@ -0,0 +1,2 @@
export { Breadcrumb } from "./Breadcrumb";

View File

@@ -0,0 +1,51 @@
import { ReactNode } from "react";
interface ButtonGroupProps {
children: ReactNode;
className?: string;
}
export const ButtonGroup: React.FC<ButtonGroupProps> = ({
children,
className = "",
}) => {
return (
<div
className={`inline-flex rounded-lg border border-gray-300 bg-white shadow-theme-xs dark:border-gray-700 dark:bg-gray-800 ${className}`}
>
{children}
</div>
);
};
interface ButtonGroupItemProps {
children: ReactNode;
onClick?: () => void;
isActive?: boolean;
className?: string;
disabled?: boolean;
}
export const ButtonGroupItem: React.FC<ButtonGroupItemProps> = ({
children,
onClick,
isActive = false,
className = "",
disabled = false,
}) => {
return (
<button
onClick={onClick}
disabled={disabled}
className={`px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-white disabled:opacity-50 disabled:cursor-not-allowed ${
isActive
? "bg-gray-100 text-gray-900 dark:bg-white/10 dark:text-white"
: ""
} ${className}`}
type="button"
>
{children}
</button>
);
};

View File

@@ -0,0 +1,2 @@
export { ButtonGroup, ButtonGroupItem } from "./ButtonGroup";

View File

@@ -0,0 +1,71 @@
/**
* Button Component
*
* 🔒 STYLE LOCKED - See DESIGN_SYSTEM.md for available variants and sizes.
* Do not modify variants or add new ones without updating DESIGN_SYSTEM.md first.
*/
import { ReactNode, forwardRef } from "react";
interface ButtonProps {
children: ReactNode; // Button text or content
size?: "sm" | "md"; // Button size
variant?: "primary" | "outline" | "secondary" | "success"; // Button variant
startIcon?: ReactNode; // Icon before the text
endIcon?: ReactNode; // Icon after the text
onClick?: () => void; // Click handler
disabled?: boolean; // Disabled state
className?: string; // Additional classes
type?: "button" | "submit" | "reset"; // Button type
}
const Button = forwardRef<HTMLButtonElement, ButtonProps>(({
children,
size = "md",
variant = "primary",
startIcon,
endIcon,
onClick,
className = "",
disabled = false,
type = "button",
}, ref) => {
// Size Classes
const sizeClasses = {
sm: "px-3 py-1.5 text-xs h-8",
md: "px-3 py-2 text-sm h-9",
};
// Variant Classes
const variantClasses = {
primary:
"bg-brand-500 text-white shadow-theme-xs hover:bg-brand-600 disabled:bg-brand-300",
outline:
"bg-white text-gray-700 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-400 dark:ring-gray-700 dark:hover:bg-white/[0.03] dark:hover:text-gray-300",
secondary:
"bg-gray-500 text-white border border-gray-500 hover:bg-gray-600 hover:border-gray-600 dark:bg-gray-500 dark:border-gray-500 dark:hover:bg-gray-600 dark:hover:border-gray-600",
success:
"bg-success-500 text-white shadow-theme-xs hover:bg-success-600 disabled:bg-success-300",
};
return (
<button
ref={ref}
type={type}
className={`inline-flex items-center justify-center gap-2 rounded-lg transition ${className} ${
sizeClasses[size]
} ${variantClasses[variant]} ${
disabled ? "cursor-not-allowed opacity-50" : ""
}`}
onClick={onClick}
disabled={disabled}
>
{startIcon && <span className="flex items-center">{startIcon}</span>}
{children}
{endIcon && <span className="flex items-center">{endIcon}</span>}
</button>
);
});
Button.displayName = "Button";
export default Button;

View File

@@ -0,0 +1,174 @@
import { ReactNode } from "react";
interface CardProps {
children: ReactNode;
className?: string;
onClick?: () => void;
}
export const Card: React.FC<CardProps> = ({
children,
className = "",
onClick,
}) => {
return (
<div
className={`rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] sm:p-6 ${className}`}
onClick={onClick}
>
{children}
</div>
);
};
interface CardImageProps {
src: string;
alt?: string;
className?: string;
}
export const CardImage: React.FC<CardImageProps> = ({
src,
alt = "card",
className = "",
}) => {
return (
<div className="mb-5 overflow-hidden rounded-lg">
<img
alt={alt}
className={`overflow-hidden rounded-lg ${className}`}
src={src}
/>
</div>
);
};
interface CardTitleProps {
children: ReactNode;
className?: string;
}
export const CardTitle: React.FC<CardTitleProps> = ({
children,
className = "",
}) => {
return (
<h4
className={`mb-1 font-medium text-gray-800 text-theme-xl dark:text-white/90 ${className}`}
>
{children}
</h4>
);
};
interface CardContentProps {
children: ReactNode;
className?: string;
}
export const CardContent: React.FC<CardContentProps> = ({
children,
className = "",
}) => {
return (
<div className={className}>
{children}
</div>
);
};
interface CardDescriptionProps {
children: ReactNode;
className?: string;
}
export const CardDescription: React.FC<CardDescriptionProps> = ({
children,
className = "",
}) => {
return (
<p className={`text-sm text-gray-500 dark:text-gray-400 ${className}`}>
{children}
</p>
);
};
interface CardActionProps {
children: ReactNode;
href?: string;
onClick?: () => void;
variant?: "button" | "link";
className?: string;
}
export const CardAction: React.FC<CardActionProps> = ({
children,
href,
onClick,
variant = "button",
className = "",
}) => {
const baseClasses =
variant === "button"
? "inline-flex items-center gap-2 px-4 py-3 mt-4 text-sm font-medium text-white rounded-lg bg-brand-500 shadow-theme-xs hover:bg-brand-600"
: "inline-flex items-center gap-1 mt-4 text-sm text-brand-500 hover:text-brand-600";
if (href) {
return (
<a
href={href}
className={`${baseClasses} ${className}`}
onClick={onClick}
>
{children}
</a>
);
}
return (
<button
className={`${baseClasses} ${className}`}
onClick={onClick}
type="button"
>
{children}
</button>
);
};
interface CardIconProps {
children: ReactNode;
className?: string;
}
export const CardIcon: React.FC<CardIconProps> = ({
children,
className = "",
}) => {
return (
<div
className={`mb-5 flex h-14 max-w-14 items-center justify-center rounded-[10.5px] bg-brand-50 text-brand-500 dark:bg-brand-500/10 ${className}`}
>
{children}
</div>
);
};
interface HorizontalCardProps {
children: ReactNode;
className?: string;
}
export const HorizontalCard: React.FC<HorizontalCardProps> = ({
children,
className = "",
}) => {
return (
<div
className={`flex flex-col gap-5 rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-white/[0.03] sm:flex-row sm:items-center sm:gap-6 ${className}`}
>
{children}
</div>
);
};

View File

@@ -0,0 +1,11 @@
export {
Card,
CardImage,
CardTitle,
CardContent,
CardDescription,
CardAction,
CardIcon,
HorizontalCard,
} from "./Card";

View File

@@ -0,0 +1,152 @@
import type React from "react";
import { useEffect, useRef, useState, useCallback } from "react";
import { createPortal } from "react-dom";
interface DropdownProps {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
className?: string;
/** Reference element for positioning (button that triggers dropdown) */
anchorRef?: React.RefObject<HTMLElement>;
/** Placement: 'left' | 'right' | 'bottom-left' | 'bottom-right' */
placement?: 'left' | 'right' | 'bottom-left' | 'bottom-right';
}
export const Dropdown: React.FC<DropdownProps> = ({
isOpen,
onClose,
children,
className = "",
anchorRef,
placement = 'right',
}) => {
const dropdownRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState({ top: 0, left: 0 });
// Calculate position based on anchor element
const updatePosition = useCallback(() => {
if (!isOpen || !anchorRef?.current || !dropdownRef.current) return;
const anchorRect = anchorRef.current.getBoundingClientRect();
const dropdownRect = dropdownRef.current.getBoundingClientRect();
// For fixed positioning, coordinates are relative to viewport (no scroll offset needed)
let top = anchorRect.bottom + 8; // 8px = mt-2 equivalent
let left = anchorRect.left;
// Handle placement - get width from actual render or fallback to className parsing
let dropdownWidth = dropdownRect.width;
if (!dropdownWidth || dropdownWidth === 0) {
// Try to extract width from className (e.g., "w-64" = 256px, "w-48" = 192px)
const widthMatch = className.match(/w-(\d+)/);
if (widthMatch) {
const widthValue = parseInt(widthMatch[1], 10);
dropdownWidth = widthValue * 4; // Tailwind: w-64 = 16rem = 256px, so w-N = N*4px
} else {
dropdownWidth = 192; // Default fallback
}
}
if (placement === 'right') {
// Align dropdown right edge to button right edge
left = anchorRect.right - dropdownWidth;
} else if (placement === 'bottom-right') {
// Position below and align to right edge of button
left = anchorRect.right - dropdownWidth;
} else if (placement === 'bottom-left') {
// Position below and align to left edge of button
left = anchorRect.left;
} else {
// 'left' - align dropdown left edge to button left edge
left = anchorRect.left;
}
// Edge detection - flip if would overflow viewport
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const dropdownHeight = dropdownRect.height || 200;
// Check right edge
if (left + dropdownWidth > viewportWidth) {
left = anchorRect.left - dropdownWidth;
}
// Check bottom edge
if (anchorRect.bottom + dropdownHeight > viewportHeight) {
// Position above instead
top = anchorRect.top - dropdownHeight - 8;
}
// Ensure doesn't go off left edge
if (left < 0) {
left = 8;
}
setPosition({ top, left });
}, [isOpen, anchorRef, placement]);
useEffect(() => {
if (isOpen && anchorRef?.current) {
// Update on scroll/resize
const handleUpdate = () => updatePosition();
window.addEventListener('scroll', handleUpdate, true);
window.addEventListener('resize', handleUpdate);
// Use multiple requestAnimationFrame calls to ensure dropdown is rendered and measured
let rafId2: number | undefined;
const rafId1 = requestAnimationFrame(() => {
updatePosition();
// Second frame to ensure dimensions are calculated
rafId2 = requestAnimationFrame(() => {
updatePosition();
});
});
return () => {
window.removeEventListener('scroll', handleUpdate, true);
window.removeEventListener('resize', handleUpdate);
cancelAnimationFrame(rafId1);
if (rafId2 !== undefined) cancelAnimationFrame(rafId2);
};
}
}, [isOpen, anchorRef, placement, updatePosition]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
!(event.target as HTMLElement).closest(".dropdown-toggle")
) {
onClose();
}
};
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [isOpen, onClose]);
if (!isOpen) return null;
const dropdownContent = (
<div
ref={dropdownRef}
className={`fixed z-[999999] rounded-xl border border-gray-200 bg-white shadow-theme-lg dark:border-gray-800 dark:bg-gray-dark ${className}`}
style={{
top: `${position.top}px`,
left: `${position.left}px`,
}}
>
{children}
</div>
);
// Portal to body to escape overflow constraints
return createPortal(dropdownContent, document.body);
};

View File

@@ -0,0 +1,46 @@
import type React from "react";
import { Link } from "react-router";
interface DropdownItemProps {
tag?: "a" | "button";
to?: string;
onClick?: () => void;
onItemClick?: () => void;
baseClassName?: string;
className?: string;
children: React.ReactNode;
}
export const DropdownItem: React.FC<DropdownItemProps> = ({
tag = "button",
to,
onClick,
onItemClick,
baseClassName = "block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900",
className = "",
children,
}) => {
const combinedClasses = `${baseClassName} ${className}`.trim();
const handleClick = (event: React.MouseEvent) => {
if (tag === "button") {
event.preventDefault();
}
if (onClick) onClick();
if (onItemClick) onItemClick();
};
if (tag === "a" && to) {
return (
<Link to={to} className={combinedClasses} onClick={handleClick}>
{children}
</Link>
);
}
return (
<button onClick={handleClick} className={combinedClasses}>
{children}
</button>
);
};

View File

@@ -0,0 +1,13 @@
export default function ResponsiveImage() {
return (
<div className="relative">
<div className="overflow-hidden">
<img
src="/images/grid-image/image-01.png"
alt="Cover"
className="w-full border border-gray-200 rounded-xl dark:border-gray-800"
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,29 @@
export default function ThreeColumnImageGrid() {
return (
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 xl:grid-cols-3">
<div>
<img
src="/images/grid-image/image-04.png"
alt=" grid"
className="border border-gray-200 rounded-xl dark:border-gray-800"
/>
</div>
<div>
<img
src="/images/grid-image/image-05.png"
alt=" grid"
className="border border-gray-200 rounded-xl dark:border-gray-800"
/>
</div>
<div>
<img
src="/images/grid-image/image-06.png"
alt=" grid"
className="border border-gray-200 rounded-xl dark:border-gray-800"
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,21 @@
export default function TwoColumnImageGrid() {
return (
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
<div>
<img
src="/images/grid-image/image-02.png"
alt=" grid"
className="border border-gray-200 rounded-xl dark:border-gray-800"
/>
</div>
<div>
<img
src="/images/grid-image/image-03.png"
alt=" grid"
className="border border-gray-200 rounded-xl dark:border-gray-800"
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,199 @@
import { ReactNode } from "react";
interface ListProps {
children: ReactNode;
variant?: "unordered" | "ordered" | "horizontal" | "button" | "icon" | "checkbox" | "radio";
className?: string;
}
export const List: React.FC<ListProps> = ({
children,
variant = "unordered",
className = "",
}) => {
const baseClasses = "rounded-lg border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03] sm:w-fit";
if (variant === "ordered") {
return (
<ol className={`flex flex-col list-decimal ${baseClasses} ${className}`}>
{children}
</ol>
);
}
if (variant === "horizontal") {
return (
<ul className={`flex flex-col md:flex-row ${baseClasses} ${className}`}>
{children}
</ul>
);
}
if (variant === "button") {
return (
<ul className={`w-full overflow-hidden rounded-lg border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03] sm:w-[228px] flex flex-col ${className}`}>
{children}
</ul>
);
}
return (
<ul className={`flex flex-col ${baseClasses} ${className}`}>
{children}
</ul>
);
};
interface ListItemProps {
children: ReactNode;
variant?: "unordered" | "ordered" | "horizontal" | "button" | "icon" | "checkbox" | "radio";
onClick?: () => void;
disabled?: boolean;
className?: string;
}
export const ListItem: React.FC<ListItemProps> = ({
children,
variant = "unordered",
onClick,
disabled = false,
className = "",
}) => {
if (variant === "button") {
return (
<li className={`border-b border-gray-200 last:border-b-0 dark:border-gray-800 ${className}`}>
<button
className={`flex w-full items-center gap-3 px-3 py-2.5 text-sm font-medium text-gray-500 hover:bg-brand-50 hover:text-brand-500 dark:text-gray-400 dark:hover:bg-brand-500/[0.12] dark:hover:text-brand-400 ${disabled ? "disabled:opacity-50" : ""}`}
onClick={onClick}
disabled={disabled}
type="button"
>
{children}
</button>
</li>
);
}
if (variant === "horizontal") {
return (
<li className={`flex items-center gap-2 border-b border-gray-200 px-3 py-2.5 text-sm text-gray-500 last:border-0 dark:border-gray-800 dark:text-gray-400 md:border-b-0 md:border-r ${className}`}>
{children}
</li>
);
}
return (
<li className={`flex items-center gap-2 border-b border-gray-200 px-3 py-2.5 text-sm text-gray-500 last:border-b-0 dark:border-gray-800 dark:text-gray-400 ${className}`}>
{children}
</li>
);
};
interface ListDotProps {
className?: string;
}
export const ListDot: React.FC<ListDotProps> = ({ className = "" }) => {
return (
<span className={`ml-2 block h-[3px] w-[3px] rounded-full bg-gray-500 dark:bg-gray-400 ${className}`}></span>
);
};
interface ListIconProps {
children: ReactNode;
className?: string;
}
export const ListIcon: React.FC<ListIconProps> = ({
children,
className = "",
}) => {
return (
<span className={`text-brand-500 dark:text-brand-400 ${className}`}>
{children}
</span>
);
};
interface ListCheckboxItemProps {
id: string;
label: string;
checked?: boolean;
disabled?: boolean;
onChange?: (checked: boolean) => void;
className?: string;
}
export const ListCheckboxItem: React.FC<ListCheckboxItemProps> = ({
id,
label,
checked = false,
disabled = false,
onChange,
className = "",
}) => {
return (
<li className={`border-b border-gray-200 px-3 py-2.5 last:border-b-0 dark:border-gray-800 ${className}`}>
<div className="flex items-center gap-2">
<label className="flex items-center space-x-3 group cursor-pointer">
<div className="relative w-5 h-5">
<input
id={id}
className="w-5 h-5 appearance-none cursor-pointer dark:border-gray-700 border border-gray-300 checked:border-transparent rounded-md checked:bg-brand-500 disabled:opacity-60"
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(e) => onChange?.(e.target.checked)}
/>
</div>
</label>
<label htmlFor={id} className="flex items-center text-sm text-gray-500 cursor-pointer select-none dark:text-gray-400">
{label}
</label>
</div>
</li>
);
};
interface ListRadioItemProps {
id: string;
name: string;
value: string;
label: string;
checked?: boolean;
onChange?: (value: string) => void;
className?: string;
}
export const ListRadioItem: React.FC<ListRadioItemProps> = ({
id,
name,
value,
label,
checked = false,
onChange,
className = "",
}) => {
return (
<li className={`border-b border-gray-200 px-3 py-2.5 last:border-b-0 dark:border-gray-800 ${className}`}>
<label htmlFor={id} className="flex cursor-pointer select-none items-center text-sm text-gray-500 dark:text-gray-400">
<span className="relative">
<input
id={id}
className="sr-only"
type="radio"
value={value}
name={name}
checked={checked}
onChange={(e) => onChange?.(e.target.value)}
/>
<span className={`mr-2 flex h-4 w-4 items-center justify-center rounded-full border ${checked ? "border-brand-500 bg-brand-500" : "bg-transparent border-gray-300 dark:border-gray-700"}`}>
<span className="h-1.5 w-1.5 rounded-full bg-white dark:bg-[#1e2636]"></span>
</span>
</span>
{label}
</label>
</li>
);
};

View File

@@ -0,0 +1,9 @@
export {
List,
ListItem,
ListDot,
ListIcon,
ListCheckboxItem,
ListRadioItem,
} from "./List";

View File

@@ -0,0 +1,94 @@
import { useRef, useEffect } from "react";
interface ModalProps {
isOpen: boolean;
onClose: () => void;
className?: string;
children: React.ReactNode;
showCloseButton?: boolean; // New prop to control close button visibility
isFullscreen?: boolean; // Default to false for backwards compatibility
}
export const Modal: React.FC<ModalProps> = ({
isOpen,
onClose,
children,
className,
showCloseButton = true, // Default to true for backwards compatibility
isFullscreen = false,
}) => {
const modalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
}
};
if (isOpen) {
document.addEventListener("keydown", handleEscape);
}
return () => {
document.removeEventListener("keydown", handleEscape);
};
}, [isOpen, onClose]);
useEffect(() => {
if (isOpen) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "unset";
}
return () => {
document.body.style.overflow = "unset";
};
}, [isOpen]);
if (!isOpen) return null;
const contentClasses = isFullscreen
? "w-full h-full"
: "relative w-full rounded-3xl bg-white dark:bg-gray-900";
return (
<div className="fixed inset-0 flex items-center justify-center overflow-y-auto modal z-99999">
{!isFullscreen && (
<div
className="fixed inset-0 h-full w-full bg-gray-400/50 backdrop-blur-[32px]"
onClick={onClose}
></div>
)}
<div
ref={modalRef}
className={`${contentClasses} ${className}`}
onClick={(e) => e.stopPropagation()}
>
{showCloseButton && (
<button
onClick={onClose}
className="absolute right-3 top-3 z-999 flex h-9.5 w-9.5 items-center justify-center rounded-full bg-gray-100 text-gray-400 transition-colors hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white sm:right-6 sm:top-6 sm:h-11 sm:w-11"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6.04289 16.5413C5.65237 16.9318 5.65237 17.565 6.04289 17.9555C6.43342 18.346 7.06658 18.346 7.45711 17.9555L11.9987 13.4139L16.5408 17.956C16.9313 18.3466 17.5645 18.3466 17.955 17.956C18.3455 17.5655 18.3455 16.9323 17.955 16.5418L13.4129 11.9997L17.955 7.4576C18.3455 7.06707 18.3455 6.43391 17.955 6.04338C17.5645 5.65286 16.9313 5.65286 16.5408 6.04338L11.9987 10.5855L7.45711 6.0439C7.06658 5.65338 6.43342 5.65338 6.04289 6.0439C5.65237 6.43442 5.65237 7.06759 6.04289 7.45811L10.5845 11.9997L6.04289 16.5413Z"
fill="currentColor"
/>
</svg>
</button>
)}
<div>{children}</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,142 @@
import React from "react";
import { AngleLeftIcon, AngleRightIcon } from "../../../icons";
interface CompactPaginationProps {
currentPage: number;
totalPages: number;
pageSize: number;
onPageChange: (page: number) => void;
onPageSizeChange: (size: number) => void;
className?: string;
}
export const CompactPagination: React.FC<CompactPaginationProps> = ({
currentPage,
totalPages,
pageSize,
onPageChange,
onPageSizeChange,
className = "",
}) => {
const getPageNumbers = () => {
const pages: (number | string)[] = [];
if (totalPages <= 7) {
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
} else {
if (currentPage <= 3) {
for (let i = 1; i <= 5; i++) {
pages.push(i);
}
pages.push("...");
pages.push(totalPages);
} else if (currentPage >= totalPages - 2) {
pages.push(1);
pages.push("...");
for (let i = totalPages - 4; i <= totalPages; i++) {
pages.push(i);
}
} else {
pages.push(1);
pages.push("...");
for (let i = currentPage - 1; i <= currentPage + 1; i++) {
pages.push(i);
}
pages.push("...");
pages.push(totalPages);
}
}
return pages;
};
const pages = getPageNumbers();
const isFirstPage = currentPage === 1;
const isLastPage = currentPage === totalPages;
return (
<div className={`flex items-center gap-3 ${className}`}>
{/* Page Size Selector */}
<div className="flex items-center gap-2">
<label htmlFor="page-size" className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
Show:
</label>
<select
id="page-size"
value={pageSize}
onChange={(e) => onPageSizeChange(Number(e.target.value))}
className="h-8 px-2 text-sm rounded-lg border border-gray-300 bg-white text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-brand-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700"
>
<option value={10}>10</option>
<option value={20}>20</option>
<option value={50}>50</option>
</select>
<span className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
per page
</span>
</div>
{/* Pagination Controls */}
<div className="flex items-center gap-1">
{/* Previous Button with Icon */}
<button
onClick={() => !isFirstPage && onPageChange(currentPage - 1)}
disabled={isFirstPage}
type="button"
className="flex items-center justify-center w-7 h-7 rounded-lg border border-gray-300 bg-white text-gray-700 shadow-sm hover:bg-gray-50 hover:text-gray-900 disabled:opacity-50 disabled:cursor-not-allowed dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200 transition-colors"
aria-label="Previous page"
>
<AngleLeftIcon className="w-4 h-4" />
</button>
{/* Page Numbers */}
<div className="flex items-center gap-0.5">
{pages.map((page, index) => {
if (page === "...") {
return (
<span
key={`ellipsis-${index}`}
className="flex items-center justify-center w-7 h-7 text-xs font-medium text-gray-700 dark:text-gray-400"
>
...
</span>
);
}
const pageNum = page as number;
const isActive = pageNum === currentPage;
return (
<button
key={pageNum}
className={`flex items-center justify-center w-7 h-7 text-xs font-medium rounded-lg transition-colors ${
isActive
? "text-white bg-brand-500 hover:bg-brand-600 shadow-sm"
: "text-gray-700 hover:bg-brand-500 hover:text-white dark:text-gray-400 dark:hover:text-white dark:hover:bg-brand-500"
}`}
onClick={() => onPageChange(pageNum)}
type="button"
aria-label={`Go to page ${pageNum}`}
aria-current={isActive ? "page" : undefined}
>
{pageNum}
</button>
);
})}
</div>
{/* Next Button with Icon */}
<button
onClick={() => !isLastPage && onPageChange(currentPage + 1)}
disabled={isLastPage}
type="button"
className="flex items-center justify-center w-7 h-7 rounded-lg border border-gray-300 bg-white text-gray-700 shadow-sm hover:bg-gray-50 hover:text-gray-900 disabled:opacity-50 disabled:cursor-not-allowed dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200 transition-colors"
aria-label="Next page"
>
<AngleRightIcon className="w-4 h-4" />
</button>
</div>
</div>
);
};

View File

@@ -0,0 +1,139 @@
import { ReactNode } from "react";
interface PaginationProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
variant?: "text" | "text-icon" | "icon";
className?: string;
showPageInfo?: boolean;
}
const PrevIcon = () => (
<svg className="fill-current" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fillRule="evenodd" clipRule="evenodd" d="M2.58203 9.99868C2.58174 10.1909 2.6549 10.3833 2.80152 10.53L7.79818 15.5301C8.09097 15.8231 8.56584 15.8233 8.85883 15.5305C9.15183 15.2377 9.152 14.7629 8.85921 14.4699L5.13911 10.7472L16.6665 10.7472C17.0807 10.7472 17.4165 10.4114 17.4165 9.99715C17.4165 9.58294 17.0807 9.24715 16.6665 9.24715L5.14456 9.24715L8.85919 5.53016C9.15199 5.23717 9.15184 4.7623 8.85885 4.4695C8.56587 4.1767 8.09099 4.17685 7.79819 4.46984L2.84069 9.43049C2.68224 9.568 2.58203 9.77087 2.58203 9.99715C2.58203 9.99766 2.58203 9.99817 2.58203 9.99868Z" fill=""></path>
</svg>
);
const NextIcon = () => (
<svg className="fill-current" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fillRule="evenodd" clipRule="evenodd" d="M17.4165 9.9986C17.4168 10.1909 17.3437 10.3832 17.197 10.53L12.2004 15.5301C11.9076 15.8231 11.4327 15.8233 11.1397 15.5305C10.8467 15.2377 10.8465 14.7629 11.1393 14.4699L14.8594 10.7472L3.33203 10.7472C2.91782 10.7472 2.58203 10.4114 2.58203 9.99715C2.58203 9.58294 2.91782 9.24715 3.33203 9.24715L14.854 9.24715L11.1393 5.53016C10.8465 5.23717 10.8467 4.7623 11.1397 4.4695C11.4327 4.1767 11.9075 4.17685 12.2003 4.46984L17.1578 9.43049C17.3163 9.568 17.4165 9.77087 17.4165 9.99715C17.4165 9.99763 17.4165 9.99812 17.4165 9.9986Z" fill=""></path>
</svg>
);
export const Pagination: React.FC<PaginationProps> = ({
currentPage,
totalPages,
onPageChange,
variant = "text",
className = "",
showPageInfo = true,
}) => {
const getPageNumbers = () => {
const pages: (number | string)[] = [];
if (totalPages <= 7) {
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
} else {
if (currentPage <= 3) {
for (let i = 1; i <= 5; i++) {
pages.push(i);
}
pages.push("...");
pages.push(totalPages);
} else if (currentPage >= totalPages - 2) {
pages.push(1);
pages.push("...");
for (let i = totalPages - 4; i <= totalPages; i++) {
pages.push(i);
}
} else {
pages.push(1);
pages.push("...");
for (let i = currentPage - 1; i <= currentPage + 1; i++) {
pages.push(i);
}
pages.push("...");
pages.push(totalPages);
}
}
return pages;
};
const pages = getPageNumbers();
const isFirstPage = currentPage === 1;
const isLastPage = currentPage === totalPages;
const buttonBaseClasses = "rounded-lg border border-gray-300 bg-white px-2 py-2 text-sm font-medium text-gray-700 shadow-theme-xs hover:bg-gray-50 hover:text-gray-800 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.03] dark:hover:text-gray-200 sm:px-3.5 sm:py-2.5 disabled:opacity-50 disabled:cursor-not-allowed";
const prevButtonClasses = variant === "icon"
? `${buttonBaseClasses} flex items-center gap-2 p-2 sm:p-2.5`
: `${buttonBaseClasses} flex items-center gap-2`;
const nextButtonClasses = prevButtonClasses;
return (
<div className={`flex items-center justify-between gap-2 px-6 py-4 sm:justify-normal ${className}`}>
<button
className={prevButtonClasses}
onClick={() => !isFirstPage && onPageChange(currentPage - 1)}
disabled={isFirstPage}
type="button"
>
{(variant === "text" || variant === "text-icon") && <span className="inline sm:hidden"><PrevIcon /></span>}
{(variant === "text-icon" || variant === "icon") && <PrevIcon />}
{(variant === "text" || variant === "text-icon") && <span className="hidden sm:inline">{variant === "text-icon" ? " Previous " : "Previous"}</span>}
</button>
{showPageInfo && (
<span className="block text-sm font-medium text-gray-700 dark:text-gray-400 sm:hidden">
Page {currentPage} of {totalPages}
</span>
)}
<ul className="hidden items-center gap-0.5 sm:flex">
{pages.map((page, index) => {
if (page === "...") {
return (
<li key={`ellipsis-${index}`}>
<span className="flex items-center justify-center w-10 h-10 text-sm font-medium text-gray-700 dark:text-gray-400">...</span>
</li>
);
}
const pageNum = page as number;
const isActive = pageNum === currentPage;
return (
<li key={pageNum}>
<button
className={`flex items-center justify-center w-10 h-10 text-sm font-medium rounded-lg ${
isActive
? "text-white bg-brand-500 hover:bg-brand-600"
: "text-gray-700 hover:bg-brand-500 hover:text-white dark:text-gray-400 dark:hover:text-white"
}`}
onClick={() => onPageChange(pageNum)}
type="button"
>
{pageNum}
</button>
</li>
);
})}
</ul>
<button
className={nextButtonClasses}
onClick={() => !isLastPage && onPageChange(currentPage + 1)}
disabled={isLastPage}
type="button"
>
{(variant === "text" || variant === "text-icon") && <span className="hidden sm:inline">{variant === "text-icon" ? " Next " : "Next"}</span>}
{(variant === "text-icon" || variant === "icon") && <NextIcon />}
{(variant === "text" || variant === "text-icon") && <span className="inline sm:hidden"><NextIcon /></span>}
</button>
</div>
);
};

View File

@@ -0,0 +1,3 @@
export { Pagination } from "./Pagination";
export { CompactPagination } from "./CompactPagination";

View File

@@ -0,0 +1,417 @@
import { useState } from 'react';
export interface PricingPlan {
id?: number;
name: string;
price: string | number; // Current displayed price (will be calculated based on period)
monthlyPrice?: string | number; // Base monthly price (used for annual discount calculation)
originalPrice?: string | number;
period?: string; // "/month", "/year", "/Lifetime"
description?: string;
features: string[];
buttonText?: string;
highlighted?: boolean; // For featured/popular plan
icon?: React.ReactNode;
disabled?: boolean;
recommended?: boolean; // For "Recommended" badge
}
export interface PricingTableProps {
variant?: '1' | '2' | '3'; // Three different table styles
title?: string;
subtitle?: string;
plans: PricingPlan[];
showToggle?: boolean; // Monthly/Annually toggle
onPlanSelect?: (plan: PricingPlan) => void;
className?: string;
}
// Checkmark SVG Icon
const CheckIcon = () => (
<svg width="1em" height="1em" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" className="text-success-500">
<path d="M13.4017 4.35986L6.12166 11.6399L2.59833 8.11657" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"></path>
</svg>
);
// X Icon for excluded features
const XIcon = () => (
<svg width="1em" height="1em" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg" className="text-gray-400">
<path fillRule="evenodd" clipRule="evenodd" d="M4.05394 4.78033C3.76105 4.48744 3.76105 4.01256 4.05394 3.71967C4.34684 3.42678 4.82171 3.42678 5.1146 3.71967L8.33437 6.93944L11.5521 3.72173C11.845 3.42883 12.3199 3.42883 12.6127 3.72173C12.9056 4.01462 12.9056 4.48949 12.6127 4.78239L9.39503 8.0001L12.6127 11.2178C12.9056 11.5107 12.9056 11.9856 12.6127 12.2785C12.3198 12.5713 11.845 12.5713 11.5521 12.2785L8.33437 9.06076L5.11462 12.2805C4.82173 12.5734 4.34685 12.5734 4.05396 12.2805C3.76107 11.9876 3.76107 11.5127 4.05396 11.2199L7.27371 8.0001L4.05394 4.78033Z" fill="currentColor"></path>
</svg>
);
export default function PricingTable({
variant = '1',
title,
subtitle,
plans,
showToggle = false,
onPlanSelect,
className = '',
}: PricingTableProps) {
const [billingPeriod, setBillingPeriod] = useState<'monthly' | 'annually'>('monthly');
const handlePlanClick = (plan: PricingPlan) => {
if (plan.disabled) return;
onPlanSelect?.(plan);
};
const formatPrice = (price: string | number) => {
if (typeof price === 'number') {
return price.toFixed(2);
}
return price;
};
// Calculate price based on billing period with 20% annual discount
const getDisplayPrice = (plan: PricingPlan): { price: number; originalPrice?: number } => {
const monthlyPrice = typeof plan.monthlyPrice === 'number'
? plan.monthlyPrice
: typeof plan.price === 'number'
? plan.price
: parseFloat(String(plan.price || 0));
if (billingPeriod === 'annually' && showToggle) {
// Annual price: monthly * 12 * 0.8 (20% discount)
const annualPrice = monthlyPrice * 12 * 0.8;
const originalAnnualPrice = monthlyPrice * 12;
return { price: annualPrice, originalPrice: originalAnnualPrice };
}
// Monthly price
return { price: monthlyPrice };
};
// Variant 1: With toggle and highlighted center card
if (variant === '1') {
return (
<div className={`space-y-6 ${className}`}>
{title && (
<div className="mx-auto w-full max-w-[385px]">
<h2 className="font-bold text-center text-gray-800 mb-7 text-title-sm dark:text-white/90">
{title}
</h2>
</div>
)}
{showToggle && (
<div className="mb-10 text-center">
<div className="relative inline-flex p-1 mx-auto bg-gray-200 rounded-full z-1 dark:bg-gray-800">
<span
className={`absolute top-1/2 -z-1 flex h-11 w-[120px] -translate-y-1/2 rounded-full bg-white shadow-theme-xs duration-200 ease-linear dark:bg-white/10 ${
billingPeriod === 'monthly' ? 'translate-x-0' : 'translate-x-[120px]'
}`}
></span>
<button
onClick={() => setBillingPeriod('monthly')}
className={`flex h-11 w-[120px] items-center justify-center text-base font-medium transition-colors ${
billingPeriod === 'monthly'
? 'text-gray-800 dark:text-white/90'
: 'text-gray-500 hover:text-gray-700 dark:hover:text-white/80 dark:text-gray-400'
}`}
>
Monthly
</button>
<button
onClick={() => setBillingPeriod('annually')}
className={`flex h-11 w-[120px] items-center justify-center text-base font-medium transition-colors ${
billingPeriod === 'annually'
? 'text-gray-800 dark:text-white/90'
: 'text-gray-500 hover:text-gray-700 dark:hover:text-white/80 dark:text-gray-400'
}`}
>
Annually
</button>
</div>
</div>
)}
<div className="grid gap-5 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 xl:gap-6">
{plans.map((plan, index) => {
const isHighlighted = plan.highlighted || false; // Use explicit highlighted prop
const displayPrice = getDisplayPrice(plan);
const period = billingPeriod === 'annually' && showToggle ? '/year' : (plan.period || '/month');
return (
<div
key={plan.id || index}
className={`rounded-2xl border p-6 flex flex-col ${
isHighlighted
? 'bg-gray-800 border-gray-800 dark:border-white/10 dark:bg-white/10'
: 'border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03]'
}`}
>
<span
className={`block mb-3 font-semibold text-theme-xl ${
isHighlighted ? 'text-white' : 'text-gray-800 dark:text-white/90'
}`}
>
{plan.name}
</span>
<div className="flex items-center justify-between mb-1">
<div className="flex items-end">
<h2
className={`font-bold text-title-md ${
isHighlighted ? 'text-white' : 'text-gray-800 dark:text-white/90'
}`}
>
${formatPrice(displayPrice.price)}
</h2>
<span
className={`inline-block mb-1 text-sm ${
isHighlighted ? 'text-white/70' : 'text-gray-500 dark:text-gray-400'
}`}
>
{period}
</span>
</div>
{(displayPrice.originalPrice || plan.originalPrice) && (
<span
className={`font-semibold line-through text-theme-xl ${
isHighlighted ? 'text-gray-300' : 'text-gray-400'
}`}
>
${formatPrice(displayPrice.originalPrice || plan.originalPrice || 0)}
</span>
)}
</div>
{plan.description && (
<p
className={`text-sm ${
isHighlighted ? 'text-white/70' : 'text-gray-500 dark:text-gray-400'
}`}
>
{plan.description}
</p>
)}
<div className={`w-full h-px my-6 ${isHighlighted ? 'bg-white/20' : 'bg-gray-200 dark:bg-gray-800'}`}></div>
<ul className="mb-8 space-y-3 flex-grow">
{plan.features.map((feature, idx) => {
const isExcluded = feature.startsWith('!');
const featureText = isExcluded ? feature.substring(1) : feature;
return (
<li
key={idx}
className={`flex items-center gap-3 text-sm ${
isHighlighted
? 'text-white/80'
: isExcluded
? 'text-gray-400'
: 'text-gray-500 dark:text-gray-400'
}`}
>
{isExcluded ? <XIcon /> : <CheckIcon />}
{featureText}
</li>
);
})}
</ul>
<button
onClick={() => handlePlanClick(plan)}
disabled={plan.disabled}
className={`flex w-full items-center justify-center rounded-lg p-3.5 text-sm font-medium text-white shadow-theme-xs transition-colors mt-auto ${
isHighlighted
? 'bg-brand-500 hover:bg-brand-600 dark:hover:bg-brand-600'
: 'bg-gray-800 hover:bg-brand-500 dark:bg-white/10 dark:hover:bg-brand-600'
} ${plan.disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{plan.buttonText || 'Choose Plan'}
</button>
</div>
);
})}
</div>
</div>
);
}
// Variant 2: With icons and border highlight
if (variant === '2') {
return (
<div className={`space-y-6 ${className}`}>
<div className="grid gap-5 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 xl:gap-6">
{plans.map((plan, index) => {
const isHighlighted = plan.highlighted || index === 1;
return (
<div
key={plan.id || index}
className={`rounded-2xl border p-6 xl:p-8 ${
isHighlighted
? 'border-2 border-brand-500 bg-white dark:border-brand-500 dark:bg-white/[0.03]'
: 'border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03]'
}`}
>
<div className="flex items-start justify-between -mb-4">
<span className="block font-semibold text-gray-800 text-theme-xl dark:text-white/90">
{plan.name}
</span>
{plan.icon && (
<span className="flex h-[56px] dark:bg-brand-500/10 w-[56px] items-center justify-center rounded-[10.5px] bg-brand-50 text-brand-500">
{plan.icon}
</span>
)}
</div>
<div className="flex items-end">
<h2 className="font-bold text-gray-800 text-title-md dark:text-white/90">
${formatPrice(plan.price)}
</h2>
<span className="inline-block mb-1 text-sm text-gray-500 dark:text-gray-400">
{plan.period || ' / Lifetime'}
</span>
</div>
{plan.description && (
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{plan.description}</p>
)}
<div className="w-full h-px my-6 bg-gray-200 dark:bg-gray-800"></div>
<ul className="mb-8 space-y-3">
{plan.features.map((feature, idx) => {
const isExcluded = feature.startsWith('!');
const featureText = isExcluded ? feature.substring(1) : feature;
return (
<li
key={idx}
className={`flex items-center gap-3 text-sm ${
isExcluded ? 'text-gray-400' : 'text-gray-700 dark:text-gray-400'
}`}
>
{isExcluded ? <XIcon /> : <CheckIcon />}
{featureText}
</li>
);
})}
</ul>
<button
onClick={() => handlePlanClick(plan)}
disabled={plan.disabled}
className={`flex w-full items-center justify-center rounded-lg p-3.5 text-sm font-medium text-white shadow-theme-xs transition-colors ${
isHighlighted
? 'bg-brand-500 hover:bg-brand-600'
: 'bg-gray-800 hover:bg-brand-500 dark:bg-white/10 dark:hover:bg-brand-600'
} ${plan.disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{plan.buttonText || (isHighlighted ? 'Choose This Plan' : 'Choose Starter')}
</button>
</div>
);
})}
</div>
</div>
);
}
// Variant 3: Compact with recommended badge
if (variant === '3') {
return (
<div className={`space-y-6 ${className}`}>
<div className="grid gap-5 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 xl:gap-3 2xl:grid-cols-4">
{plans.map((plan, index) => {
const isRecommended = plan.recommended || index === 2;
return (
<div key={plan.id || index}>
<div
className={`rounded-2xl p-6 ${
isRecommended
? 'relative bg-brand-500'
: 'bg-white dark:bg-white/[0.03]'
}`}
>
{isRecommended && (
<div className="absolute px-3 py-1 font-medium text-white rounded-lg right-4 top-4 -z-1 bg-white/10 text-theme-xs">
Recommended
</div>
)}
<span
className={`block font-semibold text-theme-xl ${
isRecommended ? 'text-white' : 'text-gray-800 dark:text-white/90'
}`}
>
{plan.name}
</span>
{plan.description && (
<p
className={`mt-1 text-sm ${
isRecommended ? 'text-white/90' : 'text-gray-500 dark:text-gray-400'
}`}
>
{plan.description}
</p>
)}
<h2
className={`mb-0.5 mt-4 text-title-sm font-bold ${
isRecommended ? 'text-white' : 'text-gray-800 dark:text-white/90'
}`}
>
{typeof plan.price === 'string' && plan.price.toLowerCase() === 'free'
? 'Free'
: `$${formatPrice(plan.price)}`}
</h2>
<span
className={`inline-block mb-6 text-sm ${
isRecommended ? 'text-white/90' : 'text-gray-500 dark:text-gray-400'
}`}
>
{plan.period || 'For a Lifetime'}
</span>
<button
onClick={() => handlePlanClick(plan)}
disabled={plan.disabled}
className={`flex h-11 w-full items-center justify-center rounded-lg p-3.5 text-sm font-medium shadow-theme-xs transition-colors ${
isRecommended
? 'bg-white text-gray-800 hover:bg-gray-50'
: plan.disabled
? 'border border-gray-300 bg-white text-gray-400 disabled:pointer-events-none dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-white/[0.03] dark:hover:text-gray-200'
: 'bg-brand-500 text-white hover:bg-brand-600'
}`}
>
{plan.buttonText || (plan.disabled ? 'Current Plan' : 'Try for Free')}
</button>
<ul className="mt-6 space-y-3">
{plan.features.map((feature, idx) => {
const isExcluded = feature.startsWith('!');
const featureText = isExcluded ? feature.substring(1) : feature;
return (
<li
key={idx}
className={`flex items-center gap-3 text-sm ${
isRecommended
? 'text-white'
: isExcluded
? 'text-gray-400'
: 'text-gray-700 dark:text-gray-400'
}`}
>
{isExcluded ? (
<XIcon />
) : (
<svg
width="1em"
height="1em"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={isRecommended ? 'text-white' : 'text-success-500'}
>
<path
d="M13.4017 4.35986L6.12166 11.6399L2.59833 8.11657"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
></path>
</svg>
)}
{featureText}
</li>
);
})}
</ul>
</div>
</div>
);
})}
</div>
</div>
);
}
return null;
}

View File

@@ -0,0 +1,3 @@
export { default as PricingTable } from './PricingTable';
export type { PricingPlan, PricingTableProps } from './PricingTable';

Some files were not shown because too many files have changed in this diff Show More