Merge branch 'dev' of github.com:rede5/gohorsejobs into dev

This commit is contained in:
GoHorse Deploy 2026-02-15 14:20:28 +00:00
commit a3c2a18e61
17 changed files with 2031 additions and 2422 deletions

View file

@ -16,6 +16,16 @@ type StorageHandler struct {
storageService *services.StorageService storageService *services.StorageService
} }
type uploadURLRequest struct {
Filename string `json:"filename"`
ContentType string `json:"contentType"`
Folder string `json:"folder"`
}
type downloadURLRequest struct {
Key string `json:"key"`
}
func NewStorageHandler(s *services.StorageService) *StorageHandler { func NewStorageHandler(s *services.StorageService) *StorageHandler {
return &StorageHandler{storageService: s} return &StorageHandler{storageService: s}
} }
@ -31,7 +41,15 @@ func (h *StorageHandler) GetUploadURL(w http.ResponseWriter, r *http.Request) {
userIDVal := r.Context().Value(middleware.ContextUserID) userIDVal := r.Context().Value(middleware.ContextUserID)
userID, _ := userIDVal.(string) userID, _ := userIDVal.(string)
var body uploadURLRequest
if r.Method == http.MethodPost {
_ = json.NewDecoder(r.Body).Decode(&body)
}
folder := r.URL.Query().Get("folder") folder := r.URL.Query().Get("folder")
if folder == "" {
folder = body.Folder
}
if folder == "" { if folder == "" {
folder = "uploads" folder = "uploads"
} }
@ -47,10 +65,20 @@ func (h *StorageHandler) GetUploadURL(w http.ResponseWriter, r *http.Request) {
} }
filename := r.URL.Query().Get("filename") filename := r.URL.Query().Get("filename")
if filename == "" {
filename = body.Filename
}
contentType := r.URL.Query().Get("contentType") contentType := r.URL.Query().Get("contentType")
if contentType == "" {
contentType = body.ContentType
}
if filename == "" {
http.Error(w, "Filename is required", http.StatusBadRequest)
return
}
// Validate folder // Validate folder
validFolders := map[string]bool{"avatars": true, "resumes": true, "logos": true, "uploads": true} validFolders := map[string]bool{"avatars": true, "resumes": true, "logos": true, "uploads": true, "documents": true}
if !validFolders[folder] { if !validFolders[folder] {
http.Error(w, "Invalid folder", http.StatusBadRequest) http.Error(w, "Invalid folder", http.StatusBadRequest)
return return
@ -83,12 +111,18 @@ func (h *StorageHandler) GetUploadURL(w http.ResponseWriter, r *http.Request) {
// Return simple JSON // Return simple JSON
resp := map[string]string{ resp := map[string]string{
"url": url, "url": url,
"uploadUrl": url,
"key": key, // Client needs key to save to DB profile "key": key, // Client needs key to save to DB profile
"publicUrl": publicURL, // Public URL for immediate use "publicUrl": publicURL, // Public URL for immediate use
} }
respWithExpiry := map[string]interface{}{}
for k, v := range resp {
respWithExpiry[k] = v
}
respWithExpiry["expiresIn"] = int((15 * time.Minute).Seconds())
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp) json.NewEncoder(w).Encode(respWithExpiry)
} }
// UploadFile handles direct file uploads via proxy // UploadFile handles direct file uploads via proxy
@ -116,7 +150,7 @@ func (h *StorageHandler) UploadFile(w http.ResponseWriter, r *http.Request) {
folder = "uploads" folder = "uploads"
} }
validFolders := map[string]bool{"avatars": true, "resumes": true, "logos": true, "uploads": true} validFolders := map[string]bool{"avatars": true, "resumes": true, "logos": true, "uploads": true, "documents": true}
if !validFolders[folder] { if !validFolders[folder] {
http.Error(w, "Invalid folder", http.StatusBadRequest) http.Error(w, "Invalid folder", http.StatusBadRequest)
return return
@ -163,3 +197,56 @@ func (h *StorageHandler) UploadFile(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp) json.NewEncoder(w).Encode(resp)
} }
// GetDownloadURL returns a pre-signed URL for downloading a file.
func (h *StorageHandler) GetDownloadURL(w http.ResponseWriter, r *http.Request) {
var body downloadURLRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if body.Key == "" {
http.Error(w, "Key is required", http.StatusBadRequest)
return
}
url, err := h.storageService.GetPresignedDownloadURL(r.Context(), body.Key)
if err != nil {
http.Error(w, "Failed to generate download URL: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"downloadUrl": url,
"expiresIn": int((60 * time.Minute).Seconds()),
})
}
// DeleteFile removes an object from storage by key.
func (h *StorageHandler) DeleteFile(w http.ResponseWriter, r *http.Request) {
key := r.URL.Query().Get("key")
if key == "" {
http.Error(w, "Key query parameter is required", http.StatusBadRequest)
return
}
if err := h.storageService.DeleteObject(r.Context(), key); err != nil {
http.Error(w, "Failed to delete file: "+err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// TestConnection validates storage credentials and bucket access.
func (h *StorageHandler) TestConnection(w http.ResponseWriter, r *http.Request) {
if err := h.storageService.TestConnection(r.Context()); err != nil {
http.Error(w, "Storage connection failed: "+err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"message": "Storage connection successful"})
}

View file

@ -47,8 +47,9 @@ type Job struct {
FeaturedUntil *time.Time `json:"featuredUntil,omitempty" db:"featured_until"` FeaturedUntil *time.Time `json:"featuredUntil,omitempty" db:"featured_until"`
// Metadata // Metadata
CreatedAt time.Time `json:"createdAt" db:"created_at"` DatePosted *time.Time `json:"datePosted,omitempty" db:"date_posted"`
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` CreatedAt time.Time `json:"createdAt" db:"created_at"`
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
} }
// JobWithCompany includes company information // JobWithCompany includes company information

View file

@ -249,8 +249,12 @@ func NewRouter() http.Handler {
// Storage (Presigned URL) // Storage (Presigned URL)
mux.Handle("GET /api/v1/storage/upload-url", authMiddleware.OptionalHeaderAuthGuard(http.HandlerFunc(storageHandler.GetUploadURL))) mux.Handle("GET /api/v1/storage/upload-url", authMiddleware.OptionalHeaderAuthGuard(http.HandlerFunc(storageHandler.GetUploadURL)))
mux.Handle("POST /api/v1/storage/upload-url", authMiddleware.OptionalHeaderAuthGuard(http.HandlerFunc(storageHandler.GetUploadURL)))
mux.Handle("POST /api/v1/storage/download-url", authMiddleware.OptionalHeaderAuthGuard(http.HandlerFunc(storageHandler.GetDownloadURL)))
mux.Handle("DELETE /api/v1/storage/files", authMiddleware.OptionalHeaderAuthGuard(http.HandlerFunc(storageHandler.DeleteFile)))
// Storage (Direct Proxy) // Storage (Direct Proxy)
mux.Handle("POST /api/v1/storage/upload", authMiddleware.OptionalHeaderAuthGuard(http.HandlerFunc(storageHandler.UploadFile))) mux.Handle("POST /api/v1/storage/upload", authMiddleware.OptionalHeaderAuthGuard(http.HandlerFunc(storageHandler.UploadFile)))
mux.Handle("POST /api/v1/admin/storage/test-connection", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(storageHandler.TestConnection))))
mux.Handle("POST /api/v1/system/cloudflare/purge", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.PurgeCache)))) mux.Handle("POST /api/v1/system/cloudflare/purge", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.PurgeCache))))

View file

@ -26,9 +26,9 @@ func (s *JobService) CreateJob(req dto.CreateJobRequest, createdBy string) (*mod
INSERT INTO jobs ( INSERT INTO jobs (
company_id, created_by, title, description, salary_min, salary_max, salary_type, currency, company_id, created_by, title, description, salary_min, salary_max, salary_type, currency,
employment_type, working_hours, location, region_id, city_id, employment_type, working_hours, location, region_id, city_id,
requirements, benefits, questions, visa_support, language_level, status, created_at, updated_at, salary_negotiable requirements, benefits, questions, visa_support, language_level, status, date_posted, created_at, updated_at, salary_negotiable
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22) ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23)
RETURNING id, created_at, updated_at RETURNING id, date_posted, created_at, updated_at
` `
job := &models.Job{ job := &models.Job{
@ -52,6 +52,7 @@ func (s *JobService) CreateJob(req dto.CreateJobRequest, createdBy string) (*mod
VisaSupport: req.VisaSupport, VisaSupport: req.VisaSupport,
LanguageLevel: req.LanguageLevel, LanguageLevel: req.LanguageLevel,
Status: req.Status, Status: req.Status,
DatePosted: ptrTime(time.Now()),
CreatedAt: time.Now(), CreatedAt: time.Now(),
UpdatedAt: time.Now(), UpdatedAt: time.Now(),
} }
@ -63,8 +64,8 @@ func (s *JobService) CreateJob(req dto.CreateJobRequest, createdBy string) (*mod
query, query,
job.CompanyID, job.CreatedBy, job.Title, job.Description, job.SalaryMin, job.SalaryMax, job.SalaryType, job.Currency, job.CompanyID, job.CreatedBy, job.Title, job.Description, job.SalaryMin, job.SalaryMax, job.SalaryType, job.Currency,
job.EmploymentType, job.WorkingHours, job.Location, job.RegionID, job.CityID, job.EmploymentType, job.WorkingHours, job.Location, job.RegionID, job.CityID,
job.Requirements, job.Benefits, job.Questions, job.VisaSupport, job.LanguageLevel, job.Status, job.CreatedAt, job.UpdatedAt, job.SalaryNegotiable, job.Requirements, job.Benefits, job.Questions, job.VisaSupport, job.LanguageLevel, job.Status, job.DatePosted, job.CreatedAt, job.UpdatedAt, job.SalaryNegotiable,
).Scan(&job.ID, &job.CreatedAt, &job.UpdatedAt) ).Scan(&job.ID, &job.DatePosted, &job.CreatedAt, &job.UpdatedAt)
if err != nil { if err != nil {
fmt.Printf("[JOB_SERVICE ERROR] INSERT query failed: %v\n", err) fmt.Printf("[JOB_SERVICE ERROR] INSERT query failed: %v\n", err)
@ -80,7 +81,7 @@ func (s *JobService) GetJobs(filter dto.JobFilterQuery) ([]models.JobWithCompany
baseQuery := ` baseQuery := `
SELECT SELECT
j.id, j.company_id, j.title, j.description, j.salary_min, j.salary_max, j.salary_type, j.id, j.company_id, j.title, j.description, j.salary_min, j.salary_max, j.salary_type,
j.employment_type, j.work_mode, j.working_hours, j.location, j.status, j.salary_negotiable, j.is_featured, j.created_at, j.updated_at, j.employment_type, j.work_mode, j.working_hours, j.location, j.status, j.salary_negotiable, j.is_featured, COALESCE(j.date_posted, j.created_at) AS date_posted, j.created_at, j.updated_at,
CASE CASE
WHEN c.type = 'CANDIDATE_WORKSPACE' OR c.name LIKE 'Candidate - %' THEN '' WHEN c.type = 'CANDIDATE_WORKSPACE' OR c.name LIKE 'Candidate - %' THEN ''
ELSE COALESCE(c.name, '') ELSE COALESCE(c.name, '')
@ -240,19 +241,19 @@ func (s *JobService) GetJobs(filter dto.JobFilterQuery) ([]models.JobWithCompany
} }
if hours > 0 { if hours > 0 {
cutoffTime := time.Now().Add(-time.Duration(hours) * time.Hour) cutoffTime := time.Now().Add(-time.Duration(hours) * time.Hour)
baseQuery += fmt.Sprintf(" AND j.created_at >= $%d", argId) baseQuery += fmt.Sprintf(" AND COALESCE(j.date_posted, j.created_at) >= $%d", argId)
countQuery += fmt.Sprintf(" AND j.created_at >= $%d", argId) countQuery += fmt.Sprintf(" AND COALESCE(j.date_posted, j.created_at) >= $%d", argId)
args = append(args, cutoffTime) args = append(args, cutoffTime)
argId++ argId++
} }
} }
// Sorting // Sorting
sortClause := " ORDER BY j.is_featured DESC, j.created_at DESC" // default sortClause := " ORDER BY j.is_featured DESC, COALESCE(j.date_posted, j.created_at) DESC" // default
if filter.SortBy != nil { if filter.SortBy != nil {
switch *filter.SortBy { switch *filter.SortBy {
case "recent", "date": case "recent", "date":
sortClause = " ORDER BY j.is_featured DESC, j.created_at DESC" sortClause = " ORDER BY j.is_featured DESC, COALESCE(j.date_posted, j.created_at) DESC"
case "salary", "salary_asc": case "salary", "salary_asc":
sortClause = " ORDER BY j.salary_min ASC NULLS LAST" sortClause = " ORDER BY j.salary_min ASC NULLS LAST"
case "salary_desc": case "salary_desc":
@ -298,7 +299,7 @@ func (s *JobService) GetJobs(filter dto.JobFilterQuery) ([]models.JobWithCompany
var j models.JobWithCompany var j models.JobWithCompany
if err := rows.Scan( if err := rows.Scan(
&j.ID, &j.CompanyID, &j.Title, &j.Description, &j.SalaryMin, &j.SalaryMax, &j.SalaryType, &j.ID, &j.CompanyID, &j.Title, &j.Description, &j.SalaryMin, &j.SalaryMax, &j.SalaryType,
&j.EmploymentType, &j.WorkMode, &j.WorkingHours, &j.Location, &j.Status, &j.SalaryNegotiable, &j.IsFeatured, &j.CreatedAt, &j.UpdatedAt, &j.EmploymentType, &j.WorkMode, &j.WorkingHours, &j.Location, &j.Status, &j.SalaryNegotiable, &j.IsFeatured, &j.DatePosted, &j.CreatedAt, &j.UpdatedAt,
&j.CompanyName, &j.CompanyLogoURL, &j.RegionName, &j.CityName, &j.CompanyName, &j.CompanyLogoURL, &j.RegionName, &j.CityName,
&j.ViewCount, &j.FeaturedUntil, &j.ApplicationsCount, &j.ViewCount, &j.FeaturedUntil, &j.ApplicationsCount,
); err != nil { ); err != nil {
@ -321,14 +322,14 @@ func (s *JobService) GetJobByID(id string) (*models.Job, error) {
query := ` query := `
SELECT id, company_id, title, description, salary_min, salary_max, salary_type, SELECT id, company_id, title, description, salary_min, salary_max, salary_type,
employment_type, working_hours, location, region_id, city_id, employment_type, working_hours, location, region_id, city_id,
requirements, benefits, visa_support, language_level, status, is_featured, featured_until, view_count, created_at, updated_at, requirements, benefits, visa_support, language_level, status, is_featured, featured_until, view_count, date_posted, created_at, updated_at,
salary_negotiable, currency, work_mode salary_negotiable, currency, work_mode
FROM jobs WHERE id = $1 FROM jobs WHERE id = $1
` `
err := s.DB.QueryRow(query, id).Scan( err := s.DB.QueryRow(query, id).Scan(
&j.ID, &j.CompanyID, &j.Title, &j.Description, &j.SalaryMin, &j.SalaryMax, &j.SalaryType, &j.ID, &j.CompanyID, &j.Title, &j.Description, &j.SalaryMin, &j.SalaryMax, &j.SalaryType,
&j.EmploymentType, &j.WorkingHours, &j.Location, &j.RegionID, &j.CityID, &j.EmploymentType, &j.WorkingHours, &j.Location, &j.RegionID, &j.CityID,
&j.Requirements, &j.Benefits, &j.VisaSupport, &j.LanguageLevel, &j.Status, &j.IsFeatured, &j.FeaturedUntil, &j.ViewCount, &j.CreatedAt, &j.UpdatedAt, &j.Requirements, &j.Benefits, &j.VisaSupport, &j.LanguageLevel, &j.Status, &j.IsFeatured, &j.FeaturedUntil, &j.ViewCount, &j.DatePosted, &j.CreatedAt, &j.UpdatedAt,
&j.SalaryNegotiable, &j.Currency, &j.WorkMode, &j.SalaryNegotiable, &j.Currency, &j.WorkMode,
) )
if err != nil { if err != nil {
@ -470,3 +471,7 @@ func (s *JobService) DeleteJob(id string) error {
_, err := s.DB.Exec("DELETE FROM jobs WHERE id = $1", id) _, err := s.DB.Exec("DELETE FROM jobs WHERE id = $1", id)
return err return err
} }
func ptrTime(t time.Time) *time.Time {
return &t
}

View file

@ -85,6 +85,16 @@ func (s *StorageService) getConfig(ctx context.Context) (UploadConfig, error) {
} }
func (s *StorageService) getClient(ctx context.Context) (*s3.PresignClient, string, error) { func (s *StorageService) getClient(ctx context.Context) (*s3.PresignClient, string, error) {
client, bucket, err := s.getS3Client(ctx)
if err != nil {
return nil, "", err
}
psClient := s3.NewPresignClient(client)
return psClient, bucket, nil
}
func (s *StorageService) getS3Client(ctx context.Context) (*s3.Client, string, error) {
uCfg, err := s.getConfig(ctx) uCfg, err := s.getConfig(ctx)
if err != nil { if err != nil {
return nil, "", err return nil, "", err
@ -104,13 +114,20 @@ func (s *StorageService) getClient(ctx context.Context) (*s3.PresignClient, stri
o.BaseEndpoint = aws.String(uCfg.Endpoint) o.BaseEndpoint = aws.String(uCfg.Endpoint)
o.UsePathStyle = true // Often needed for R2/MinIO o.UsePathStyle = true // Often needed for R2/MinIO
}) })
return client, uCfg.Bucket, nil
}
psClient := s3.NewPresignClient(client) func (s *StorageService) sanitizeObjectKey(key string) string {
return psClient, uCfg.Bucket, nil return strings.TrimLeft(strings.TrimSpace(key), "/")
} }
// GetPresignedUploadURL generates a URL for PUT requests // GetPresignedUploadURL generates a URL for PUT requests
func (s *StorageService) GetPresignedUploadURL(ctx context.Context, key string, contentType string) (string, error) { func (s *StorageService) GetPresignedUploadURL(ctx context.Context, key string, contentType string) (string, error) {
key = s.sanitizeObjectKey(key)
if key == "" {
return "", fmt.Errorf("key is required")
}
psClient, bucket, err := s.getClient(ctx) psClient, bucket, err := s.getClient(ctx)
if err != nil { if err != nil {
return "", err return "", err
@ -131,6 +148,54 @@ func (s *StorageService) GetPresignedUploadURL(ctx context.Context, key string,
return req.URL, nil return req.URL, nil
} }
// GetPresignedDownloadURL generates a URL for GET requests.
func (s *StorageService) GetPresignedDownloadURL(ctx context.Context, key string) (string, error) {
key = s.sanitizeObjectKey(key)
if key == "" {
return "", fmt.Errorf("key is required")
}
psClient, bucket, err := s.getClient(ctx)
if err != nil {
return "", err
}
req, err := psClient.PresignGetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
}, func(o *s3.PresignOptions) {
o.Expires = 60 * time.Minute
})
if err != nil {
return "", fmt.Errorf("failed to presign download: %w", err)
}
return req.URL, nil
}
// DeleteObject removes an object from storage.
func (s *StorageService) DeleteObject(ctx context.Context, key string) error {
key = s.sanitizeObjectKey(key)
if key == "" {
return fmt.Errorf("key is required")
}
client, bucket, err := s.getS3Client(ctx)
if err != nil {
return err
}
_, err = client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
})
if err != nil {
return fmt.Errorf("failed to delete object: %w", err)
}
return nil
}
// TestConnection checks if the creds are valid and bucket is accessible // TestConnection checks if the creds are valid and bucket is accessible
func (s *StorageService) TestConnection(ctx context.Context) error { func (s *StorageService) TestConnection(ctx context.Context) error {
psClient, bucket, err := s.getClient(ctx) psClient, bucket, err := s.getClient(ctx)

View file

@ -0,0 +1,16 @@
-- Migration: Add date_posted to jobs
-- Description: Supports explicit posting date field consumed by frontend filters/sorting.
ALTER TABLE jobs
ADD COLUMN IF NOT EXISTS date_posted TIMESTAMP WITH TIME ZONE;
UPDATE jobs
SET date_posted = created_at
WHERE date_posted IS NULL;
ALTER TABLE jobs
ALTER COLUMN date_posted SET DEFAULT NOW();
CREATE INDEX IF NOT EXISTS idx_jobs_date_posted ON jobs(date_posted DESC);
COMMENT ON COLUMN jobs.date_posted IS 'Public posting timestamp used by listing/filtering UX';

View file

@ -0,0 +1,12 @@
# Rotas de publicar vaga (frontend)
As principais rotas usadas para publicar vagas no projeto são:
1. `/publicar-vaga` — landing/formulário público de anúncio de vaga.
2. `/post-job` — fluxo principal multi-etapas para publicação com dados da empresa e da vaga.
3. `/register/job` — formulário público alternativo para criação de vaga.
## Pontos de entrada no sistema
- Link para `/publicar-vaga` na página **About** e no **Footer**.
- Link para `/post-job` na página **Contact**.

View file

@ -1,330 +1,5 @@
"use client" import { redirect } from "next/navigation"
import { useState, useEffect } from "react" export default function DashboardNewJobRedirectPage() {
import { useRouter } from "next/navigation" redirect("/publicar-vaga")
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Separator } from "@/components/ui/separator"
import { ArrowLeft, Loader2, Building2, DollarSign, FileText, Briefcase, MapPin, Clock } from "lucide-react"
import { jobsApi, adminCompaniesApi, type CreateJobPayload, type AdminCompany } from "@/lib/api"
import { useTranslation } from "@/lib/i18n"
import { toast } from "sonner"
export default function NewJobPage() {
const router = useRouter()
const { t } = useTranslation()
const [isSubmitting, setIsSubmitting] = useState(false)
const [companies, setCompanies] = useState<AdminCompany[]>([])
const [loadingCompanies, setLoadingCompanies] = useState(true)
const [formData, setFormData] = useState({
// Job Details
title: "",
description: "",
location: "",
// Salary
salaryMin: "",
salaryMax: "",
salaryType: "monthly",
currency: "BRL",
employmentType: "",
workingHours: "",
// Company
companyId: "",
// Status
status: "draft" as "draft" | "published",
})
useEffect(() => {
const loadCompanies = async () => {
try {
setLoadingCompanies(true)
const data = await adminCompaniesApi.list(undefined, 1, 100)
setCompanies(data.data ?? [])
} catch (error) {
console.error("Failed to load companies:", error)
toast.error("Failed to load companies")
} finally {
setLoadingCompanies(false)
}
}
loadCompanies()
}, [])
const updateField = (field: string, value: string | boolean) => {
setFormData(prev => ({ ...prev, [field]: value }))
}
const canSubmit = () => {
return formData.title.length >= 5 &&
formData.description.length >= 20 &&
formData.companyId !== ""
}
const handleSubmit = async (publishNow: boolean = false) => {
if (!canSubmit()) {
toast.error("Please fill in all required fields")
return
}
setIsSubmitting(true)
try {
const payload: CreateJobPayload = {
companyId: formData.companyId,
title: formData.title,
description: formData.description,
location: formData.location || undefined,
employmentType: formData.employmentType as CreateJobPayload['employmentType'] || undefined,
salaryMin: formData.salaryMin ? parseFloat(formData.salaryMin) : undefined,
salaryMax: formData.salaryMax ? parseFloat(formData.salaryMax) : undefined,
salaryType: formData.salaryType as CreateJobPayload['salaryType'] || undefined,
currency: formData.currency as CreateJobPayload['currency'] || undefined,
workingHours: formData.workingHours || undefined,
status: publishNow ? "published" : "draft",
}
await jobsApi.create(payload)
toast.success(publishNow ? "Job published successfully!" : "Job saved as draft!")
router.push("/dashboard/jobs")
} catch (error) {
console.error("Failed to create job:", error)
toast.error("Failed to create job. Please try again.")
} finally {
setIsSubmitting(false)
}
}
return (
<div className="max-w-4xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.back()}>
<ArrowLeft className="h-5 w-5" />
</Button>
<div>
<h1 className="text-3xl font-bold">{t('admin.jobs.newJob')}</h1>
<p className="text-muted-foreground">{t('admin.jobs.edit.subtitle')}</p>
</div>
</div>
{/* Job Details Section */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
{t('admin.jobs.details.title')}
</CardTitle>
<CardDescription>{t('admin.jobs.details.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">{t('admin.jobs.edit.jobTitle')} *</Label>
<Input
id="title"
placeholder="e.g. Senior Software Engineer"
value={formData.title}
onChange={(e) => updateField("title", e.target.value)}
/>
{formData.title.length > 0 && formData.title.length < 5 && (
<p className="text-xs text-destructive">Title must be at least 5 characters</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="description">{t('admin.jobs.details.description')} *</Label>
<Textarea
id="description"
placeholder="Describe the role, responsibilities, and requirements..."
rows={6}
value={formData.description}
onChange={(e) => updateField("description", e.target.value)}
/>
{formData.description.length > 0 && formData.description.length < 20 && (
<p className="text-xs text-destructive">Description must be at least 20 characters</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="location" className="flex items-center gap-1">
<MapPin className="h-4 w-4" />
{t('admin.candidates_page.table.location')}
</Label>
<Input
id="location"
placeholder="e.g. São Paulo, SP or Remote"
value={formData.location}
onChange={(e) => updateField("location", e.target.value)}
/>
</div>
</CardContent>
</Card>
{/* Salary & Type Section */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<DollarSign className="h-5 w-5" />
Salary & Contract
</CardTitle>
<CardDescription>Compensation and employment details</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Minimum Salary</Label>
<Input
type="number"
placeholder="e.g. 5000"
value={formData.salaryMin}
onChange={(e) => updateField("salaryMin", e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Maximum Salary</Label>
<Input
type="number"
placeholder="e.g. 10000"
value={formData.salaryMax}
onChange={(e) => updateField("salaryMax", e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label>Currency</Label>
<Select value={formData.currency} onValueChange={(v) => updateField("currency", v)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="BRL">R$ (BRL)</SelectItem>
<SelectItem value="USD">$ (USD)</SelectItem>
<SelectItem value="EUR"> (EUR)</SelectItem>
<SelectItem value="GBP">£ (GBP)</SelectItem>
<SelectItem value="JPY">¥ (JPY)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Salary Period</Label>
<Select value={formData.salaryType} onValueChange={(v) => updateField("salaryType", v)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="hourly">Per hour</SelectItem>
<SelectItem value="daily">Per day</SelectItem>
<SelectItem value="weekly">Per week</SelectItem>
<SelectItem value="monthly">Per month</SelectItem>
<SelectItem value="yearly">Per year</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Contract Type</Label>
<Select value={formData.employmentType} onValueChange={(v) => updateField("employmentType", v)}>
<SelectTrigger><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>
<SelectItem value="permanent">Permanent</SelectItem>
<SelectItem value="full-time">Full-time</SelectItem>
<SelectItem value="part-time">Part-time</SelectItem>
<SelectItem value="contract">Contract</SelectItem>
<SelectItem value="temporary">Temporary</SelectItem>
<SelectItem value="training">Training</SelectItem>
<SelectItem value="voluntary">Voluntary</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label className="flex items-center gap-1">
<Clock className="h-4 w-4" />
Working Hours
</Label>
<Input
placeholder="e.g. 9:00 - 18:00, Mon-Fri"
value={formData.workingHours}
onChange={(e) => updateField("workingHours", e.target.value)}
/>
</div>
</CardContent>
</Card>
{/* Company Section */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building2 className="h-5 w-5" />
Company
</CardTitle>
<CardDescription>Select the company posting this job</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label>{t('admin.jobs.table.company')} *</Label>
{loadingCompanies ? (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading companies...
</div>
) : (
<Select value={formData.companyId} onValueChange={(v) => updateField("companyId", v)}>
<SelectTrigger><SelectValue placeholder="Select a company" /></SelectTrigger>
<SelectContent>
{companies.length === 0 ? (
<SelectItem value="__none" disabled>No companies available</SelectItem>
) : (
companies.map((company) => (
<SelectItem key={company.id} value={company.id}>
{company.name}
</SelectItem>
))
)}
</SelectContent>
</Select>
)}
</div>
</CardContent>
</Card>
<Separator />
{/* Action Buttons */}
<div className="flex justify-between items-center">
<Button variant="outline" onClick={() => router.back()}>
{t('admin.jobs.edit.cancel')}
</Button>
<div className="flex gap-3">
<Button
variant="outline"
onClick={() => handleSubmit(false)}
disabled={isSubmitting || !canSubmit()}
>
{isSubmitting ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Saving...
</>
) : (
"Save as Draft"
)}
</Button>
<Button
onClick={() => handleSubmit(true)}
disabled={isSubmitting || !canSubmit()}
>
{isSubmitting ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Publishing...
</>
) : (
<>
<Briefcase className="h-4 w-4 mr-2" />
{t('admin.jobs.edit.save')}
</>
)}
</Button>
</div>
</div>
</div>
)
} }

View file

@ -191,7 +191,7 @@ export default function AdminJobsPage() {
<h1 className="text-3xl font-bold text-foreground">{t('admin.jobs.title')}</h1> <h1 className="text-3xl font-bold text-foreground">{t('admin.jobs.title')}</h1>
<p className="text-muted-foreground mt-1">{t('admin.jobs.subtitle')}</p> <p className="text-muted-foreground mt-1">{t('admin.jobs.subtitle')}</p>
</div> </div>
<Link href="/dashboard/jobs/new"> <Link href="/publicar-vaga">
<Button className="gap-2"> <Button className="gap-2">
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
{t('admin.jobs.newJob')} {t('admin.jobs.newJob')}

View file

@ -140,7 +140,7 @@ export default function Home() {
<div className="overflow-hidden" ref={emblaRef}> <div className="overflow-hidden" ref={emblaRef}>
<div className="flex gap-6"> <div className="flex gap-6">
{mockJobs.slice(0, 8).map((job, index) => ( {mockJobs.slice(0, 8).map((job, index) => (
<div key={`latest-${job.id}-${index}`} className="flex-[0_0_100%] sm:flex-[0_0_50%] lg:flex-[0_0_33.333%] xl:flex-[0_0_25%] min-w-0 pb-1"> <div key={`latest-${job.id}-${index}`} className="flex-[0_0_100%] sm:flex-[0_0_50%] lg:flex-[0_0_50%] xl:flex-[0_0_33.333%] 2xl:flex-[0_0_25%] min-w-0 pb-1">
<JobCard job={job} /> <JobCard job={job} />
</div> </div>
))} ))}
@ -163,7 +163,7 @@ export default function Home() {
</Link> </Link>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-6">
{mockJobs.slice(0, 8).map((job, index) => ( {mockJobs.slice(0, 8).map((job, index) => (
<JobCard key={`more-${job.id}-${index}`} job={job} /> <JobCard key={`more-${job.id}-${index}`} job={job} />
))} ))}

File diff suppressed because it is too large Load diff

View file

@ -1,40 +1,116 @@
"use client" "use client"
import { useState } from "react" import { useEffect, useState } from "react"
import { Navbar } from "@/components/navbar" import { Navbar } from "@/components/navbar"
import { Footer } from "@/components/footer" import { Footer } from "@/components/footer"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" 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 { Check, Briefcase, Users, TrendingUp, Building2 } from "lucide-react" import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Check, Briefcase, Loader2, MapPin, DollarSign, Clock, Building2 } from "lucide-react"
import Image from "next/image" import Image from "next/image"
import Link from "next/link" import Link from "next/link"
import { jobsApi, adminCompaniesApi, type CreateJobPayload, type AdminCompany } from "@/lib/api"
import { toast } from "sonner"
export default function PublicarVagaPage() { export default function PublicarVagaPage() {
const [loading, setLoading] = useState(false)
const [loadingCompanies, setLoadingCompanies] = useState(true)
const [companies, setCompanies] = useState<AdminCompany[]>([])
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
cargoVaga: "", title: "",
nomeEmpresa: "", description: "",
cnpj: "", location: "",
numeroFuncionarios: "", salaryMin: "",
cep: "", salaryMax: "",
nome: "", salaryType: "monthly",
sobrenome: "", currency: "BRL",
email: "", employmentType: "",
telefone: "", workingHours: "",
celular: "" companyId: "",
}) })
const [acceptTerms, setAcceptTerms] = useState(false) useEffect(() => {
const [acceptMarketing, setAcceptMarketing] = useState(false) const loadCompanies = async () => {
try {
setLoadingCompanies(true)
const data = await adminCompaniesApi.list(undefined, 1, 100)
setCompanies(data.data ?? [])
} catch (error) {
console.error("Falha ao carregar empresas:", error)
toast.error("Falha ao carregar empresas")
} finally {
setLoadingCompanies(false)
}
}
const handleSubmit = (e: React.FormEvent) => { loadCompanies()
e.preventDefault() }, [])
console.log("Form submitted:", formData)
const canSubmit = () => {
return (
formData.title.length >= 5 &&
formData.description.length >= 20 &&
formData.companyId !== ""
)
} }
const handleInputChange = (field: string, value: string) => { const handleInputChange = (field: string, value: string) => {
setFormData(prev => ({ ...prev, [field]: value })) setFormData((prev) => ({ ...prev, [field]: value }))
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!canSubmit()) {
toast.error("Preencha os campos obrigatórios")
return
}
setLoading(true)
try {
const payload: CreateJobPayload = {
companyId: formData.companyId,
title: formData.title,
description: formData.description,
location: formData.location || undefined,
employmentType: formData.employmentType as CreateJobPayload["employmentType"] || undefined,
salaryMin: formData.salaryMin ? parseFloat(formData.salaryMin) : undefined,
salaryMax: formData.salaryMax ? parseFloat(formData.salaryMax) : undefined,
salaryType: formData.salaryType as CreateJobPayload["salaryType"] || undefined,
currency: formData.currency as CreateJobPayload["currency"] || undefined,
workingHours: formData.workingHours || undefined,
status: "draft",
}
await jobsApi.create(payload)
toast.success("Vaga publicada com sucesso! Ela será revisada em breve.")
setFormData({
title: "",
description: "",
location: "",
salaryMin: "",
salaryMax: "",
salaryType: "monthly",
currency: "BRL",
employmentType: "",
workingHours: "",
companyId: "",
})
} catch (error: any) {
console.error("Falha ao publicar vaga:", error)
toast.error(error.message || "Falha ao publicar vaga")
} finally {
setLoading(false)
}
} }
return ( return (
@ -42,7 +118,6 @@ export default function PublicarVagaPage() {
<Navbar /> <Navbar />
<main className="flex-1 flex"> <main className="flex-1 flex">
{/* Left Side - Brand Section */}
<div className="hidden lg:flex lg:w-2/5 bg-[#F0932B] relative overflow-hidden"> <div className="hidden lg:flex lg:w-2/5 bg-[#F0932B] relative overflow-hidden">
<Image <Image
src="/6.png" src="/6.png"
@ -52,65 +127,29 @@ export default function PublicarVagaPage() {
quality={100} quality={100}
priority priority
/> />
{/* Background Pattern */}
<div className="absolute inset-0 opacity-10"> <div className="absolute inset-0 opacity-10">
<div className="absolute top-0 left-0 w-full h-full bg-[radial-gradient(circle_at_30%_50%,rgba(255,255,255,0.1)_0%,transparent_50%)]"></div> <div className="absolute top-0 left-0 w-full h-full bg-[radial-gradient(circle_at_30%_50%,rgba(255,255,255,0.1)_0%,transparent_50%)]"></div>
</div> </div>
<div className="relative z-10 flex flex-col justify-between p-12 text-white"> <div className="relative z-10 flex flex-col justify-between p-12 text-white">
{/* Top Section */}
<div> <div>
<h2 className="text-4xl md:text-5xl font-bold mb-6 leading-tight"> <h2 className="text-4xl md:text-5xl font-bold mb-6 leading-tight">
Anuncie vagas de emprego<br /> Anuncie vagas de emprego<br />
de forma rápida e eficiente de forma rápida e eficiente
</h2> </h2>
{/* Stats */}
<div className="space-y-4 mb-8"> <div className="space-y-4 mb-8">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3"><Check className="w-5 h-5 mt-1" /><p className="font-semibold">Uma das maiores comunidades de profissionais do mercado</p></div>
<div className="mt-1"> <div className="flex items-start gap-3"><Check className="w-5 h-5 mt-1" /><p className="font-semibold">Plataforma com alta visibilidade e acesso diário</p></div>
<Check className="w-5 h-5" /> <div className="flex items-start gap-3"><Check className="w-5 h-5 mt-1" /><p className="font-semibold">Grande movimentação de candidaturas todos os dias</p></div>
</div> <div className="flex items-start gap-3"><Check className="w-5 h-5 mt-1" /><p className="font-semibold">Novos talentos se cadastrando constantemente</p></div>
<div>
<p className="font-semibold">Uma das maiores comunidades de profissionais do mercado</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="mt-1">
<Check className="w-5 h-5" />
</div>
<div>
<p className="font-semibold">Plataforma com alta visibilidade e acesso diário</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="mt-1">
<Check className="w-5 h-5" />
</div>
<div>
<p className="font-semibold">Grande movimentação de candidaturas todos os dias</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="mt-1">
<Check className="w-5 h-5" />
</div>
<div>
<p className="font-semibold">Novos talentos se cadastrando constantemente</p>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{/* Right Side - Form Section */}
<div className="flex-1 bg-gray-50 overflow-y-auto"> <div className="flex-1 bg-gray-50 overflow-y-auto">
<div className="max-w-3xl mx-auto py-12 px-6 md:px-12"> <div className="max-w-3xl mx-auto py-12 px-6 md:px-12">
{/* Header */}
<div className="text-center mb-8"> <div className="text-center mb-8">
<h1 className="text-3xl md:text-4xl font-bold text-gray-900 mb-3"> <h1 className="text-3xl md:text-4xl font-bold text-gray-900 mb-3">
Anuncie a sua vaga de emprego GRÁTIS! Anuncie a sua vaga de emprego GRÁTIS!
@ -120,219 +159,172 @@ export default function PublicarVagaPage() {
</p> </p>
</div> </div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-5"> <form onSubmit={handleSubmit} className="space-y-5">
{/* Row 1 */}
<div className="grid md:grid-cols-2 gap-4">
<div>
<Label htmlFor="cargoVaga" className="text-gray-700 mb-1.5 block text-sm">
Cargo da vaga
</Label>
<Input
id="cargoVaga"
placeholder="Ex: Desenvolvedor Full Stack"
value={formData.cargoVaga}
onChange={(e) => handleInputChange("cargoVaga", e.target.value)}
className="h-11 bg-white border-gray-300"
required
/>
</div>
<div>
<Label htmlFor="nomeEmpresa" className="text-gray-700 mb-1.5 block text-sm">
Nome da Empresa
</Label>
<Input
id="nomeEmpresa"
placeholder="Ex: Tech Company Ltda"
value={formData.nomeEmpresa}
onChange={(e) => handleInputChange("nomeEmpresa", e.target.value)}
className="h-11 bg-white border-gray-300"
required
/>
</div>
</div>
{/* Row 2 */}
<div className="grid md:grid-cols-3 gap-4">
<div>
<Label htmlFor="cnpj" className="text-gray-700 mb-1.5 block text-sm">
CNPJ da Empresa
</Label>
<Input
id="cnpj"
placeholder="00.000.000/0000-00"
value={formData.cnpj}
onChange={(e) => handleInputChange("cnpj", e.target.value)}
className="h-11 bg-white border-gray-300"
required
/>
</div>
<div>
<Label htmlFor="numeroFuncionarios" className="text-gray-700 mb-1.5 block text-sm">
de funcionários da unidade
</Label>
<Input
id="numeroFuncionarios"
placeholder="Ex: 50"
type="number"
value={formData.numeroFuncionarios}
onChange={(e) => handleInputChange("numeroFuncionarios", e.target.value)}
className="h-11 bg-white border-gray-300"
required
/>
</div>
<div>
<Label htmlFor="cep" className="text-gray-700 mb-1.5 block text-sm">
CEP
</Label>
<div className="flex gap-2">
<Input
id="cep"
placeholder="00000-000"
value={formData.cep}
onChange={(e) => handleInputChange("cep", e.target.value)}
className="h-11 bg-white border-gray-300"
required
/>
<button
type="button"
className="px-4 h-11 bg-gray-200 hover:bg-gray-300 rounded-md transition-colors flex-shrink-0"
>
<svg className="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</button>
</div>
</div>
</div>
{/* Row 3 */}
<div className="grid md:grid-cols-2 gap-4">
<div>
<Label htmlFor="nome" className="text-gray-700 mb-1.5 block text-sm">
Nome
</Label>
<Input
id="nome"
placeholder="Seu nome"
value={formData.nome}
onChange={(e) => handleInputChange("nome", e.target.value)}
className="h-11 bg-white border-gray-300"
required
/>
</div>
<div>
<Label htmlFor="sobrenome" className="text-gray-700 mb-1.5 block text-sm">
Sobrenome
</Label>
<Input
id="sobrenome"
placeholder="Seu sobrenome"
value={formData.sobrenome}
onChange={(e) => handleInputChange("sobrenome", e.target.value)}
className="h-11 bg-white border-gray-300"
required
/>
</div>
</div>
{/* Row 4 */}
<div> <div>
<Label htmlFor="email" className="text-gray-700 mb-1.5 block text-sm"> <Label htmlFor="title" className="text-gray-700 mb-1.5 block text-sm">
Seu e-mail corporativo <Briefcase className="inline w-4 h-4 mr-1" /> Título da vaga *
</Label> </Label>
<Input <Input
id="email" id="title"
type="email" placeholder="Ex: Desenvolvedor(a) Full Stack Sênior"
placeholder="seu.email@empresa.com" value={formData.title}
value={formData.email} onChange={(e) => handleInputChange("title", e.target.value)}
onChange={(e) => handleInputChange("email", e.target.value)}
className="h-11 bg-white border-gray-300" className="h-11 bg-white border-gray-300"
required
/> />
</div> </div>
{/* Row 5 */} <div>
<Label htmlFor="description" className="text-gray-700 mb-1.5 block text-sm">Descrição da vaga *</Label>
<Textarea
id="description"
placeholder="Descreva responsabilidades, requisitos e diferenciais..."
rows={6}
value={formData.description}
onChange={(e) => handleInputChange("description", e.target.value)}
className="bg-white border-gray-300"
/>
</div>
<div className="grid md:grid-cols-2 gap-4"> <div className="grid md:grid-cols-2 gap-4">
<div> <div>
<Label htmlFor="telefone" className="text-gray-700 mb-1.5 block text-sm"> <Label htmlFor="location" className="text-gray-700 mb-1.5 block text-sm">
Seu telefone fixo <MapPin className="inline w-4 h-4 mr-1" /> Localização
</Label> </Label>
<Input <Input
id="telefone" id="location"
placeholder="(00) 0000-0000" placeholder="Ex: São Paulo/SP ou Remoto"
value={formData.telefone} value={formData.location}
onChange={(e) => handleInputChange("telefone", e.target.value)} onChange={(e) => handleInputChange("location", e.target.value)}
className="h-11 bg-white border-gray-300" className="h-11 bg-white border-gray-300"
/> />
</div> </div>
<div> <div>
<Label htmlFor="celular" className="text-gray-700 mb-1.5 block text-sm"> <Label className="text-gray-700 mb-1.5 block text-sm">
Seu celular <Clock className="inline w-4 h-4 mr-1" /> Tipo de contrato
</Label>
<Select value={formData.employmentType} onValueChange={(v) => handleInputChange("employmentType", v)}>
<SelectTrigger className="h-11 bg-white border-gray-300">
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
<SelectItem value="permanent">Permanente</SelectItem>
<SelectItem value="full-time">Tempo integral</SelectItem>
<SelectItem value="part-time">Meio período</SelectItem>
<SelectItem value="contract">Contrato</SelectItem>
<SelectItem value="temporary">Temporário</SelectItem>
<SelectItem value="training">Estágio/Trainee</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid md:grid-cols-2 gap-4">
<div>
<Label htmlFor="salaryMin" className="text-gray-700 mb-1.5 block text-sm">
<DollarSign className="inline w-4 h-4 mr-1" /> Salário mínimo
</Label> </Label>
<Input <Input
id="celular" id="salaryMin"
placeholder="(00) 00000-0000" type="number"
value={formData.celular} min="0"
onChange={(e) => handleInputChange("celular", e.target.value)} placeholder="Ex: 3000"
value={formData.salaryMin}
onChange={(e) => handleInputChange("salaryMin", e.target.value)}
className="h-11 bg-white border-gray-300"
/>
</div>
<div>
<Label htmlFor="salaryMax" className="text-gray-700 mb-1.5 block text-sm">Salário máximo</Label>
<Input
id="salaryMax"
type="number"
min="0"
placeholder="Ex: 6000"
value={formData.salaryMax}
onChange={(e) => handleInputChange("salaryMax", e.target.value)}
className="h-11 bg-white border-gray-300" className="h-11 bg-white border-gray-300"
required
/> />
</div> </div>
</div> </div>
{/* Checkboxes */} <div className="grid md:grid-cols-3 gap-4">
<div className="space-y-3 pt-2"> <div>
<label className="flex items-start gap-3 cursor-pointer group"> <Label className="text-gray-700 mb-1.5 block text-sm">Moeda</Label>
<input <Select value={formData.currency} onValueChange={(v) => handleInputChange("currency", v)}>
type="checkbox" <SelectTrigger className="h-11 bg-white border-gray-300"><SelectValue /></SelectTrigger>
checked={acceptTerms} <SelectContent>
onChange={(e) => setAcceptTerms(e.target.checked)} <SelectItem value="BRL">BRL - R$</SelectItem>
className="mt-1 w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer" <SelectItem value="USD">USD - $</SelectItem>
required <SelectItem value="EUR">EUR - </SelectItem>
/> <SelectItem value="GBP">GBP - £</SelectItem>
<span className="text-sm text-gray-700 leading-relaxed"> </SelectContent>
Li e aceito as{" "} </Select>
<Link href="/terms" className="text-primary hover:underline font-medium"> </div>
Condições Legais
</Link>{" "}
e a{" "}
<Link href="/privacy" className="text-primary hover:underline font-medium">
Política de Privacidade
</Link>{" "}
do GoHorse Jobs.
</span>
</label>
<label className="flex items-start gap-3 cursor-pointer group"> <div>
<input <Label className="text-gray-700 mb-1.5 block text-sm">Período do salário</Label>
type="checkbox" <Select value={formData.salaryType} onValueChange={(v) => handleInputChange("salaryType", v)}>
checked={acceptMarketing} <SelectTrigger className="h-11 bg-white border-gray-300"><SelectValue /></SelectTrigger>
onChange={(e) => setAcceptMarketing(e.target.checked)} <SelectContent>
className="mt-1 w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer" <SelectItem value="hourly">Por hora</SelectItem>
<SelectItem value="daily">Por dia</SelectItem>
<SelectItem value="weekly">Por semana</SelectItem>
<SelectItem value="monthly">Por mês</SelectItem>
<SelectItem value="yearly">Por ano</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="workingHours" className="text-gray-700 mb-1.5 block text-sm">Jornada de trabalho</Label>
<Input
id="workingHours"
placeholder="Ex: 9h às 18h"
value={formData.workingHours}
onChange={(e) => handleInputChange("workingHours", e.target.value)}
className="h-11 bg-white border-gray-300"
/> />
<span className="text-sm text-gray-700 leading-relaxed"> </div>
Autorizo o GoHorse Jobs a enviar comunicações comerciais sobre produtos, serviços e eventos dos seus parceiros e colaboradores. </div>
</span>
</label> <div>
<Label className="text-gray-700 mb-1.5 block text-sm">
<Building2 className="inline w-4 h-4 mr-1" /> Empresa *
</Label>
{loadingCompanies ? (
<div className="h-11 px-3 flex items-center text-sm text-gray-500 bg-white border border-gray-300 rounded-md">
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> Carregando empresas...
</div>
) : (
<Select value={formData.companyId} onValueChange={(v) => handleInputChange("companyId", v)}>
<SelectTrigger className="h-11 bg-white border-gray-300">
<SelectValue placeholder="Selecione uma empresa" />
</SelectTrigger>
<SelectContent>
{companies.length === 0 ? (
<SelectItem value="__none" disabled>Nenhuma empresa disponível</SelectItem>
) : (
companies.map((company) => (
<SelectItem key={company.id} value={company.id}>{company.name}</SelectItem>
))
)}
</SelectContent>
</Select>
)}
</div> </div>
{/* Submit Button */}
<div className="pt-4"> <div className="pt-4">
<button <Button
type="submit" type="submit"
className="w-full bg-[#F0932B] hover:bg-[#d97d1a] text-white font-bold py-4 rounded-full text-lg transition-colors shadow-lg hover:shadow-xl" disabled={loading || !canSubmit()}
className="w-full bg-[#F0932B] hover:bg-[#d97d1a] text-white font-bold py-6 rounded-full text-lg transition-colors shadow-lg hover:shadow-xl"
> >
ANUNCIAR VAGA GRÁTIS {loading ? <><Loader2 className="w-4 h-4 mr-2 animate-spin" /> PUBLICANDO...</> : "ANUNCIAR VAGA GRÁTIS"}
</button> </Button>
</div> </div>
{/* Bottom Link */}
<div className="text-center pt-4"> <div className="text-center pt-4">
<p className="text-sm text-gray-600"> <p className="text-sm text-gray-600">
Você é um candidato?{" "} Você é um candidato?{" "}
@ -343,15 +335,14 @@ export default function PublicarVagaPage() {
</div> </div>
</form> </form>
{/* Footer Note */}
<div className="text-center mt-8 pt-6 border-t border-gray-200"> <div className="text-center mt-8 pt-6 border-t border-gray-200">
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">© GoHorse Jobs Brasil. Todos os direitos reservados.</p>
© GoHorse Jobs Brasil. Todos os direitos reservados.
</p>
</div> </div>
</div> </div>
</div> </div>
</main> </main>
<Footer />
</div> </div>
) )
} }

View file

@ -74,6 +74,8 @@ export function JobCard({ job, isApplied, applicationStatus }: JobCardProps) {
notify.error("Erro", "Faça login para salvar vagas."); notify.error("Erro", "Faça login para salvar vagas.");
} }
}; };
const formatTimeAgo = (dateString: string) => {
const date = new Date(dateString); const date = new Date(dateString);
const now = new Date(); const now = new Date();
const diffInMs = now.getTime() - date.getTime(); const diffInMs = now.getTime() - date.getTime();