mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-23 07:11:13 -05:00
feat: Implement Combobox component and Autocomplete for Country
This commit is contained in:
@@ -8,6 +8,8 @@ import { Slider } from '@/components/ui/slider';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||||
import { ChevronDown, Filter, X } from 'lucide-react';
|
import { ChevronDown, Filter, X } from 'lucide-react';
|
||||||
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
|
import { useCountries, useStatesProvinces, useManufacturers, useCompanyHeadquarters } from '@/hooks/useAutocompleteData';
|
||||||
|
|
||||||
export interface SearchFilters {
|
export interface SearchFilters {
|
||||||
// Park filters
|
// Park filters
|
||||||
@@ -47,7 +49,13 @@ interface SearchFiltersProps {
|
|||||||
|
|
||||||
export function SearchFiltersComponent({ filters, onFiltersChange, activeTab }: SearchFiltersProps) {
|
export function SearchFiltersComponent({ filters, onFiltersChange, activeTab }: SearchFiltersProps) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
// Fetch autocomplete data
|
||||||
|
const { countries, loading: countriesLoading } = useCountries();
|
||||||
|
const { statesProvinces, loading: statesLoading } = useStatesProvinces(filters.country);
|
||||||
|
const { manufacturers, loading: manufacturersLoading } = useManufacturers();
|
||||||
|
const { headquarters, loading: headquartersLoading } = useCompanyHeadquarters();
|
||||||
|
|
||||||
const updateFilter = (key: keyof SearchFilters, value: any) => {
|
const updateFilter = (key: keyof SearchFilters, value: any) => {
|
||||||
onFiltersChange({ ...filters, [key]: value });
|
onFiltersChange({ ...filters, [key]: value });
|
||||||
};
|
};
|
||||||
@@ -145,19 +153,26 @@ export function SearchFiltersComponent({ filters, onFiltersChange, activeTab }:
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Country</Label>
|
<Label>Country</Label>
|
||||||
<Input
|
<Combobox
|
||||||
placeholder="Enter country"
|
options={countries}
|
||||||
value={filters.country || ''}
|
value={filters.country}
|
||||||
onChange={(e) => updateFilter('country', e.target.value || undefined)}
|
onValueChange={(value) => updateFilter('country', value || undefined)}
|
||||||
|
placeholder="Select country"
|
||||||
|
searchPlaceholder="Search countries..."
|
||||||
|
loading={countriesLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>State/Province</Label>
|
<Label>State/Province</Label>
|
||||||
<Input
|
<Combobox
|
||||||
placeholder="Enter state/province"
|
options={statesProvinces}
|
||||||
value={filters.stateProvince || ''}
|
value={filters.stateProvince}
|
||||||
onChange={(e) => updateFilter('stateProvince', e.target.value || undefined)}
|
onValueChange={(value) => updateFilter('stateProvince', value || undefined)}
|
||||||
|
placeholder="Select state/province"
|
||||||
|
searchPlaceholder="Search states/provinces..."
|
||||||
|
loading={statesLoading}
|
||||||
|
disabled={!filters.country}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -273,6 +288,18 @@ export function SearchFiltersComponent({ filters, onFiltersChange, activeTab }:
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Manufacturer</Label>
|
||||||
|
<Combobox
|
||||||
|
options={manufacturers}
|
||||||
|
value={filters.manufacturer}
|
||||||
|
onValueChange={(value) => updateFilter('manufacturer', value || undefined)}
|
||||||
|
placeholder="Select manufacturer"
|
||||||
|
searchPlaceholder="Search manufacturers..."
|
||||||
|
loading={manufacturersLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -343,10 +370,13 @@ export function SearchFiltersComponent({ filters, onFiltersChange, activeTab }:
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Headquarters</Label>
|
<Label>Headquarters</Label>
|
||||||
<Input
|
<Combobox
|
||||||
placeholder="Enter country/location"
|
options={headquarters}
|
||||||
value={filters.headquarters || ''}
|
value={filters.headquarters}
|
||||||
onChange={(e) => updateFilter('headquarters', e.target.value || undefined)}
|
onValueChange={(value) => updateFilter('headquarters', value || undefined)}
|
||||||
|
placeholder="Select headquarters"
|
||||||
|
searchPlaceholder="Search headquarters..."
|
||||||
|
loading={headquartersLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
96
src/components/ui/combobox.tsx
Normal file
96
src/components/ui/combobox.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Check, ChevronsUpDown } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
|
||||||
|
export interface ComboboxOption {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ComboboxProps {
|
||||||
|
options: ComboboxOption[];
|
||||||
|
value?: string;
|
||||||
|
onValueChange?: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
searchPlaceholder?: string;
|
||||||
|
emptyText?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Combobox({
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onValueChange,
|
||||||
|
placeholder = "Select option...",
|
||||||
|
searchPlaceholder = "Search...",
|
||||||
|
emptyText = "No options found.",
|
||||||
|
disabled = false,
|
||||||
|
className,
|
||||||
|
loading = false,
|
||||||
|
}: ComboboxProps) {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
|
||||||
|
const selectedOption = options.find((option) => option.value === value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
className={cn("w-full justify-between", className)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{selectedOption ? selectedOption.label : placeholder}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-full p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder={searchPlaceholder} />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>{loading ? "Loading..." : emptyText}</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{options.map((option) => (
|
||||||
|
<CommandItem
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
onSelect={(currentValue) => {
|
||||||
|
const newValue = currentValue === value ? "" : currentValue;
|
||||||
|
onValueChange?.(newValue);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
value === option.value ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{option.label}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
162
src/hooks/useAutocompleteData.ts
Normal file
162
src/hooks/useAutocompleteData.ts
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
|
import { ComboboxOption } from '@/components/ui/combobox';
|
||||||
|
|
||||||
|
export function useCountries() {
|
||||||
|
const [countries, setCountries] = useState<ComboboxOption[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchCountries() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('locations')
|
||||||
|
.select('country')
|
||||||
|
.not('country', 'is', null);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
const uniqueCountries = Array.from(
|
||||||
|
new Set(data?.map(item => item.country) || [])
|
||||||
|
).sort();
|
||||||
|
|
||||||
|
setCountries(
|
||||||
|
uniqueCountries.map(country => ({
|
||||||
|
label: country,
|
||||||
|
value: country.toLowerCase().replace(/\s+/g, '_')
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching countries:', error);
|
||||||
|
setCountries([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchCountries();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { countries, loading };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useStatesProvinces(country?: string) {
|
||||||
|
const [statesProvinces, setStatesProvinces] = useState<ComboboxOption[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!country) {
|
||||||
|
setStatesProvinces([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchStatesProvinces() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('locations')
|
||||||
|
.select('state_province')
|
||||||
|
.eq('country', country)
|
||||||
|
.not('state_province', 'is', null);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
const uniqueStates = Array.from(
|
||||||
|
new Set(data?.map(item => item.state_province) || [])
|
||||||
|
).sort();
|
||||||
|
|
||||||
|
setStatesProvinces(
|
||||||
|
uniqueStates.map(state => ({
|
||||||
|
label: state,
|
||||||
|
value: state.toLowerCase().replace(/\s+/g, '_')
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching states/provinces:', error);
|
||||||
|
setStatesProvinces([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchStatesProvinces();
|
||||||
|
}, [country]);
|
||||||
|
|
||||||
|
return { statesProvinces, loading };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useManufacturers() {
|
||||||
|
const [manufacturers, setManufacturers] = useState<ComboboxOption[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchManufacturers() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('companies')
|
||||||
|
.select('name')
|
||||||
|
.eq('company_type', 'manufacturer')
|
||||||
|
.order('name');
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
setManufacturers(
|
||||||
|
(data || []).map(company => ({
|
||||||
|
label: company.name,
|
||||||
|
value: company.name.toLowerCase().replace(/\s+/g, '_')
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching manufacturers:', error);
|
||||||
|
setManufacturers([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchManufacturers();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { manufacturers, loading };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCompanyHeadquarters() {
|
||||||
|
const [headquarters, setHeadquarters] = useState<ComboboxOption[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchHeadquarters() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('companies')
|
||||||
|
.select('headquarters_location')
|
||||||
|
.not('headquarters_location', 'is', null);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
const uniqueHeadquarters = Array.from(
|
||||||
|
new Set(data?.map(item => item.headquarters_location) || [])
|
||||||
|
).sort();
|
||||||
|
|
||||||
|
setHeadquarters(
|
||||||
|
uniqueHeadquarters.map(hq => ({
|
||||||
|
label: hq,
|
||||||
|
value: hq.toLowerCase().replace(/\s+/g, '_')
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching headquarters:', error);
|
||||||
|
setHeadquarters([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchHeadquarters();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { headquarters, loading };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user