feat: add appointment booking form slide-out during calls, wired to platform createAppointment mutation

This commit is contained in:
2026-03-18 11:07:15 +05:30
parent 66ad398b81
commit 9690ac416e
6 changed files with 1064 additions and 9 deletions

View File

@@ -0,0 +1,270 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router';
import { SearchLg, User01, Phone01, Calendar } from '@untitledui/icons';
import { Input } from '@/components/base/input/input';
import { Badge } from '@/components/base/badges/badges';
import { useData } from '@/providers/data-provider';
import { formatPhone } from '@/lib/format';
import { cx } from '@/utils/cx';
type SearchResultType = 'lead' | 'patient' | 'appointment';
type SearchResult = {
id: string;
type: SearchResultType;
title: string;
subtitle: string;
phone?: string;
};
type GlobalSearchProps = {
onSelectResult?: (result: SearchResult) => void;
};
const TYPE_ICONS: Record<SearchResultType, typeof User01> = {
lead: User01,
patient: User01,
appointment: Calendar,
};
const TYPE_BADGE_COLORS: Record<SearchResultType, 'brand' | 'success' | 'blue'> = {
lead: 'brand',
patient: 'success',
appointment: 'blue',
};
const TYPE_LABELS: Record<SearchResultType, string> = {
lead: 'Lead',
patient: 'Patient',
appointment: 'Appointment',
};
export const GlobalSearch = ({ onSelectResult }: GlobalSearchProps) => {
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [isOpen, setIsOpen] = useState(false);
const [isSearching, setIsSearching] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const navigate = useNavigate();
const { leads } = useData();
const searchLeads = useCallback(
(searchQuery: string): SearchResult[] => {
const normalizedQuery = searchQuery.trim().toLowerCase();
if (normalizedQuery.length < 3) return [];
const matched = leads.filter((lead) => {
const firstName = lead.contactName?.firstName?.toLowerCase() ?? '';
const lastName = lead.contactName?.lastName?.toLowerCase() ?? '';
const fullName = `${firstName} ${lastName}`.trim();
const phones = (lead.contactPhone ?? []).map((p) => `${p.callingCode}${p.number}`.toLowerCase());
const matchesName =
firstName.includes(normalizedQuery) ||
lastName.includes(normalizedQuery) ||
fullName.includes(normalizedQuery);
const matchesPhone = phones.some((phone) => phone.includes(normalizedQuery));
return matchesName || matchesPhone;
});
return matched.slice(0, 5).map((lead) => {
const firstName = lead.contactName?.firstName ?? '';
const lastName = lead.contactName?.lastName ?? '';
const phone = lead.contactPhone?.[0] ? formatPhone(lead.contactPhone[0]) : undefined;
const email = lead.contactEmail?.[0]?.address ?? undefined;
return {
id: lead.id,
type: 'lead' as const,
title: `${firstName} ${lastName}`.trim() || 'Unknown Lead',
subtitle: [phone, email, lead.interestedService].filter(Boolean).join(' · '),
phone,
};
});
},
[leads],
);
useEffect(() => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
if (query.trim().length < 3) {
setResults([]);
setIsOpen(false);
setIsSearching(false);
return;
}
setIsSearching(true);
debounceRef.current = setTimeout(() => {
const searchResults = searchLeads(query);
setResults(searchResults);
setIsOpen(true);
setIsSearching(false);
setHighlightedIndex(-1);
}, 300);
return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
};
}, [query, searchLeads]);
// Close on outside click
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleSelect = (result: SearchResult) => {
setIsOpen(false);
setQuery('');
if (onSelectResult) {
onSelectResult(result);
} else {
navigate(`/patient/${result.id}`);
}
};
const handleKeyDown = (event: React.KeyboardEvent) => {
if (!isOpen || results.length === 0) return;
if (event.key === 'ArrowDown') {
event.preventDefault();
setHighlightedIndex((prev) => (prev < results.length - 1 ? prev + 1 : 0));
} else if (event.key === 'ArrowUp') {
event.preventDefault();
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : results.length - 1));
} else if (event.key === 'Enter' && highlightedIndex >= 0) {
event.preventDefault();
handleSelect(results[highlightedIndex]);
} else if (event.key === 'Escape') {
setIsOpen(false);
}
};
// Group results by type
const groupedResults: Record<string, SearchResult[]> = {};
for (const result of results) {
const groupLabel = `${TYPE_LABELS[result.type]}s`;
if (!groupedResults[groupLabel]) {
groupedResults[groupLabel] = [];
}
groupedResults[groupLabel].push(result);
}
let flatIndex = -1;
return (
<div ref={containerRef} className="relative w-64" onKeyDown={handleKeyDown}>
<Input
placeholder="Search leads..."
icon={SearchLg}
aria-label="Global search"
value={query}
onChange={(value) => setQuery(value)}
onFocus={() => {
if (results.length > 0) setIsOpen(true);
}}
shortcut="/"
/>
{isOpen && (
<div className="absolute top-full right-0 left-0 z-50 mt-1 overflow-hidden rounded-xl border border-secondary bg-primary shadow-lg">
{isSearching && (
<div className="flex items-center justify-center px-4 py-6">
<svg
fill="none"
viewBox="0 0 20 20"
className="size-5 animate-spin text-fg-brand-primary"
>
<circle
className="stroke-current opacity-30"
cx="10"
cy="10"
r="8"
fill="none"
strokeWidth="2"
/>
<circle
className="origin-center stroke-current"
cx="10"
cy="10"
r="8"
fill="none"
strokeWidth="2"
strokeDasharray="12.5 50"
strokeLinecap="round"
/>
</svg>
</div>
)}
{!isSearching && results.length === 0 && (
<div className="flex flex-col items-center gap-1 px-4 py-6 text-center">
<SearchLg className="size-5 text-fg-quaternary" />
<p className="text-sm font-medium text-secondary">No results</p>
<p className="text-xs text-tertiary">Try a different search term</p>
</div>
)}
{!isSearching &&
results.length > 0 &&
Object.entries(groupedResults).map(([groupLabel, groupResults]) => (
<div key={groupLabel}>
<div className="px-4 py-2 text-xs font-bold uppercase text-quaternary">
{groupLabel}
</div>
{groupResults.map((result) => {
flatIndex++;
const currentIndex = flatIndex;
const Icon = TYPE_ICONS[result.type];
return (
<button
key={result.id}
type="button"
className={cx(
'flex w-full cursor-pointer items-center gap-3 px-4 py-2.5 text-left transition duration-100 ease-linear',
currentIndex === highlightedIndex
? 'bg-active'
: 'hover:bg-primary_hover',
)}
onClick={() => handleSelect(result)}
onMouseEnter={() => setHighlightedIndex(currentIndex)}
>
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-secondary">
<Icon className="size-4 text-fg-quaternary" />
</div>
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-semibold text-primary">
{result.title}
</span>
<span className="truncate text-xs text-tertiary">
{result.subtitle}
</span>
</div>
<Badge size="sm" type="pill-color" color={TYPE_BADGE_COLORS[result.type]}>
{TYPE_LABELS[result.type]}
</Badge>
</button>
);
})}
</div>
))}
</div>
)}
</div>
);
};