diff --git a/src/app/cohartManage/components/AddCohart.tsx b/src/app/cohartManage/components/AddCohart.tsx index 496b4f0..ba9bdb2 100644 --- a/src/app/cohartManage/components/AddCohart.tsx +++ b/src/app/cohartManage/components/AddCohart.tsx @@ -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 ( -
- - {isOpen &&
{children}
} -
- ); -} - // ─── 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> = 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 = ( -
- {STEPS.map((s, i) => ( - -
-

= s.num ? 'text-[#1B9869]' : 'text-gray-400'}`}> - {s.num} – {s.label} -

-

= s.num ? 'text-gray-900' : 'text-gray-400'}`}> - {s.subtitle} -

-
- {i === 0 && ( -
1 ? 'bg-[#1B9869]' : 'bg-gray-200'}`} /> - )} - - ))} +
+ {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 ( + +
+

+ {s.num} – {s.label} +

+

+ {s.subtitle} +

+
+ {i === 0 && ( +
1 + ? { background: 'linear-gradient(90deg, #EBF6F3 0%, #A9DACB 71.31%)' } + : { background: 'linear-gradient(90deg, #EBF6F3 0%, #CBD5E1 71.31%)' } + } + /> + )} + + ); + })}
); @@ -325,12 +350,18 @@ export default function AddCohart({ isOpen, onClose, onSuccess, editData }: AddC )} } - onClick={step === 2 ? handleSubmit : () => setStep(2)} + rightIcon={step === 2 && isOnFinalSection ? undefined : } + 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'}
); @@ -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={} + headerExtra={stepper} footer={footer} allowBackdropClose={false} contentClassName="!p-0" > - {stepper} - {error && (
{error} @@ -394,31 +424,31 @@ export default function AddCohart({ isOpen, onClose, onSuccess, editData }: AddC {step === 2 && (
{/* Summary card */} -
+
- Cohort Name - {stepOne.name} + Cohort Name + {stepOne.name}
{stepOne.description && ( -
- Description - {stepOne.description} +
+ Description + {stepOne.description}
)} -
+
{/* Passenger Attributes */} - } + } title="Passenger Attributes" isOpen={openSection === 'passenger'} hasData={passengerHasData} onToggle={() => toggleSection('passenger')} > -
+
- + {/* Customer Value */} - } + } title="Customer Value" isOpen={openSection === 'customer'} hasData={customerHasData} onToggle={() => toggleSection('customer')} > -
-
+
+
-
+
High Value Passenger -
+
{HIGH_VALUE_OPTIONS.map(opt => (
- + {/* Journey Context */} - } title="Journey Context" isOpen={openSection === 'journey'} hasData={journeyHasData} onToggle={() => toggleSection('journey')} > -
-
+
+
Flight Type -
+
-
+
- +
)}
diff --git a/src/components/custom/CustomAccordionSection.tsx b/src/components/custom/CustomAccordionSection.tsx new file mode 100644 index 0000000..40d4095 --- /dev/null +++ b/src/components/custom/CustomAccordionSection.tsx @@ -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 = ({ + icon, + title, + isOpen, + hasData, + onToggle, + children, +}) => { + return ( +
+ + {isOpen &&
{children}
} +
+ ); +}; + +export default CustomAccordionSection; diff --git a/src/components/custom/CustomCheckBox.tsx b/src/components/custom/CustomCheckBox.tsx index 6038bfe..b5d1cf1 100644 --- a/src/components/custom/CustomCheckBox.tsx +++ b/src/components/custom/CustomCheckBox.tsx @@ -30,7 +30,7 @@ const CustomCheckBox = forwardRef( 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 diff --git a/src/components/custom/CustomDropdown.tsx b/src/components/custom/CustomDropdown.tsx index 88a7141..3a82d2a 100644 --- a/src/components/custom/CustomDropdown.tsx +++ b/src/components/custom/CustomDropdown.tsx @@ -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( ) => { const [isOpen, setIsOpen] = useState(false); const dropdownRef = useRef(null); + const panelRef = useRef(null); const sizeClasses = { sm: "py-1.5 text-sm h-[36px]", @@ -49,9 +51,11 @@ const CustomDropdown = 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); } @@ -122,41 +126,52 @@ const CustomDropdown = React.forwardRef(
{/* Dropdown Menu */} - {isOpen && !disabled && ( -
- {options.length === 0 ? ( -
No options available
- ) : ( - options.map((option) => { - const isSelected = String(option.value) === String(value); - return ( - - ); - }) - )} -
- )} + {option.label} + + + ); + }) + )} +
{error &&

{error}

}
diff --git a/src/components/custom/CustomModal.tsx b/src/components/custom/CustomModal.tsx index 3915919..3fc99b7 100644 --- a/src/components/custom/CustomModal.tsx +++ b/src/components/custom/CustomModal.tsx @@ -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 = ({ title, description, icon, + headerExtra, footer, primaryAction, secondaryAction, @@ -107,23 +109,27 @@ const CustomModal: React.FC = ({ > {/* Header */} {(title || icon || showCloseButton) && ( -
+
{icon && (
{icon}
)} -
- {title && ( -

- {title} -

- )} - {description && ( -

- {description} -

- )} +
+
+ {title && ( +

+ {title} +

+ )} + {description && ( +

+ {description} +

+ )} +
+ + {headerExtra}
{showCloseButton && ( @@ -149,7 +155,7 @@ const CustomModal: React.FC = ({ {/* Footer */} {(footer || primaryAction || secondaryAction) && ( -
+
{footer} {!footer && ( <> diff --git a/src/components/custom/CustomMultiSelect.tsx b/src/components/custom/CustomMultiSelect.tsx index 85ed065..6772914 100644 --- a/src/components/custom/CustomMultiSelect.tsx +++ b/src/components/custom/CustomMultiSelect.tsx @@ -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 { const [isOpen, setIsOpen] = useState(false); const dropdownRef = useRef(null); + const panelRef = useRef(null); const sizeClasses = { sm: "py-1.5 text-sm min-h-[36px]", @@ -49,9 +51,11 @@ const CustomMultiSelect = React.forwardRef { 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 {/* Dropdown Menu */} - {isOpen && !disabled && ( -
- {options.length === 0 ? ( -
No options available
- ) : ( - options.map((option) => { - const isSelected = value.some(v => String(v) === String(option.value)); - return ( - - ); - }) - )} -
- )} + + {options.length === 0 ? ( +
No options available
+ ) : ( + options.map((option) => { + const isSelected = value.some(v => String(v) === String(option.value)); + return ( + + ); + }) + )} +
{error &&

{error}

}
diff --git a/src/components/custom/CustomRadio.tsx b/src/components/custom/CustomRadio.tsx index abf3151..20bbed5 100644 --- a/src/components/custom/CustomRadio.tsx +++ b/src/components/custom/CustomRadio.tsx @@ -29,7 +29,7 @@ const CustomRadio = forwardRef( 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 diff --git a/src/components/custom/CustomSearchableDropdown.tsx b/src/components/custom/CustomSearchableDropdown.tsx index 3e70eed..84d9ea3 100644 --- a/src/components/custom/CustomSearchableDropdown.tsx +++ b/src/components/custom/CustomSearchableDropdown.tsx @@ -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(null); const inputRef = useRef(null); + const panelRef = useRef(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<
{/* Dropdown Menu */} - {isOpen && !disabled && filteredOptions.length > 0 && ( -
- {filteredOptions.map((option, index) => { + + {filteredOptions.length === 0 ? ( +
No results found
+ ) : ( + filteredOptions.map((option, index) => { const isSelected = String(option.value) === String(value); return ( ); - })} -
- )} - {isOpen && !disabled && filteredOptions.length === 0 && ( -
- No results found -
- )} + }) + )} +
{props.error &&

{props.error}

}
diff --git a/src/components/custom/CustomStatus.tsx b/src/components/custom/CustomStatus.tsx index 2d08cc1..6096195 100644 --- a/src/components/custom/CustomStatus.tsx +++ b/src/components/custom/CustomStatus.tsx @@ -34,12 +34,12 @@ const CustomStatus: React.FC = ({ 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 = ({ 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} `} diff --git a/src/components/custom/DropdownPortal.tsx b/src/components/custom/DropdownPortal.tsx new file mode 100644 index 0000000..07b8274 --- /dev/null +++ b/src/components/custom/DropdownPortal.tsx @@ -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; + 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( + ({ anchorRef, isOpen, className = "", children }, forwardedRef) => { + const [style, setStyle] = useState(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( +
+ {children} +
, + document.body + ); + } +); + +DropdownPortal.displayName = "DropdownPortal"; + +export default DropdownPortal; diff --git a/src/components/custom/index.ts b/src/components/custom/index.ts index 9d4e42e..0479e27 100644 --- a/src/components/custom/index.ts +++ b/src/components/custom/index.ts @@ -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 };