From d3e2944fa3b47dfd83ca23beddb5f13421dc995b Mon Sep 17 00:00:00 2001 From: Satyam <95536056+Satyam-Rastogi@users.noreply.github.com> Date: Fri, 13 Mar 2026 17:22:58 +0530 Subject: [PATCH] fix(leads): portaled dropdowns, flex-wrapper icons, responsive layout, and UI polish - Replace all native `: +- Trigger button styled identically to the other input fields +- Panel rendered via `ReactDOM.createPortal` into `document.body` β€” escapes all `overflow:hidden` ancestors (critical because Framer Motion collapse animations use overflow:hidden) +- Position: `getBoundingClientRect()` + `window.scrollY/scrollX` β†’ `position: absolute` on body +- Scroll detection: `e.composedPath()` β€” reliable across Chrome, Safari, Firefox, and mobile touch +- `accent` prop: `'blue'` (Job Details) or `'emerald'` (Property section) +- `compact` prop: for inline type pickers (phone/email type selectors, `w-24` container) +- `LeadCombobox`: input-based variant with live fuzzy filtering β€” `Enter` selects top result, `Escape` cancels --- -## πŸ“ Project Structure (Abbreviated) +## Project Structure (Abbreviated) ``` src/ -β”œβ”€β”€ pages/ # Route-level pages by role +β”œβ”€β”€ pages/ +β”‚ β”œβ”€β”€ CreateLeadPage.jsx # Lead creation orchestrator (form state, sections, progress) +β”‚ └── ... # Other role pages β”œβ”€β”€ components/ -β”‚ β”œβ”€β”€ estimates/ # EstimateBuilder modals (MaterialDetails, Measurements, etc.) -β”‚ β”œβ”€β”€ leads/ # Lead creation form components (in development) -β”‚ β”œβ”€β”€ dispatch/ # LynkDispatch AI panels (in development) -β”‚ β”œβ”€β”€ dashboard/ # Admin dashboard widgets -β”‚ β”œβ”€β”€ ProCanvas/ # Gamification subcomponents -β”‚ └── ... # Shared components (Layout, AnimatedCounter, etc.) -β”œβ”€β”€ context/ # AuthContext, ThemeContext, GamificationContext -β”œβ”€β”€ data/ # mockStore.jsx β€” central data layer -└── hooks/ # useGamification, custom hooks +β”‚ β”œβ”€β”€ leads/ +β”‚ β”‚ β”œβ”€β”€ LeadSectionWrapper.jsx # Collapsible accordion with spring animation +β”‚ β”‚ β”œβ”€β”€ LeadContactSection.jsx # Name, dynamic phone rows, dynamic email rows +β”‚ β”‚ β”œβ”€β”€ LeadPropertySection.jsx # Address, city/state/zip, property type, site photos +β”‚ β”‚ β”œβ”€β”€ LeadJobSection.jsx # Lead source, type, work/trade type, urgency, notes +β”‚ β”‚ β”œβ”€β”€ LeadCustomSelect.jsx # Portaled custom dropdown (accent + compact modes) +β”‚ β”‚ β”œβ”€β”€ LeadCombobox.jsx # Fuzzy-search combobox for state field +β”‚ β”‚ β”œβ”€β”€ UrgencyPillSelector.jsx # Sliding pill with Framer Motion layoutId +β”‚ β”‚ β”œβ”€β”€ PhoneEntryRow.jsx # Phone input with mask, type picker, primary star +β”‚ β”‚ └── EmailEntryRow.jsx # Email input with type picker, primary star +β”‚ β”œβ”€β”€ estimates/ # EstimateBuilder modals +β”‚ β”œβ”€β”€ dispatch/ # LynkDispatch AI panels (in development) +β”‚ β”œβ”€β”€ dashboard/ # Admin dashboard widgets +β”‚ └── ... # Layout, AnimatedCounter, shared components +β”œβ”€β”€ context/ # AuthContext, ThemeContext, GamificationContext +β”œβ”€β”€ data/ +β”‚ └── mockStore.jsx # Central data layer + LEAD_FORM_OPTIONS +└── hooks/ # useGamification, custom hooks ``` --- -**Maintained by Satyam Rastogi | Branch: revamp | Last updated: March 12, 2026** +**Maintained by Satyam Rastogi | Branch: revamp | Last updated: March 13, 2026** diff --git a/src/components/leads/EmailEntryRow.jsx b/src/components/leads/EmailEntryRow.jsx index d36a128..dbbda5a 100644 --- a/src/components/leads/EmailEntryRow.jsx +++ b/src/components/leads/EmailEntryRow.jsx @@ -1,62 +1,43 @@ import React from 'react'; import { Mail, Star, X } from 'lucide-react'; -import { useTheme } from '../../context/ThemeContext'; import { LEAD_FORM_OPTIONS } from '../../data/mockStore'; +import LeadCustomSelect from './LeadCustomSelect'; export default function EmailEntryRow({ email, isOnly, onUpdate, onSetPrimary, onRemove }) { - const { theme } = useTheme(); - const isDark = theme === 'dark'; - - const inputBase = `w-full rounded-xl px-4 py-3 text-sm font-medium outline-none transition-all duration-150 - ${isDark - ? 'bg-zinc-800/60 border border-zinc-700 text-white placeholder-zinc-500 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20' - : 'bg-white border border-zinc-200 text-zinc-800 placeholder-zinc-400 focus:border-blue-400 focus:ring-2 focus:ring-blue-100' - }`; - return (
- {/* Email input */} -
- + {/* Email input β€” flex wrapper owns border/bg */} +
+ onUpdate('address', e.target.value)} placeholder="name@example.com" - className={`${inputBase} pl-9`} + className="flex-1 py-3 pl-3 pr-4 text-sm font-medium outline-none bg-transparent + text-zinc-800 dark:text-white + placeholder-zinc-400 dark:placeholder-zinc-500" />
{/* Controls */}
- {/* Type selector */} -
- - - - + onChange={v => onUpdate('type', v)} + options={LEAD_FORM_OPTIONS.emailTypes} + accent="blue" + compact + />
- {/* Primary star */} - {/* Remove */} {!isOnly && ( + ); + }) + )} +
+ + )} + , + document.body + )} +
+ ); +} diff --git a/src/components/leads/LeadContactSection.jsx b/src/components/leads/LeadContactSection.jsx index 17a1f27..9ae663a 100644 --- a/src/components/leads/LeadContactSection.jsx +++ b/src/components/leads/LeadContactSection.jsx @@ -1,7 +1,6 @@ import React from 'react'; import { motion, AnimatePresence, LayoutGroup } from 'framer-motion'; import { PlusCircle } from 'lucide-react'; -import { useTheme } from '../../context/ThemeContext'; import PhoneEntryRow from './PhoneEntryRow'; import EmailEntryRow from './EmailEntryRow'; @@ -17,9 +16,6 @@ function FieldLabel({ children }) { } function LeadInput({ label, value, onChange, placeholder, type = 'text', autoComplete }) { - const { theme } = useTheme(); - const isDark = theme === 'dark'; - return (
{label && {label}} @@ -29,13 +25,13 @@ function LeadInput({ label, value, onChange, placeholder, type = 'text', autoCom onChange={e => onChange(e.target.value)} placeholder={placeholder} autoComplete={autoComplete} - className={`w-full rounded-xl px-4 py-3 text-sm font-medium - outline-none transition-all duration-150 - ${isDark - ? 'bg-zinc-800/60 border border-zinc-700 text-white placeholder-zinc-500 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20' - : 'bg-white border border-zinc-200 text-zinc-800 placeholder-zinc-400 focus:border-blue-400 focus:ring-2 focus:ring-blue-100' - } - `} + className="w-full rounded-xl px-4 py-3 text-sm font-medium outline-none transition-all duration-150 + bg-white dark:bg-zinc-800/60 + border border-zinc-200 dark:border-zinc-700 + text-zinc-800 dark:text-white + placeholder-zinc-400 dark:placeholder-zinc-500 + focus:border-blue-400 dark:focus:border-blue-500 + focus:ring-2 focus:ring-blue-100 dark:focus:ring-blue-500/20" />
); diff --git a/src/components/leads/LeadCustomSelect.jsx b/src/components/leads/LeadCustomSelect.jsx new file mode 100644 index 0000000..f8f8c49 --- /dev/null +++ b/src/components/leads/LeadCustomSelect.jsx @@ -0,0 +1,187 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import { motion, AnimatePresence } from 'framer-motion'; +import { ChevronDown, Check } from 'lucide-react'; + +function FieldLabel({ children }) { + return ( + + ); +} + +/** + * accent: 'blue' | 'emerald' + * compact: true for small inline selects (phone type, email type) + */ +export default function LeadCustomSelect({ + label, + value, + onChange, + options, + placeholder, + accent = 'blue', + compact = false, +}) { + const [open, setOpen] = useState(false); + const [pos, setPos] = useState(null); + const triggerRef = useRef(null); + const dropdownRef = useRef(null); + + // Measure trigger position when opening + const computePos = () => { + if (triggerRef.current) { + const r = triggerRef.current.getBoundingClientRect(); + setPos({ + top: r.bottom + window.scrollY + 4, + left: r.left + window.scrollX, + width: r.width, + }); + } + }; + + const handleToggle = () => { + if (!open) computePos(); + setOpen(o => !o); + }; + + // Close on outside click β€” check both trigger and portaled dropdown + useEffect(() => { + if (!open) return; + const handler = (e) => { + const inTrigger = triggerRef.current?.contains(e.target); + const inDropdown = dropdownRef.current?.contains(e.target); + if (!inTrigger && !inDropdown) setOpen(false); + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [open]); + + // Close on page scroll β€” but NOT when the scroll originates inside the dropdown. + // Use composedPath() because e.target can be unreliable across browsers/touch. + useEffect(() => { + if (!open) return; + const close = (e) => { + const path = e.composedPath?.() ?? []; + if (dropdownRef.current && path.includes(dropdownRef.current)) return; + setOpen(false); + }; + window.addEventListener('scroll', close, true); + window.addEventListener('resize', close); + return () => { + window.removeEventListener('scroll', close, true); + window.removeEventListener('resize', close); + }; + }, [open]); + + const focusRing = accent === 'emerald' + ? 'border-emerald-400 dark:border-emerald-500 ring-2 ring-emerald-100 dark:ring-emerald-500/20' + : 'border-blue-400 dark:border-blue-500 ring-2 ring-blue-100 dark:ring-blue-500/20'; + + const selectedAccent = accent === 'emerald' + ? 'text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-500/10' + : 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-500/10'; + + // Trigger size classes + const triggerSize = compact + ? 'px-3 py-[9px] text-xs font-bold uppercase tracking-wide' + : 'px-4 py-3 text-sm font-medium'; + + return ( +
+ {label && {label}} + + {/* Trigger */} + + + {/* Portaled dropdown β€” renders into body, escapes all overflow:hidden */} + {createPortal( + + {open && pos && ( + + {/* Placeholder header */} + {placeholder && ( +
+ {placeholder} +
+ )} + + {/* Options */} +
+ {options.map(opt => { + const isSelected = opt === value; + return ( + + ); + })} +
+
+ )} +
, + document.body + )} +
+ ); +} diff --git a/src/components/leads/LeadJobSection.jsx b/src/components/leads/LeadJobSection.jsx index 0333842..2b3e01c 100644 --- a/src/components/leads/LeadJobSection.jsx +++ b/src/components/leads/LeadJobSection.jsx @@ -1,12 +1,11 @@ import React from 'react'; import { motion, AnimatePresence } from 'framer-motion'; -import { ChevronDown } from 'lucide-react'; -import { useTheme } from '../../context/ThemeContext'; import { LEAD_FORM_OPTIONS } from '../../data/mockStore'; import UrgencyPillSelector from './UrgencyPillSelector'; +import LeadCustomSelect from './LeadCustomSelect'; // --------------------------------------------------------------------------- -// Shared field primitives (local to Job section for now) +// Shared field primitives (local to Job section) // --------------------------------------------------------------------------- function FieldLabel({ children }) { return ( @@ -16,44 +15,7 @@ function FieldLabel({ children }) { ); } -function LeadSelect({ label, value, onChange, options, placeholder }) { - const { theme } = useTheme(); - const isDark = theme === 'dark'; - - return ( -
- {label} -
- - -
-
- ); -} - function LeadTextarea({ label, value, onChange, placeholder, rows = 3 }) { - const { theme } = useTheme(); - const isDark = theme === 'dark'; - return (
{label} @@ -62,13 +24,14 @@ function LeadTextarea({ label, value, onChange, placeholder, rows = 3 }) { onChange={e => onChange(e.target.value)} placeholder={placeholder} rows={rows} - className={`w-full rounded-xl px-4 py-3 text-sm font-medium resize-none + className="w-full rounded-xl px-4 py-3 text-sm font-medium resize-none outline-none transition-all duration-150 - ${isDark - ? 'bg-zinc-800/60 border border-zinc-700 text-white placeholder-zinc-500 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20' - : 'bg-white border border-zinc-200 text-zinc-800 placeholder-zinc-400 focus:border-blue-400 focus:ring-2 focus:ring-blue-100' - } - `} + bg-white dark:bg-zinc-800/60 + border border-zinc-200 dark:border-zinc-700 + text-zinc-800 dark:text-white + placeholder-zinc-400 dark:placeholder-zinc-500 + focus:border-blue-400 dark:focus:border-blue-500 + focus:ring-2 focus:ring-blue-100 dark:focus:ring-blue-500/20" />
); @@ -87,16 +50,15 @@ const collapseTransition = { // --------------------------------------------------------------------------- export default function LeadJobSection({ formData, updateField, isQuickCapture }) { return ( - // No gap/space-y β€” each block controls its own top padding to avoid - // phantom gaps from collapsed motion.divs
{/* --- Lead Source β€” always visible --- */} - updateField('leadSource', v)} options={LEAD_FORM_OPTIONS.leadSources} placeholder="How did you find this lead?" + accent="blue" /> {/* --- Lead Type β€” Full Form only --- */} @@ -111,12 +73,13 @@ export default function LeadJobSection({ formData, updateField, isQuickCapture } className="overflow-hidden" >
- updateField('leadType', v)} options={LEAD_FORM_OPTIONS.leadTypes} placeholder="Residential, Commercial…" + accent="blue" />
@@ -135,19 +98,21 @@ export default function LeadJobSection({ formData, updateField, isQuickCapture } className="overflow-hidden" >
- updateField('workType', v)} options={LEAD_FORM_OPTIONS.workTypes} placeholder="Roof Replacement, Repair…" + accent="blue" /> - updateField('tradeType', v)} options={LEAD_FORM_OPTIONS.tradeTypes} placeholder="Roofing, Gutter, Siding…" + accent="blue" />
diff --git a/src/components/leads/LeadPropertySection.jsx b/src/components/leads/LeadPropertySection.jsx index ece6314..0228e0f 100644 --- a/src/components/leads/LeadPropertySection.jsx +++ b/src/components/leads/LeadPropertySection.jsx @@ -1,8 +1,9 @@ import React, { useRef } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; -import { MapPin, Camera, X, ChevronDown, Image } from 'lucide-react'; -import { useTheme } from '../../context/ThemeContext'; +import { MapPin, Camera, X, Image } from 'lucide-react'; import { LEAD_FORM_OPTIONS } from '../../data/mockStore'; +import LeadCustomSelect from './LeadCustomSelect'; +import LeadCombobox from './LeadCombobox'; // --------------------------------------------------------------------------- // Shared field primitives (local) @@ -16,8 +17,6 @@ function FieldLabel({ children }) { } function LeadInput({ label, value, onChange, placeholder, type = 'text', autoComplete, inputMode }) { - const { theme } = useTheme(); - const isDark = theme === 'dark'; return (
{label && {label}} @@ -28,47 +27,19 @@ function LeadInput({ label, value, onChange, placeholder, type = 'text', autoCom onChange={e => onChange(e.target.value)} placeholder={placeholder} autoComplete={autoComplete} - className={`w-full rounded-xl px-4 py-3 text-sm font-medium + className="w-full rounded-xl px-4 py-3 text-sm font-medium outline-none transition-all duration-150 - ${isDark - ? 'bg-zinc-800/60 border border-zinc-700 text-white placeholder-zinc-500 focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20' - : 'bg-white border border-zinc-200 text-zinc-800 placeholder-zinc-400 focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100' - } - `} + bg-white dark:bg-zinc-800/60 + border border-zinc-200 dark:border-zinc-700 + text-zinc-800 dark:text-white + placeholder-zinc-400 dark:placeholder-zinc-500 + focus:border-emerald-400 dark:focus:border-emerald-500 + focus:ring-2 focus:ring-emerald-100 dark:focus:ring-emerald-500/20" />
); } -function LeadSelect({ label, value, onChange, options, placeholder }) { - const { theme } = useTheme(); - const isDark = theme === 'dark'; - return ( -
- {label && {label}} -
- - -
-
- ); -} // Collapse transition shared config const collapseTransition = { @@ -82,8 +53,6 @@ const collapseTransition = { // Property Section // --------------------------------------------------------------------------- export default function LeadPropertySection({ formData, updateField, isQuickCapture }) { - const { theme } = useTheme(); - const isDark = theme === 'dark'; const fileInputRef = useRef(null); const handlePhotoAdd = (e) => { @@ -106,34 +75,38 @@ export default function LeadPropertySection({ formData, updateField, isQuickCapt return (
{/* ---- Street Address ---- */} -
+
Street Address -
- + {/* Flex wrapper owns the border/bg β€” MapPin is a real flex sibling */} +
+ updateField('address', e.target.value)} placeholder="123 Main St" autoComplete="street-address" - className={`w-full rounded-xl pl-9 pr-4 py-3 text-sm font-medium - outline-none transition-all duration-150 - ${isDark - ? 'bg-zinc-800/60 border border-zinc-700 text-white placeholder-zinc-500 focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20' - : 'bg-white border border-zinc-200 text-zinc-800 placeholder-zinc-400 focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100' - } - `} + className="flex-1 py-3 pl-3 pr-4 text-sm font-medium outline-none bg-transparent + text-zinc-800 dark:text-white + placeholder-zinc-400 dark:placeholder-zinc-500" />
{/* ---- City / State / ZIP ---- */} -
- {/* City β€” flex-1 */} -
+ {/* + Mobile : City full-width on row 1, State + ZIP share row 2 + sm+ : all three in one row (City flex-1, State fixed, ZIP fixed) + */} +
+ {/* City β€” spans both columns on mobile, single column on sm+ */} +
- {/* State β€” fixed narrow */} -
- updateField('state', v)} - options={LEAD_FORM_OPTIONS.usStates} - /> -
+ {/* State β€” combobox with fuzzy search on abbr + full name */} + updateField('state', v)} + options={LEAD_FORM_OPTIONS.usStates} + accent="emerald" + /> - {/* ZIP β€” fixed narrow */} -
- updateField('zip', v.replace(/\D/g, '').slice(0, 5))} - placeholder="75023" - type="text" - inputMode="numeric" - autoComplete="postal-code" - /> -
+ {/* ZIP */} + updateField('zip', v.replace(/\D/g, '').slice(0, 5))} + placeholder="75023" + type="text" + inputMode="numeric" + autoComplete="postal-code" + />
{/* ---- Property Type + Photo Upload β€” Full Form only ---- */} @@ -179,12 +149,13 @@ export default function LeadPropertySection({ formData, updateField, isQuickCapt className="overflow-hidden" >
- updateField('propertyType', v)} options={LEAD_FORM_OPTIONS.leadTypes} placeholder="Residential, Commercial…" + accent="emerald" />
@@ -196,13 +167,11 @@ export default function LeadPropertySection({ formData, updateField, isQuickCapt