Skip to content

Commit d18f510

Browse files
committed
update search bar in competitors
1 parent 4619a7b commit d18f510

File tree

2 files changed

+225
-80
lines changed

2 files changed

+225
-80
lines changed

web/src/components/CompetitorView.tsx

Lines changed: 139 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { parseDateOrdinal, TimelineBar } from './MarkmapView';
44
import { findDateIndex } from '../hooks/useTimelineCutoff';
55
import type { TimelineRange } from '../hooks/useTimelineCutoff';
66
import { queryCompetitorsAI } from '../api';
7-
import { ReactSearchAutocomplete } from 'react-search-autocomplete';
87

98
interface CompetitorViewProps {
109
data: LandscapeData;
@@ -218,36 +217,26 @@ function TableView({ competitors, onHover, onLeave }: {
218217
const HISTORY_KEY = 'ai-competitor-search-history';
219218
const MAX_HISTORY = 10;
220219

221-
interface SearchItem {
220+
interface HistoryItem {
222221
id: number;
223222
name: string;
224-
type: 'history' | 'suggestion';
225223
}
226224

227-
const PRESET_SUGGESTIONS: SearchItem[] = [
228-
{ id: 1001, name: 'Which companies use AI for compliance?', type: 'suggestion' },
229-
{ id: 1002, name: 'High threat competitors targeting CNAs', type: 'suggestion' },
230-
{ id: 1003, name: 'Compare pricing models across competitors', type: 'suggestion' },
231-
{ id: 1004, name: 'Companies serving both CNA and RN markets', type: 'suggestion' },
232-
{ id: 1005, name: 'Well-funded competitors with AI capabilities', type: 'suggestion' },
233-
{ id: 1006, name: 'What are the key differentiators of top threats?', type: 'suggestion' },
234-
];
235-
236-
function loadHistory(): SearchItem[] {
225+
function loadHistory(): HistoryItem[] {
237226
try {
238227
const raw = localStorage.getItem(HISTORY_KEY);
239228
if (!raw) return [];
240-
return JSON.parse(raw) as SearchItem[];
229+
return JSON.parse(raw) as HistoryItem[];
241230
} catch { return []; }
242231
}
243232

244-
function saveHistory(items: SearchItem[]) {
233+
function saveHistory(items: HistoryItem[]) {
245234
localStorage.setItem(HISTORY_KEY, JSON.stringify(items.slice(0, MAX_HISTORY)));
246235
}
247236

248237
function addToHistory(query: string) {
249238
const history = loadHistory().filter(h => h.name !== query);
250-
const newItem: SearchItem = { id: Date.now(), name: query, type: 'history' };
239+
const newItem: HistoryItem = { id: Date.now(), name: query };
251240
saveHistory([newItem, ...history]);
252241
}
253242

@@ -364,28 +353,30 @@ function AIResultModal({ result, competitors, onClose }: {
364353

365354
/* ── Main component ─────────────────────────────────────── */
366355

356+
/* ── Quick filter types ─────────────────────────────────── */
357+
358+
type ThreatFilter = 'high' | 'medium' | 'low';
359+
type BoolFilter = 'uses_ai' | 'serves_cna' | 'serves_rn';
360+
367361
export function CompetitorView({ data, timelineRange, onTimelineRangeChange }: CompetitorViewProps) {
368362
const [view, setView] = useState<'map' | 'table'>('map');
369363
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
370364
const hideTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
371365

372-
// AI query state
373-
const [aiQuery, setAiQuery] = useState('');
366+
// Quick filter state
367+
const [textFilter, setTextFilter] = useState('');
368+
const [threatFilters, setThreatFilters] = useState<Set<ThreatFilter>>(new Set());
369+
const [boolFilters, setBoolFilters] = useState<Set<BoolFilter>>(new Set());
370+
const [sectionFilter, setSectionFilter] = useState<string>('');
371+
372+
// AI query state (uses textFilter as the shared input)
374373
const [aiLoading, setAiLoading] = useState(false);
375374
const [aiResult, setAiResult] = useState<AIQueryResult | null>(null);
376375
const [aiError, setAiError] = useState<string | null>(null);
377376

378-
// Autocomplete items: history + suggestions
379-
const [searchItems, setSearchItems] = useState<SearchItem[]>([]);
380-
381-
useEffect(() => {
382-
setSearchItems([...loadHistory(), ...PRESET_SUGGESTIONS]);
383-
}, []);
384-
385377
const fireQuery = useCallback(async (query: string) => {
386378
if (!query.trim() || aiLoading) return;
387379
addToHistory(query);
388-
setSearchItems([...loadHistory(), ...PRESET_SUGGESTIONS]);
389380
setAiLoading(true);
390381
setAiError(null);
391382
try {
@@ -443,7 +434,66 @@ export function CompetitorView({ data, timelineRange, onTimelineRangeChange }: C
443434
});
444435
}, [data.competitors, allDates, startIndex, endIndex, getDate]);
445436

446-
const sections = useMemo(() => groupBySection(filtered), [filtered]);
437+
// Apply quick filters on top of date-filtered results
438+
const quickFiltered = useMemo(() => {
439+
let result = filtered;
440+
// Text filter (name, category, primary_focus)
441+
if (textFilter.trim()) {
442+
const q = textFilter.toLowerCase();
443+
result = result.filter(c =>
444+
c.name.toLowerCase().includes(q) ||
445+
(c.category || '').toLowerCase().includes(q) ||
446+
(c.primary_focus || '').toLowerCase().includes(q) ||
447+
(c.subcategory || '').toLowerCase().includes(q)
448+
);
449+
}
450+
// Threat level filter
451+
if (threatFilters.size > 0) {
452+
result = result.filter(c => threatFilters.has(c.threat));
453+
}
454+
// Boolean attribute filters (AND — must match all selected)
455+
if (boolFilters.has('uses_ai')) result = result.filter(c => c.uses_ai);
456+
if (boolFilters.has('serves_cna')) result = result.filter(c => c.serves_cna);
457+
if (boolFilters.has('serves_rn')) result = result.filter(c => c.serves_rn);
458+
// Section filter
459+
if (sectionFilter) {
460+
result = result.filter(c => c.section === sectionFilter);
461+
}
462+
return result;
463+
}, [filtered, textFilter, threatFilters, boolFilters, sectionFilter]);
464+
465+
// Unique sections for dropdown
466+
const sectionOptions = useMemo(() => {
467+
const set = new Set(filtered.map(c => c.section));
468+
return Array.from(set).sort();
469+
}, [filtered]);
470+
471+
const hasActiveFilters = textFilter.trim() || threatFilters.size > 0 || boolFilters.size > 0 || sectionFilter;
472+
473+
const toggleThreat = (t: ThreatFilter) => {
474+
setThreatFilters(prev => {
475+
const next = new Set(prev);
476+
if (next.has(t)) next.delete(t); else next.add(t);
477+
return next;
478+
});
479+
};
480+
481+
const toggleBool = (b: BoolFilter) => {
482+
setBoolFilters(prev => {
483+
const next = new Set(prev);
484+
if (next.has(b)) next.delete(b); else next.add(b);
485+
return next;
486+
});
487+
};
488+
489+
const clearFilters = () => {
490+
setTextFilter('');
491+
setThreatFilters(new Set());
492+
setBoolFilters(new Set());
493+
setSectionFilter('');
494+
};
495+
496+
const sections = useMemo(() => groupBySection(quickFiltered), [quickFiltered]);
447497

448498
const showTooltip = useCallback((row: CompetitorRow, e: React.MouseEvent) => {
449499
clearTimeout(hideTimer.current);
@@ -457,15 +507,6 @@ export function CompetitorView({ data, timelineRange, onTimelineRangeChange }: C
457507

458508
const { meta } = data;
459509

460-
const formatResult = (item: SearchItem) => (
461-
<div className="ai-search-result-item">
462-
<span className={`ai-search-icon ${item.type}`}>
463-
{item.type === 'history' ? '\u23F3' : '\u2728'}
464-
</span>
465-
<span>{item.name}</span>
466-
</div>
467-
);
468-
469510
return (
470511
<div className="competitor-view">
471512
<div className="competitor-scroll">
@@ -474,52 +515,70 @@ export function CompetitorView({ data, timelineRange, onTimelineRangeChange }: C
474515
<p>{meta.subtitle}{meta.last_update ? ` · Updated ${meta.last_update}` : ''}</p>
475516
</div>
476517

477-
<div className="ai-query-bar">
478-
{aiLoading && (
479-
<div className="ai-query-loading-overlay">
480-
<div className="ai-query-spinner" />
481-
<span>Analyzing competitors...</span>
482-
</div>
483-
)}
484-
<ReactSearchAutocomplete<SearchItem>
485-
items={searchItems}
486-
onSearch={(string) => setAiQuery(string)}
487-
onSelect={(item) => fireQuery(item.name)}
488-
onClear={() => setAiQuery('')}
489-
inputSearchString={aiQuery}
490-
placeholder="Ask AI about competitors..."
491-
formatResult={formatResult}
492-
showItemsOnFocus
493-
maxResults={8}
494-
styling={{
495-
height: '44px',
496-
border: '1px solid var(--border)',
497-
borderRadius: '8px',
498-
backgroundColor: 'var(--surface)',
499-
color: 'var(--text)',
500-
fontSize: '13px',
501-
fontFamily: 'var(--font-body)',
502-
iconColor: 'var(--purple)',
503-
placeholderColor: 'var(--text3)',
504-
hoverBackgroundColor: 'var(--purple-light)',
505-
boxShadow: 'none',
506-
clearIconMargin: '3px 8px 0 0',
507-
zIndex: 10,
508-
}}
509-
fuseOptions={{ keys: ['name'], threshold: 0.4 }}
510-
/>
511-
<button
512-
className="ai-query-btn"
513-
disabled={aiLoading || !aiQuery.trim()}
514-
onClick={() => fireQuery(aiQuery)}
515-
>
516-
Ask AI
517-
</button>
518+
<div className="competitor-quick-filters">
519+
<div className="ai-query-bar">
520+
{aiLoading && (
521+
<div className="ai-query-loading-overlay">
522+
<div className="ai-query-spinner" />
523+
<span>Analyzing competitors...</span>
524+
</div>
525+
)}
526+
<input
527+
type="text"
528+
className="competitor-text-filter"
529+
placeholder="Filter by name or ask AI..."
530+
value={textFilter}
531+
onChange={(e) => setTextFilter(e.target.value)}
532+
onKeyDown={(e) => { if (e.key === 'Enter') fireQuery(textFilter); }}
533+
/>
534+
<button
535+
className="ai-query-btn"
536+
disabled={aiLoading || !textFilter.trim()}
537+
onClick={() => fireQuery(textFilter)}
538+
>
539+
Ask AI
540+
</button>
541+
</div>
542+
{aiError && <div className="ai-query-error">{aiError}</div>}
543+
<div className="competitor-filter-chips">
544+
<span className="competitor-filter-label">Threat:</span>
545+
{(['high', 'medium', 'low'] as ThreatFilter[]).map(t => (
546+
<button
547+
key={t}
548+
className={`competitor-filter-chip threat-${t}${threatFilters.has(t) ? ' active' : ''}`}
549+
onClick={() => toggleThreat(t)}
550+
>
551+
{t}
552+
</button>
553+
))}
554+
<span className="competitor-filter-sep" />
555+
{([['uses_ai', 'AI'], ['serves_cna', 'CNA'], ['serves_rn', 'RN']] as [BoolFilter, string][]).map(([key, label]) => (
556+
<button
557+
key={key}
558+
className={`competitor-filter-chip bool${boolFilters.has(key) ? ' active' : ''}`}
559+
onClick={() => toggleBool(key)}
560+
>
561+
{label}
562+
</button>
563+
))}
564+
<select
565+
className="competitor-section-select"
566+
value={sectionFilter}
567+
onChange={(e) => setSectionFilter(e.target.value)}
568+
>
569+
<option value="">All sections</option>
570+
{sectionOptions.map(s => (
571+
<option key={s} value={s}>{s}</option>
572+
))}
573+
</select>
574+
{hasActiveFilters && (
575+
<button className="competitor-filter-clear" onClick={clearFilters}>Clear</button>
576+
)}
577+
</div>
518578
</div>
519-
{aiError && <div className="ai-query-error">{aiError}</div>}
520579

521580
<div className="landscape-toolbar">
522-
<span className="landscape-count">{filtered.length} companies{allDates.length > 1 ? ` (of ${data.competitors.length})` : ''}</span>
581+
<span className="landscape-count">{quickFiltered.length} companies{quickFiltered.length !== data.competitors.length ? ` (of ${data.competitors.length})` : ''}</span>
523582
<div className="view-toggle">
524583
<button className={view === 'map' ? 'active' : ''} onClick={() => setView('map')}>Map</button>
525584
<button className={view === 'table' ? 'active' : ''} onClick={() => setView('table')}>Table</button>
@@ -529,7 +588,7 @@ export function CompetitorView({ data, timelineRange, onTimelineRangeChange }: C
529588
{view === 'map' ? (
530589
<MapView sections={sections} onHover={showTooltip} onLeave={hideTooltip} />
531590
) : (
532-
<TableView competitors={filtered} onHover={showTooltip} onLeave={hideTooltip} />
591+
<TableView competitors={quickFiltered} onHover={showTooltip} onLeave={hideTooltip} />
533592
)}
534593

535594
</div>

web/src/theme.css

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1434,6 +1434,92 @@ body::before {
14341434
border-radius: 6px;
14351435
}
14361436

1437+
/* ── Competitor Quick Filters ──────────────── */
1438+
.competitor-quick-filters {
1439+
display: flex;
1440+
flex-direction: column;
1441+
gap: 8px;
1442+
margin-bottom: 12px;
1443+
}
1444+
.competitor-text-filter {
1445+
width: 100%;
1446+
padding: 8px 12px;
1447+
border: 1px solid var(--border);
1448+
border-radius: 8px;
1449+
background: var(--surface);
1450+
color: var(--text);
1451+
font-size: 13px;
1452+
font-family: var(--font-body);
1453+
outline: none;
1454+
}
1455+
.competitor-text-filter:focus {
1456+
border-color: var(--purple);
1457+
}
1458+
.competitor-filter-chips {
1459+
display: flex;
1460+
flex-wrap: wrap;
1461+
align-items: center;
1462+
gap: 6px;
1463+
}
1464+
.competitor-filter-label {
1465+
font-size: 11px;
1466+
font-weight: 600;
1467+
color: var(--text3);
1468+
text-transform: uppercase;
1469+
letter-spacing: 0.5px;
1470+
}
1471+
.competitor-filter-sep {
1472+
width: 1px;
1473+
height: 16px;
1474+
background: var(--border);
1475+
margin: 0 2px;
1476+
}
1477+
.competitor-filter-chip {
1478+
font-size: 11px;
1479+
font-weight: 600;
1480+
padding: 3px 10px;
1481+
border-radius: 12px;
1482+
border: 1px solid var(--border);
1483+
background: var(--surface);
1484+
color: var(--text2);
1485+
cursor: pointer;
1486+
transition: all 0.15s;
1487+
}
1488+
.competitor-filter-chip:hover {
1489+
background: var(--surface-hover);
1490+
}
1491+
.competitor-filter-chip.active {
1492+
color: #fff;
1493+
border-color: transparent;
1494+
}
1495+
.competitor-filter-chip.threat-high.active { background: var(--red); }
1496+
.competitor-filter-chip.threat-medium.active { background: var(--orange); }
1497+
.competitor-filter-chip.threat-low.active { background: var(--text3); }
1498+
.competitor-filter-chip.bool.active { background: var(--purple); }
1499+
.competitor-section-select {
1500+
font-size: 11px;
1501+
padding: 3px 8px;
1502+
border-radius: 8px;
1503+
border: 1px solid var(--border);
1504+
background: var(--surface);
1505+
color: var(--text2);
1506+
cursor: pointer;
1507+
max-width: 200px;
1508+
}
1509+
.competitor-filter-clear {
1510+
font-size: 11px;
1511+
padding: 3px 10px;
1512+
border-radius: 12px;
1513+
border: 1px solid var(--border);
1514+
background: var(--surface);
1515+
color: var(--red);
1516+
cursor: pointer;
1517+
font-weight: 600;
1518+
}
1519+
.competitor-filter-clear:hover {
1520+
background: var(--red-light);
1521+
}
1522+
14371523
/* Search autocomplete dropdown styling */
14381524
.ai-search-result-item {
14391525
display: flex; align-items: center; gap: 8px;

0 commit comments

Comments
 (0)