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 { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { LocationAutocomplete } from "@/components/location-autocomplete";
|
||||||
|
|
||||||
import { Navbar } from "@/components/navbar";
|
import { Navbar } from "@/components/navbar";
|
||||||
import { Footer } from "@/components/footer";
|
import { Footer } from "@/components/footer";
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { toast } from "sonner"
|
||||||
import { useTranslation } from "@/lib/i18n"
|
import { useTranslation } from "@/lib/i18n"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
import { LocationAutocomplete } from "@/components/location-autocomplete"
|
||||||
|
|
||||||
const formatCNPJ = (value: string) => {
|
const formatCNPJ = (value: string) => {
|
||||||
return value
|
return value
|
||||||
|
|
@ -257,13 +258,12 @@ export default function NewCompanyPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="address">{t('admin.companies.fields.address')}</Label>
|
<Label htmlFor="address">{t('admin.companies.fields.address')}</Label>
|
||||||
<Input
|
<LocationAutocomplete
|
||||||
id="address"
|
|
||||||
value={formData.address}
|
value={formData.address}
|
||||||
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
|
onSelect={(label) => setFormData({ ...formData, address: label })}
|
||||||
placeholder="Endereço completo"
|
placeholder="Buscar cidade ou estado..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,8 @@ type ApiCountry = {
|
||||||
|
|
||||||
const DESCRIPTION_MIN_LENGTH = 20
|
const DESCRIPTION_MIN_LENGTH = 20
|
||||||
|
|
||||||
|
import { LocationAutocomplete } from "@/components/location-autocomplete"
|
||||||
|
|
||||||
export default function DashboardNewJobPage() {
|
export default function DashboardNewJobPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
@ -52,7 +54,7 @@ export default function DashboardNewJobPage() {
|
||||||
title: "",
|
title: "",
|
||||||
description: "",
|
description: "",
|
||||||
location: "",
|
location: "",
|
||||||
country: "",
|
country: "BR",
|
||||||
employmentType: "",
|
employmentType: "",
|
||||||
workMode: "",
|
workMode: "",
|
||||||
workingHours: "",
|
workingHours: "",
|
||||||
|
|
@ -71,12 +73,7 @@ export default function DashboardNewJobPage() {
|
||||||
status: "draft",
|
status: "draft",
|
||||||
})
|
})
|
||||||
|
|
||||||
const [apiCountries, setApiCountries] = useState<ApiCountry[]>([])
|
|
||||||
const [locationIds, setLocationIds] = useState<{ cityId: number | null; regionId: number | null }>({ cityId: null, regionId: null })
|
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(() => {
|
useEffect(() => {
|
||||||
adminCompaniesApi.list(undefined, 1, 100)
|
adminCompaniesApi.list(undefined, 1, 100)
|
||||||
|
|
@ -85,53 +82,6 @@ export default function DashboardNewJobPage() {
|
||||||
.finally(() => setLoadingCompanies(false))
|
.finally(() => setLoadingCompanies(false))
|
||||||
}, [t])
|
}, [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) =>
|
const set = (field: string, value: string | boolean) =>
|
||||||
setFormData((prev) => ({ ...prev, [field]: value }))
|
setFormData((prev) => ({ ...prev, [field]: value }))
|
||||||
|
|
||||||
|
|
@ -335,61 +285,15 @@ export default function DashboardNewJobPage() {
|
||||||
|
|
||||||
<div className="space-y-1.5 md:col-span-2">
|
<div className="space-y-1.5 md:col-span-2">
|
||||||
<Label>{t("admin.jobs.create.fields.cityState")}</Label>
|
<Label>{t("admin.jobs.create.fields.cityState")}</Label>
|
||||||
<div ref={locationRef} className="relative">
|
<LocationAutocomplete
|
||||||
<Input
|
|
||||||
placeholder={formData.country
|
|
||||||
? t("admin.jobs.create.placeholders.locationSearch")
|
|
||||||
: t("admin.jobs.create.placeholders.selectCountryFirst")}
|
|
||||||
value={formData.location}
|
value={formData.location}
|
||||||
disabled={!formData.country}
|
countryIso={formData.country}
|
||||||
autoComplete="off"
|
onSelect={(label, cityId, regionId) => {
|
||||||
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)
|
set("location", label)
|
||||||
setLocationIds({
|
setLocationIds({ cityId, regionId })
|
||||||
cityId: result.type === "city" ? result.id : null,
|
|
||||||
regionId: result.type === "state" ? result.id : (result.state_id ?? null),
|
|
||||||
})
|
|
||||||
setShowLocationDropdown(false)
|
|
||||||
}}
|
}}
|
||||||
>
|
placeholder={t("admin.jobs.create.placeholders.locationSearch")}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { useTranslation } from "@/lib/i18n"
|
import { useTranslation } from "@/lib/i18n"
|
||||||
|
import { LocationAutocomplete } from "@/components/location-autocomplete"
|
||||||
|
|
||||||
export function HomeSearch() {
|
export function HomeSearch() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
@ -84,15 +85,13 @@ export function HomeSearch() {
|
||||||
{/* Location */}
|
{/* Location */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-semibold text-gray-700 uppercase tracking-wide">{t("home.search.location")}</label>
|
<label className="text-xs font-semibold text-gray-700 uppercase tracking-wide">{t("home.search.location")}</label>
|
||||||
<div className="relative">
|
<LocationAutocomplete
|
||||||
<Input
|
|
||||||
placeholder={t("home.search.location")}
|
|
||||||
className="h-12 bg-gray-50 border-gray-200"
|
|
||||||
value={location}
|
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>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Salary */}
|
{/* Salary */}
|
||||||
<div className="space-y-2">
|
<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