Merge pull request 'feat: enhance AddCohart component with new sections and improved navigation; UI fixes' (#12) from ameenah into development
Reviewed-on: https://gitea.maskantech.in/gitea_admin/aeroresolve_frontend/pulls/12 Reviewed-by: Syed Waseem khadri Rafai <waseem.khadri@maskatech.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { FileText, ChevronRight, ChevronLeft, Users, CircleDollarSign, Globe, CircleCheck } from 'lucide-react';
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { FileText, ChevronRight, ChevronLeft, User, CircleCheck, Globe } from 'lucide-react';
|
||||
import {
|
||||
CustomModal,
|
||||
CustomInput,
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
CustomStatus,
|
||||
CustomRadio,
|
||||
CustomCheckBox,
|
||||
CustomAccordionSection,
|
||||
} from '../../../components/custom';
|
||||
import CustomSuccessModal from '../../../components/custom/CustomSuccessModal';
|
||||
import { createCohart, updateCohart } from '../CohartManageApi';
|
||||
@@ -27,6 +28,7 @@ import type {
|
||||
CohartResponse,
|
||||
CreateCohartPayload,
|
||||
CohortStatus,
|
||||
FlightType,
|
||||
HighValuePassenger,
|
||||
} from '../CohartManageTypes';
|
||||
|
||||
@@ -60,6 +62,9 @@ const AIRPORTS = [
|
||||
{ label: 'SIN (Singapore)', value: 'SIN' },
|
||||
];
|
||||
|
||||
const SECTION_ORDER = ['passenger', 'customer', 'journey'] as const;
|
||||
type Section = typeof SECTION_ORDER[number];
|
||||
|
||||
const STEPS = [
|
||||
{ num: 1, label: 'INFORMATION', subtitle: 'Cohort Identity' },
|
||||
{ num: 2, label: 'TARGETING', subtitle: 'Targeting Criteria' },
|
||||
@@ -95,34 +100,6 @@ interface AddCohartProps {
|
||||
editData?: CohartResponse;
|
||||
}
|
||||
|
||||
// ─── Accordion Section ───────────────────────────────────────────────────────
|
||||
|
||||
interface SectionProps {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
isOpen: boolean;
|
||||
hasData: boolean;
|
||||
onToggle: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function AccordionSection({ icon, title, isOpen, hasData, onToggle, children }: SectionProps) {
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-[12px] bg-gray-50/50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="w-full flex items-center gap-2 px-4 py-3.5 text-left"
|
||||
>
|
||||
{icon}
|
||||
<span className="text-[11px] font-bold text-gray-700 uppercase tracking-wider flex-1">{title}</span>
|
||||
{!isOpen && hasData && <CircleCheck size={18} className="text-[#1B9869]" strokeWidth={2} />}
|
||||
</button>
|
||||
{isOpen && <div className="px-4 pb-4">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Master Data Dropdown wrapper ─────────────────────────────────────────────
|
||||
|
||||
function toDropdownOptions(items: MasterDataItem[]) {
|
||||
@@ -175,6 +152,7 @@ export default function AddCohart({ isOpen, onClose, onSuccess, editData }: AddC
|
||||
// Load master data when modal opens
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- fetch master data on modal open
|
||||
setMasterDataLoading(true);
|
||||
Promise.all([
|
||||
getCabinClasses(),
|
||||
@@ -201,6 +179,7 @@ export default function AddCohart({ isOpen, onClose, onSuccess, editData }: AddC
|
||||
// Pre-fill form when editing
|
||||
useEffect(() => {
|
||||
if (isOpen && editData) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- sync form fields from editData when modal opens in edit mode
|
||||
setStepOne({
|
||||
name: editData.name,
|
||||
status: editData.status,
|
||||
@@ -249,7 +228,7 @@ export default function AddCohart({ isOpen, onClose, onSuccess, editData }: AddC
|
||||
loyaltyTierIds: stepTwo.loyaltyTierIds.length ? stepTwo.loyaltyTierIds : undefined,
|
||||
revenueSegmentIds: stepTwo.revenueSegmentIds.length ? stepTwo.revenueSegmentIds : undefined,
|
||||
highValuePassenger: stepTwo.highValuePassenger || undefined,
|
||||
flightType: stepTwo.flightType as any || undefined,
|
||||
flightType: (stepTwo.flightType as FlightType) || undefined,
|
||||
regionIds: stepTwo.regionIds.length ? stepTwo.regionIds : undefined,
|
||||
tripPurposeIds: stepTwo.tripPurposeIds.length ? stepTwo.tripPurposeIds : undefined,
|
||||
scopeType: stepTwo.scopeType || undefined,
|
||||
@@ -288,25 +267,71 @@ export default function AddCohart({ isOpen, onClose, onSuccess, editData }: AddC
|
||||
stepTwo.originAirports.length > 0 ||
|
||||
stepTwo.destinationAirports.length > 0;
|
||||
|
||||
// Auto-advance to the next section the moment the active one gains its
|
||||
// first value. Reopening a prior section later never re-triggers this,
|
||||
// since it only fires on the false -> true transition while that section
|
||||
// is the one currently open.
|
||||
const sectionHasData: Partial<Record<Section, boolean>> = useMemo(
|
||||
() => ({ passenger: passengerHasData, customer: customerHasData }),
|
||||
[passengerHasData, customerHasData]
|
||||
);
|
||||
const prevSectionHasData = useRef(sectionHasData);
|
||||
|
||||
useEffect(() => {
|
||||
const current = openSection as Section;
|
||||
const currentIndex = SECTION_ORDER.indexOf(current);
|
||||
const justFilled = !prevSectionHasData.current[current] && sectionHasData[current];
|
||||
if (justFilled && currentIndex >= 0 && currentIndex < SECTION_ORDER.length - 1) {
|
||||
setOpenSection(SECTION_ORDER[currentIndex + 1]);
|
||||
}
|
||||
prevSectionHasData.current = sectionHasData;
|
||||
}, [sectionHasData, openSection]);
|
||||
|
||||
const advanceSection = () => {
|
||||
const currentIndex = SECTION_ORDER.indexOf(openSection as Section);
|
||||
if (currentIndex >= 0 && currentIndex < SECTION_ORDER.length - 1) {
|
||||
setOpenSection(SECTION_ORDER[currentIndex + 1]);
|
||||
}
|
||||
};
|
||||
|
||||
const isOnFinalSection = openSection === 'journey';
|
||||
|
||||
// ─── Stepper ───────────────────────────────────────────────────────────────
|
||||
|
||||
const stepper = (
|
||||
<div className="px-6 py-5 border-b border-gray-100 flex items-center">
|
||||
{STEPS.map((s, i) => (
|
||||
<React.Fragment key={s.num}>
|
||||
<div className="flex flex-col min-w-[140px]">
|
||||
<p className={`text-[10px] font-bold tracking-wider mb-0.5 ${step >= s.num ? 'text-[#1B9869]' : 'text-gray-400'}`}>
|
||||
{s.num} – {s.label}
|
||||
</p>
|
||||
<p className={`text-base font-bold ${step >= s.num ? 'text-gray-900' : 'text-gray-400'}`}>
|
||||
{s.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
{i === 0 && (
|
||||
<div className={`flex-1 h-px mx-6 ${step > 1 ? 'bg-[#1B9869]' : 'bg-gray-200'}`} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
<div className="flex items-center gap-[18px]">
|
||||
{STEPS.map((s, i) => {
|
||||
const reached = step >= s.num;
|
||||
const isCurrent = step === s.num;
|
||||
const eyebrowColor = reached ? 'text-[#059669]' : 'text-slate-400';
|
||||
const titleColor = !reached
|
||||
? 'text-slate-400'
|
||||
: isCurrent && i > 0
|
||||
? 'text-[#0F172A]'
|
||||
: 'text-[#059669]';
|
||||
return (
|
||||
<React.Fragment key={s.num}>
|
||||
<div className={`flex flex-col gap-1 min-w-[140px] ${i === 1 ? 'items-end text-right' : ''}`}>
|
||||
<p className={`text-[10px] font-semibold leading-none tracking-[1px] uppercase ${eyebrowColor}`}>
|
||||
{s.num} – {s.label}
|
||||
</p>
|
||||
<p className={`text-[18px] font-semibold leading-none ${titleColor}`}>
|
||||
{s.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
{i === 0 && (
|
||||
<div
|
||||
className="flex-1 h-[2px] rounded-full"
|
||||
style={
|
||||
step > 1
|
||||
? { background: 'linear-gradient(90deg, #EBF6F3 0%, #A9DACB 71.31%)' }
|
||||
: { background: 'linear-gradient(90deg, #EBF6F3 0%, #CBD5E1 71.31%)' }
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -325,12 +350,18 @@ export default function AddCohart({ isOpen, onClose, onSuccess, editData }: AddC
|
||||
)}
|
||||
<CustomButton
|
||||
variant="primary"
|
||||
rightIcon={step === 2 ? undefined : <ChevronRight size={18} />}
|
||||
onClick={step === 2 ? handleSubmit : () => setStep(2)}
|
||||
rightIcon={step === 2 && isOnFinalSection ? undefined : <ChevronRight size={18} />}
|
||||
onClick={
|
||||
step === 1
|
||||
? () => setStep(2)
|
||||
: isOnFinalSection
|
||||
? handleSubmit
|
||||
: advanceSection
|
||||
}
|
||||
disabled={(step === 1 && !canAdvance) || submitting}
|
||||
loading={submitting}
|
||||
>
|
||||
{step === 2 ? (isEditMode ? 'Update' : 'Submit') : 'Next Step'}
|
||||
{step === 1 ? 'Next Step' : isOnFinalSection ? (isEditMode ? 'Update' : 'Submit') : 'Next'}
|
||||
</CustomButton>
|
||||
</div>
|
||||
);
|
||||
@@ -347,12 +378,11 @@ export default function AddCohart({ isOpen, onClose, onSuccess, editData }: AddC
|
||||
title={isEditMode ? 'Edit Cohort' : 'New Dynamic Cohorts'}
|
||||
description="Configure targeting criteria for high-precision recovery."
|
||||
icon={<FileText size={20} />}
|
||||
headerExtra={stepper}
|
||||
footer={footer}
|
||||
allowBackdropClose={false}
|
||||
contentClassName="!p-0"
|
||||
>
|
||||
{stepper}
|
||||
|
||||
{error && (
|
||||
<div className="mx-6 mt-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||
{error}
|
||||
@@ -394,31 +424,31 @@ export default function AddCohart({ isOpen, onClose, onSuccess, editData }: AddC
|
||||
{step === 2 && (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Summary card */}
|
||||
<div className="bg-[#EEF3F1] rounded-[12px] px-5 py-4 flex items-center gap-6">
|
||||
<div className="bg-[#F0F7FF] border border-[#D8E8FF] rounded-[12px] px-5 py-4 flex items-center justify-between gap-6">
|
||||
<div className="flex flex-col gap-0.5 shrink-0">
|
||||
<span className="text-[10px] font-bold text-[#1B9869] uppercase tracking-wider">Cohort Name</span>
|
||||
<span className="text-[14px] font-bold text-[#111827]">{stepOne.name}</span>
|
||||
<span className="text-[10px] font-semibold leading-none tracking-[1px] text-[#059669] uppercase">Cohort Name</span>
|
||||
<span className="text-[14px] font-semibold leading-none text-[#032D20]">{stepOne.name}</span>
|
||||
</div>
|
||||
{stepOne.description && (
|
||||
<div className="flex flex-col gap-0.5 flex-1 min-w-0">
|
||||
<span className="text-[10px] font-bold text-[#1B9869] uppercase tracking-wider">Description</span>
|
||||
<span className="text-[13px] font-medium text-[#475569] truncate">{stepOne.description}</span>
|
||||
<div className="flex flex-col gap-0.5 shrink-0 max-w-[280px]">
|
||||
<span className="text-[10px] font-semibold leading-none tracking-[1px] text-[#059669] uppercase">Description</span>
|
||||
<span className="text-[14px] font-semibold leading-none text-[#032D20] truncate">{stepOne.description}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="ml-auto shrink-0">
|
||||
<div className="shrink-0">
|
||||
<CustomStatus status={stepOne.status} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Passenger Attributes */}
|
||||
<AccordionSection
|
||||
icon={<Users size={15} className="text-gray-700" strokeWidth={2} />}
|
||||
<CustomAccordionSection
|
||||
icon={<User size={15} className="text-gray-700" strokeWidth={2} />}
|
||||
title="Passenger Attributes"
|
||||
isOpen={openSection === 'passenger'}
|
||||
hasData={passengerHasData}
|
||||
onToggle={() => toggleSection('passenger')}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-2 gap-5">
|
||||
<CustomMultiSelect
|
||||
label="Cabin Class"
|
||||
options={toDropdownOptions(cabinClasses)}
|
||||
@@ -444,18 +474,18 @@ export default function AddCohart({ isOpen, onClose, onSuccess, editData }: AddC
|
||||
disabled={masterDataLoading}
|
||||
/>
|
||||
</div>
|
||||
</AccordionSection>
|
||||
</CustomAccordionSection>
|
||||
|
||||
{/* Customer Value */}
|
||||
<AccordionSection
|
||||
icon={<CircleDollarSign size={15} className="text-gray-700" strokeWidth={2} />}
|
||||
<CustomAccordionSection
|
||||
icon={<CircleCheck size={15} className="text-gray-700" strokeWidth={2} />}
|
||||
title="Customer Value"
|
||||
isOpen={openSection === 'customer'}
|
||||
hasData={customerHasData}
|
||||
onToggle={() => toggleSection('customer')}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="grid grid-cols-2 gap-5">
|
||||
<CustomMultiSelect
|
||||
label="Loyalty Tier"
|
||||
options={toDropdownOptions(membershipTiers)}
|
||||
@@ -473,9 +503,9 @@ export default function AddCohart({ isOpen, onClose, onSuccess, editData }: AddC
|
||||
disabled={masterDataLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-3">
|
||||
<span className="text-sm font-medium text-gray-900">High Value Passenger</span>
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="flex items-center gap-7">
|
||||
{HIGH_VALUE_OPTIONS.map(opt => (
|
||||
<CustomRadio
|
||||
key={opt.value}
|
||||
@@ -489,20 +519,20 @@ export default function AddCohart({ isOpen, onClose, onSuccess, editData }: AddC
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionSection>
|
||||
</CustomAccordionSection>
|
||||
|
||||
{/* Journey Context */}
|
||||
<AccordionSection
|
||||
<CustomAccordionSection
|
||||
icon={<Globe size={15} className="text-gray-700" strokeWidth={2} />}
|
||||
title="Journey Context"
|
||||
isOpen={openSection === 'journey'}
|
||||
hasData={journeyHasData}
|
||||
onToggle={() => toggleSection('journey')}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3">
|
||||
<span className="text-sm font-medium text-gray-900">Flight Type</span>
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="flex items-center gap-7">
|
||||
<CustomCheckBox
|
||||
label="Domestic Only"
|
||||
checked={stepTwo.flightType === 'Domestic Only'}
|
||||
@@ -520,7 +550,7 @@ export default function AddCohart({ isOpen, onClose, onSuccess, editData }: AddC
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-2 gap-5">
|
||||
<CustomMultiSelect
|
||||
label="Region"
|
||||
options={toDropdownOptions(regions)}
|
||||
@@ -611,7 +641,7 @@ export default function AddCohart({ isOpen, onClose, onSuccess, editData }: AddC
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AccordionSection>
|
||||
</CustomAccordionSection>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from "react";
|
||||
import { CircleCheck } from "lucide-react";
|
||||
|
||||
interface CustomAccordionSectionProps {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
isOpen: boolean;
|
||||
hasData: boolean;
|
||||
onToggle: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const CustomAccordionSection: React.FC<CustomAccordionSectionProps> = ({
|
||||
icon,
|
||||
title,
|
||||
isOpen,
|
||||
hasData,
|
||||
onToggle,
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<div className="border border-[#E3EDE5] rounded-[12px] bg-[#F8FAF8]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="w-full flex items-center gap-2 px-4 py-3.5 text-left"
|
||||
>
|
||||
{icon}
|
||||
<span className="text-[11px] font-bold text-gray-700 uppercase tracking-wider flex-1">{title}</span>
|
||||
{!isOpen && hasData && <CircleCheck size={18} className="text-[#1B9869]" strokeWidth={2} />}
|
||||
</button>
|
||||
{isOpen && <div className="px-4 pb-4 pt-3">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomAccordionSection;
|
||||
@@ -30,7 +30,7 @@ const CustomCheckBox = forwardRef<HTMLInputElement, CustomCheckBoxProps>(
|
||||
transition-all duration-200
|
||||
flex items-center justify-center
|
||||
border-gray-300 bg-white
|
||||
peer-checked:bg-primary peer-checked:border-primary
|
||||
peer-checked:border-transparent peer-checked:bg-gradient-to-b peer-checked:from-[#1B9869] peer-checked:to-[#14704E]
|
||||
peer-checked:[&_svg]:opacity-100
|
||||
peer-hover:border-primary
|
||||
peer-focus:ring-2 peer-focus:ring-primary/20
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { ChevronDown, Check } from "lucide-react";
|
||||
import DropdownPortal from "./DropdownPortal";
|
||||
|
||||
interface Option {
|
||||
label: string;
|
||||
@@ -40,6 +41,7 @@ const CustomDropdown = React.forwardRef<HTMLDivElement, CustomDropdownProps>(
|
||||
) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const sizeClasses = {
|
||||
sm: "py-1.5 text-sm h-[36px]",
|
||||
@@ -49,9 +51,11 @@ const CustomDropdown = React.forwardRef<HTMLDivElement, CustomDropdownProps>(
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
!dropdownRef.current.contains(target) &&
|
||||
!(panelRef.current && panelRef.current.contains(target))
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
@@ -122,41 +126,52 @@ const CustomDropdown = React.forwardRef<HTMLDivElement, CustomDropdownProps>(
|
||||
</div>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
{isOpen && !disabled && (
|
||||
<div className="absolute z-[9999] w-full mt-2 bg-white border border-gray-200 rounded-lg shadow-lg max-h-60 overflow-y-auto flex flex-col">
|
||||
{options.length === 0 ? (
|
||||
<div className="px-4 py-3 text-sm text-gray-500 text-center">No options available</div>
|
||||
) : (
|
||||
options.map((option) => {
|
||||
const isSelected = String(option.value) === String(value);
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
disabled={option.disabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSelect(option);
|
||||
}}
|
||||
className={`
|
||||
w-full text-left px-4 py-2.5 text-[15px]
|
||||
transition-colors duration-150 flex items-center gap-2
|
||||
${isSelected
|
||||
? "bg-[#F0FDF4] text-primary-dark font-medium"
|
||||
: option.disabled
|
||||
? "text-gray-400 cursor-not-allowed"
|
||||
: "text-gray-700 hover:bg-gray-50"
|
||||
}
|
||||
`}
|
||||
<DropdownPortal
|
||||
anchorRef={dropdownRef}
|
||||
isOpen={isOpen && !disabled}
|
||||
ref={panelRef}
|
||||
className="bg-white border border-gray-200 rounded-lg shadow-lg overflow-y-auto flex flex-col"
|
||||
>
|
||||
{options.length === 0 ? (
|
||||
<div className="px-4 py-3 text-sm text-gray-500 text-center">No options available</div>
|
||||
) : (
|
||||
options.map((option) => {
|
||||
const isSelected = String(option.value) === String(value);
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
disabled={option.disabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSelect(option);
|
||||
}}
|
||||
className={`
|
||||
w-full text-left px-3 min-h-[42px] text-[14px] leading-none
|
||||
transition-colors duration-150 flex items-center gap-2
|
||||
${isSelected
|
||||
? "bg-[#EEF9EF]"
|
||||
: option.disabled
|
||||
? "text-gray-400 cursor-not-allowed"
|
||||
: "text-gray-700 hover:bg-gray-50"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{isSelected && <Check size={16} className="text-primary shrink-0" strokeWidth={2.5} />}
|
||||
<span
|
||||
className={
|
||||
isSelected
|
||||
? "ml-1 font-medium bg-gradient-to-b from-[#1B9869] to-[#14704E] bg-clip-text text-transparent"
|
||||
: "ml-6"
|
||||
}
|
||||
>
|
||||
{isSelected && <Check size={16} className="text-primary shrink-0" strokeWidth={2.5} />}
|
||||
<span className={isSelected ? "ml-1" : "ml-6"}>{option.label}</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{option.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</DropdownPortal>
|
||||
</div>
|
||||
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@ interface CustomModalProps {
|
||||
title?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
headerExtra?: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
|
||||
primaryAction?: {
|
||||
@@ -52,6 +53,7 @@ const CustomModal: React.FC<CustomModalProps> = ({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
headerExtra,
|
||||
footer,
|
||||
primaryAction,
|
||||
secondaryAction,
|
||||
@@ -107,23 +109,27 @@ const CustomModal: React.FC<CustomModalProps> = ({
|
||||
>
|
||||
{/* Header */}
|
||||
{(title || icon || showCloseButton) && (
|
||||
<div className="relative border-b border-gray-100 px-6 py-5 flex-shrink-0 flex items-start gap-3">
|
||||
<div className="relative border-b border-[#F1F5F9] bg-[#F8FAFC]/50 px-6 pt-5 pb-4 flex-shrink-0 flex items-start gap-3">
|
||||
{icon && (
|
||||
<div className="mt-1 flex-shrink-0 text-primary">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 pr-8">
|
||||
{title && (
|
||||
<h2 className="text-[17px] font-bold text-[#111827]">
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
{description && (
|
||||
<p className="mt-0.5 text-[13px] font-medium text-slate-500">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex-1 pr-8 flex flex-col gap-6">
|
||||
<div>
|
||||
{title && (
|
||||
<h2 className="text-[18px] font-semibold leading-[28px] tracking-normal text-[#0F172A]">
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
{description && (
|
||||
<p className="mt-0.5 text-[13px] font-normal leading-none tracking-normal text-[#0F172A]">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{headerExtra}
|
||||
</div>
|
||||
|
||||
{showCloseButton && (
|
||||
@@ -149,7 +155,7 @@ const CustomModal: React.FC<CustomModalProps> = ({
|
||||
|
||||
{/* Footer */}
|
||||
{(footer || primaryAction || secondaryAction) && (
|
||||
<div className={`flex items-center ${footer ? 'justify-end' : 'justify-between'} gap-3 border-t border-gray-100 bg-white px-6 py-4 flex-shrink-0`}>
|
||||
<div className={`flex items-center ${footer ? 'justify-end' : 'justify-between'} gap-3 border-t border-[#F1F5F9] bg-[#F8FAFC]/50 px-6 py-4 flex-shrink-0`}>
|
||||
{footer}
|
||||
{!footer && (
|
||||
<>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { ChevronDown, Check } from "lucide-react";
|
||||
import DropdownPortal from "./DropdownPortal";
|
||||
|
||||
interface Option {
|
||||
label: string;
|
||||
@@ -40,6 +41,7 @@ const CustomMultiSelect = React.forwardRef<HTMLDivElement, CustomMultiSelectProp
|
||||
) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const sizeClasses = {
|
||||
sm: "py-1.5 text-sm min-h-[36px]",
|
||||
@@ -49,9 +51,11 @@ const CustomMultiSelect = React.forwardRef<HTMLDivElement, CustomMultiSelectProp
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
!dropdownRef.current.contains(target) &&
|
||||
!(panelRef.current && panelRef.current.contains(target))
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
@@ -131,43 +135,46 @@ const CustomMultiSelect = React.forwardRef<HTMLDivElement, CustomMultiSelectProp
|
||||
</div>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
{isOpen && !disabled && (
|
||||
<div className="absolute z-[9999] w-full mt-2 bg-white border border-gray-200 rounded-lg shadow-lg max-h-60 overflow-y-auto flex flex-col">
|
||||
{options.length === 0 ? (
|
||||
<div className="px-4 py-3 text-sm text-gray-500 text-center">No options available</div>
|
||||
) : (
|
||||
options.map((option) => {
|
||||
const isSelected = value.some(v => String(v) === String(option.value));
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
disabled={option.disabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSelect(option);
|
||||
}}
|
||||
className={`
|
||||
w-full text-left px-4 py-2.5 text-[15px]
|
||||
transition-colors duration-150 flex items-center gap-2
|
||||
${isSelected
|
||||
? "bg-[#F0FDF4] text-[#1B9869] font-medium"
|
||||
: option.disabled
|
||||
? "text-gray-400 cursor-not-allowed"
|
||||
: "text-gray-700 hover:bg-gray-50"
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 ${isSelected ? 'bg-[#1B9869] border-[#1B9869]' : 'border-gray-300'}`}>
|
||||
{isSelected && <Check size={12} className="text-white" strokeWidth={3} />}
|
||||
</div>
|
||||
<span className="ml-1">{option.label}</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<DropdownPortal
|
||||
anchorRef={dropdownRef}
|
||||
isOpen={isOpen && !disabled}
|
||||
ref={panelRef}
|
||||
className="bg-white border border-gray-200 rounded-lg shadow-lg overflow-y-auto flex flex-col"
|
||||
>
|
||||
{options.length === 0 ? (
|
||||
<div className="px-4 py-3 text-sm text-gray-500 text-center">No options available</div>
|
||||
) : (
|
||||
options.map((option) => {
|
||||
const isSelected = value.some(v => String(v) === String(option.value));
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
disabled={option.disabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSelect(option);
|
||||
}}
|
||||
className={`
|
||||
w-full text-left px-4 py-2.5 text-[15px]
|
||||
transition-colors duration-150 flex items-center gap-2
|
||||
${isSelected
|
||||
? "bg-[#F0FDF4] text-[#1B9869] font-medium"
|
||||
: option.disabled
|
||||
? "text-gray-400 cursor-not-allowed"
|
||||
: "text-gray-700 hover:bg-gray-50"
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 ${isSelected ? 'bg-[#1B9869] border-[#1B9869]' : 'border-gray-300'}`}>
|
||||
{isSelected && <Check size={12} className="text-white" strokeWidth={3} />}
|
||||
</div>
|
||||
<span className="ml-1">{option.label}</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</DropdownPortal>
|
||||
</div>
|
||||
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
|
||||
</div>
|
||||
|
||||
@@ -29,7 +29,7 @@ const CustomRadio = forwardRef<HTMLInputElement, CustomRadioProps>(
|
||||
transition-all duration-200
|
||||
flex items-center justify-center
|
||||
border-gray-300 bg-white
|
||||
peer-checked:border-primary peer-checked:bg-primary
|
||||
peer-checked:border-transparent peer-checked:bg-gradient-to-b peer-checked:from-[#1B9869] peer-checked:to-[#14704E]
|
||||
peer-checked:[&>div]:opacity-100
|
||||
peer-hover:border-primary
|
||||
peer-focus:ring-2 peer-focus:ring-primary/20
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { ChevronDown, X, Check } from "lucide-react";
|
||||
import DropdownPortal from "./DropdownPortal";
|
||||
|
||||
interface Option {
|
||||
label: string;
|
||||
@@ -43,6 +44,7 @@ const CustomSearchableDropdown = React.forwardRef<
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const sizeClasses = {
|
||||
sm: "py-1 text-sm",
|
||||
@@ -66,9 +68,11 @@ const CustomSearchableDropdown = React.forwardRef<
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
!dropdownRef.current.contains(target) &&
|
||||
!(panelRef.current && panelRef.current.contains(target))
|
||||
) {
|
||||
setIsOpen(false);
|
||||
// Revert to selected value label if closing without selection
|
||||
@@ -216,9 +220,16 @@ const CustomSearchableDropdown = React.forwardRef<
|
||||
</div>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
{isOpen && !disabled && filteredOptions.length > 0 && (
|
||||
<div className="absolute z-[9999] w-full mt-2 bg-white border border-gray-200 rounded-lg shadow-lg max-h-60 overflow-y-auto flex flex-col">
|
||||
{filteredOptions.map((option, index) => {
|
||||
<DropdownPortal
|
||||
anchorRef={dropdownRef}
|
||||
isOpen={isOpen && !disabled}
|
||||
ref={panelRef}
|
||||
className="bg-white border border-gray-200 rounded-lg shadow-lg overflow-y-auto flex flex-col"
|
||||
>
|
||||
{filteredOptions.length === 0 ? (
|
||||
<div className="px-4 py-3 text-sm text-gray-500 text-center">No results found</div>
|
||||
) : (
|
||||
filteredOptions.map((option, index) => {
|
||||
const isSelected = String(option.value) === String(value);
|
||||
return (
|
||||
<button
|
||||
@@ -227,10 +238,10 @@ const CustomSearchableDropdown = React.forwardRef<
|
||||
onClick={() => handleSelect(option)}
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
className={`
|
||||
w-full text-left px-4 py-2.5 text-[15px]
|
||||
w-full text-left px-3 min-h-[42px] text-[14px] leading-none
|
||||
transition-colors duration-150 flex items-center gap-2
|
||||
${isSelected
|
||||
? "bg-[#F0FDF4] text-primary-dark font-medium"
|
||||
? "bg-[#EEF9EF]"
|
||||
: highlightedIndex === index
|
||||
? "bg-gray-50 text-gray-900"
|
||||
: "text-gray-700 hover:bg-gray-50"
|
||||
@@ -238,17 +249,20 @@ const CustomSearchableDropdown = React.forwardRef<
|
||||
`}
|
||||
>
|
||||
{isSelected && <Check size={16} className="text-primary shrink-0" strokeWidth={2.5} />}
|
||||
<span className={isSelected ? "ml-1" : "ml-6"}>{option.label}</span>
|
||||
<span
|
||||
className={
|
||||
isSelected
|
||||
? "ml-1 font-medium bg-gradient-to-b from-[#1B9869] to-[#14704E] bg-clip-text text-transparent"
|
||||
: "ml-6"
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{isOpen && !disabled && filteredOptions.length === 0 && (
|
||||
<div className="absolute z-[9999] w-full mt-2 bg-white border border-gray-200 rounded-lg shadow-lg p-4 text-center text-sm text-gray-500">
|
||||
No results found
|
||||
</div>
|
||||
)}
|
||||
})
|
||||
)}
|
||||
</DropdownPortal>
|
||||
</div>
|
||||
{props.error && <p className="text-xs text-red-500 mt-1">{props.error}</p>}
|
||||
</div>
|
||||
|
||||
@@ -34,12 +34,12 @@ const CustomStatus: React.FC<CustomStatusProps> = ({
|
||||
const finalVariant = variant || getVariantFromStatus(status);
|
||||
|
||||
const styles = {
|
||||
success: { bg: "bg-[#E4FAE7]", text: "text-[#258B33]", dot: "bg-[#258B33]" },
|
||||
error: { bg: "bg-rose-50", text: "text-rose-600", dot: "bg-rose-600" },
|
||||
warning: { bg: "bg-amber-50", text: "text-amber-600", dot: "bg-amber-600" },
|
||||
info: { bg: "bg-sky-50", text: "text-sky-600", dot: "bg-sky-600" },
|
||||
neutral: { bg: "bg-slate-100", text: "text-slate-600", dot: "bg-slate-600" },
|
||||
brand: { bg: "bg-[#E4FAE7]", text: "text-[#258B33]", dot: "bg-[#258B33]" },
|
||||
success: { bg: "bg-[#E4FAE7]", text: "text-[#1B9869]", dot: "bg-[#1B9869]", border: "border border-[#1B9869]" },
|
||||
error: { bg: "bg-rose-50", text: "text-rose-600", dot: "bg-rose-600", border: "" },
|
||||
warning: { bg: "bg-amber-50", text: "text-amber-600", dot: "bg-amber-600", border: "" },
|
||||
info: { bg: "bg-sky-50", text: "text-sky-600", dot: "bg-sky-600", border: "" },
|
||||
neutral: { bg: "bg-slate-100", text: "text-slate-600", dot: "bg-slate-600", border: "" },
|
||||
brand: { bg: "bg-[#E4FAE7]", text: "text-[#1B9869]", dot: "bg-[#1B9869]", border: "border border-[#1B9869]" },
|
||||
};
|
||||
|
||||
const currentStyle = styles[finalVariant];
|
||||
@@ -56,7 +56,7 @@ const CustomStatus: React.FC<CustomStatusProps> = ({
|
||||
className={`
|
||||
inline-flex items-center justify-center gap-2 px-3 py-1.5 rounded-full
|
||||
text-[11px] font-semibold leading-[16.5px] tracking-[0px] antialiased transition-all duration-300
|
||||
${currentStyle.bg} ${currentStyle.text}
|
||||
${currentStyle.bg} ${currentStyle.text} ${currentStyle.border}
|
||||
${isClickable ? "hover:brightness-95 active:scale-95 cursor-pointer" : "cursor-default pointer-events-none"}
|
||||
${className}
|
||||
`}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import React, { useCallback, useLayoutEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
const GAP = 8;
|
||||
const MAX_PANEL_HEIGHT = 240;
|
||||
const MIN_PANEL_HEIGHT = 100;
|
||||
|
||||
interface DropdownPortalProps {
|
||||
anchorRef: React.RefObject<HTMLElement | null>;
|
||||
isOpen: boolean;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
// Portals the panel to document.body with fixed positioning so it isn't clipped by scrollable ancestors, and flips above the trigger when there's no room below.
|
||||
const DropdownPortal = React.forwardRef<HTMLDivElement, DropdownPortalProps>(
|
||||
({ anchorRef, isOpen, className = "", children }, forwardedRef) => {
|
||||
const [style, setStyle] = useState<React.CSSProperties | null>(null);
|
||||
|
||||
const recompute = useCallback(() => {
|
||||
const anchor = anchorRef.current;
|
||||
if (!anchor) return;
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
const spaceAbove = rect.top;
|
||||
const openUp = spaceBelow < MAX_PANEL_HEIGHT && spaceAbove > spaceBelow;
|
||||
const clampHeight = (space: number) =>
|
||||
Math.min(MAX_PANEL_HEIGHT, Math.max(space - GAP * 2, MIN_PANEL_HEIGHT));
|
||||
|
||||
setStyle({
|
||||
position: "fixed",
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
...(openUp
|
||||
? {
|
||||
bottom: window.innerHeight - rect.top + GAP,
|
||||
maxHeight: clampHeight(spaceAbove),
|
||||
}
|
||||
: {
|
||||
top: rect.bottom + GAP,
|
||||
maxHeight: clampHeight(spaceBelow),
|
||||
}),
|
||||
});
|
||||
}, [anchorRef]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isOpen) {
|
||||
setStyle(null);
|
||||
return;
|
||||
}
|
||||
recompute();
|
||||
window.addEventListener("scroll", recompute, true);
|
||||
window.addEventListener("resize", recompute);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", recompute, true);
|
||||
window.removeEventListener("resize", recompute);
|
||||
};
|
||||
}, [isOpen, recompute]);
|
||||
|
||||
if (!isOpen || !style) return null;
|
||||
|
||||
return createPortal(
|
||||
<div ref={forwardedRef} style={style} className={`z-[10050] ${className}`}>
|
||||
{children}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
DropdownPortal.displayName = "DropdownPortal";
|
||||
|
||||
export default DropdownPortal;
|
||||
@@ -22,6 +22,7 @@ import CustomStatus from "./CustomStatus";
|
||||
import CustomAlertBanner from "./CustomAlertBanner";
|
||||
import Skeleton from "./CustomSkeleton";
|
||||
import CustomTimePicker from "./CustomTimePicker";
|
||||
import CustomAccordionSection from "./CustomAccordionSection";
|
||||
|
||||
export {
|
||||
CustomInput,
|
||||
@@ -47,5 +48,6 @@ export {
|
||||
CustomStatus,
|
||||
CustomAlertBanner,
|
||||
Skeleton,
|
||||
CustomTimePicker
|
||||
CustomTimePicker,
|
||||
CustomAccordionSection
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user