fix(leads): portaled dropdowns, flex-wrapper icons, responsive layout, and UI polish
- Replace all native <select> with LeadCustomSelect (ReactDOM.createPortal into body, getBoundingClientRect positioning, e.composedPath() scroll detection) - Add LeadCombobox: fuzzy-search state picker (searches abbr + full name, portal, onMouseDown) - Convert Phone/MapPin icons from absolute-positioned to flex-wrapper siblings of bg-transparent input - Fix dark mode invisible text: all inputs converted from isDark JS conditionals to dark: variants - Fix urgency Standard pill visibility: container bg-zinc-900, selected bg-zinc-700 ring-1 shadow-lg - Fix City/State/ZIP mobile overlap: flex -> grid-cols-2 (mobile) / grid-cols-[1fr_5.5rem_6rem] (sm+) - Fix urgency section hidden behind mobile sticky footer: pb-28 -> pb-44 md:pb-10 - Replace all compact type pickers (phone/email) with LeadCustomSelect compact mode - Update README.md with full lead creation architecture docs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,25 +8,24 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🏛️ PRODUCT OVERVIEW
|
## PRODUCT OVERVIEW
|
||||||
|
|
||||||
### Value Proposition
|
### 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.
|
- **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.
|
- **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
|
### ProCanvas — Gamified Agent Dashboard
|
||||||
- FIFA-style player card with XP bar, streak tracker, and rank badge
|
- FIFA-style player card with XP bar, streak tracker, and rank badge
|
||||||
- Log Action hub (Door Knocked, Lead Gained, Appointment, Client Meet)
|
- Log Action hub (Door Knocked, Lead Gained, Appointment, Client Meet)
|
||||||
- Daily Missions with Energy Bars, Weekly Challenge with progress tracking
|
- Daily Missions with Energy Bars, Weekly Challenge with progress tracking
|
||||||
- Live event cards: Showdown, Hot Streak, Weekly Objective
|
- Live event cards: Showdown, Hot Streak, Weekly Objective
|
||||||
- Rewards & Checkpoints milestone track
|
- Rewards & Checkpoints milestone track, Badges & Achievements trophy room
|
||||||
- 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
|
- **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
|
### Estimate Builder
|
||||||
@@ -36,18 +35,21 @@
|
|||||||
- Financial Summary sidebar with adjustable margin slider
|
- Financial Summary sidebar with adjustable margin slider
|
||||||
- Full light/dark theme support
|
- 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)_
|
### LynkDispatch AI _(investor demo — in development)_
|
||||||
- 3-panel command center: Lead Queue + Map + AI Recommendation Drawer
|
- 3-panel command center: Lead Queue + Map + AI Recommendation Drawer
|
||||||
- Rep scoring with proximity, drive time, calendar fit, weather, and skill factors
|
- 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
|
- 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)
|
- 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
|
### Maps & Territory
|
||||||
- Interactive Leaflet map with color-coded property polygons
|
- Interactive Leaflet map with color-coded property polygons
|
||||||
- Reverse geocoding to create properties from map clicks
|
- 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`**
|
**Password for ALL accounts: `password`**
|
||||||
|
|
||||||
@@ -81,19 +83,18 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 💻 DEVELOPER GUIDE
|
## DEVELOPER GUIDE
|
||||||
|
|
||||||
### Tech Stack
|
### Tech Stack
|
||||||
- **Frontend**: React 19 (Vite), Tailwind CSS (`darkMode: 'class'`)
|
- **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)
|
- **Routing**: React Router v7 with ProtectedRoute (role-based)
|
||||||
- **Animations**: Framer Motion (page transitions, stagger entrances, height animations)
|
- **Animations**: Framer Motion (page transitions, stagger, height animations, layoutId), GSAP (progress bars, counters)
|
||||||
- **Maps**: react-leaflet (Leaflet.js)
|
- **Maps**: react-leaflet
|
||||||
- **Charts**: Recharts (bar, area, pie, scatter)
|
- **Charts**: Recharts
|
||||||
- **Icons**: lucide-react
|
- **Icons**: lucide-react
|
||||||
- **AI**: Groq SDK (qwen-32b) with dynamic role-aware system prompts
|
- **AI**: Groq SDK (qwen-32b) with dynamic role-aware system prompts
|
||||||
- **Notifications**: sonner (toasts)
|
- **Notifications**: sonner (toasts)
|
||||||
- **Smooth Scroll**: Lenis
|
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
1. **Clone**: `git clone <repo>`
|
1. **Clone**: `git clone <repo>`
|
||||||
@@ -102,30 +103,51 @@
|
|||||||
4. **Run**: `pnpm run dev`
|
4. **Run**: `pnpm run dev`
|
||||||
|
|
||||||
### Design System
|
### Design System
|
||||||
- Dark mode: Glass panels (`bg-zinc-900/70 backdrop-blur-xl`), neon accents, Barlow Condensed uppercase
|
- **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
|
- **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`
|
- 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 `<select>`:
|
||||||
|
- 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/
|
src/
|
||||||
├── pages/ # Route-level pages by role
|
├── pages/
|
||||||
|
│ ├── CreateLeadPage.jsx # Lead creation orchestrator (form state, sections, progress)
|
||||||
|
│ └── ... # Other role pages
|
||||||
├── components/
|
├── components/
|
||||||
│ ├── estimates/ # EstimateBuilder modals (MaterialDetails, Measurements, etc.)
|
│ ├── leads/
|
||||||
│ ├── leads/ # Lead creation form components (in development)
|
│ │ ├── LeadSectionWrapper.jsx # Collapsible accordion with spring animation
|
||||||
│ ├── dispatch/ # LynkDispatch AI panels (in development)
|
│ │ ├── LeadContactSection.jsx # Name, dynamic phone rows, dynamic email rows
|
||||||
│ ├── dashboard/ # Admin dashboard widgets
|
│ │ ├── LeadPropertySection.jsx # Address, city/state/zip, property type, site photos
|
||||||
│ ├── ProCanvas/ # Gamification subcomponents
|
│ │ ├── LeadJobSection.jsx # Lead source, type, work/trade type, urgency, notes
|
||||||
│ └── ... # Shared components (Layout, AnimatedCounter, etc.)
|
│ │ ├── LeadCustomSelect.jsx # Portaled custom dropdown (accent + compact modes)
|
||||||
├── context/ # AuthContext, ThemeContext, GamificationContext
|
│ │ ├── LeadCombobox.jsx # Fuzzy-search combobox for state field
|
||||||
├── data/ # mockStore.jsx — central data layer
|
│ │ ├── UrgencyPillSelector.jsx # Sliding pill with Framer Motion layoutId
|
||||||
└── hooks/ # useGamification, custom hooks
|
│ │ ├── 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**
|
||||||
|
|||||||
@@ -1,62 +1,43 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Mail, Star, X } from 'lucide-react';
|
import { Mail, Star, X } from 'lucide-react';
|
||||||
import { useTheme } from '../../context/ThemeContext';
|
|
||||||
import { LEAD_FORM_OPTIONS } from '../../data/mockStore';
|
import { LEAD_FORM_OPTIONS } from '../../data/mockStore';
|
||||||
|
import LeadCustomSelect from './LeadCustomSelect';
|
||||||
|
|
||||||
export default function EmailEntryRow({ email, isOnly, onUpdate, onSetPrimary, onRemove }) {
|
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 (
|
return (
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2">
|
<div className="flex flex-col sm:flex-row sm:items-center gap-2">
|
||||||
{/* Email input */}
|
{/* Email input — flex wrapper owns border/bg */}
|
||||||
<div className="relative flex-1">
|
<div className="flex items-center flex-1 rounded-xl
|
||||||
<Mail
|
bg-white dark:bg-zinc-800/60
|
||||||
size={14}
|
border border-zinc-200 dark:border-zinc-700
|
||||||
className="absolute left-3.5 top-1/2 -translate-y-1/2 text-zinc-400 pointer-events-none"
|
focus-within:border-blue-400 dark:focus-within:border-blue-500
|
||||||
/>
|
focus-within:ring-2 focus-within:ring-blue-100 dark:focus-within:ring-blue-500/20
|
||||||
|
transition-all duration-150"
|
||||||
|
>
|
||||||
|
<Mail size={14} className="ml-4 shrink-0 text-zinc-400 pointer-events-none" />
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={email.address}
|
value={email.address}
|
||||||
onChange={e => onUpdate('address', e.target.value)}
|
onChange={e => onUpdate('address', e.target.value)}
|
||||||
placeholder="name@example.com"
|
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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Controls */}
|
{/* Controls */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* Type selector */}
|
<div className="w-24">
|
||||||
<div className="relative">
|
<LeadCustomSelect
|
||||||
<select
|
|
||||||
value={email.type}
|
value={email.type}
|
||||||
onChange={e => onUpdate('type', e.target.value)}
|
onChange={v => onUpdate('type', v)}
|
||||||
className={`appearance-none rounded-xl px-3 py-3 pr-8 text-xs font-bold uppercase tracking-wide cursor-pointer outline-none transition-all duration-150
|
options={LEAD_FORM_OPTIONS.emailTypes}
|
||||||
${isDark
|
accent="blue"
|
||||||
? 'bg-zinc-800/60 border border-zinc-700 text-zinc-300 focus:border-blue-500'
|
compact
|
||||||
: 'bg-white border border-zinc-200 text-zinc-600 focus:border-blue-400'
|
/>
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{LEAD_FORM_OPTIONS.emailTypes.map(t => (
|
|
||||||
<option key={t} value={t}>{t}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<svg
|
|
||||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 pointer-events-none text-zinc-400"
|
|
||||||
width="10" height="10" viewBox="0 0 10 10"
|
|
||||||
>
|
|
||||||
<path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinecap="round" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Primary star */}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onSetPrimary}
|
onClick={onSetPrimary}
|
||||||
@@ -70,7 +51,6 @@ export default function EmailEntryRow({ email, isOnly, onUpdate, onSetPrimary, o
|
|||||||
<Star size={15} fill={email.isPrimary ? 'currentColor' : 'none'} strokeWidth={1.5} />
|
<Star size={15} fill={email.isPrimary ? 'currentColor' : 'none'} strokeWidth={1.5} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Remove */}
|
|
||||||
{!isOnly && (
|
{!isOnly && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import React, { useState, useRef, useEffect, useMemo } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { Check, ChevronDown } from 'lucide-react';
|
||||||
|
|
||||||
|
// Full name map so users can search "Texas" and land on TX
|
||||||
|
const US_STATE_NAMES = {
|
||||||
|
AL:'Alabama', AK:'Alaska', AZ:'Arizona', AR:'Arkansas', CA:'California',
|
||||||
|
CO:'Colorado', CT:'Connecticut', DE:'Delaware', FL:'Florida', GA:'Georgia',
|
||||||
|
HI:'Hawaii', ID:'Idaho', IL:'Illinois', IN:'Indiana', IA:'Iowa',
|
||||||
|
KS:'Kansas', KY:'Kentucky', LA:'Louisiana', ME:'Maine', MD:'Maryland',
|
||||||
|
MA:'Massachusetts', MI:'Michigan', MN:'Minnesota', MS:'Mississippi', MO:'Missouri',
|
||||||
|
MT:'Montana', NE:'Nebraska', NV:'Nevada', NH:'New Hampshire', NJ:'New Jersey',
|
||||||
|
NM:'New Mexico', NY:'New York', NC:'North Carolina', ND:'North Dakota', OH:'Ohio',
|
||||||
|
OK:'Oklahoma', OR:'Oregon', PA:'Pennsylvania', RI:'Rhode Island', SC:'South Carolina',
|
||||||
|
SD:'South Dakota', TN:'Tennessee', TX:'Texas', UT:'Utah', VT:'Vermont',
|
||||||
|
VA:'Virginia', WA:'Washington', WV:'West Virginia', WI:'Wisconsin', WY:'Wyoming',
|
||||||
|
};
|
||||||
|
|
||||||
|
function FieldLabel({ children }) {
|
||||||
|
return (
|
||||||
|
<label className="block text-[11px] uppercase tracking-widest font-bold text-zinc-400 dark:text-zinc-500 mb-1.5">
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combobox for state selection.
|
||||||
|
* - Input shows the abbreviation when idle, allows free-text search when focused
|
||||||
|
* - Dropdown filters on abbreviation OR full state name (fuzzy substring)
|
||||||
|
* - Portal rendering so it escapes overflow:hidden wrappers
|
||||||
|
* - accent: 'blue' | 'emerald'
|
||||||
|
*/
|
||||||
|
export default function LeadCombobox({ label, value, onChange, options, accent = 'emerald' }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [query, setQuery] = useState(''); // what user is typing
|
||||||
|
const [pos, setPos] = useState(null);
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
const dropdownRef = useRef(null);
|
||||||
|
|
||||||
|
// Filter options: match abbr or full name, case-insensitive
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (!query.trim()) return options;
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
return options.filter(abbr => {
|
||||||
|
const fullName = (US_STATE_NAMES[abbr] || '').toLowerCase();
|
||||||
|
return abbr.toLowerCase().startsWith(q) || fullName.includes(q);
|
||||||
|
});
|
||||||
|
}, [query, options]);
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
|
const computePos = () => {
|
||||||
|
if (inputRef.current) {
|
||||||
|
const r = inputRef.current.getBoundingClientRect();
|
||||||
|
setPos({
|
||||||
|
top: r.bottom + window.scrollY + 4,
|
||||||
|
left: r.left + window.scrollX,
|
||||||
|
width: Math.max(r.width, 220), // at least 220px so full names fit
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFocus = () => {
|
||||||
|
computePos();
|
||||||
|
setQuery(''); // clear so user can type fresh
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (e) => {
|
||||||
|
setQuery(e.target.value);
|
||||||
|
if (!open) {
|
||||||
|
computePos();
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelect = (abbr) => {
|
||||||
|
onChange(abbr);
|
||||||
|
setQuery('');
|
||||||
|
setOpen(false);
|
||||||
|
inputRef.current?.blur();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlur = (e) => {
|
||||||
|
// Give dropdown clicks a chance to fire before closing
|
||||||
|
if (dropdownRef.current?.contains(e.relatedTarget)) return;
|
||||||
|
setOpen(false);
|
||||||
|
setQuery('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (e.key === 'Escape') { setOpen(false); setQuery(''); inputRef.current?.blur(); }
|
||||||
|
if (e.key === 'Enter' && filtered.length > 0) { handleSelect(filtered[0]); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Close on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const handler = (e) => {
|
||||||
|
const inInput = inputRef.current?.contains(e.target);
|
||||||
|
const inDropdown = dropdownRef.current?.contains(e.target);
|
||||||
|
if (!inInput && !inDropdown) { setOpen(false); setQuery(''); }
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handler);
|
||||||
|
return () => document.removeEventListener('mousedown', handler);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Close on page scroll — but NOT when the scroll originates inside the dropdown.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const close = (e) => {
|
||||||
|
const path = e.composedPath?.() ?? [];
|
||||||
|
if (dropdownRef.current && path.includes(dropdownRef.current)) return;
|
||||||
|
setOpen(false);
|
||||||
|
setQuery('');
|
||||||
|
};
|
||||||
|
window.addEventListener('scroll', close, true);
|
||||||
|
window.addEventListener('resize', close);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', close, true);
|
||||||
|
window.removeEventListener('resize', close);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Display: show query while typing, otherwise show selected abbreviation
|
||||||
|
const displayValue = open ? query : (value || '');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{label && <FieldLabel>{label}</FieldLabel>}
|
||||||
|
|
||||||
|
{/* Input trigger */}
|
||||||
|
<div className={`flex items-center rounded-xl border transition-all duration-150
|
||||||
|
bg-white dark:bg-zinc-800/60
|
||||||
|
${open ? focusRing : 'border-zinc-200 dark:border-zinc-700'}
|
||||||
|
`}>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={displayValue}
|
||||||
|
placeholder={value || 'TX'}
|
||||||
|
onChange={handleChange}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
className="flex-1 min-w-0 py-3 pl-4 pr-1 text-sm font-medium outline-none bg-transparent
|
||||||
|
text-zinc-800 dark:text-white
|
||||||
|
placeholder-zinc-400 dark:placeholder-zinc-500"
|
||||||
|
/>
|
||||||
|
<motion.span
|
||||||
|
animate={{ rotate: open ? 180 : 0 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 300, damping: 28 }}
|
||||||
|
className="pr-2.5 shrink-0 pointer-events-none"
|
||||||
|
>
|
||||||
|
<ChevronDown size={13} className="text-zinc-400" />
|
||||||
|
</motion.span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Portaled dropdown */}
|
||||||
|
{createPortal(
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && pos && (
|
||||||
|
<motion.div
|
||||||
|
ref={dropdownRef}
|
||||||
|
key="state-dropdown"
|
||||||
|
initial={{ opacity: 0, y: -6, scale: 0.98 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: -6, scale: 0.98 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: pos.top,
|
||||||
|
left: pos.left,
|
||||||
|
width: pos.width,
|
||||||
|
zIndex: 9999,
|
||||||
|
}}
|
||||||
|
className="rounded-xl overflow-hidden
|
||||||
|
bg-white dark:bg-zinc-900
|
||||||
|
border border-zinc-200 dark:border-zinc-700
|
||||||
|
shadow-xl shadow-black/10 dark:shadow-black/50"
|
||||||
|
>
|
||||||
|
<div className="max-h-52 overflow-y-auto">
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<p className="px-4 py-3 text-xs text-zinc-400 dark:text-zinc-500">
|
||||||
|
No states match "{query}"
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
filtered.map(abbr => {
|
||||||
|
const isSelected = abbr === value;
|
||||||
|
const fullName = US_STATE_NAMES[abbr] || abbr;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={abbr}
|
||||||
|
type="button"
|
||||||
|
// mousedown fires before blur so selection registers
|
||||||
|
onMouseDown={(e) => { e.preventDefault(); handleSelect(abbr); }}
|
||||||
|
className={`w-full flex items-center justify-between px-4 py-2.5 text-sm text-left transition-colors duration-100
|
||||||
|
${isSelected
|
||||||
|
? selectedAccent
|
||||||
|
: 'text-zinc-700 dark:text-zinc-200 hover:bg-zinc-50 dark:hover:bg-zinc-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<span className="font-bold">{abbr}</span>
|
||||||
|
<span className="ml-2 text-zinc-400 dark:text-zinc-500">{fullName}</span>
|
||||||
|
</span>
|
||||||
|
{isSelected && <Check size={12} strokeWidth={2.5} className="shrink-0 ml-2 opacity-70" />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { motion, AnimatePresence, LayoutGroup } from 'framer-motion';
|
import { motion, AnimatePresence, LayoutGroup } from 'framer-motion';
|
||||||
import { PlusCircle } from 'lucide-react';
|
import { PlusCircle } from 'lucide-react';
|
||||||
import { useTheme } from '../../context/ThemeContext';
|
|
||||||
import PhoneEntryRow from './PhoneEntryRow';
|
import PhoneEntryRow from './PhoneEntryRow';
|
||||||
import EmailEntryRow from './EmailEntryRow';
|
import EmailEntryRow from './EmailEntryRow';
|
||||||
|
|
||||||
@@ -17,9 +16,6 @@ function FieldLabel({ children }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function LeadInput({ label, value, onChange, placeholder, type = 'text', autoComplete }) {
|
function LeadInput({ label, value, onChange, placeholder, type = 'text', autoComplete }) {
|
||||||
const { theme } = useTheme();
|
|
||||||
const isDark = theme === 'dark';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{label && <FieldLabel>{label}</FieldLabel>}
|
{label && <FieldLabel>{label}</FieldLabel>}
|
||||||
@@ -29,13 +25,13 @@ function LeadInput({ label, value, onChange, placeholder, type = 'text', autoCom
|
|||||||
onChange={e => onChange(e.target.value)}
|
onChange={e => onChange(e.target.value)}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
autoComplete={autoComplete}
|
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
|
||||||
outline-none transition-all duration-150
|
bg-white dark:bg-zinc-800/60
|
||||||
${isDark
|
border border-zinc-200 dark:border-zinc-700
|
||||||
? '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'
|
text-zinc-800 dark:text-white
|
||||||
: 'bg-white border border-zinc-200 text-zinc-800 placeholder-zinc-400 focus:border-blue-400 focus:ring-2 focus:ring-blue-100'
|
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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<label className="block text-[11px] uppercase tracking-widest font-bold text-zinc-400 dark:text-zinc-500 mb-1.5">
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<div>
|
||||||
|
{label && <FieldLabel>{label}</FieldLabel>}
|
||||||
|
|
||||||
|
{/* Trigger */}
|
||||||
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
|
type="button"
|
||||||
|
onClick={handleToggle}
|
||||||
|
className={`w-full flex items-center justify-between rounded-xl cursor-pointer
|
||||||
|
outline-none transition-all duration-150
|
||||||
|
bg-white dark:bg-zinc-800/60
|
||||||
|
border
|
||||||
|
${open ? focusRing : 'border-zinc-200 dark:border-zinc-700'}
|
||||||
|
${value
|
||||||
|
? 'text-zinc-800 dark:text-white'
|
||||||
|
: compact
|
||||||
|
? 'text-zinc-500 dark:text-zinc-400'
|
||||||
|
: 'text-zinc-400 dark:text-zinc-500'
|
||||||
|
}
|
||||||
|
${triggerSize}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<span className="truncate">{value || placeholder || 'Select…'}</span>
|
||||||
|
<motion.span
|
||||||
|
animate={{ rotate: open ? 180 : 0 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 300, damping: 28 }}
|
||||||
|
className="ml-1.5 shrink-0"
|
||||||
|
>
|
||||||
|
<ChevronDown size={compact ? 12 : 14} className="text-zinc-400" />
|
||||||
|
</motion.span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Portaled dropdown — renders into body, escapes all overflow:hidden */}
|
||||||
|
{createPortal(
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && pos && (
|
||||||
|
<motion.div
|
||||||
|
ref={dropdownRef}
|
||||||
|
key="dropdown"
|
||||||
|
initial={{ opacity: 0, y: -6, scale: 0.98 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: -6, scale: 0.98 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: pos.top,
|
||||||
|
left: pos.left,
|
||||||
|
width: pos.width,
|
||||||
|
zIndex: 9999,
|
||||||
|
}}
|
||||||
|
className="rounded-xl overflow-hidden
|
||||||
|
bg-white dark:bg-zinc-900
|
||||||
|
border border-zinc-200 dark:border-zinc-700
|
||||||
|
shadow-xl shadow-black/10 dark:shadow-black/50"
|
||||||
|
>
|
||||||
|
{/* Placeholder header */}
|
||||||
|
{placeholder && (
|
||||||
|
<div className="px-4 py-2 text-[11px] uppercase tracking-widest font-bold
|
||||||
|
text-zinc-400 dark:text-zinc-500
|
||||||
|
border-b border-zinc-100 dark:border-zinc-800">
|
||||||
|
{placeholder}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Options */}
|
||||||
|
<div className="max-h-52 overflow-y-auto">
|
||||||
|
{options.map(opt => {
|
||||||
|
const isSelected = opt === value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt}
|
||||||
|
type="button"
|
||||||
|
onClick={() => { onChange(opt); setOpen(false); }}
|
||||||
|
className={`w-full flex items-center justify-between text-left transition-colors duration-100
|
||||||
|
${compact ? 'px-3 py-2 text-xs font-semibold uppercase tracking-wide' : 'px-4 py-2.5 text-sm'}
|
||||||
|
${isSelected
|
||||||
|
? selectedAccent
|
||||||
|
: 'text-zinc-700 dark:text-zinc-200 hover:bg-zinc-50 dark:hover:bg-zinc-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>{opt}</span>
|
||||||
|
{isSelected && (
|
||||||
|
<Check size={12} strokeWidth={2.5} className="shrink-0 ml-2 opacity-70" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { ChevronDown } from 'lucide-react';
|
|
||||||
import { useTheme } from '../../context/ThemeContext';
|
|
||||||
import { LEAD_FORM_OPTIONS } from '../../data/mockStore';
|
import { LEAD_FORM_OPTIONS } from '../../data/mockStore';
|
||||||
import UrgencyPillSelector from './UrgencyPillSelector';
|
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 }) {
|
function FieldLabel({ children }) {
|
||||||
return (
|
return (
|
||||||
@@ -16,44 +15,7 @@ function FieldLabel({ children }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function LeadSelect({ label, value, onChange, options, placeholder }) {
|
|
||||||
const { theme } = useTheme();
|
|
||||||
const isDark = theme === 'dark';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<FieldLabel>{label}</FieldLabel>
|
|
||||||
<div className="relative">
|
|
||||||
<select
|
|
||||||
value={value}
|
|
||||||
onChange={e => onChange(e.target.value)}
|
|
||||||
className={`w-full appearance-none rounded-xl px-4 py-3 pr-10 text-sm font-medium cursor-pointer
|
|
||||||
outline-none transition-all duration-150
|
|
||||||
${isDark
|
|
||||||
? 'bg-zinc-800/60 border border-zinc-700 text-white focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20'
|
|
||||||
: 'bg-white border border-zinc-200 text-zinc-800 focus:border-blue-400 focus:ring-2 focus:ring-blue-100'
|
|
||||||
}
|
|
||||||
${!value ? (isDark ? 'text-zinc-500' : 'text-zinc-400') : ''}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<option value="" disabled>{placeholder || 'Select…'}</option>
|
|
||||||
{options.map(opt => (
|
|
||||||
<option key={opt} value={opt}>{opt}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<ChevronDown
|
|
||||||
size={14}
|
|
||||||
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-zinc-400 pointer-events-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function LeadTextarea({ label, value, onChange, placeholder, rows = 3 }) {
|
function LeadTextarea({ label, value, onChange, placeholder, rows = 3 }) {
|
||||||
const { theme } = useTheme();
|
|
||||||
const isDark = theme === 'dark';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<FieldLabel>{label}</FieldLabel>
|
<FieldLabel>{label}</FieldLabel>
|
||||||
@@ -62,13 +24,14 @@ function LeadTextarea({ label, value, onChange, placeholder, rows = 3 }) {
|
|||||||
onChange={e => onChange(e.target.value)}
|
onChange={e => onChange(e.target.value)}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
rows={rows}
|
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
|
outline-none transition-all duration-150
|
||||||
${isDark
|
bg-white dark:bg-zinc-800/60
|
||||||
? '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'
|
border border-zinc-200 dark:border-zinc-700
|
||||||
: 'bg-white border border-zinc-200 text-zinc-800 placeholder-zinc-400 focus:border-blue-400 focus:ring-2 focus:ring-blue-100'
|
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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -87,16 +50,15 @@ const collapseTransition = {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
export default function LeadJobSection({ formData, updateField, isQuickCapture }) {
|
export default function LeadJobSection({ formData, updateField, isQuickCapture }) {
|
||||||
return (
|
return (
|
||||||
// No gap/space-y — each block controls its own top padding to avoid
|
|
||||||
// phantom gaps from collapsed motion.divs
|
|
||||||
<div>
|
<div>
|
||||||
{/* --- Lead Source — always visible --- */}
|
{/* --- Lead Source — always visible --- */}
|
||||||
<LeadSelect
|
<LeadCustomSelect
|
||||||
label="Lead Source"
|
label="Lead Source"
|
||||||
value={formData.leadSource}
|
value={formData.leadSource}
|
||||||
onChange={v => updateField('leadSource', v)}
|
onChange={v => updateField('leadSource', v)}
|
||||||
options={LEAD_FORM_OPTIONS.leadSources}
|
options={LEAD_FORM_OPTIONS.leadSources}
|
||||||
placeholder="How did you find this lead?"
|
placeholder="How did you find this lead?"
|
||||||
|
accent="blue"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* --- Lead Type — Full Form only --- */}
|
{/* --- Lead Type — Full Form only --- */}
|
||||||
@@ -111,12 +73,13 @@ export default function LeadJobSection({ formData, updateField, isQuickCapture }
|
|||||||
className="overflow-hidden"
|
className="overflow-hidden"
|
||||||
>
|
>
|
||||||
<div className="pt-4">
|
<div className="pt-4">
|
||||||
<LeadSelect
|
<LeadCustomSelect
|
||||||
label="Lead Type"
|
label="Lead Type"
|
||||||
value={formData.leadType}
|
value={formData.leadType}
|
||||||
onChange={v => updateField('leadType', v)}
|
onChange={v => updateField('leadType', v)}
|
||||||
options={LEAD_FORM_OPTIONS.leadTypes}
|
options={LEAD_FORM_OPTIONS.leadTypes}
|
||||||
placeholder="Residential, Commercial…"
|
placeholder="Residential, Commercial…"
|
||||||
|
accent="blue"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -135,19 +98,21 @@ export default function LeadJobSection({ formData, updateField, isQuickCapture }
|
|||||||
className="overflow-hidden"
|
className="overflow-hidden"
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 pt-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 pt-4">
|
||||||
<LeadSelect
|
<LeadCustomSelect
|
||||||
label="Work Type"
|
label="Work Type"
|
||||||
value={formData.workType}
|
value={formData.workType}
|
||||||
onChange={v => updateField('workType', v)}
|
onChange={v => updateField('workType', v)}
|
||||||
options={LEAD_FORM_OPTIONS.workTypes}
|
options={LEAD_FORM_OPTIONS.workTypes}
|
||||||
placeholder="Roof Replacement, Repair…"
|
placeholder="Roof Replacement, Repair…"
|
||||||
|
accent="blue"
|
||||||
/>
|
/>
|
||||||
<LeadSelect
|
<LeadCustomSelect
|
||||||
label="Trade Type"
|
label="Trade Type"
|
||||||
value={formData.tradeType}
|
value={formData.tradeType}
|
||||||
onChange={v => updateField('tradeType', v)}
|
onChange={v => updateField('tradeType', v)}
|
||||||
options={LEAD_FORM_OPTIONS.tradeTypes}
|
options={LEAD_FORM_OPTIONS.tradeTypes}
|
||||||
placeholder="Roofing, Gutter, Siding…"
|
placeholder="Roofing, Gutter, Siding…"
|
||||||
|
accent="blue"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import React, { useRef } from 'react';
|
import React, { useRef } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { MapPin, Camera, X, ChevronDown, Image } from 'lucide-react';
|
import { MapPin, Camera, X, Image } from 'lucide-react';
|
||||||
import { useTheme } from '../../context/ThemeContext';
|
|
||||||
import { LEAD_FORM_OPTIONS } from '../../data/mockStore';
|
import { LEAD_FORM_OPTIONS } from '../../data/mockStore';
|
||||||
|
import LeadCustomSelect from './LeadCustomSelect';
|
||||||
|
import LeadCombobox from './LeadCombobox';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Shared field primitives (local)
|
// Shared field primitives (local)
|
||||||
@@ -16,8 +17,6 @@ function FieldLabel({ children }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function LeadInput({ label, value, onChange, placeholder, type = 'text', autoComplete, inputMode }) {
|
function LeadInput({ label, value, onChange, placeholder, type = 'text', autoComplete, inputMode }) {
|
||||||
const { theme } = useTheme();
|
|
||||||
const isDark = theme === 'dark';
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{label && <FieldLabel>{label}</FieldLabel>}
|
{label && <FieldLabel>{label}</FieldLabel>}
|
||||||
@@ -28,47 +27,19 @@ function LeadInput({ label, value, onChange, placeholder, type = 'text', autoCom
|
|||||||
onChange={e => onChange(e.target.value)}
|
onChange={e => onChange(e.target.value)}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
autoComplete={autoComplete}
|
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
|
outline-none transition-all duration-150
|
||||||
${isDark
|
bg-white dark:bg-zinc-800/60
|
||||||
? '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'
|
border border-zinc-200 dark:border-zinc-700
|
||||||
: 'bg-white border border-zinc-200 text-zinc-800 placeholder-zinc-400 focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100'
|
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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function LeadSelect({ label, value, onChange, options, placeholder }) {
|
|
||||||
const { theme } = useTheme();
|
|
||||||
const isDark = theme === 'dark';
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{label && <FieldLabel>{label}</FieldLabel>}
|
|
||||||
<div className="relative">
|
|
||||||
<select
|
|
||||||
value={value}
|
|
||||||
onChange={e => onChange(e.target.value)}
|
|
||||||
className={`w-full appearance-none rounded-xl px-4 py-3 pr-9 text-sm font-medium cursor-pointer
|
|
||||||
outline-none transition-all duration-150
|
|
||||||
${isDark
|
|
||||||
? 'bg-zinc-800/60 border border-zinc-700 text-white focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20'
|
|
||||||
: 'bg-white border border-zinc-200 text-zinc-800 focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100'
|
|
||||||
}
|
|
||||||
${!value ? (isDark ? 'text-zinc-500' : 'text-zinc-400') : ''}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{placeholder && <option value="" disabled>{placeholder}</option>}
|
|
||||||
{options.map(opt => (
|
|
||||||
<option key={opt} value={opt}>{opt}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<ChevronDown size={14} className="absolute right-3.5 top-1/2 -translate-y-1/2 text-zinc-400 pointer-events-none" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collapse transition shared config
|
// Collapse transition shared config
|
||||||
const collapseTransition = {
|
const collapseTransition = {
|
||||||
@@ -82,8 +53,6 @@ const collapseTransition = {
|
|||||||
// Property Section
|
// Property Section
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
export default function LeadPropertySection({ formData, updateField, isQuickCapture }) {
|
export default function LeadPropertySection({ formData, updateField, isQuickCapture }) {
|
||||||
const { theme } = useTheme();
|
|
||||||
const isDark = theme === 'dark';
|
|
||||||
const fileInputRef = useRef(null);
|
const fileInputRef = useRef(null);
|
||||||
|
|
||||||
const handlePhotoAdd = (e) => {
|
const handlePhotoAdd = (e) => {
|
||||||
@@ -106,34 +75,38 @@ export default function LeadPropertySection({ formData, updateField, isQuickCapt
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* ---- Street Address ---- */}
|
{/* ---- Street Address ---- */}
|
||||||
<div className="relative">
|
<div>
|
||||||
<FieldLabel>Street Address</FieldLabel>
|
<FieldLabel>Street Address</FieldLabel>
|
||||||
<div className="relative">
|
{/* Flex wrapper owns the border/bg — MapPin is a real flex sibling */}
|
||||||
<MapPin
|
<div className="flex items-center rounded-xl
|
||||||
size={14}
|
bg-white dark:bg-zinc-800/60
|
||||||
className="absolute left-3.5 top-1/2 -translate-y-1/2 text-zinc-400 pointer-events-none"
|
border border-zinc-200 dark:border-zinc-700
|
||||||
/>
|
focus-within:border-emerald-400 dark:focus-within:border-emerald-500
|
||||||
|
focus-within:ring-2 focus-within:ring-emerald-100 dark:focus-within:ring-emerald-500/20
|
||||||
|
transition-all duration-150"
|
||||||
|
>
|
||||||
|
<MapPin size={14} className="ml-4 shrink-0 text-zinc-400 pointer-events-none" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.address}
|
value={formData.address}
|
||||||
onChange={e => updateField('address', e.target.value)}
|
onChange={e => updateField('address', e.target.value)}
|
||||||
placeholder="123 Main St"
|
placeholder="123 Main St"
|
||||||
autoComplete="street-address"
|
autoComplete="street-address"
|
||||||
className={`w-full rounded-xl pl-9 pr-4 py-3 text-sm font-medium
|
className="flex-1 py-3 pl-3 pr-4 text-sm font-medium outline-none bg-transparent
|
||||||
outline-none transition-all duration-150
|
text-zinc-800 dark:text-white
|
||||||
${isDark
|
placeholder-zinc-400 dark:placeholder-zinc-500"
|
||||||
? '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'
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ---- City / State / ZIP ---- */}
|
{/* ---- City / State / ZIP ---- */}
|
||||||
<div className="flex gap-3 pt-4">
|
{/*
|
||||||
{/* City — flex-1 */}
|
Mobile : City full-width on row 1, State + ZIP share row 2
|
||||||
<div className="flex-1 min-w-0">
|
sm+ : all three in one row (City flex-1, State fixed, ZIP fixed)
|
||||||
|
*/}
|
||||||
|
<div className="pt-4 grid grid-cols-2 sm:grid-cols-[1fr_5.5rem_6rem] gap-3">
|
||||||
|
{/* City — spans both columns on mobile, single column on sm+ */}
|
||||||
|
<div className="col-span-2 sm:col-span-1">
|
||||||
<LeadInput
|
<LeadInput
|
||||||
label="City"
|
label="City"
|
||||||
value={formData.city}
|
value={formData.city}
|
||||||
@@ -143,28 +116,25 @@ export default function LeadPropertySection({ formData, updateField, isQuickCapt
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* State — fixed narrow */}
|
{/* State — combobox with fuzzy search on abbr + full name */}
|
||||||
<div className="w-[5.5rem] shrink-0">
|
<LeadCombobox
|
||||||
<LeadSelect
|
label="State"
|
||||||
label="State"
|
value={formData.state}
|
||||||
value={formData.state}
|
onChange={v => updateField('state', v)}
|
||||||
onChange={v => updateField('state', v)}
|
options={LEAD_FORM_OPTIONS.usStates}
|
||||||
options={LEAD_FORM_OPTIONS.usStates}
|
accent="emerald"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ZIP — fixed narrow */}
|
{/* ZIP */}
|
||||||
<div className="w-24 shrink-0">
|
<LeadInput
|
||||||
<LeadInput
|
label="ZIP"
|
||||||
label="ZIP"
|
value={formData.zip}
|
||||||
value={formData.zip}
|
onChange={v => updateField('zip', v.replace(/\D/g, '').slice(0, 5))}
|
||||||
onChange={v => updateField('zip', v.replace(/\D/g, '').slice(0, 5))}
|
placeholder="75023"
|
||||||
placeholder="75023"
|
type="text"
|
||||||
type="text"
|
inputMode="numeric"
|
||||||
inputMode="numeric"
|
autoComplete="postal-code"
|
||||||
autoComplete="postal-code"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ---- Property Type + Photo Upload — Full Form only ---- */}
|
{/* ---- Property Type + Photo Upload — Full Form only ---- */}
|
||||||
@@ -179,12 +149,13 @@ export default function LeadPropertySection({ formData, updateField, isQuickCapt
|
|||||||
className="overflow-hidden"
|
className="overflow-hidden"
|
||||||
>
|
>
|
||||||
<div className="pt-4">
|
<div className="pt-4">
|
||||||
<LeadSelect
|
<LeadCustomSelect
|
||||||
label="Property Type"
|
label="Property Type"
|
||||||
value={formData.propertyType}
|
value={formData.propertyType}
|
||||||
onChange={v => updateField('propertyType', v)}
|
onChange={v => updateField('propertyType', v)}
|
||||||
options={LEAD_FORM_OPTIONS.leadTypes}
|
options={LEAD_FORM_OPTIONS.leadTypes}
|
||||||
placeholder="Residential, Commercial…"
|
placeholder="Residential, Commercial…"
|
||||||
|
accent="emerald"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -196,13 +167,11 @@ export default function LeadPropertySection({ formData, updateField, isQuickCapt
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
className={`w-full rounded-xl border-2 border-dashed px-4 py-5 flex flex-col items-center gap-2
|
className="w-full rounded-xl border-2 border-dashed px-4 py-5 flex flex-col items-center gap-2
|
||||||
transition-all duration-150 cursor-pointer group
|
transition-all duration-150 cursor-pointer group
|
||||||
${isDark
|
border-zinc-200 dark:border-zinc-700
|
||||||
? 'border-zinc-700 hover:border-emerald-600/50 hover:bg-emerald-500/5'
|
hover:border-emerald-300 dark:hover:border-emerald-600/50
|
||||||
: 'border-zinc-200 hover:border-emerald-300 hover:bg-emerald-50/50'
|
hover:bg-emerald-50/50 dark:hover:bg-emerald-500/5"
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 text-zinc-400 group-hover:text-emerald-500 transition-colors duration-150">
|
<div className="flex items-center gap-3 text-zinc-400 group-hover:text-emerald-500 transition-colors duration-150">
|
||||||
<Camera size={22} />
|
<Camera size={22} />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Phone, Star, X } from 'lucide-react';
|
import { Phone, Star, X } from 'lucide-react';
|
||||||
import { useTheme } from '../../context/ThemeContext';
|
|
||||||
import { LEAD_FORM_OPTIONS } from '../../data/mockStore';
|
import { LEAD_FORM_OPTIONS } from '../../data/mockStore';
|
||||||
|
import LeadCustomSelect from './LeadCustomSelect';
|
||||||
|
|
||||||
const formatPhone = (raw) => {
|
const formatPhone = (raw) => {
|
||||||
const digits = raw.replace(/\D/g, '').slice(0, 10);
|
const digits = raw.replace(/\D/g, '').slice(0, 10);
|
||||||
@@ -11,29 +11,25 @@ const formatPhone = (raw) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function PhoneEntryRow({ phone, isOnly, showFullControls, onUpdate, onSetPrimary, onRemove }) {
|
export default function PhoneEntryRow({ phone, isOnly, showFullControls, 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 (
|
return (
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2">
|
<div className="flex flex-col sm:flex-row sm:items-center gap-2">
|
||||||
{/* Phone input */}
|
{/* Phone input — flex wrapper owns the border/bg so icon is a real sibling */}
|
||||||
<div className="relative flex-1">
|
<div className="flex items-center flex-1 rounded-xl
|
||||||
<Phone
|
bg-white dark:bg-zinc-800/60
|
||||||
size={14}
|
border border-zinc-200 dark:border-zinc-700
|
||||||
className="absolute left-3.5 top-1/2 -translate-y-1/2 text-zinc-400 pointer-events-none"
|
focus-within:border-blue-400 dark:focus-within:border-blue-500
|
||||||
/>
|
focus-within:ring-2 focus-within:ring-blue-100 dark:focus-within:ring-blue-500/20
|
||||||
|
transition-all duration-150"
|
||||||
|
>
|
||||||
|
<Phone size={14} className="ml-4 shrink-0 text-zinc-400 pointer-events-none" />
|
||||||
<input
|
<input
|
||||||
type="tel"
|
type="tel"
|
||||||
value={phone.number}
|
value={phone.number}
|
||||||
onChange={e => onUpdate('number', formatPhone(e.target.value))}
|
onChange={e => onUpdate('number', formatPhone(e.target.value))}
|
||||||
placeholder="(555) 000-0000"
|
placeholder="(555) 000-0000"
|
||||||
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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -41,27 +37,14 @@ export default function PhoneEntryRow({ phone, isOnly, showFullControls, onUpdat
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* Type selector — shown in Full Form, hidden in Quick Capture */}
|
{/* Type selector — shown in Full Form, hidden in Quick Capture */}
|
||||||
{showFullControls && (
|
{showFullControls && (
|
||||||
<div className="relative">
|
<div className="w-24">
|
||||||
<select
|
<LeadCustomSelect
|
||||||
value={phone.type}
|
value={phone.type}
|
||||||
onChange={e => onUpdate('type', e.target.value)}
|
onChange={v => onUpdate('type', v)}
|
||||||
className={`appearance-none rounded-xl px-3 py-3 pr-8 text-xs font-bold uppercase tracking-wide cursor-pointer outline-none transition-all duration-150
|
options={LEAD_FORM_OPTIONS.phoneTypes}
|
||||||
${isDark
|
accent="blue"
|
||||||
? 'bg-zinc-800/60 border border-zinc-700 text-zinc-300 focus:border-blue-500'
|
compact
|
||||||
: 'bg-white border border-zinc-200 text-zinc-600 focus:border-blue-400'
|
/>
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{LEAD_FORM_OPTIONS.phoneTypes.map(t => (
|
|
||||||
<option key={t} value={t}>{t}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<svg
|
|
||||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 pointer-events-none text-zinc-400"
|
|
||||||
width="10" height="10" viewBox="0 0 10 10"
|
|
||||||
>
|
|
||||||
<path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinecap="round" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import { motion } from 'framer-motion';
|
|||||||
import { useTheme } from '../../context/ThemeContext';
|
import { useTheme } from '../../context/ThemeContext';
|
||||||
|
|
||||||
const OPTIONS = [
|
const OPTIONS = [
|
||||||
{ value: 'standard', label: 'Standard', color: '#6B7280' },
|
{ value: 'standard', label: 'Standard', darkColor: '#d4d4d8', lightColor: '#52525b' },
|
||||||
{ value: 'high', label: 'High', color: '#F59E0B' },
|
{ value: 'high', label: 'High', darkColor: '#F59E0B', lightColor: '#d97706' },
|
||||||
{ value: 'emergency', label: 'Emergency', color: '#EF4444' },
|
{ value: 'emergency', label: 'Emergency', darkColor: '#EF4444', lightColor: '#dc2626' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function UrgencyPillSelector({ value, onChange }) {
|
export default function UrgencyPillSelector({ value, onChange }) {
|
||||||
@@ -13,7 +13,8 @@ export default function UrgencyPillSelector({ value, onChange }) {
|
|||||||
const isDark = theme === 'dark';
|
const isDark = theme === 'dark';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex items-center p-1 rounded-xl ${isDark ? 'bg-zinc-800' : 'bg-zinc-100/80'}`}>
|
// Container is 2 steps darker than the selected pill for clear contrast
|
||||||
|
<div className={`flex items-center p-1 rounded-xl ${isDark ? 'bg-zinc-900' : 'bg-zinc-100/80'}`}>
|
||||||
{OPTIONS.map(opt => {
|
{OPTIONS.map(opt => {
|
||||||
const isSelected = value === opt.value;
|
const isSelected = value === opt.value;
|
||||||
return (
|
return (
|
||||||
@@ -23,21 +24,24 @@ export default function UrgencyPillSelector({ value, onChange }) {
|
|||||||
onClick={() => onChange(opt.value)}
|
onClick={() => onChange(opt.value)}
|
||||||
className="relative flex-1 flex items-center justify-center py-2.5 rounded-lg text-xs font-bold uppercase tracking-wider transition-colors duration-150"
|
className="relative flex-1 flex items-center justify-center py-2.5 rounded-lg text-xs font-bold uppercase tracking-wider transition-colors duration-150"
|
||||||
>
|
>
|
||||||
{/* Sliding background — Framer Motion layoutId handles the move */}
|
{/* Sliding pill background */}
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<motion.div
|
<motion.div
|
||||||
layoutId="urgency-pill-bg"
|
layoutId="urgency-pill-bg"
|
||||||
className={`absolute inset-0.5 rounded-[7px] shadow-sm ${isDark ? 'bg-zinc-700' : 'bg-white'}`}
|
className={`absolute inset-0.5 rounded-[7px] ${
|
||||||
|
isDark
|
||||||
|
? 'bg-zinc-700 shadow-lg ring-1 ring-white/10'
|
||||||
|
: 'bg-white shadow-sm'
|
||||||
|
}`}
|
||||||
transition={{ type: 'spring', stiffness: 420, damping: 36 }}
|
transition={{ type: 'spring', stiffness: 420, damping: 36 }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span
|
<span
|
||||||
className="relative z-10"
|
className="relative z-10 transition-colors duration-150"
|
||||||
style={{
|
style={{
|
||||||
color: isSelected
|
color: isSelected
|
||||||
? opt.color
|
? (isDark ? opt.darkColor : opt.lightColor)
|
||||||
: isDark ? '#71717a' : '#a1a1aa',
|
: isDark ? '#3f3f46' : '#a1a1aa',
|
||||||
transition: 'color 0.15s',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
|
|||||||
@@ -245,7 +245,7 @@ export default function CreateLeadPage() {
|
|||||||
{/* ----------------------------------------------------------------
|
{/* ----------------------------------------------------------------
|
||||||
Page content
|
Page content
|
||||||
---------------------------------------------------------------- */}
|
---------------------------------------------------------------- */}
|
||||||
<div className="max-w-3xl mx-auto px-4 sm:px-6 py-6 lg:py-10 pb-28 md:pb-10">
|
<div className="max-w-3xl mx-auto px-4 sm:px-6 py-6 lg:py-10 pb-44 md:pb-10">
|
||||||
|
|
||||||
{/* --- Page Header --- */}
|
{/* --- Page Header --- */}
|
||||||
<div className="mb-7">
|
<div className="mb-7">
|
||||||
|
|||||||
Reference in New Issue
Block a user