feat: add location selector and contract types
Backend: - Created LocationHandler, LocationService, LocationRepository - Added endpoints: GET /api/v1/locations/countries, states, cities, search - Added migration 029_expand_employment_types.sql with new contract types (permanent, training, temporary, voluntary) - Fixed .gitignore to allow internal/api folder Frontend: - Created LocationPicker component with country dropdown and city/state autocomplete search - Integrated LocationPicker into PostJobPage - Updated contract type options in job form (Permanent, Contract, Training, Temporary, Voluntary) - Added locationsApi with search functionality to api.ts
This commit is contained in:
parent
818edf2575
commit
fb79e987bb
10 changed files with 724 additions and 28 deletions
2
backend/.gitignore
vendored
2
backend/.gitignore
vendored
|
|
@ -21,4 +21,4 @@ go.work
|
|||
.env
|
||||
|
||||
# Compiled binary
|
||||
api
|
||||
/api
|
||||
|
|
|
|||
113
backend/internal/api/handlers/location_handlers.go
Normal file
113
backend/internal/api/handlers/location_handlers.go
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/rede5/gohorsejobs/backend/internal/services"
|
||||
)
|
||||
|
||||
type LocationHandlers struct {
|
||||
service *services.LocationService
|
||||
}
|
||||
|
||||
func NewLocationHandlers(service *services.LocationService) *LocationHandlers {
|
||||
return &LocationHandlers{service: service}
|
||||
}
|
||||
|
||||
// ListCountries returns all countries
|
||||
func (h *LocationHandlers) ListCountries(w http.ResponseWriter, r *http.Request) {
|
||||
countries, err := h.service.ListCountries(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(countries)
|
||||
}
|
||||
|
||||
// ListStatesByCountry returns states for a given country ID
|
||||
// Expects path param {id}
|
||||
func (h *LocationHandlers) ListStatesByCountry(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := r.PathValue("id")
|
||||
if idStr == "" {
|
||||
http.Error(w, "Country ID is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
countryID, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid Country ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
states, err := h.service.ListStates(r.Context(), countryID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(states)
|
||||
}
|
||||
|
||||
// ListCitiesByState returns cities for a given state ID
|
||||
// Expects path param {id}
|
||||
func (h *LocationHandlers) ListCitiesByState(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := r.PathValue("id")
|
||||
if idStr == "" {
|
||||
http.Error(w, "State ID is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
stateID, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid State ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
cities, err := h.service.ListCities(r.Context(), stateID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(cities)
|
||||
}
|
||||
|
||||
// SearchLocations searches for cities or states in a country
|
||||
// Query params: q (query), country_id (required)
|
||||
func (h *LocationHandlers) SearchLocations(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query().Get("q")
|
||||
countryIDStr := r.URL.Query().Get("country_id")
|
||||
|
||||
if q == "" || len(q) < 2 {
|
||||
// Return empty list if query too short to avoid massive scan (though DB handles it, better UX)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte("[]"))
|
||||
return
|
||||
}
|
||||
|
||||
if countryIDStr == "" {
|
||||
http.Error(w, "country_id is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
countryID, err := strconv.ParseInt(countryIDStr, 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid country_id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
results, err := h.service.Search(r.Context(), q, countryID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(results)
|
||||
}
|
||||
45
backend/internal/core/domain/entity/location.go
Normal file
45
backend/internal/core/domain/entity/location.go
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
package entity
|
||||
|
||||
import "time"
|
||||
|
||||
type Country struct {
|
||||
ID int64 `json:"id" db:"id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
ISO2 string `json:"iso2" db:"iso2"`
|
||||
ISO3 string `json:"iso3" db:"iso3"`
|
||||
PhoneCode string `json:"phone_code" db:"phonecode"`
|
||||
Currency string `json:"currency" db:"currency"`
|
||||
Emoji string `json:"emoji" db:"emoji"`
|
||||
EmojiU string `json:"emojiU" db:"emoji_u"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
|
||||
type State struct {
|
||||
ID int64 `json:"id" db:"id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
CountryID int64 `json:"country_id" db:"country_id"`
|
||||
CountryCode string `json:"country_code" db:"country_code"`
|
||||
ISO2 string `json:"iso2" db:"iso2"`
|
||||
Type string `json:"type" db:"type"` // State, Province, etc.
|
||||
Latitude float64 `json:"latitude" db:"latitude"`
|
||||
Longitude float64 `json:"longitude" db:"longitude"`
|
||||
}
|
||||
|
||||
type City struct {
|
||||
ID int64 `json:"id" db:"id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
StateID int64 `json:"state_id" db:"state_id"`
|
||||
CountryID int64 `json:"country_id" db:"country_id"`
|
||||
Latitude float64 `json:"latitude" db:"latitude"`
|
||||
Longitude float64 `json:"longitude" db:"longitude"`
|
||||
}
|
||||
|
||||
type LocationSearchResult struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"` // "state" or "city"
|
||||
CountryID int64 `json:"country_id"`
|
||||
StateID *int64 `json:"state_id,omitempty"`
|
||||
RegionName string `json:"region_name,omitempty"` // e.g. State name if city
|
||||
}
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/rede5/gohorsejobs/backend/internal/core/domain/entity"
|
||||
)
|
||||
|
||||
type LocationRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewLocationRepository(db *sql.DB) *LocationRepository {
|
||||
return &LocationRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *LocationRepository) ListCountries(ctx context.Context) ([]*entity.Country, error) {
|
||||
query := `SELECT id, name, iso2, iso3, phonecode, currency, emoji, emoji_u, created_at, updated_at FROM countries ORDER BY name ASC`
|
||||
rows, err := r.db.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var countries []*entity.Country
|
||||
for rows.Next() {
|
||||
c := &entity.Country{}
|
||||
// Scan columns. Be careful with NULL handling if fields are nullable, but schema says NOT NULL mostly.
|
||||
// Check schema again. phonecode, etc can be null.
|
||||
// For simplicity, we scan into sql.NullString or just string (and risk error if NULL).
|
||||
// Migration 021 says:
|
||||
// name VARCHAR(100) NOT NULL
|
||||
// iso2 CHAR(2)
|
||||
// phonecode VARCHAR(255)
|
||||
// ...
|
||||
// So nullable fields need handling.
|
||||
|
||||
var iso2, iso3, phonecode, currency, emoji, emojiU sql.NullString
|
||||
|
||||
if err := rows.Scan(&c.ID, &c.Name, &iso2, &iso3, &phonecode, ¤cy, &emoji, &emojiU, &c.CreatedAt, &c.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.ISO2 = iso2.String
|
||||
c.ISO3 = iso3.String
|
||||
c.PhoneCode = phonecode.String
|
||||
c.Currency = currency.String
|
||||
c.Emoji = emoji.String
|
||||
c.EmojiU = emojiU.String
|
||||
|
||||
countries = append(countries, c)
|
||||
}
|
||||
return countries, nil
|
||||
}
|
||||
|
||||
func (r *LocationRepository) ListStates(ctx context.Context, countryID int64) ([]*entity.State, error) {
|
||||
query := `SELECT id, name, country_id, country_code, iso2, type, latitude, longitude FROM states WHERE country_id = $1 ORDER BY name ASC`
|
||||
rows, err := r.db.QueryContext(ctx, query, countryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var states []*entity.State
|
||||
for rows.Next() {
|
||||
s := &entity.State{}
|
||||
var iso2, typeStr sql.NullString
|
||||
var lat, lng sql.NullFloat64
|
||||
|
||||
if err := rows.Scan(&s.ID, &s.Name, &s.CountryID, &s.CountryCode, &iso2, &typeStr, &lat, &lng); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.ISO2 = iso2.String
|
||||
s.Type = typeStr.String
|
||||
s.Latitude = lat.Float64
|
||||
s.Longitude = lng.Float64
|
||||
|
||||
states = append(states, s)
|
||||
}
|
||||
return states, nil
|
||||
}
|
||||
|
||||
func (r *LocationRepository) ListCities(ctx context.Context, stateID int64) ([]*entity.City, error) {
|
||||
query := `SELECT id, name, state_id, country_id, latitude, longitude FROM cities WHERE state_id = $1 ORDER BY name ASC`
|
||||
rows, err := r.db.QueryContext(ctx, query, stateID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var cities []*entity.City
|
||||
for rows.Next() {
|
||||
c := &entity.City{}
|
||||
// schema: latitude NOT NULL, longitude NOT NULL.
|
||||
if err := rows.Scan(&c.ID, &c.Name, &c.StateID, &c.CountryID, &c.Latitude, &c.Longitude); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cities = append(cities, c)
|
||||
}
|
||||
return cities, nil
|
||||
}
|
||||
|
||||
func (r *LocationRepository) Search(ctx context.Context, query string, countryID int64) ([]*entity.LocationSearchResult, error) {
|
||||
// Search Cities and States
|
||||
// Simple LIKE query
|
||||
q := "%" + query + "%"
|
||||
|
||||
sqlQuery := `
|
||||
SELECT id, name, 'state' as type, country_id, NULL as state_id, '' as region_name
|
||||
FROM states
|
||||
WHERE country_id = $1 AND name ILIKE $2
|
||||
UNION ALL
|
||||
SELECT c.id, c.name, 'city' as type, c.country_id, c.state_id, s.name as region_name
|
||||
FROM cities c
|
||||
JOIN states s ON c.state_id = s.id
|
||||
WHERE c.country_id = $1 AND c.name ILIKE $2
|
||||
LIMIT 20
|
||||
`
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, sqlQuery, countryID, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []*entity.LocationSearchResult
|
||||
for rows.Next() {
|
||||
res := &entity.LocationSearchResult{}
|
||||
var stateID sql.NullInt64
|
||||
var regionName sql.NullString
|
||||
|
||||
if err := rows.Scan(&res.ID, &res.Name, &res.Type, &res.CountryID, &stateID, ®ionName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if stateID.Valid {
|
||||
id := stateID.Int64
|
||||
res.StateID = &id
|
||||
}
|
||||
res.RegionName = regionName.String
|
||||
results = append(results, res)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
|
@ -35,8 +35,10 @@ func NewRouter() http.Handler {
|
|||
|
||||
// --- CORE ARCHITECTURE INITIALIZATION ---
|
||||
// Infrastructure
|
||||
// Infrastructure
|
||||
userRepo := postgres.NewUserRepository(database.DB)
|
||||
companyRepo := postgres.NewCompanyRepository(database.DB)
|
||||
locationRepo := postgres.NewLocationRepository(database.DB)
|
||||
|
||||
// Utils Services (Moved up for dependency injection)
|
||||
credentialsService := services.NewCredentialsService(database.DB)
|
||||
|
|
@ -45,6 +47,7 @@ func NewRouter() http.Handler {
|
|||
fcmService := services.NewFCMService(credentialsService)
|
||||
cloudflareService := services.NewCloudflareService(credentialsService)
|
||||
emailService := services.NewEmailService(database.DB, credentialsService)
|
||||
locationService := services.NewLocationService(locationRepo)
|
||||
|
||||
adminService := services.NewAdminService(database.DB)
|
||||
|
||||
|
|
@ -94,6 +97,7 @@ func NewRouter() http.Handler {
|
|||
settingsHandler := apiHandlers.NewSettingsHandler(settingsService)
|
||||
storageHandler := apiHandlers.NewStorageHandler(storageService)
|
||||
adminHandlers := apiHandlers.NewAdminHandlers(adminService, auditService, jobService, cloudflareService)
|
||||
locationHandlers := apiHandlers.NewLocationHandlers(locationService)
|
||||
|
||||
// Initialize Legacy Handlers
|
||||
jobHandler := handlers.NewJobHandler(jobService)
|
||||
|
|
@ -210,6 +214,12 @@ func NewRouter() http.Handler {
|
|||
// Get Company by ID (Public)
|
||||
mux.HandleFunc("GET /api/v1/companies/{id}", coreHandlers.GetCompanyByID)
|
||||
|
||||
// Location Routes (Public)
|
||||
mux.HandleFunc("GET /api/v1/locations/countries", locationHandlers.ListCountries)
|
||||
mux.HandleFunc("GET /api/v1/locations/countries/{id}/states", locationHandlers.ListStatesByCountry)
|
||||
mux.HandleFunc("GET /api/v1/locations/states/{id}/cities", locationHandlers.ListCitiesByState)
|
||||
mux.HandleFunc("GET /api/v1/locations/search", locationHandlers.SearchLocations)
|
||||
|
||||
// Notifications Route
|
||||
mux.Handle("GET /api/v1/notifications", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.ListNotifications)))
|
||||
mux.Handle("POST /api/v1/tokens", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.SaveFCMToken)))
|
||||
|
|
|
|||
32
backend/internal/services/location_service.go
Normal file
32
backend/internal/services/location_service.go
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/rede5/gohorsejobs/backend/internal/core/domain/entity"
|
||||
"github.com/rede5/gohorsejobs/backend/internal/infrastructure/persistence/postgres"
|
||||
)
|
||||
|
||||
type LocationService struct {
|
||||
repo *postgres.LocationRepository
|
||||
}
|
||||
|
||||
func NewLocationService(repo *postgres.LocationRepository) *LocationService {
|
||||
return &LocationService{repo: repo}
|
||||
}
|
||||
|
||||
func (s *LocationService) ListCountries(ctx context.Context) ([]*entity.Country, error) {
|
||||
return s.repo.ListCountries(ctx)
|
||||
}
|
||||
|
||||
func (s *LocationService) ListStates(ctx context.Context, countryID int64) ([]*entity.State, error) {
|
||||
return s.repo.ListStates(ctx, countryID)
|
||||
}
|
||||
|
||||
func (s *LocationService) ListCities(ctx context.Context, stateID int64) ([]*entity.City, error) {
|
||||
return s.repo.ListCities(ctx, stateID)
|
||||
}
|
||||
|
||||
func (s *LocationService) Search(ctx context.Context, query string, countryID int64) ([]*entity.LocationSearchResult, error) {
|
||||
return s.repo.Search(ctx, query, countryID)
|
||||
}
|
||||
19
backend/migrations/029_expand_employment_types.sql
Normal file
19
backend/migrations/029_expand_employment_types.sql
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
-- Migration: Update employment_type constraint with additional values
|
||||
-- Description: Add permanent, training, temporary, voluntary to employment_type
|
||||
|
||||
-- Drop existing constraint and recreate with expanded values
|
||||
ALTER TABLE jobs DROP CONSTRAINT IF EXISTS jobs_employment_type_check;
|
||||
|
||||
ALTER TABLE jobs ADD CONSTRAINT jobs_employment_type_check
|
||||
CHECK (employment_type IN (
|
||||
'full-time',
|
||||
'part-time',
|
||||
'dispatch',
|
||||
'contract',
|
||||
'permanent',
|
||||
'training',
|
||||
'temporary',
|
||||
'voluntary'
|
||||
));
|
||||
|
||||
COMMENT ON COLUMN jobs.employment_type IS 'Employment type: full-time, part-time, dispatch, contract, permanent, training, temporary, voluntary';
|
||||
|
|
@ -10,7 +10,32 @@ import { Input } from "@/components/ui/input";
|
|||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Building2, Briefcase, User, Mail, Lock, Phone, MapPin } from "lucide-react";
|
||||
import {
|
||||
Building2, Briefcase, Mail, Lock, Phone, MapPin,
|
||||
Eye, EyeOff, Globe
|
||||
} from "lucide-react";
|
||||
import { LocationPicker } from "@/components/location-picker";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
// Common Country Codes
|
||||
const COUNTRY_CODES = [
|
||||
{ code: "+55", country: "Brasil (BR)" },
|
||||
{ code: "+1", country: "Estados Unidos (US)" },
|
||||
{ code: "+351", country: "Portugal (PT)" },
|
||||
{ code: "+44", country: "Reino Unido (UK)" },
|
||||
{ code: "+33", country: "França (FR)" },
|
||||
{ code: "+49", country: "Alemanha (DE)" },
|
||||
{ code: "+34", country: "Espanha (ES)" },
|
||||
{ code: "+39", country: "Itália (IT)" },
|
||||
{ code: "+81", country: "Japão (JP)" },
|
||||
{ code: "+86", country: "China (CN)" },
|
||||
{ code: "+91", country: "Índia (IN)" },
|
||||
{ code: "+52", country: "México (MX)" },
|
||||
{ code: "+54", country: "Argentina (AR)" },
|
||||
{ code: "+57", country: "Colômbia (CO)" },
|
||||
{ code: "+56", country: "Chile (CL)" },
|
||||
{ code: "+51", country: "Peru (PE)" },
|
||||
].sort((a, b) => a.country.localeCompare(b.country));
|
||||
|
||||
export default function PostJobPage() {
|
||||
const router = useRouter();
|
||||
|
|
@ -22,9 +47,14 @@ export default function PostJobPage() {
|
|||
name: "",
|
||||
email: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
ddi: "+55",
|
||||
phone: "",
|
||||
});
|
||||
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
|
||||
// Job data
|
||||
const [job, setJob] = useState({
|
||||
title: "",
|
||||
|
|
@ -32,16 +62,35 @@ export default function PostJobPage() {
|
|||
location: "",
|
||||
salaryMin: "",
|
||||
salaryMax: "",
|
||||
employmentType: "full-time",
|
||||
employmentType: "",
|
||||
workMode: "remote",
|
||||
});
|
||||
|
||||
const formatPhoneForDisplay = (value: string) => {
|
||||
// Simple formatting to just allow numbers and basic separators if needed
|
||||
// For now, just pass through but maybe restrict chars?
|
||||
return value.replace(/[^\d\s-]/g, "");
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!company.name || !company.email || !company.password) {
|
||||
toast.error("Preencha os dados da empresa");
|
||||
toast.error("Preencha os dados obrigatórios da empresa");
|
||||
setStep(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (company.password !== company.confirmPassword) {
|
||||
toast.error("As senhas não coincidem");
|
||||
setStep(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (company.password.length < 8) {
|
||||
toast.error("A senha deve ter pelo menos 8 caracteres");
|
||||
setStep(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!job.title || !job.description) {
|
||||
toast.error("Preencha os dados da vaga");
|
||||
setStep(2);
|
||||
|
|
@ -52,6 +101,10 @@ export default function PostJobPage() {
|
|||
try {
|
||||
const apiBase = process.env.NEXT_PUBLIC_API_URL || "";
|
||||
|
||||
// Format phone: DDI + Phone (digits only)
|
||||
const cleanPhone = company.phone.replace(/\D/g, "");
|
||||
const finalPhone = cleanPhone ? `${company.ddi}${cleanPhone}` : "";
|
||||
|
||||
// 1. Register Company (creates user + company)
|
||||
const registerRes = await fetch(`${apiBase}/api/v1/auth/register/company`, {
|
||||
method: "POST",
|
||||
|
|
@ -60,7 +113,7 @@ export default function PostJobPage() {
|
|||
companyName: company.name,
|
||||
email: company.email,
|
||||
password: company.password,
|
||||
phone: company.phone,
|
||||
phone: finalPhone,
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
@ -181,31 +234,89 @@ export default function PostJobPage() {
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password Field */}
|
||||
<div>
|
||||
<Label>Senha *</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={company.password}
|
||||
onChange={(e) => setCompany({ ...company, password: e.target.value })}
|
||||
placeholder="••••••••"
|
||||
className="pl-10"
|
||||
className="pl-10 pr-10" // Extra padding for eye icon
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-3 text-muted-foreground hover:text-foreground focus:outline-none"
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirm Password Field */}
|
||||
<div>
|
||||
<Label>Confirmar Senha *</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
value={company.confirmPassword}
|
||||
onChange={(e) => setCompany({ ...company, confirmPassword: e.target.value })}
|
||||
placeholder="••••••••"
|
||||
className="pl-10 pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
className="absolute right-3 top-3 text-muted-foreground hover:text-foreground focus:outline-none"
|
||||
>
|
||||
{showConfirmPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phone Field with DDI */}
|
||||
<div>
|
||||
<Label>Telefone</Label>
|
||||
<div className="relative">
|
||||
<div className="flex gap-2">
|
||||
<div className="w-[140px]">
|
||||
<Select value={company.ddi} onValueChange={(val) => setCompany({ ...company, ddi: val })}>
|
||||
<SelectTrigger className="pl-9 relative">
|
||||
<Globe className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<SelectValue placeholder="DDI" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{COUNTRY_CODES.map((item) => (
|
||||
<SelectItem key={item.country} value={item.code}>
|
||||
<span className="flex items-center justify-between w-full min-w-[80px]">
|
||||
<span>{item.code}</span>
|
||||
<span className="text-muted-foreground ml-2 text-xs truncate max-w-[100px]">{item.country}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="relative flex-1">
|
||||
<Phone className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={company.phone}
|
||||
type="tel"
|
||||
value={formatPhoneForDisplay(company.phone)}
|
||||
onChange={(e) => setCompany({ ...company, phone: e.target.value })}
|
||||
placeholder="(11) 99999-9999"
|
||||
placeholder="11 99999-9999"
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1 ml-1">
|
||||
Selecione o código do país e digite o número com DDD.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button onClick={() => setStep(2)} className="w-full">
|
||||
Próximo: Dados da Vaga
|
||||
</Button>
|
||||
|
|
@ -237,14 +348,11 @@ export default function PostJobPage() {
|
|||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Localização</Label>
|
||||
<div className="relative">
|
||||
<MapPin className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
<div>
|
||||
{/* Includes Label and Layout internally. */}
|
||||
<LocationPicker
|
||||
value={job.location}
|
||||
onChange={(e) => setJob({ ...job, location: e.target.value })}
|
||||
placeholder="São Paulo, SP (ou Remoto)"
|
||||
className="pl-10"
|
||||
onChange={(val) => setJob({ ...job, location: val })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -274,12 +382,14 @@ export default function PostJobPage() {
|
|||
<select
|
||||
value={job.employmentType}
|
||||
onChange={(e) => setJob({ ...job, employmentType: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
className="w-full px-3 py-2 border rounded-lg bg-background"
|
||||
>
|
||||
<option value="full-time">CLT</option>
|
||||
<option value="part-time">Meio Período</option>
|
||||
<option value="contract">PJ</option>
|
||||
<option value="internship">Estágio</option>
|
||||
<option value="">Qualquer</option>
|
||||
<option value="permanent">Permanente</option>
|
||||
<option value="contract">Contrato (PJ)</option>
|
||||
<option value="training">Estágio/Trainee</option>
|
||||
<option value="temporary">Temporário</option>
|
||||
<option value="voluntary">Voluntário</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -315,7 +425,7 @@ export default function PostJobPage() {
|
|||
</h3>
|
||||
<p><strong>Nome:</strong> {company.name}</p>
|
||||
<p><strong>Email:</strong> {company.email}</p>
|
||||
{company.phone && <p><strong>Telefone:</strong> {company.phone}</p>}
|
||||
{company.phone && <p><strong>Telefone:</strong> {company.ddi} {company.phone}</p>}
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded-lg p-4">
|
||||
<h3 className="font-semibold mb-2 flex items-center gap-2">
|
||||
|
|
|
|||
173
frontend/src/components/location-picker.tsx
Normal file
173
frontend/src/components/location-picker.tsx
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { locationsApi, Country, LocationSearchResult } from "@/lib/api";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { MapPin, Loader2, Globe } from "lucide-react";
|
||||
|
||||
interface LocationPickerProps {
|
||||
value: string; // The final string "City, State, Country"
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function LocationPicker({ value, onChange }: LocationPickerProps) {
|
||||
const [countries, setCountries] = useState<Country[]>([]);
|
||||
const [selectedCountry, setSelectedCountry] = useState<string>(""); // ID as string
|
||||
|
||||
// Search
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<LocationSearchResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showResults, setShowResults] = useState(false);
|
||||
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Initial Load
|
||||
useEffect(() => {
|
||||
locationsApi.listCountries().then(res => {
|
||||
setCountries(res);
|
||||
// Default to Brazil if available (User is Brazilian based on context)
|
||||
const br = res.find(c => c.iso2 === "BR");
|
||||
if (br) setSelectedCountry(br.id.toString());
|
||||
}).catch(console.error);
|
||||
}, []);
|
||||
|
||||
// Helper to get country name
|
||||
const getCountryName = (id: string) => countries.find(c => c.id.toString() === id)?.name || "";
|
||||
|
||||
// Debounce Search
|
||||
useEffect(() => {
|
||||
if (!selectedCountry || query.length < 2) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
setLoading(true);
|
||||
locationsApi.search(query, selectedCountry)
|
||||
.then(setResults)
|
||||
.catch(err => {
|
||||
console.error("Search failed", err);
|
||||
setResults([]);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [query, selectedCountry]);
|
||||
|
||||
// Click Outside to close
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
|
||||
setShowResults(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleSelectResult = (item: LocationSearchResult) => {
|
||||
const countryName = getCountryName(selectedCountry);
|
||||
let finalString = "";
|
||||
|
||||
if (item.type === 'city') {
|
||||
// City, State, Country
|
||||
finalString = `${item.name}, ${item.region_name}, ${countryName}`;
|
||||
} else {
|
||||
// State, Country
|
||||
finalString = `${item.name}, ${countryName}`;
|
||||
}
|
||||
|
||||
onChange(finalString);
|
||||
setQuery(item.name); // Updates visible input to just name or full string?
|
||||
// User image shows "City or state" as input.
|
||||
// Usually we show the full formatted string or just the name.
|
||||
// Let's show full string to be clear.
|
||||
setQuery(finalString);
|
||||
setShowResults(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* Country Select (Right in design but logical Left in flow, design shows separate) */}
|
||||
{/* User image: Left: City/State (Input with target icon), Right: Country (Dropdown) */}
|
||||
|
||||
{/* City/State Search (2/3 width) */}
|
||||
<div className="md:col-span-2 relative" ref={wrapperRef}>
|
||||
<Label className="mb-1.5 block">Localização da Vaga</Label>
|
||||
<div className="relative">
|
||||
<MapPin className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setShowResults(true);
|
||||
// If user types manually conform to standard?
|
||||
// We can allow free text, but search helps.
|
||||
onChange(e.target.value);
|
||||
}}
|
||||
onFocus={() => setShowResults(true)}
|
||||
placeholder={selectedCountry ? "Busque cidade ou estado..." : "Selecione o país primeiro"}
|
||||
className="pl-10"
|
||||
disabled={!selectedCountry}
|
||||
/>
|
||||
{loading && (
|
||||
<div className="absolute right-3 top-3">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results Dropdown */}
|
||||
{showResults && results.length > 0 && (
|
||||
<div className="absolute z-10 w-full mt-1 bg-background border rounded-md shadow-lg max-h-60 overflow-auto">
|
||||
{results.map((item) => (
|
||||
<button
|
||||
key={`${item.type}-${item.id}`}
|
||||
onClick={() => handleSelectResult(item)}
|
||||
className="w-full text-left px-4 py-2 hover:bg-muted text-sm flex flex-col"
|
||||
>
|
||||
<span className="font-medium">{item.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{item.type === 'city' ? `${item.region_name}, Cidade` : `Estado/Região`}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Country Select (1/3 width) */}
|
||||
<div>
|
||||
<Label className="mb-1.5 block">País</Label>
|
||||
<Select
|
||||
value={selectedCountry}
|
||||
onValueChange={(val) => {
|
||||
setSelectedCountry(val);
|
||||
setQuery(""); // Clear location on country change
|
||||
onChange("");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Selecione" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{countries.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id.toString()}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{c.emoji}</span>
|
||||
<span>{c.name}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -748,3 +748,53 @@ export const emailSettingsApi = {
|
|||
body: JSON.stringify(data),
|
||||
}),
|
||||
};
|
||||
|
||||
// --- Location API ---
|
||||
export interface Country {
|
||||
id: number;
|
||||
name: string;
|
||||
iso2: string;
|
||||
iso3: string;
|
||||
phone_code: string;
|
||||
currency: string;
|
||||
emoji: string;
|
||||
emojiU: string;
|
||||
}
|
||||
|
||||
export interface State {
|
||||
id: number;
|
||||
name: string;
|
||||
country_id: number;
|
||||
country_code: string;
|
||||
iso2: string;
|
||||
type: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
export interface City {
|
||||
id: number;
|
||||
name: string;
|
||||
state_id: number;
|
||||
country_id: number;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
export interface LocationSearchResult {
|
||||
id: number;
|
||||
name: string;
|
||||
type: 'state' | 'city';
|
||||
country_id: number;
|
||||
state_id?: number;
|
||||
region_name?: string;
|
||||
}
|
||||
|
||||
export const locationsApi = {
|
||||
listCountries: () => apiRequest<Country[]>("/api/v1/locations/countries"),
|
||||
listStates: (countryId: number | string) => apiRequest<State[]>(`/api/v1/locations/countries/${countryId}/states`),
|
||||
listCities: (stateId: number | string) => apiRequest<City[]>(`/api/v1/locations/states/${stateId}/cities`),
|
||||
search: (query: string, countryId: number | string) =>
|
||||
apiRequest<LocationSearchResult[]>(`/api/v1/locations/search?q=${encodeURIComponent(query)}&country_id=${countryId}`),
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue