[Frontend] Marc/frontend granular city/state geo proxy (#4156)

This commit is contained in:
Marc Kelechava
2025-12-01 18:25:08 -08:00
committed by GitHub
parent d0a9095b0d
commit 36c2af90b0
12 changed files with 636 additions and 76 deletions

View File

@@ -0,0 +1,207 @@
import * as React from "react";
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
import { cn } from "@/util/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";
import { GeoTarget } from "@/api/types";
import { formatGeoTargetCompact } from "@/util/geoData";
import {
GroupedSearchResults,
searchGeoData,
SearchResultItem,
} from "@/util/geoSearch";
import { useDebouncedCallback } from "use-debounce";
interface GeoTargetSelectorProps {
value: GeoTarget | null;
onChange: (value: GeoTarget) => void;
className?: string;
}
export function GeoTargetSelector({
value,
onChange,
className,
}: GeoTargetSelectorProps) {
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState("");
const [results, setResults] = React.useState<GroupedSearchResults>({
countries: [],
subdivisions: [],
cities: [],
});
const [loading, setLoading] = React.useState(false);
const handleSearch = useDebouncedCallback(async (searchQuery: string) => {
setLoading(true);
try {
const data = await searchGeoData(searchQuery);
setResults(data);
} catch (error) {
console.error("Failed to search geo data", error);
} finally {
setLoading(false);
}
}, 300);
// Initial load of countries
React.useEffect(() => {
if (open) {
handleSearch("");
}
}, [open, handleSearch]);
const onInput = (val: string) => {
setQuery(val);
handleSearch(val);
};
const handleSelect = (item: SearchResultItem) => {
onChange(item.value);
setOpen(false);
};
const isSelected = (itemValue: GeoTarget) => {
if (!value) return false;
return (
value.country === itemValue.country &&
value.subdivision === itemValue.subdivision &&
value.city === itemValue.city
);
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={cn("w-full justify-between", className)}
>
<span className="truncate">
{value ? formatGeoTargetCompact(value) : "Select proxy location..."}
</span>
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0" align="start">
<Command shouldFilter={false}>
<CommandInput
placeholder="Search country, state, or city..."
value={query}
onValueChange={onInput}
/>
<CommandList>
{loading && (
<div className="py-6 text-center text-sm text-muted-foreground">
Loading...
</div>
)}
{!loading &&
results.countries.length === 0 &&
results.subdivisions.length === 0 &&
results.cities.length === 0 && (
<CommandEmpty>No location found.</CommandEmpty>
)}
{!loading && (
<>
{results.countries.length > 0 && (
<CommandGroup heading="Countries">
{results.countries.map((item) => (
<CommandItem
key={`country-${item.value.country}`}
value={JSON.stringify(item.value)}
onSelect={() => handleSelect(item)}
>
<span className="mr-2 text-lg">{item.icon}</span>
<span>{item.label}</span>
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
isSelected(item.value)
? "opacity-100"
: "opacity-0",
)}
/>
</CommandItem>
))}
</CommandGroup>
)}
{results.subdivisions.length > 0 && (
<CommandGroup heading="States / Regions">
{results.subdivisions.map((item) => (
<CommandItem
key={`sub-${item.value.country}-${item.value.subdivision}`}
value={JSON.stringify(item.value)}
onSelect={() => handleSelect(item)}
>
<span className="mr-2 text-lg">{item.icon}</span>
<div className="flex flex-col">
<span>{item.label}</span>
<span className="text-xs text-muted-foreground">
{item.description}
</span>
</div>
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
isSelected(item.value)
? "opacity-100"
: "opacity-0",
)}
/>
</CommandItem>
))}
</CommandGroup>
)}
{results.cities.length > 0 && (
<CommandGroup heading="Cities">
{results.cities.map((item) => (
<CommandItem
key={`city-${item.value.country}-${item.value.subdivision}-${item.value.city}`}
value={JSON.stringify(item.value)}
onSelect={() => handleSelect(item)}
>
<span className="mr-2 text-lg">{item.icon}</span>
<div className="flex flex-col">
<span>{item.label}</span>
<span className="text-xs text-muted-foreground">
{item.description}
</span>
</div>
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
isSelected(item.value)
? "opacity-100"
: "opacity-0",
)}
/>
</CommandItem>
))}
</CommandGroup>
)}
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -1,82 +1,31 @@
import { ProxyLocation } from "@/api/types";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "./ui/select";
geoTargetToProxyLocationInput,
proxyLocationToGeoTarget,
} from "@/util/geoData";
import { GeoTargetSelector } from "./GeoTargetSelector";
type Props = {
value: ProxyLocation | null;
value: ProxyLocation;
onChange: (value: ProxyLocation) => void;
className?: string;
};
function ProxySelector({ value, onChange, className }: Props) {
// Convert input (string enum or object) to GeoTarget for the selector
const geoTargetValue = proxyLocationToGeoTarget(value);
return (
<Select value={value ?? ""} onValueChange={onChange}>
<SelectTrigger className={className}>
<SelectValue placeholder="Proxy Location" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ProxyLocation.Residential}>Residential</SelectItem>
<SelectItem value={ProxyLocation.ResidentialISP}>
Residential ISP (US)
</SelectItem>
<SelectItem value={ProxyLocation.ResidentialAR}>
Residential (Argentina)
</SelectItem>
<SelectItem value={ProxyLocation.ResidentialAU}>
Residential (Australia)
</SelectItem>
<SelectItem value={ProxyLocation.ResidentialBR}>
Residential (Brazil)
</SelectItem>
<SelectItem value={ProxyLocation.ResidentialCA}>
Residential (Canada)
</SelectItem>
<SelectItem value={ProxyLocation.ResidentialFR}>
Residential (France)
</SelectItem>
<SelectItem value={ProxyLocation.ResidentialDE}>
Residential (Germany)
</SelectItem>
<SelectItem value={ProxyLocation.ResidentialIN}>
Residential (India)
</SelectItem>
<SelectItem value={ProxyLocation.ResidentialIE}>
Residential (Ireland)
</SelectItem>
<SelectItem value={ProxyLocation.ResidentialIT}>
Residential (Italy)
</SelectItem>
<SelectItem value={ProxyLocation.ResidentialJP}>
Residential (Japan)
</SelectItem>
<SelectItem value={ProxyLocation.ResidentialMX}>
Residential (Mexico)
</SelectItem>
<SelectItem value={ProxyLocation.ResidentialNL}>
Residential (Netherlands)
</SelectItem>
<SelectItem value={ProxyLocation.ResidentialNZ}>
Residential (New Zealand)
</SelectItem>
<SelectItem value={ProxyLocation.ResidentialZA}>
Residential (South Africa)
</SelectItem>
<SelectItem value={ProxyLocation.ResidentialES}>
Residential (Spain)
</SelectItem>
<SelectItem value={ProxyLocation.ResidentialTR}>
Residential (Turkey)
</SelectItem>
<SelectItem value={ProxyLocation.ResidentialGB}>
Residential (United Kingdom)
</SelectItem>
</SelectContent>
</Select>
<GeoTargetSelector
className={className}
value={geoTargetValue}
onChange={(newTarget) => {
// Convert back to ProxyLocation enum if possible (for simple countries)
// or keep as GeoTarget object
const newValue = geoTargetToProxyLocationInput(newTarget);
onChange(newValue);
}}
/>
);
}