diff --git a/README.md b/README.md index cc1a495..cffb21b 100644 --- a/README.md +++ b/README.md @@ -8,25 +8,24 @@ --- -## πŸ›οΈ PRODUCT OVERVIEW +## PRODUCT OVERVIEW ### Value Proposition -- **Precision Targeting**: Identify "Golden Leads" (older, high-value homes) instantly on a map. +- **Precision Targeting**: Identify high-value homes instantly on a map. - **AI-Powered Dispatch**: LynkDispatch AI scores and recommends rep assignments in real-time. -- **Gamified Field Performance**: ProCanvas turns door-knocking into a game β€” XP, streaks, leaderboards, daily missions. +- **Gamified Field Performance**: ProCanvas turns door-knocking into a performance game β€” XP, streaks, leaderboards, daily missions. - **Intelligent Lead Capture**: Mobile-optimized lead creation with Quick Capture for the door, Full Form for the office. --- -## 🎨 KEY FEATURES +## KEY FEATURES ### ProCanvas β€” Gamified Agent Dashboard - FIFA-style player card with XP bar, streak tracker, and rank badge - Log Action hub (Door Knocked, Lead Gained, Appointment, Client Meet) - Daily Missions with Energy Bars, Weekly Challenge with progress tracking - Live event cards: Showdown, Hot Streak, Weekly Objective -- Rewards & Checkpoints milestone track -- Badges & Achievements trophy room +- Rewards & Checkpoints milestone track, Badges & Achievements trophy room - **Dual design identity**: Light mode (energetic/clean β€” solid colored header banners on white cards) and Dark mode (neon/glass arcade aesthetic) β€” distinct, never washed-out ### Estimate Builder @@ -36,18 +35,21 @@ - Financial Summary sidebar with adjustable margin slider - Full light/dark theme support +### Lead Creation Page _(Phases 1–4 complete, Phases 5–8 remaining)_ +- Quick Capture (essential fields only) for agents at the door; Full Form for the office +- 5 color-coded accordion sections: Contact (blue), Property (emerald), Job Details (amber), Insurance (purple), Assignment (cyan) +- **Custom portaled dropdowns** (`LeadCustomSelect`) β€” dark zinc-900 panel, fully scrollable, escapes all `overflow:hidden` containers via `ReactDOM.createPortal`; scroll detection uses `e.composedPath()` for reliability across browsers and touch +- **Fuzzy-search state combobox** (`LeadCombobox`) β€” type "tex" to find Texas, searches both abbreviation and full state name simultaneously +- **Flex-wrapper icon pattern** for Phone/MapPin icons β€” eliminates z-index conflicts, icons are true flex siblings of the input +- Animated progress bar (GSAP), section completion check marks, spring height animations (Framer Motion) +- Mobile-first: sticky Save footer, responsive City/State/ZIP grid layout, ample bottom padding for footer clearance + ### LynkDispatch AI _(investor demo β€” in development)_ - 3-panel command center: Lead Queue + Map + AI Recommendation Drawer - Rep scoring with proximity, drive time, calendar fit, weather, and skill factors - Storm Mode β€” full UI shift to amber, storm path on map, weather-aware recommendations - KPI bar with animated metrics (leads in queue, assigned today, response time, storm alerts) -### Lead Creation Page _(in development)_ -- Quick Capture (7 fields) for agents at the door, Full Form for office -- 5 color-coded sections: Contact (blue), Property (emerald), Job Details (amber), Insurance (purple), Assignment (cyan) -- Role-aware: agents get simplified assignment, admins/owners get Smart Assign with rep scoring -- Mobile-first, works beautifully on tablet and desktop too - ### Maps & Territory - Interactive Leaflet map with color-coded property polygons - Reverse geocoding to create properties from map clicks @@ -65,7 +67,7 @@ --- -## πŸ‘₯ USER ROLES & DEMO ACCOUNTS +## USER ROLES & DEMO ACCOUNTS **Password for ALL accounts: `password`** @@ -81,19 +83,18 @@ --- -## πŸ’» DEVELOPER GUIDE +## DEVELOPER GUIDE ### Tech Stack - **Frontend**: React 19 (Vite), Tailwind CSS (`darkMode: 'class'`) -- **State**: MockStore (simulates backend), AuthContext (RBAC), ThemeContext (light/dark), GamificationContext (XP/streaks) +- **State**: MockStore (simulates backend), AuthContext (RBAC), ThemeContext (light/dark), GamificationContext - **Routing**: React Router v7 with ProtectedRoute (role-based) -- **Animations**: Framer Motion (page transitions, stagger entrances, height animations) -- **Maps**: react-leaflet (Leaflet.js) -- **Charts**: Recharts (bar, area, pie, scatter) +- **Animations**: Framer Motion (page transitions, stagger, height animations, layoutId), GSAP (progress bars, counters) +- **Maps**: react-leaflet +- **Charts**: Recharts - **Icons**: lucide-react - **AI**: Groq SDK (qwen-32b) with dynamic role-aware system prompts - **Notifications**: sonner (toasts) -- **Smooth Scroll**: Lenis ### Setup 1. **Clone**: `git clone ` @@ -102,30 +103,51 @@ 4. **Run**: `pnpm run dev` ### Design System -- Dark mode: Glass panels (`bg-zinc-900/70 backdrop-blur-xl`), neon accents, Barlow Condensed uppercase -- Light mode: White cards (`bg-white shadow-md`), solid colored header banners, `bg-zinc-100/80` page background +- **Dark mode**: Glass panels (`bg-zinc-900/70 backdrop-blur-xl`), neon accents, Barlow Condensed uppercase +- **Light mode**: White cards (`bg-white shadow-md`), solid colored header banners, `bg-zinc-100/80` page background - Theme controlled via `useTheme()` from `src/context/ThemeContext.jsx` -- All components use `dark:` Tailwind variants β€” no JS theme switching per-element needed +- **Critical rule**: always use `dark:` Tailwind variants on inputs and text β€” never `isDark ? 'classA' : 'classB'` β€” JS conditionals caused invisible text bugs in dark mode that dark: variants solve + +### Custom Dropdown Architecture (LeadCustomSelect / LeadCombobox) +All select fields in the Lead Creation form use custom components rather than native ` 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