feat: implementa autocomplete de localização global (Home, Jobs, Companies)
This commit is contained in:
parent
689b794432
commit
4ce321fce2
6 changed files with 209 additions and 121 deletions
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
<LocationAutocomplete
|
||||
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
|
||||
countryIso={formData.country}
|
||||
onSelect={(label, cityId, regionId) => {
|
||||
set("location", label)
|
||||
setLocationIds({
|
||||
cityId: result.type === "city" ? result.id : null,
|
||||
regionId: result.type === "state" ? result.id : (result.state_id ?? null),
|
||||
})
|
||||
setShowLocationDropdown(false)
|
||||
setLocationIds({ cityId, regionId })
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
placeholder={t("admin.jobs.create.placeholders.locationSearch")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
|
|
|
|||
|
|
@ -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,15 +85,13 @@ 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"
|
||||
<LocationAutocomplete
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.target.value)}
|
||||
onSelect={(label) => setLocation(label)}
|
||||
className="h-12 bg-gray-50 border-gray-200"
|
||||
placeholder={t("home.search.location")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Salary */}
|
||||
<div className="space-y-2">
|
||||
|
|
|
|||
171
frontend/src/components/location-autocomplete.tsx
Normal file
171
frontend/src/components/location-autocomplete.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
13
frontend/src/lib/i18n/pt.json
Normal file
13
frontend/src/lib/i18n/pt.json
Normal 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue