feat: implementa autocomplete de localização global (Home, Jobs, Companies)

This commit is contained in:
GoHorse Deploy 2026-03-07 18:59:09 -03:00
parent 689b794432
commit 4ce321fce2
6 changed files with 209 additions and 121 deletions

View file

@ -30,6 +30,7 @@ import { Label } from "@/components/ui/label";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { LocationAutocomplete } from "@/components/location-autocomplete";
import { Navbar } from "@/components/navbar";
import { Footer } from "@/components/footer";

View file

@ -12,6 +12,7 @@ import { toast } from "sonner"
import { useTranslation } from "@/lib/i18n"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import Link from "next/link"
import { LocationAutocomplete } from "@/components/location-autocomplete"
const formatCNPJ = (value: string) => {
return value
@ -257,13 +258,12 @@ export default function NewCompanyPage() {
/>
</div>
</div>
<div className="grid gap-2">
<div className="space-y-2">
<Label htmlFor="address">{t('admin.companies.fields.address')}</Label>
<Input
id="address"
<LocationAutocomplete
value={formData.address}
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
placeholder="Endereço completo"
onSelect={(label) => setFormData({ ...formData, address: label })}
placeholder="Buscar cidade ou estado..."
/>
</div>
</div>

View file

@ -40,6 +40,8 @@ type ApiCountry = {
const DESCRIPTION_MIN_LENGTH = 20
import { LocationAutocomplete } from "@/components/location-autocomplete"
export default function DashboardNewJobPage() {
const router = useRouter()
const { t } = useTranslation()
@ -52,7 +54,7 @@ export default function DashboardNewJobPage() {
title: "",
description: "",
location: "",
country: "",
country: "BR",
employmentType: "",
workMode: "",
workingHours: "",
@ -71,12 +73,7 @@ export default function DashboardNewJobPage() {
status: "draft",
})
const [apiCountries, setApiCountries] = useState<ApiCountry[]>([])
const [locationIds, setLocationIds] = useState<{ cityId: number | null; regionId: number | null }>({ cityId: null, regionId: null })
const [locationResults, setLocationResults] = useState<LocationResult[]>([])
const [locationSearching, setLocationSearching] = useState(false)
const [showLocationDropdown, setShowLocationDropdown] = useState(false)
const locationRef = useRef<HTMLDivElement>(null)
useEffect(() => {
adminCompaniesApi.list(undefined, 1, 100)
@ -85,53 +82,6 @@ export default function DashboardNewJobPage() {
.finally(() => setLoadingCompanies(false))
}, [t])
useEffect(() => {
const apiBase = process.env.NEXT_PUBLIC_API_URL || ""
fetch(`${apiBase}/api/v1/locations/countries`)
.then((r) => r.json())
.then((data: ApiCountry[]) => setApiCountries(data))
.catch(() => { })
}, [])
const selectedCountryId = useMemo(
() => apiCountries.find((c) => c.iso2 === formData.country)?.id ?? null,
[apiCountries, formData.country]
)
const searchLocation = useCallback((query: string, countryId: number) => {
const apiBase = process.env.NEXT_PUBLIC_API_URL || ""
setLocationSearching(true)
fetch(`${apiBase}/api/v1/locations/search?q=${encodeURIComponent(query)}&country_id=${countryId}`)
.then((r) => r.json())
.then((data) => {
setLocationResults(Array.isArray(data) ? data : [])
setShowLocationDropdown(true)
})
.catch(() => setLocationResults([]))
.finally(() => setLocationSearching(false))
}, [])
useEffect(() => {
const query = formData.location.trim()
if (query.length < 2 || !selectedCountryId) {
setLocationResults([])
setShowLocationDropdown(false)
return
}
const timer = setTimeout(() => searchLocation(query, selectedCountryId), 350)
return () => clearTimeout(timer)
}, [formData.location, selectedCountryId, searchLocation])
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (locationRef.current && !locationRef.current.contains(e.target as Node)) {
setShowLocationDropdown(false)
}
}
document.addEventListener("mousedown", handleClickOutside)
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [])
const set = (field: string, value: string | boolean) =>
setFormData((prev) => ({ ...prev, [field]: value }))
@ -335,61 +285,15 @@ export default function DashboardNewJobPage() {
<div className="space-y-1.5 md:col-span-2">
<Label>{t("admin.jobs.create.fields.cityState")}</Label>
<div ref={locationRef} className="relative">
<Input
placeholder={formData.country
? t("admin.jobs.create.placeholders.locationSearch")
: t("admin.jobs.create.placeholders.selectCountryFirst")}
value={formData.location}
disabled={!formData.country}
autoComplete="off"
onChange={(e) => {
set("location", e.target.value)
setLocationIds({ cityId: null, regionId: null })
}}
onFocus={() => {
if (locationResults.length > 0) setShowLocationDropdown(true)
}}
/>
{locationSearching && (
<div className="absolute right-3 top-2.5">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
)}
{showLocationDropdown && locationResults.length > 0 && (
<div className="absolute z-50 mt-1 max-h-60 w-full overflow-y-auto rounded-md border bg-white shadow-md">
{locationResults.map((result) => (
<button
key={`${result.type}-${result.id}`}
type="button"
className="flex w-full items-center justify-between px-3 py-2 text-left hover:bg-gray-50"
onMouseDown={(e) => {
e.preventDefault()
const label = result.region_name
? `${result.name}, ${result.region_name}`
: result.name
set("location", label)
setLocationIds({
cityId: result.type === "city" ? result.id : null,
regionId: result.type === "state" ? result.id : (result.state_id ?? null),
})
setShowLocationDropdown(false)
}}
>
<span>
<span className="text-sm font-medium">{result.name}</span>
{result.region_name && (
<span className="ml-1 text-xs text-muted-foreground"> - {result.region_name}</span>
)}
</span>
<span className={`rounded px-1.5 py-0.5 text-xs ${result.type === "city" ? "bg-blue-50 text-blue-600" : "bg-emerald-50 text-emerald-600"}`}>
{t(`admin.jobs.create.location.resultType.${result.type}`)}
</span>
</button>
))}
</div>
)}
</div>
<LocationAutocomplete
value={formData.location}
countryIso={formData.country}
onSelect={(label, cityId, regionId) => {
set("location", label)
setLocationIds({ cityId, regionId })
}}
placeholder={t("admin.jobs.create.placeholders.locationSearch")}
/>
</div>
<div className="space-y-1.5">

View file

@ -7,6 +7,7 @@ import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { useRouter } from "next/navigation"
import { useTranslation } from "@/lib/i18n"
import { LocationAutocomplete } from "@/components/location-autocomplete"
export function HomeSearch() {
const router = useRouter()
@ -84,14 +85,12 @@ export function HomeSearch() {
{/* Location */}
<div className="space-y-2">
<label className="text-xs font-semibold text-gray-700 uppercase tracking-wide">{t("home.search.location")}</label>
<div className="relative">
<Input
placeholder={t("home.search.location")}
className="h-12 bg-gray-50 border-gray-200"
value={location}
onChange={(e) => setLocation(e.target.value)}
/>
</div>
<LocationAutocomplete
value={location}
onSelect={(label) => setLocation(label)}
className="h-12 bg-gray-50 border-gray-200"
placeholder={t("home.search.location")}
/>
</div>
{/* Salary */}

View file

@ -0,0 +1,171 @@
"use client"
import * as React from "react"
import { Check, ChevronsUpDown, Loader2, MapPin } 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"
import { useTranslation } from "@/lib/i18n"
export type LocationResult = {
id: number
name: string
type: "city" | "state"
country_id: number
state_id?: number
region_name?: string
}
interface LocationAutocompleteProps {
value: string
onSelect: (location: string, cityId: number | null, stateId: number | null) => void
placeholder?: string
disabled?: boolean
className?: string
countryIso?: string
}
export function LocationAutocomplete({
value,
onSelect,
placeholder,
disabled = false,
className,
countryIso = "BR"
}: LocationAutocompleteProps) {
const { t } = useTranslation()
const [open, setOpen] = React.useState(false)
const [loading, setLoading] = React.useState(false)
const [results, setResults] = React.useState<LocationResult[]>([])
const [search, setSearch] = React.useState("")
const fetchLocations = React.useCallback(async (query: string) => {
if (query.length < 2) return
setLoading(true)
try {
const apiBase = process.env.NEXT_PUBLIC_API_URL || ""
const response = await fetch(`${apiBase}/api/v1/locations/search?q=${encodeURIComponent(query)}&country_iso=${countryIso}`)
const data = await response.json()
setResults(Array.isArray(data) ? data : [])
} catch (error) {
console.error("Error fetching locations:", error)
setResults([])
} finally {
setLoading(false)
}
}, [countryIso])
React.useEffect(() => {
const timer = setTimeout(() => {
if (search) fetchLocations(search)
}, 300)
return () => clearTimeout(timer)
}, [search, fetchLocations])
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn("w-full justify-between font-normal", !value && "text-muted-foreground", className)}
>
<div className="flex items-center gap-2 truncate">
<MapPin className="h-4 w-4 shrink-0 opacity-50" />
{value ? value : placeholder || t("common.location.select", { defaultValue: "Selecionar localização..." })}
</div>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
<Command shouldFilter={false}>
<CommandInput
placeholder={t("common.location.search", { defaultValue: "Buscar cidade ou estado..." })}
onValueChange={setSearch}
/>
<CommandList>
{loading && (
<div className="flex items-center justify-center p-4">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
{!loading && results.length === 0 && search.length >= 2 && (
<CommandEmpty>{t("common.location.empty", { defaultValue: "Nenhuma localização encontrada." })}</CommandEmpty>
)}
{!loading && search.length < 2 && (
<div className="p-4 text-xs text-center text-muted-foreground">
Digite pelo menos 2 caracteres para buscar
</div>
)}
<CommandGroup>
{results.map((result) => {
const label = result.region_name
? `${result.name}, ${result.region_name}`
: result.name
return (
<CommandItem
key={`${result.type}-${result.id}`}
value={label}
onSelect={() => {
onSelect(
label,
result.type === "city" ? result.id : null,
result.type === "state" ? result.id : (result.state_id || null)
)
setOpen(false)
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === label ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span>{result.name}</span>
{result.region_name && (
<span className="text-xs text-muted-foreground">{result.region_name}</span>
)}
</div>
<Badge variant="secondary" className="ml-auto text-[10px] uppercase">
{result.type}
</Badge>
</CommandItem>
)
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
function Badge({ className, variant, ...props }: any) {
return (
<div
className={cn(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
variant === "secondary" ? "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80" : "",
className
)}
{...props}
/>
)
}

View file

@ -0,0 +1,13 @@
{
"admin.notifications.push.success": "Notificação push enviada com sucesso",
"admin.notifications.push.error": "Falha ao enviar notificação push",
"admin.notifications.messaging.connected": "Conectado ao serviço de mensageria",
"admin.notifications.messaging.error": "Falha ao conectar ao serviço de mensageria",
"common": {
"location": {
"select": "Selecionar localização...",
"search": "Buscar cidade ou estado...",
"empty": "Nenhuma localização encontrada."
}
}
}