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
|
.env
|
||||||
|
|
||||||
# Compiled binary
|
# 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 ---
|
// --- CORE ARCHITECTURE INITIALIZATION ---
|
||||||
// Infrastructure
|
// Infrastructure
|
||||||
|
// Infrastructure
|
||||||
userRepo := postgres.NewUserRepository(database.DB)
|
userRepo := postgres.NewUserRepository(database.DB)
|
||||||
companyRepo := postgres.NewCompanyRepository(database.DB)
|
companyRepo := postgres.NewCompanyRepository(database.DB)
|
||||||
|
locationRepo := postgres.NewLocationRepository(database.DB)
|
||||||
|
|
||||||
// Utils Services (Moved up for dependency injection)
|
// Utils Services (Moved up for dependency injection)
|
||||||
credentialsService := services.NewCredentialsService(database.DB)
|
credentialsService := services.NewCredentialsService(database.DB)
|
||||||
|
|
@ -45,6 +47,7 @@ func NewRouter() http.Handler {
|
||||||
fcmService := services.NewFCMService(credentialsService)
|
fcmService := services.NewFCMService(credentialsService)
|
||||||
cloudflareService := services.NewCloudflareService(credentialsService)
|
cloudflareService := services.NewCloudflareService(credentialsService)
|
||||||
emailService := services.NewEmailService(database.DB, credentialsService)
|
emailService := services.NewEmailService(database.DB, credentialsService)
|
||||||
|
locationService := services.NewLocationService(locationRepo)
|
||||||
|
|
||||||
adminService := services.NewAdminService(database.DB)
|
adminService := services.NewAdminService(database.DB)
|
||||||
|
|
||||||
|
|
@ -94,6 +97,7 @@ func NewRouter() http.Handler {
|
||||||
settingsHandler := apiHandlers.NewSettingsHandler(settingsService)
|
settingsHandler := apiHandlers.NewSettingsHandler(settingsService)
|
||||||
storageHandler := apiHandlers.NewStorageHandler(storageService)
|
storageHandler := apiHandlers.NewStorageHandler(storageService)
|
||||||
adminHandlers := apiHandlers.NewAdminHandlers(adminService, auditService, jobService, cloudflareService)
|
adminHandlers := apiHandlers.NewAdminHandlers(adminService, auditService, jobService, cloudflareService)
|
||||||
|
locationHandlers := apiHandlers.NewLocationHandlers(locationService)
|
||||||
|
|
||||||
// Initialize Legacy Handlers
|
// Initialize Legacy Handlers
|
||||||
jobHandler := handlers.NewJobHandler(jobService)
|
jobHandler := handlers.NewJobHandler(jobService)
|
||||||
|
|
@ -210,6 +214,12 @@ func NewRouter() http.Handler {
|
||||||
// Get Company by ID (Public)
|
// Get Company by ID (Public)
|
||||||
mux.HandleFunc("GET /api/v1/companies/{id}", coreHandlers.GetCompanyByID)
|
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
|
// Notifications Route
|
||||||
mux.Handle("GET /api/v1/notifications", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.ListNotifications)))
|
mux.Handle("GET /api/v1/notifications", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.ListNotifications)))
|
||||||
mux.Handle("POST /api/v1/tokens", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.SaveFCMToken)))
|
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 { Textarea } from "@/components/ui/textarea";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
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() {
|
export default function PostJobPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
@ -22,9 +47,14 @@ export default function PostJobPage() {
|
||||||
name: "",
|
name: "",
|
||||||
email: "",
|
email: "",
|
||||||
password: "",
|
password: "",
|
||||||
|
confirmPassword: "",
|
||||||
|
ddi: "+55",
|
||||||
phone: "",
|
phone: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||||
|
|
||||||
// Job data
|
// Job data
|
||||||
const [job, setJob] = useState({
|
const [job, setJob] = useState({
|
||||||
title: "",
|
title: "",
|
||||||
|
|
@ -32,16 +62,35 @@ export default function PostJobPage() {
|
||||||
location: "",
|
location: "",
|
||||||
salaryMin: "",
|
salaryMin: "",
|
||||||
salaryMax: "",
|
salaryMax: "",
|
||||||
employmentType: "full-time",
|
employmentType: "",
|
||||||
workMode: "remote",
|
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 () => {
|
const handleSubmit = async () => {
|
||||||
if (!company.name || !company.email || !company.password) {
|
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);
|
setStep(1);
|
||||||
return;
|
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) {
|
if (!job.title || !job.description) {
|
||||||
toast.error("Preencha os dados da vaga");
|
toast.error("Preencha os dados da vaga");
|
||||||
setStep(2);
|
setStep(2);
|
||||||
|
|
@ -52,6 +101,10 @@ export default function PostJobPage() {
|
||||||
try {
|
try {
|
||||||
const apiBase = process.env.NEXT_PUBLIC_API_URL || "";
|
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)
|
// 1. Register Company (creates user + company)
|
||||||
const registerRes = await fetch(`${apiBase}/api/v1/auth/register/company`, {
|
const registerRes = await fetch(`${apiBase}/api/v1/auth/register/company`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -60,7 +113,7 @@ export default function PostJobPage() {
|
||||||
companyName: company.name,
|
companyName: company.name,
|
||||||
email: company.email,
|
email: company.email,
|
||||||
password: company.password,
|
password: company.password,
|
||||||
phone: company.phone,
|
phone: finalPhone,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -181,31 +234,89 @@ export default function PostJobPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Password Field */}
|
||||||
<div>
|
<div>
|
||||||
<Label>Senha *</Label>
|
<Label>Senha *</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type={showPassword ? "text" : "password"}
|
||||||
value={company.password}
|
value={company.password}
|
||||||
onChange={(e) => setCompany({ ...company, password: e.target.value })}
|
onChange={(e) => setCompany({ ...company, password: e.target.value })}
|
||||||
placeholder="••••••••"
|
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>
|
||||||
</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>
|
<div>
|
||||||
<Label>Telefone</Label>
|
<Label>Telefone</Label>
|
||||||
<div className="relative">
|
<div className="flex gap-2">
|
||||||
<Phone className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
<div className="w-[140px]">
|
||||||
<Input
|
<Select value={company.ddi} onValueChange={(val) => setCompany({ ...company, ddi: val })}>
|
||||||
value={company.phone}
|
<SelectTrigger className="pl-9 relative">
|
||||||
onChange={(e) => setCompany({ ...company, phone: e.target.value })}
|
<Globe className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
placeholder="(11) 99999-9999"
|
<SelectValue placeholder="DDI" />
|
||||||
className="pl-10"
|
</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
|
||||||
|
type="tel"
|
||||||
|
value={formatPhoneForDisplay(company.phone)}
|
||||||
|
onChange={(e) => setCompany({ ...company, phone: e.target.value })}
|
||||||
|
placeholder="11 99999-9999"
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<Button onClick={() => setStep(2)} className="w-full">
|
<Button onClick={() => setStep(2)} className="w-full">
|
||||||
Próximo: Dados da Vaga
|
Próximo: Dados da Vaga
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -237,14 +348,11 @@ export default function PostJobPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label>Localização</Label>
|
<div>
|
||||||
<div className="relative">
|
{/* Includes Label and Layout internally. */}
|
||||||
<MapPin className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
<LocationPicker
|
||||||
<Input
|
|
||||||
value={job.location}
|
value={job.location}
|
||||||
onChange={(e) => setJob({ ...job, location: e.target.value })}
|
onChange={(val) => setJob({ ...job, location: val })}
|
||||||
placeholder="São Paulo, SP (ou Remoto)"
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -274,12 +382,14 @@ export default function PostJobPage() {
|
||||||
<select
|
<select
|
||||||
value={job.employmentType}
|
value={job.employmentType}
|
||||||
onChange={(e) => setJob({ ...job, employmentType: e.target.value })}
|
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="">Qualquer</option>
|
||||||
<option value="part-time">Meio Período</option>
|
<option value="permanent">Permanente</option>
|
||||||
<option value="contract">PJ</option>
|
<option value="contract">Contrato (PJ)</option>
|
||||||
<option value="internship">Estágio</option>
|
<option value="training">Estágio/Trainee</option>
|
||||||
|
<option value="temporary">Temporário</option>
|
||||||
|
<option value="voluntary">Voluntário</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -315,7 +425,7 @@ export default function PostJobPage() {
|
||||||
</h3>
|
</h3>
|
||||||
<p><strong>Nome:</strong> {company.name}</p>
|
<p><strong>Nome:</strong> {company.name}</p>
|
||||||
<p><strong>Email:</strong> {company.email}</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>
|
||||||
<div className="bg-muted/50 rounded-lg p-4">
|
<div className="bg-muted/50 rounded-lg p-4">
|
||||||
<h3 className="font-semibold mb-2 flex items-center gap-2">
|
<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),
|
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