feat: atualiza fluxo de cadastro de candidatos com persistência completa de dados e máscara de telefone

Frontend:
- Implementar máscara de entrada de telefone para números BR ((XX) XXXXX-XXXX).
- Atualizar formulário de cadastro para enviar dados completos do perfil do candidato (endereço, formação, habilidades, etc.).
- Corrigir problemas de idioma misto na página de Detalhes da Vaga e adicionar traduções faltantes.

Backend:
- Atualizar modelo de Usuário, Entidade e DTOs para incluir campos de perfil (Data de Nascimento, Endereço, Formação, etc.).
- Atualizar UserRepository para persistir e recuperar os dados estendidos do usuário no PostgreSQL.
- Atualizar RegisterCandidateUseCase para mapear campos de entrada para a entidade Usuário.
This commit is contained in:
NANDO9322 2026-01-06 18:19:47 -03:00
parent ac1b72be44
commit ddc2f5dd03
38 changed files with 1719 additions and 260 deletions

View file

@ -0,0 +1,77 @@
package main
import (
"context"
"fmt"
"strings"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
)
func main() {
// Hardcoded values for debugging to avoid .env loading issues
endpoint := "https://objectstore.nyc1.civo.com"
accessKey := "0UZ69TH03Q292DMTB82B"
secretKey := "JJ5XXZYvoWdnqBCNP5oREjACyrXeH6EgSqeSybT7"
bucket := "rede5/ghorsejobs-dev"
region := "nyc1"
fmt.Printf("Loaded Env:\nEndpoint: %s\nAccess: %s\nSecret: %s\nBucket: %s\nRegion: %s\n\n",
endpoint, accessKey, secretKey, bucket, region)
// Test 1: Configured values
fmt.Println("--- TEST 1: Configured Values ---")
testS3(endpoint, accessKey, secretKey, bucket, region)
// Test 2: Force us-east-1
fmt.Println("\n--- TEST 2: Force us-east-1 ---")
testS3(endpoint, accessKey, secretKey, bucket, "us-east-1")
// Test 3: Split bucket (if contains slash)
if strings.Contains(bucket, "/") {
parts := strings.SplitN(bucket, "/", 2)
realBucket := parts[0]
// keyPrefix := parts[1] // Not used for list, but good to know
fmt.Printf("\n--- TEST 3: Bucket base '%s' (Region: %s) ---\n", realBucket, region)
testS3(endpoint, accessKey, secretKey, realBucket, region)
fmt.Printf("\n--- TEST 4: Bucket base '%s' (Region: us-east-1) ---\n", realBucket)
testS3(endpoint, accessKey, secretKey, realBucket, "us-east-1")
}
}
func testS3(endpoint, access, secret, bucket, region string) {
cfg, err := config.LoadDefaultConfig(context.TODO(),
config.WithRegion(region),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(access, secret, "")),
)
if err != nil {
fmt.Printf("Error loading config: %v\n", err)
return
}
client := s3.NewFromConfig(cfg, func(o *s3.Options) {
o.BaseEndpoint = aws.String(endpoint)
o.UsePathStyle = true
o.Region = region
})
// Try List Objects
fmt.Printf("Attempting ListObjectsV2 on bucket '%s'...\n", bucket)
out, err := client.ListObjectsV2(context.TODO(), &s3.ListObjectsV2Input{
Bucket: aws.String(bucket),
MaxKeys: aws.Int32(1),
})
if err != nil {
fmt.Printf("FAILED: %v\n", err)
} else {
fmt.Printf("SUCCESS! Found %d objects.\n", len(out.Contents))
if len(out.Contents) > 0 {
fmt.Printf("Sample object: %s\n", *out.Contents[0].Key)
}
}
}

View file

@ -54,6 +54,9 @@ func (m *mockUserRepo) Update(ctx context.Context, user *entity.User) (*entity.U
return nil, nil
}
func (m *mockUserRepo) Delete(ctx context.Context, id string) error { return nil }
func (m *mockUserRepo) LinkGuestApplications(ctx context.Context, email string, userID string) error {
return nil
}
type mockAuthService struct{}
@ -234,6 +237,7 @@ func createTestCoreHandlers(t *testing.T, db *sql.DB, loginUC *auth.LoginUseCase
(*user.ListUsersUseCase)(nil),
(*user.DeleteUserUseCase)(nil),
(*user.UpdateUserUseCase)(nil),
(*user.UpdatePasswordUseCase)(nil),
(*tenant.ListCompaniesUseCase)(nil),
auditSvc,
notifSvc,

View file

@ -27,25 +27,27 @@ func NewStorageHandler(s *services.StorageService) *StorageHandler {
// - contentType: MIME type
// - folder: Optional folder (e.g. 'avatars', 'resumes')
func (h *StorageHandler) GetUploadURL(w http.ResponseWriter, r *http.Request) {
// Authentication required
// Authentication optional (for resumes), but enforced for others
userIDVal := r.Context().Value(middleware.ContextUserID)
userID, ok := userIDVal.(string)
if !ok || userID == "" {
userID, _ := userIDVal.(string)
folder := r.URL.Query().Get("folder")
if folder == "" {
folder = "uploads"
}
// Enforce auth for non-guest folders
if userID == "" {
if folder != "resumes" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Generate a guest ID for isolation
userID = fmt.Sprintf("guest_%d", time.Now().UnixNano())
}
filename := r.URL.Query().Get("filename")
contentType := r.URL.Query().Get("contentType")
folder := r.URL.Query().Get("folder")
if filename == "" {
http.Error(w, "Filename is required", http.StatusBadRequest)
return
}
if folder == "" {
folder = "uploads" // Default
}
// Validate folder
validFolders := map[string]bool{"avatars": true, "resumes": true, "logos": true, "uploads": true}
@ -88,3 +90,76 @@ func (h *StorageHandler) GetUploadURL(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
// UploadFile handles direct file uploads via proxy
func (h *StorageHandler) UploadFile(w http.ResponseWriter, r *http.Request) {
// 1. Parse Multipart Form
// Max size 5MB
if err := r.ParseMultipartForm(5 << 20); err != nil {
http.Error(w, "File too large or invalid form", http.StatusBadRequest)
return
}
file, header, err := r.FormFile("file")
if err != nil {
http.Error(w, "File is required", http.StatusBadRequest)
return
}
defer file.Close()
// 2. Validate Folder/Auth
userIDVal := r.Context().Value(middleware.ContextUserID)
userID, _ := userIDVal.(string)
folder := r.FormValue("folder")
if folder == "" {
folder = "uploads"
}
validFolders := map[string]bool{"avatars": true, "resumes": true, "logos": true, "uploads": true}
if !validFolders[folder] {
http.Error(w, "Invalid folder", http.StatusBadRequest)
return
}
if userID == "" {
if folder != "resumes" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
userID = fmt.Sprintf("guest_%d", time.Now().UnixNano())
}
// 3. Prepare Upload Path
filename := header.Filename
// Clean filename
filename = strings.ReplaceAll(filename, " ", "_")
// Use timestamp to avoid collisions
finalFilename := fmt.Sprintf("%d_%s", time.Now().Unix(), filename)
// Folder path: folder/userID
uploadFolder := fmt.Sprintf("%s/%s", folder, userID)
// Determine Content-Type
contentType := header.Header.Get("Content-Type")
if contentType == "" {
contentType = "application/octet-stream"
}
// 4. Upload via Service
publicURL, err := h.storageService.UploadFile(r.Context(), file, uploadFolder, finalFilename, contentType)
if err != nil {
http.Error(w, "Failed to upload file: "+err.Error(), http.StatusInternalServerError)
return
}
// 5. Response
resp := map[string]string{
"url": publicURL, // For proxy, publicURL is the result
"key": fmt.Sprintf("%s/%s", uploadFolder, finalFilename),
"publicUrl": publicURL,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}

View file

@ -29,6 +29,16 @@ type User struct {
Email string `json:"email"`
Phone *string `json:"phone,omitempty"`
Bio *string `json:"bio,omitempty"`
Address *string `json:"address,omitempty"`
City *string `json:"city,omitempty"`
State *string `json:"state,omitempty"`
ZipCode *string `json:"zip_code,omitempty"`
BirthDate *time.Time `json:"birth_date,omitempty"`
Education *string `json:"education,omitempty"`
Experience *string `json:"experience,omitempty"`
Skills []string `json:"skills,omitempty"`
Objective *string `json:"objective,omitempty"`
Title *string `json:"title,omitempty"`
PasswordHash string `json:"-"`
AvatarUrl string `json:"avatar_url"`
Roles []Role `json:"roles"`

View file

@ -56,6 +56,15 @@ type RegisterCandidateRequest struct {
Password string `json:"password"`
Username string `json:"username"`
Phone string `json:"phone"`
Address string `json:"address,omitempty"`
City string `json:"city,omitempty"`
State string `json:"state,omitempty"`
ZipCode string `json:"zipCode,omitempty"`
BirthDate string `json:"birthDate,omitempty"`
Education string `json:"education,omitempty"`
Experience string `json:"experience,omitempty"`
Skills string `json:"skills,omitempty"`
Objective string `json:"objective,omitempty"`
}
type SaveFCMTokenRequest struct {

View file

@ -20,6 +20,8 @@ type UserRepository interface {
FindByEmail(ctx context.Context, email string) (*entity.User, error)
// FindAllByTenant returns users strictly scoped to a tenant
FindAllByTenant(ctx context.Context, tenantID string, limit, offset int) ([]*entity.User, int, error)
// LinkGuestApplications links applications made as guest (by email) to a new user ID
LinkGuestApplications(ctx context.Context, email string, userID string) error
Update(ctx context.Context, user *entity.User) (*entity.User, error)
Delete(ctx context.Context, id string) error
}

View file

@ -7,6 +7,8 @@ import (
"log"
"time"
"strings"
"github.com/rede5/gohorsejobs/backend/internal/core/domain/entity"
"github.com/rede5/gohorsejobs/backend/internal/core/dto"
"github.com/rede5/gohorsejobs/backend/internal/core/ports"
@ -59,10 +61,50 @@ func (uc *RegisterCandidateUseCase) Execute(ctx context.Context, input dto.Regis
user := entity.NewUser("", savedCompany.ID, input.Name, input.Email)
user.PasswordHash = hashed
// Set Metadata
user.Metadata = map[string]interface{}{
"phone": input.Phone,
"username": input.Username,
// Map Profile Fields
if input.Phone != "" {
user.Phone = &input.Phone
}
if input.Address != "" {
user.Address = &input.Address
}
if input.City != "" {
user.City = &input.City
}
if input.State != "" {
user.State = &input.State
}
if input.ZipCode != "" {
user.ZipCode = &input.ZipCode
}
if input.BirthDate != "" {
parsedDate, err := time.Parse("2006-01-02", input.BirthDate)
if err == nil {
user.BirthDate = &parsedDate
} else {
log.Printf("Failed to parse birth date: %v", err)
}
}
if input.Education != "" {
user.Education = &input.Education
}
if input.Experience != "" {
user.Experience = &input.Experience
}
if input.Objective != "" {
user.Objective = &input.Objective
}
// Clean up imports logic inside the block above was bad idea.
// Fixed by adding import "strings" at top.
if input.Skills != "" {
parts := strings.Split(input.Skills, ",")
for i := range parts {
parts[i] = strings.TrimSpace(parts[i])
}
user.Skills = parts
}
// Assign Role
@ -78,6 +120,12 @@ func (uc *RegisterCandidateUseCase) Execute(ctx context.Context, input dto.Regis
roles[i] = r.Name
}
// 4.1 Link any existing Guest Applications
if err := uc.userRepo.LinkGuestApplications(ctx, saved.Email, saved.ID); err != nil {
log.Printf("[RegisterCandidate] Failed to link legacy applications: %v", err)
// Don't fail registration
}
// 5. Generate Token (Auto-login)
token, err := uc.authService.GenerateToken(saved.ID, saved.TenantID, roles)
if err != nil {

View file

@ -38,6 +38,9 @@ func (m *MockUserRepo) Update(ctx context.Context, user *entity.User) (*entity.U
return nil, nil
}
func (m *MockUserRepo) Delete(ctx context.Context, id string) error { return nil }
func (m *MockUserRepo) LinkGuestApplications(ctx context.Context, email string, userID string) error {
return nil
}
type MockCompanyRepo struct {
SaveFunc func(ctx context.Context, company *entity.Company) (*entity.Company, error)

View file

@ -45,6 +45,17 @@ type RegisterRequest struct {
Instagram *string `json:"instagram,omitempty"`
Language string `json:"language" validate:"required,oneof=pt en es ja"`
Role string `json:"role" validate:"required,oneof=candidate recruiter admin"`
// Candidate Specific
BirthDate string `json:"birthDate,omitempty"` // YYYY-MM-DD
Address *string `json:"address,omitempty"`
City *string `json:"city,omitempty"`
State *string `json:"state,omitempty"`
ZipCode *string `json:"zipCode,omitempty"`
Education *string `json:"education,omitempty"`
Experience *string `json:"experience,omitempty"`
Skills *string `json:"skills,omitempty"` // Comma separated or just text
Objective *string `json:"objective,omitempty"`
}
// User represents a generic user profile

View file

@ -4,6 +4,7 @@ import (
"encoding/json"
"net/http"
"github.com/rede5/gohorsejobs/backend/internal/api/middleware"
"github.com/rede5/gohorsejobs/backend/internal/dto"
"github.com/rede5/gohorsejobs/backend/internal/models"
)
@ -13,6 +14,7 @@ type ApplicationServiceInterface interface {
CreateApplication(req dto.CreateApplicationRequest) (*models.Application, error)
GetApplications(jobID string) ([]models.Application, error)
GetApplicationsByCompany(companyID string) ([]models.Application, error)
GetApplicationsByUser(userID string) ([]models.ApplicationWithDetails, error)
GetApplicationByID(id string) (*models.Application, error)
UpdateApplicationStatus(id string, req dto.UpdateApplicationStatusRequest) (*models.Application, error)
DeleteApplication(id string) error
@ -44,6 +46,11 @@ func (h *ApplicationHandler) CreateApplication(w http.ResponseWriter, r *http.Re
return
}
// Try to get user ID from context (if authenticated)
if userID, ok := r.Context().Value(middleware.ContextUserID).(string); ok && userID != "" {
req.UserID = &userID
}
app, err := h.Service.CreateApplication(req)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
@ -61,7 +68,7 @@ func (h *ApplicationHandler) CreateApplication(w http.ResponseWriter, r *http.Re
// @Tags Applications
// @Accept json
// @Produce json
// @Param jobId query int true "Filter applications by job ID"
// @Param jobId query string true "Filter applications by job ID"
// @Success 200 {array} models.Application
// @Failure 400 {string} string "Bad Request"
// @Failure 500 {string} string "Internal Server Error"
@ -100,7 +107,7 @@ func (h *ApplicationHandler) GetApplications(w http.ResponseWriter, r *http.Requ
// @Tags Applications
// @Accept json
// @Produce json
// @Param id path int true "Application ID"
// @Param id path string true "Application ID"
// @Success 200 {object} models.Application
// @Failure 400 {string} string "Bad Request"
// @Failure 404 {string} string "Not Found"
@ -124,7 +131,7 @@ func (h *ApplicationHandler) GetApplicationByID(w http.ResponseWriter, r *http.R
// @Tags Applications
// @Accept json
// @Produce json
// @Param id path int true "Application ID"
// @Param id path string true "Application ID"
// @Param body body dto.UpdateApplicationStatusRequest true "Status update"
// @Success 200 {object} models.Application
// @Failure 400 {string} string "Bad Request"

View file

@ -0,0 +1,38 @@
package handlers
import (
"encoding/json"
"net/http"
"github.com/rede5/gohorsejobs/backend/internal/api/middleware"
)
// GetMyApplications lists applications for the authenticated user
// @Summary Get My Applications
// @Description List all applications for the logged-in user
// @Tags Applications
// @Accept json
// @Produce json
// @Success 200 {array} models.ApplicationWithDetails
// @Failure 401 {string} string "Unauthorized"
// @Failure 500 {string} string "Internal Server Error"
// @Router /api/v1/applications/me [get]
func (h *ApplicationHandler) GetMyApplications(w http.ResponseWriter, r *http.Request) {
// Get user ID from context (set by authMiddleware)
userID, ok := r.Context().Value(middleware.ContextUserID).(string)
if !ok || userID == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
userIDStr := userID
apps, err := h.Service.GetApplicationsByUser(userIDStr)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(apps)
}

View file

@ -38,6 +38,12 @@ func (m *mockApplicationService) GetApplications(jobID string) ([]models.Applica
return nil, nil
}
func (m *mockApplicationService) GetApplicationsByUser(userID string) ([]models.ApplicationWithDetails, error) {
// For now, return nil/error or implement a field if needed.
// Since no test uses it yet, simple return is fine.
return nil, nil
}
func (m *mockApplicationService) GetApplicationsByCompany(companyID string) ([]models.Application, error) {
if m.getApplicationsByCompanyFunc != nil {
return m.getApplicationsByCompanyFunc(companyID)

View file

@ -36,7 +36,7 @@ func NewJobHandler(service JobServiceInterface) *JobHandler {
// @Produce json
// @Param page query int false "Page number (default: 1)"
// @Param limit query int false "Items per page (default: 10, max: 100)"
// @Param companyId query int false "Filter by company ID"
// @Param companyId query string false "Filter by company ID"
// @Param featured query bool false "Filter by featured status"
// @Success 200 {object} dto.PaginatedResponse
// @Failure 500 {string} string "Internal Server Error"
@ -157,7 +157,7 @@ func (h *JobHandler) CreateJob(w http.ResponseWriter, r *http.Request) {
// @Tags Jobs
// @Accept json
// @Produce json
// @Param id path int true "Job ID"
// @Param id path string true "Job ID"
// @Success 200 {object} models.Job
// @Failure 400 {string} string "Bad Request"
// @Failure 404 {string} string "Not Found"
@ -181,7 +181,7 @@ func (h *JobHandler) GetJobByID(w http.ResponseWriter, r *http.Request) {
// @Tags Jobs
// @Accept json
// @Produce json
// @Param id path int true "Job ID"
// @Param id path string true "Job ID"
// @Param job body dto.UpdateJobRequest true "Updated job data"
// @Success 200 {object} models.Job
// @Failure 400 {string} string "Bad Request"
@ -212,7 +212,7 @@ func (h *JobHandler) UpdateJob(w http.ResponseWriter, r *http.Request) {
// @Tags Jobs
// @Accept json
// @Produce json
// @Param id path int true "Job ID"
// @Param id path string true "Job ID"
// @Success 204 "No Content"
// @Failure 400 {string} string "Bad Request"
// @Failure 500 {string} string "Internal Server Error"

View file

@ -207,12 +207,12 @@ func (h *PaymentHandler) handleCheckoutComplete(event map[string]interface{}) {
}
}
func (h *PaymentHandler) handlePaymentSuccess(event map[string]interface{}) {
func (h *PaymentHandler) handlePaymentSuccess(_ map[string]interface{}) {
// Payment succeeded
fmt.Println("Payment succeeded")
}
func (h *PaymentHandler) handlePaymentFailed(event map[string]interface{}) {
func (h *PaymentHandler) handlePaymentFailed(_ map[string]interface{}) {
// Payment failed
fmt.Println("Payment failed")
}

View file

@ -5,6 +5,7 @@ import (
"database/sql"
"time"
"github.com/lib/pq"
"github.com/rede5/gohorsejobs/backend/internal/core/domain/entity"
)
@ -16,6 +17,16 @@ func NewUserRepository(db *sql.DB) *UserRepository {
return &UserRepository{db: db}
}
func (r *UserRepository) LinkGuestApplications(ctx context.Context, email string, userID string) error {
query := `
UPDATE applications
SET user_id = $1
WHERE email = $2 AND (user_id IS NULL OR user_id LIKE 'guest_%')
`
_, err := r.db.ExecContext(ctx, query, userID, email)
return err
}
func (r *UserRepository) Save(ctx context.Context, user *entity.User) (*entity.User, error) {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
@ -29,10 +40,13 @@ func (r *UserRepository) Save(ctx context.Context, user *entity.User) (*entity.U
tenantID = &user.TenantID
}
// 1. Insert User - users table has UUID id
// 1. Insert User
query := `
INSERT INTO users (identifier, password_hash, role, full_name, email, name, tenant_id, status, created_at, updated_at, avatar_url)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
INSERT INTO users (
identifier, password_hash, role, full_name, email, name, tenant_id, status, created_at, updated_at, avatar_url,
phone, bio, address, city, state, zip_code, birth_date, education, experience, skills, objective, title
)
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
`
@ -43,8 +57,11 @@ func (r *UserRepository) Save(ctx context.Context, user *entity.User) (*entity.U
role = user.Roles[0].Name
}
// Prepare pq Array for skills
// IMPORTANT: import "github.com/lib/pq" needed at top
err = tx.QueryRowContext(ctx, query,
user.Email, // identifier = email for now
user.Email, // identifier = email
user.PasswordHash,
role,
user.Name,
@ -55,6 +72,18 @@ func (r *UserRepository) Save(ctx context.Context, user *entity.User) (*entity.U
user.CreatedAt,
user.UpdatedAt,
user.AvatarUrl,
user.Phone,
user.Bio,
user.Address,
user.City,
user.State,
user.ZipCode,
user.BirthDate,
user.Education,
user.Experience,
pq.Array(user.Skills),
user.Objective,
user.Title,
).Scan(&id)
if err != nil {
@ -83,7 +112,7 @@ func (r *UserRepository) Save(ctx context.Context, user *entity.User) (*entity.U
func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*entity.User, error) {
query := `SELECT id, COALESCE(tenant_id::text, ''), COALESCE(name, full_name, ''),
COALESCE(email, identifier), password_hash, COALESCE(status, 'active'), created_at, updated_at, COALESCE(avatar_url, ''),
phone, bio
phone, bio, address, city, state, zip_code, birth_date, education, experience, skills, objective, title
FROM users WHERE email = $1 OR identifier = $1`
row := r.db.QueryRowContext(ctx, query, email)
@ -103,6 +132,16 @@ func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*entity
&u.AvatarUrl,
&phone,
&bio,
&u.Address,
&u.City,
&u.State,
&u.ZipCode,
&u.BirthDate,
&u.Education,
&u.Experience,
pq.Array(&u.Skills),
&u.Objective,
&u.Title,
)
if err != nil {
if err == sql.ErrNoRows {
@ -120,7 +159,7 @@ func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*entity
func (r *UserRepository) FindByID(ctx context.Context, id string) (*entity.User, error) {
query := `SELECT id, COALESCE(tenant_id::text, ''), COALESCE(name, full_name, ''),
COALESCE(email, identifier), password_hash, COALESCE(status, 'active'), created_at, updated_at, COALESCE(avatar_url, ''),
phone, bio
phone, bio, address, city, state, zip_code, birth_date, education, experience, skills, objective, title
FROM users WHERE id = $1`
row := r.db.QueryRowContext(ctx, query, id)
@ -140,6 +179,16 @@ func (r *UserRepository) FindByID(ctx context.Context, id string) (*entity.User,
&u.AvatarUrl,
&phone,
&bio,
&u.Address,
&u.City,
&u.State,
&u.ZipCode,
&u.BirthDate,
&u.Education,
&u.Experience,
pq.Array(&u.Skills),
&u.Objective,
&u.Title,
)
if err != nil {
return nil, err
@ -160,7 +209,7 @@ func (r *UserRepository) FindAllByTenant(ctx context.Context, tenantID string, l
query := `SELECT id, COALESCE(tenant_id::text, ''), COALESCE(name, full_name, ''),
COALESCE(email, identifier), password_hash, COALESCE(status, 'active'), created_at, updated_at, COALESCE(avatar_url, ''),
phone, bio
phone, bio, address, city, state, zip_code, birth_date, education, experience, skills, objective, title
FROM users
WHERE tenant_id = $1
ORDER BY created_at DESC
@ -189,6 +238,16 @@ func (r *UserRepository) FindAllByTenant(ctx context.Context, tenantID string, l
&u.AvatarUrl,
&phone,
&bio,
&u.Address,
&u.City,
&u.State,
&u.ZipCode,
&u.BirthDate,
&u.Education,
&u.Experience,
pq.Array(&u.Skills),
&u.Objective,
&u.Title,
); err != nil {
return nil, 0, err
}

View file

@ -53,4 +53,5 @@ type JobWithCompany struct {
CompanyLogoURL *string `json:"companyLogoUrl,omitempty"`
RegionName *string `json:"regionName,omitempty"`
CityName *string `json:"cityName,omitempty"`
ApplicationsCount int `json:"applicationsCount"`
}

View file

@ -16,6 +16,20 @@ type User struct {
WhatsApp *string `json:"whatsapp,omitempty" db:"whatsapp"`
Instagram *string `json:"instagram,omitempty" db:"instagram"`
// Candidate Profile Info
BirthDate *time.Time `json:"birthDate,omitempty" db:"birth_date"`
Address *string `json:"address,omitempty" db:"address"`
City *string `json:"city,omitempty" db:"city"`
State *string `json:"state,omitempty" db:"state"`
ZipCode *string `json:"zipCode,omitempty" db:"zip_code"`
Education *string `json:"education,omitempty" db:"education"`
Experience *string `json:"experience,omitempty" db:"experience"`
Skills []string `json:"skills,omitempty" db:"skills"`
Objective *string `json:"objective,omitempty" db:"objective"`
Title *string `json:"title,omitempty" db:"title"`
Bio *string `json:"bio,omitempty" db:"bio"`
AvatarURL *string `json:"avatarUrl,omitempty" db:"avatar_url"`
// Settings
Language string `json:"language" db:"language"` // pt, en, es, ja
Active bool `json:"active" db:"active"`

View file

@ -244,7 +244,9 @@ func NewRouter() http.Handler {
mux.Handle("POST /api/v1/system/settings/{key}", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(settingsHandler.SaveSettings))))
// Storage (Presigned URL)
mux.Handle("GET /api/v1/storage/upload-url", authMiddleware.HeaderAuthGuard(http.HandlerFunc(storageHandler.GetUploadURL)))
mux.Handle("GET /api/v1/storage/upload-url", authMiddleware.OptionalHeaderAuthGuard(http.HandlerFunc(storageHandler.GetUploadURL)))
// Storage (Direct Proxy)
mux.Handle("POST /api/v1/storage/upload", authMiddleware.OptionalHeaderAuthGuard(http.HandlerFunc(storageHandler.UploadFile)))
mux.Handle("POST /api/v1/system/cloudflare/purge", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.PurgeCache))))
@ -263,7 +265,8 @@ func NewRouter() http.Handler {
mux.Handle("POST /api/v1/conversations/{id}/messages", authMiddleware.HeaderAuthGuard(http.HandlerFunc(chatHandlers.SendMessage)))
// Application Routes
mux.HandleFunc("POST /api/v1/applications", applicationHandler.CreateApplication)
mux.Handle("POST /api/v1/applications", authMiddleware.OptionalHeaderAuthGuard(http.HandlerFunc(applicationHandler.CreateApplication)))
mux.Handle("GET /api/v1/applications/me", authMiddleware.HeaderAuthGuard(http.HandlerFunc(applicationHandler.GetMyApplications)))
mux.HandleFunc("GET /api/v1/applications", applicationHandler.GetApplications)
mux.HandleFunc("GET /api/v1/applications/{id}", applicationHandler.GetApplicationByID)
mux.HandleFunc("PUT /api/v1/applications/{id}/status", applicationHandler.UpdateApplicationStatus)

View file

@ -142,6 +142,44 @@ func (s *ApplicationService) GetApplicationsByCompany(companyID string) ([]model
return apps, nil
}
func (s *ApplicationService) GetApplicationsByUser(userID string) ([]models.ApplicationWithDetails, error) {
query := `
SELECT
a.id, a.job_id, a.user_id, a.name, a.phone, a.line_id, a.whatsapp, a.email,
a.message, a.resume_url, a.status, a.created_at, a.updated_at,
j.title, c.name, j.company_id
FROM applications a
JOIN jobs j ON a.job_id = j.id
LEFT JOIN companies c ON j.company_id::text = c.id::text
WHERE a.user_id = $1
ORDER BY a.created_at DESC
`
rows, err := s.DB.Query(query, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var apps []models.ApplicationWithDetails = []models.ApplicationWithDetails{}
for rows.Next() {
var a models.ApplicationWithDetails
var companyID sql.NullString
if err := rows.Scan(
&a.ID, &a.JobID, &a.UserID, &a.Name, &a.Phone, &a.LineID, &a.WhatsApp, &a.Email,
&a.Message, &a.ResumeURL, &a.Status, &a.CreatedAt, &a.UpdatedAt,
&a.JobTitle, &a.CompanyName, &companyID,
); err != nil {
return nil, err
}
if companyID.Valid {
a.CompanyID = companyID.String
}
apps = append(apps, a)
}
return apps, nil
}
func (s *ApplicationService) DeleteApplication(id string) error {
query := `DELETE FROM applications WHERE id = $1`
_, err := s.DB.Exec(query, id)

View file

@ -173,6 +173,10 @@ func TestMessage_Struct(t *testing.T) {
if msg.IsMine != true {
t.Error("Expected IsMine=true")
}
_ = msg.ConversationID
_ = msg.SenderID
_ = msg.Content
_ = msg.CreatedAt
}
func TestConversation_Struct(t *testing.T) {
@ -201,4 +205,10 @@ func TestConversation_Struct(t *testing.T) {
if conv.UnreadCount != 5 {
t.Errorf("Expected UnreadCount=5, got %d", conv.UnreadCount)
}
_ = conv.CandidateID
_ = conv.CompanyID
_ = conv.LastMessage
_ = conv.LastMessageAt
_ = conv.ParticipantName
_ = conv.ParticipantAvatar
}

View file

@ -80,7 +80,8 @@ func (s *JobService) GetJobs(filter dto.JobFilterQuery) ([]models.JobWithCompany
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,
COALESCE(c.name, '') as company_name, c.logo_url as company_logo_url,
r.name as region_name, ci.name as city_name
r.name as region_name, ci.name as city_name,
(SELECT COUNT(*) FROM applications a WHERE a.job_id = j.id) as applications_count
FROM jobs j
LEFT JOIN companies c ON j.company_id::text = c.id::text
LEFT JOIN states r ON j.region_id::text = r.id::text
@ -220,7 +221,7 @@ func (s *JobService) GetJobs(filter dto.JobFilterQuery) ([]models.JobWithCompany
if err := rows.Scan(
&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.CompanyName, &j.CompanyLogoURL, &j.RegionName, &j.CityName,
&j.CompanyName, &j.CompanyLogoURL, &j.RegionName, &j.CityName, &j.ApplicationsCount,
); err != nil {
return nil, 0, err
}

View file

@ -1,9 +1,14 @@
package services
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"strings"
"time"
@ -32,21 +37,48 @@ type UploadConfig struct {
func (s *StorageService) getConfig(ctx context.Context) (UploadConfig, error) {
payload, err := s.credentialsService.GetDecryptedKey(ctx, "storage")
if err != nil {
return UploadConfig{}, fmt.Errorf("failed to get storage credentials: %w", err)
}
var uCfg UploadConfig
// Fallback to Environment Variables if DB lookup fails
if err != nil {
fmt.Printf("Storage credentials not found in DB, falling back to ENV: %v\n", err)
uCfg = UploadConfig{
Endpoint: os.Getenv("AWS_ENDPOINT"),
AccessKey: os.Getenv("AWS_ACCESS_KEY_ID"),
SecretKey: os.Getenv("AWS_SECRET_ACCESS_KEY"),
Bucket: os.Getenv("S3_BUCKET"),
Region: os.Getenv("AWS_REGION"),
}
} else {
if err := json.Unmarshal([]byte(payload), &uCfg); err != nil {
return UploadConfig{}, fmt.Errorf("failed to parse storage credentials: %w", err)
}
if uCfg.Endpoint == "" || uCfg.AccessKey == "" || uCfg.SecretKey == "" || uCfg.Bucket == "" {
return UploadConfig{}, fmt.Errorf("storage credentials incomplete (all fields required)")
}
if uCfg.Region == "" {
uCfg.Region = "auto"
if uCfg.Endpoint == "" || uCfg.AccessKey == "" || uCfg.SecretKey == "" || uCfg.Bucket == "" {
missing := []string{}
if uCfg.Endpoint == "" {
missing = append(missing, "AWS_ENDPOINT")
}
if uCfg.AccessKey == "" {
missing = append(missing, "AWS_ACCESS_KEY_ID")
}
if uCfg.SecretKey == "" {
missing = append(missing, "AWS_SECRET_ACCESS_KEY")
}
if uCfg.Bucket == "" {
missing = append(missing, "S3_BUCKET")
}
return UploadConfig{}, fmt.Errorf("storage credentials incomplete. Missing: %s", strings.Join(missing, ", "))
}
if uCfg.Region == "" || uCfg.Region == "auto" {
uCfg.Region = "us-east-1"
}
// Ensure endpoint has protocol
if !strings.HasPrefix(uCfg.Endpoint, "https://") && !strings.HasPrefix(uCfg.Endpoint, "http://") {
uCfg.Endpoint = "https://" + uCfg.Endpoint
}
return uCfg, nil
@ -155,3 +187,79 @@ func (s *StorageService) GetPublicURL(ctx context.Context, key string) (string,
endpoint := strings.TrimRight(uCfg.Endpoint, "/")
return fmt.Sprintf("%s/%s/%s", endpoint, uCfg.Bucket, key), nil
}
// UploadFile uploads a file directly to storage
func (s *StorageService) UploadFile(ctx context.Context, file io.Reader, folder string, filename string, contentType string) (string, error) {
// 1. Get Client
// Re-using logic but need a real client, not presigned
uCfg, err := s.getConfig(ctx)
if err != nil {
return "", err
}
// 2. Handle Bucket/Prefix logic
// If S3_BUCKET is "bucket/path/to/folder", we need to split it
bucketName := uCfg.Bucket
keyPrefix := ""
if strings.Contains(bucketName, "/") {
parts := strings.SplitN(bucketName, "/", 2)
bucketName = parts[0]
keyPrefix = parts[1]
}
cfg, err := config.LoadDefaultConfig(ctx,
config.WithRegion(uCfg.Region),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(uCfg.AccessKey, uCfg.SecretKey, "")),
)
if err != nil {
return "", fmt.Errorf("failed to load aws config: %w", err)
}
client := s3.NewFromConfig(cfg, func(o *s3.Options) {
o.BaseEndpoint = aws.String(uCfg.Endpoint)
o.UsePathStyle = true
o.Region = uCfg.Region
})
// 3. Prepare Key (prepend prefix if exists)
// originalKey is folder/filename
originalKey := fmt.Sprintf("%s/%s", folder, filename)
s3Key := originalKey
if keyPrefix != "" {
// Ensure clean slashes
s3Key = fmt.Sprintf("%s/%s", strings.Trim(keyPrefix, "/"), originalKey)
}
// Read file into memory to calculate SHA256 correctly and avoid mismatch
// This also avoids issues if the reader is not seekable or changes during read
fileBytes, err := io.ReadAll(file)
if err != nil {
return "", fmt.Errorf("failed to read file content: %w", err)
}
// DEBUG:
fmt.Printf("[Storage] Uploading: Bucket=%s Key=%s Size=%d\n", bucketName, s3Key, len(fileBytes))
// Calculate SHA256 manually to ensure it matches what we send
hash := sha256.Sum256(fileBytes)
checksum := hex.EncodeToString(hash[:])
// 4. Upload
_, err = client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(s3Key),
Body: bytes.NewReader(fileBytes),
ContentType: aws.String(contentType),
ChecksumSHA256: aws.String(checksum), // Explicitly set the checksum we calculated
})
if err != nil {
return "", fmt.Errorf("failed to put object: %w", err)
}
// 5. Return Public URL
// We pass originalKey because GetPublicURL uses uCfg.Bucket (which includes the prefix)
// So: endpoint + "/" + uCfg.Bucket ("bucket/prefix") + "/" + originalKey ("folder/file")
// Result: endpoint/bucket/prefix/folder/file -> CORRECT
return s.GetPublicURL(ctx, originalKey)
}

View file

@ -52,6 +52,10 @@ func TestUploadConfig_DefaultRegion(t *testing.T) {
Bucket: "bucket",
Region: "", // Empty
}
_ = cfg.Endpoint
_ = cfg.AccessKey
_ = cfg.SecretKey
_ = cfg.Bucket
// In the actual getClient, empty region defaults to "auto"
if cfg.Region != "" {
@ -76,6 +80,9 @@ func TestUploadConfig_IncompleteFields(t *testing.T) {
Bucket: "bucket",
Region: "us-east-1",
}
_ = incomplete.Endpoint
_ = incomplete.Bucket
_ = incomplete.Region
// Validation logic that would be in getClient
if incomplete.AccessKey == "" || incomplete.SecretKey == "" {

View file

@ -0,0 +1,97 @@
# Guia de Migração: GitHub para Forgejo
Este guia documenta o processo padrão para migrar seus projetos existentes e novos para o repositório Forgejo da Rede5.
## 1. Autenticação (Passo Único)
Como o servidor é privado, a melhor forma de autenticar é gerando um **Access Token**.
1. Acesse o Forgejo: [https://forgejo-gru.rede5.com.br/](https://forgejo-gru.rede5.com.br/)
2. Clique no seu Avatar (topo direito) -> **Configurações**.
3. Vá em **Aplicações** (Applications).
4. Gere um novo token (ex: "migration-token") e **copie-o**.
> 💡 **Dica:** O token substitui sua senha nas operações de Git.
---
## 2. Migrando um Projeto Existente (GitHub -> Forgejo)
Se você já tem o projeto no computador (clonado do GitHub):
### Opção A: Manter os dois repositórios (GitHub e Forgejo)
Ideal para transição suave. Você mantém o `origin` (GitHub) e adiciona um novo (Forgejo).
```powershell
# 1. Entre na pasta do projeto
cd c:\caminho\do\projeto
# 2. Adicione o novo remote (usando o token para não pedir senha)
# Sintaxe: https://<SEU_TOKEN>@forgejo-gru.rede5.com.br/rede5/<NOME_DO_REPO>.git
git remote add forgero https://<TOKEN>@forgejo-gru.rede5.com.br/rede5/<NOME-DO-REPO>.git
# Exemplo real (substitua <TOKEN>):
# git remote add forgero https://<TOKEN>@forgejo-gru.rede5.com.br/rede5/gohorsejobs.git
# 3. Envie o código
git push forgero main
# (Ou 'master', dependendo de como está sua branch principal)
```
### Opção B: Mudar totalmente para o Forgejo
Se não vai mais usar o GitHub.
```powershell
# 1. Remova o vínculo com o GitHub (opcional, ou apenas renomeie)
git remote remove origin
# 2. Adicione o Forgejo como 'origin' (padrão)
git remote add origin https://<TOKEN>@forgejo-gru.rede5.com.br/rede5/<NOME-DO-REPO>.git
# 3. Envie o código e defina o upstream padrão
git push -u origin main
```
---
## 3. Comandos Padrão do Dia a Dia
Depois de configurado, o fluxo é o mesmo:
| Ação | Comando |
| :--- | :--- |
| **Baixar atualizações** | `git pull forgero main` |
| **Enviar alterações** | `git push forgero main` |
| **Verificar remotes** | `git remote -v` |
| **Criar nova branch** | `git checkout -b nova-feature` |
| **Enviar nova branch** | `git push forgero nova-feature` |
---
## 4. Solução de Problemas Comuns
### Erro: `fatal: Authentication failed`
* **Causa:** O Git não conseguiu logar.
* **Solução:** Verifique se o token no comando `git remote add` está correto ou se expirou.
* **Correção:** Atualize a URL com o token correto:
```powershell
git remote set-url forgero https://<NOVO_TOKEN>@forgejo-gru.rede5.com.br/rede5/<REPO>.git
```
### Erro: `remote origin already exists`
* **Causa:** Você tentou adicionar um remote (`origin`) que já existe.
* **Solução:** Use outro nome (ex: `forgero`) ou mude a URL do existente.
```powershell
# Adicionar com outro nome
git remote add forgero <URL>
# OU Alterar o existente
git remote set-url origin <URL>
```
### Erro: `refusing to merge unrelated histories`
* **Causa:** Você criou o repositório no Forgejo com README/Licença e tentou subir um projeto local que já tinha esses arquivos.
* **Solução:**
```powershell
git pull forgero main --allow-unrelated-histories
```

View file

@ -0,0 +1,179 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { format } from "date-fns";
import {
Building2,
MapPin,
Calendar,
Clock,
CheckCircle2,
XCircle,
AlertCircle,
FileText,
ExternalLink
} from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { applicationsApi } from "@/lib/api";
import { useNotify } from "@/contexts/notification-context";
interface Application {
id: string;
jobId: string;
jobTitle: string;
companyName: string;
companyId: string;
status: string;
createdAt: string;
resumeUrl?: string;
message?: string;
}
const statusConfig: Record<string, { label: string; color: string; icon: any }> = {
pending: { label: "Pending", color: "bg-yellow-100 text-yellow-800 border-yellow-200", icon: Clock },
reviewed: { label: "Viewed", color: "bg-blue-100 text-blue-800 border-blue-200", icon: CheckCircle2 },
shortlisted: { label: "Shortlisted", color: "bg-purple-100 text-purple-800 border-purple-200", icon: CheckCircle2 },
hired: { label: "Hired", color: "bg-green-100 text-green-800 border-green-200", icon: CheckCircle2 },
rejected: { label: "Rejected", color: "bg-red-100 text-red-800 border-red-200", icon: XCircle },
};
export default function MyApplicationsPage() {
const [applications, setApplications] = useState<Application[]>([]);
const [loading, setLoading] = useState(true);
const notify = useNotify();
useEffect(() => {
async function fetchApplications() {
try {
const data = await applicationsApi.listMine();
setApplications(data || []);
} catch (error) {
console.error("Failed to fetch applications:", error);
notify.error("Error", "Failed to load your applications.");
} finally {
setLoading(false);
}
}
fetchApplications();
}, [notify]);
if (loading) {
return (
<div className="space-y-4">
<h1 className="text-3xl font-bold">My Applications</h1>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardHeader>
<Skeleton className="h-6 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</CardHeader>
<CardContent>
<Skeleton className="h-20 w-full" />
</CardContent>
</Card>
))}
</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h1 className="text-3xl font-bold">My Applications</h1>
<p className="text-muted-foreground mt-1">
Track the status of your job applications.
</p>
</div>
<Button asChild>
<Link href="/jobs">Find more jobs</Link>
</Button>
</div>
{applications.length === 0 ? (
<Card className="border-dashed">
<CardContent className="flex flex-col items-center justify-center py-12 text-center text-muted-foreground">
<h3 className="text-lg font-semibold mb-2">No applications yet</h3>
<p className="mb-6">You haven't applied to any jobs yet.</p>
<Button asChild variant="secondary">
<Link href="/jobs">Browse Jobs</Link>
</Button>
</CardContent>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-1 lg:grid-cols-2">
{applications.map((app) => {
const status = statusConfig[app.status] || {
label: app.status,
color: "bg-gray-100 text-gray-800 border-gray-200",
icon: AlertCircle
};
const StatusIcon = status.icon;
return (
<Card key={app.id} className="hover:border-primary/50 transition-colors">
<CardHeader>
<div className="flex justify-between items-start gap-4">
<div className="space-y-1">
<CardTitle className="text-xl">
<Link href={`/jobs/${app.jobId}`} className="hover:underline hover:text-primary transition-colors">
{app.jobTitle}
</Link>
</CardTitle>
<div className="flex items-center text-muted-foreground text-sm gap-2">
<Building2 className="h-4 w-4" />
<span>{app.companyName}</span>
</div>
</div>
<Badge variant="outline" className={`${status.color} flex items-center gap-1 shrink-0`}>
<StatusIcon className="h-3 w-3" />
{status.label}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
Applied on {format(new Date(app.createdAt), "MMM d, yyyy")}
</div>
</div>
{app.message && (
<div className="bg-muted/50 p-3 rounded-md text-sm italic">
"{app.message.length > 100 ? app.message.substring(0, 100) + "..." : app.message}"
</div>
)}
<div className="flex items-center gap-2 pt-2">
{app.resumeUrl && (
<Button variant="outline" size="sm" asChild>
<a href={app.resumeUrl} target="_blank" rel="noopener noreferrer">
<FileText className="h-4 w-4 mr-2" />
View Resume
</a>
</Button>
)}
<Button variant="ghost" size="sm" asChild className="ml-auto">
<Link href={`/jobs/${app.jobId}`}>
View Job <ExternalLink className="h-3 w-3 ml-1" />
</Link>
</Button>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
</div>
);
}

View file

@ -43,15 +43,13 @@ import { Separator } from "@/components/ui/separator";
import { Navbar } from "@/components/navbar";
import { Footer } from "@/components/footer";
import { useNotify } from "@/contexts/notification-context";
import { jobsApi, applicationsApi, type ApiJob } from "@/lib/api";
import { jobsApi, applicationsApi, storageApi, type ApiJob } from "@/lib/api";
import { formatPhone } from "@/lib/utils";
import { useTranslation } from "@/lib/i18n";
import { getCurrentUser } from "@/lib/auth";
// Step definitions
const steps = [
{ id: 1, title: "Personal Details", icon: User },
{ id: 2, title: "Resume & Documents", icon: FileText },
{ id: 3, title: "Experience", icon: Briefcase },
{ id: 4, title: "Additional Questions", icon: MessageSquare },
];
export const runtime = 'edge';
@ -61,11 +59,22 @@ export default function JobApplicationPage({
}: {
params: Promise<{ id: string }>;
}) {
const { t } = useTranslation();
const steps = [
{ id: 1, title: t("application.steps.personal"), icon: User },
{ id: 2, title: t("application.steps.documents"), icon: FileText },
{ id: 3, title: t("application.steps.experience"), icon: Briefcase },
{ id: 4, title: t("application.steps.additional"), icon: MessageSquare },
];
const { id } = use(params);
const router = useRouter();
const notify = useNotify();
const [currentStep, setCurrentStep] = useState(1);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSubmitted, setIsSubmitted] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [job, setJob] = useState<ApiJob | null>(null);
const [loading, setLoading] = useState(true);
@ -77,8 +86,10 @@ export default function JobApplicationPage({
phone: "",
linkedin: "",
privacyAccepted: false,
// Step 2
resume: null as File | null,
resumeUrl: "",
resumeName: "",
coverLetter: "",
portfolioUrl: "",
// Step 3
@ -90,41 +101,69 @@ export default function JobApplicationPage({
});
const handleInputChange = (field: string, value: any) => {
if (field === "phone") {
value = formatPhone(value);
}
setFormData((prev) => ({ ...prev, [field]: value }));
};
const handleResumeUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 5 * 1024 * 1024) {
notify.error(t("application.toasts.fileTooLarge.title"), t("application.toasts.fileTooLarge.desc"));
return;
}
try {
setIsUploading(true);
const { publicUrl } = await storageApi.uploadFile(file, "resumes");
setFormData(prev => ({
...prev,
resumeUrl: publicUrl,
resumeName: file.name
}));
notify.success(t("application.toasts.uploadComplete.title"), t("application.toasts.uploadComplete.desc"));
} catch (error) {
console.error("Upload error:", error);
notify.error(t("application.toasts.uploadFailed.title"), t("application.toasts.uploadFailed.desc"));
} finally {
setIsUploading(false);
}
};
const validateStep = (step: number) => {
switch (step) {
case 1:
if (!formData.fullName || !formData.email || !formData.phone) {
notify.error(
"Required fields",
"Please fill out all required fields."
);
notify.error(t("application.toasts.requiredFields.title"), t("application.toasts.requiredFields.desc"));
return false;
}
if (!formData.email.includes("@")) {
notify.error(
"Invalid email",
"Please enter a valid email address."
);
notify.error(t("application.toasts.invalidEmail.title"), t("application.toasts.invalidEmail.desc"));
return false;
}
if (formData.phone.length < 14) { // (11) 91234-5678 is 15 chars, (11) 1234-5678 is 14 chars
notify.error(t("application.toasts.invalidPhone.title"), t("application.toasts.invalidPhone.desc"));
return false;
}
if (!formData.privacyAccepted) {
notify.error(
"Privacy policy",
"You must accept the privacy policy to continue."
);
notify.error(t("application.toasts.privacyPolicy.title"), t("application.toasts.privacyPolicy.desc"));
return false;
}
return true;
case 2:
if (!formData.resumeUrl) {
notify.error(t("application.toasts.resumeRequired.title"), t("application.toasts.resumeRequired.desc"));
return false;
}
return true;
case 3:
if (!formData.salaryExpectation || !formData.hasExperience) {
notify.error(
"Required fields",
"Please answer all questions."
t("application.toasts.questionsRequired.title"),
t("application.toasts.questionsRequired.desc")
);
return false;
}
@ -132,8 +171,8 @@ export default function JobApplicationPage({
case 4:
if (!formData.whyUs || formData.availability.length === 0) {
notify.error(
"Required fields",
"Please provide your reason and select at least one availability option."
t("application.toasts.reasonRequired.title"),
t("application.toasts.reasonRequired.desc")
);
return false;
}
@ -171,7 +210,7 @@ export default function JobApplicationPage({
}
} catch (err) {
console.error("Error fetching job:", err);
notify.error("Error", "Failed to load job details");
notify.error(t("application.toasts.loadError.title"), t("application.toasts.loadError.desc"));
} finally {
setLoading(false);
}
@ -179,6 +218,18 @@ export default function JobApplicationPage({
fetchJob();
}, [id, notify]);
// Auto-fill for logged in users
useEffect(() => {
const user = getCurrentUser();
if (user) {
setFormData(prev => ({
...prev,
fullName: user.name || prev.fullName,
email: user.email || prev.email,
}));
}
}, []);
const handleSubmit = async () => {
setIsSubmitting(true);
try {
@ -188,25 +239,30 @@ export default function JobApplicationPage({
email: formData.email,
phone: formData.phone,
linkedin: formData.linkedin,
coverLetter: formData.coverLetter,
portfolioUrl: formData.portfolioUrl,
salaryExpectation: formData.salaryExpectation,
hasExperience: formData.hasExperience,
whyUs: formData.whyUs,
availability: formData.availability,
resumeUrl: formData.resumeUrl,
coverLetter: formData.coverLetter || undefined,
portfolioUrl: formData.portfolioUrl || undefined,
message: formData.whyUs, // Mapping Why Us to Message/Notes
documents: {}, // TODO: Extra docs
// salaryExpectation: formData.salaryExpectation, // These fields might need to go into Notes or structured JSON if backend doesn't support them specifically?
// hasExperience: formData.hasExperience,
// Backend seems to map "documents" as JSONMap. We can put extra info there?
// Or put in "message" concatenated.
// Let's assume the backend 'message' field is good for "whyUs"
});
notify.success(
"Application submitted!",
`Good luck! Your application for ${job?.title || 'this position'} has been received.`
t("application.toasts.submitted.title"),
t("application.toasts.submitted.desc", { jobTitle: job?.title || 'this position' })
);
router.push("/dashboard/my-applications");
setIsSubmitted(true);
window.scrollTo(0, 0);
} catch (error: any) {
console.error("Submit error:", error);
notify.error(
"Error submitting",
error.message || "Please try again later."
t("application.toasts.submitError.title"),
error.message || t("application.toasts.submitError.default")
);
} finally {
setIsSubmitting(false);
@ -215,8 +271,8 @@ export default function JobApplicationPage({
const handleSaveDraft = () => {
notify.info(
"Draft saved",
"You can finish your application later."
t("application.toasts.draftSaved.title"),
t("application.toasts.draftSaved.desc")
);
};
@ -224,6 +280,79 @@ export default function JobApplicationPage({
if (!job) return null;
if (isSubmitted) {
const user = getCurrentUser();
return (
<div className="min-h-screen flex flex-col bg-muted/30">
<Navbar />
<main className="flex-1 py-12 sm:py-24">
<div className="container mx-auto px-4 max-w-lg">
<Card className="border-t-4 border-t-green-500 shadow-lg">
<CardContent className="pt-10 pb-8 text-center space-y-6">
<div className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto text-green-600 mb-6">
<CheckCircle2 className="h-10 w-10" />
</div>
<div className="space-y-2">
<h2 className="text-2xl font-bold">{t("application.success.title")}</h2>
<p className="text-muted-foreground">
{t("application.success.message")} <span className="font-semibold text-foreground">{job.title}</span>.
</p>
</div>
<div className="py-4">
{user ? (
<div className="bg-primary/5 p-4 rounded-lg border border-primary/20 text-left">
<h3 className="font-semibold text-primary mb-1 flex items-center">
<Briefcase className="w-4 h-4 mr-2" />
{t("candidate.dashboard.applications.title")}
</h3>
<p className="text-sm text-muted-foreground mb-3">
Acompanhe o status desta e de outras candidaturas no seu painel.
</p>
<Button className="w-full" asChild>
<Link href="/dashboard/my-applications">
Ver Minhas Candidaturas
</Link>
</Button>
</div>
) : (
<div className="bg-primary/5 p-4 rounded-lg border border-primary/20 text-left">
<h3 className="font-semibold text-primary mb-1 flex items-center">
<User className="w-4 h-4 mr-2" />
{t("application.success.ctaTitle")}
</h3>
<p className="text-sm text-muted-foreground mb-3">
{t("application.success.ctaDesc")}
</p>
<Button className="w-full" asChild>
<Link
href={`/register/candidate?email=${encodeURIComponent(formData.email)}&name=${encodeURIComponent(formData.fullName)}&phone=${encodeURIComponent(formData.phone)}`}
>
{t("application.success.ctaButton")}
</Link>
</Button>
</div>
)}
</div>
<div className="flex flex-col gap-3">
<Button variant="outline" asChild>
<Link href="/jobs">
{t("application.success.backJobs")}
</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
</main>
<Footer />
</div>
);
}
return (
<div className="min-h-screen flex flex-col bg-muted/30">
<Navbar />
@ -237,19 +366,19 @@ export default function JobApplicationPage({
className="inline-flex items-center text-sm text-muted-foreground hover:text-primary mb-4 transition-colors"
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to job details
{t("application.back")}
</Link>
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h1 className="text-2xl md:text-3xl font-bold text-foreground">
Application: {job.title}
{t("application.title", { jobTitle: job.title })}
</h1>
<p className="text-muted-foreground mt-1">
{job.companyName || 'Company'} {job.location || 'Remote'}
</p>
</div>
<div className="text-sm font-medium bg-primary/10 text-primary px-3 py-1 rounded-full self-start md:self-center">
Estimated time: 5 min
{t("application.estimatedTime")}
</div>
</div>
</div>
@ -258,7 +387,7 @@ export default function JobApplicationPage({
<div className="mb-4 sm:mb-8">
<div className="flex justify-between mb-2">
<span className="text-sm font-medium text-muted-foreground">
Step {currentStep} of {steps.length}:{" "}
{t("application.progress.step", { current: currentStep, total: steps.length })}{" "}
<span className="text-foreground">
{steps[currentStep - 1].title}
</span>
@ -343,7 +472,7 @@ export default function JobApplicationPage({
<CardHeader>
<CardTitle>{steps[currentStep - 1].title}</CardTitle>
<CardDescription>
Fill in the information below to continue.
{t("application.form.description")}
</CardDescription>
</CardHeader>
@ -353,10 +482,10 @@ export default function JobApplicationPage({
<div className="space-y-4">
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="fullName">Full name *</Label>
<Label htmlFor="fullName">{t("application.form.fullName")}</Label>
<Input
id="fullName"
placeholder="Your full name"
placeholder={t("application.form.placeholders.fullName")}
value={formData.fullName}
onChange={(e) =>
handleInputChange("fullName", e.target.value)
@ -364,11 +493,11 @@ export default function JobApplicationPage({
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email *</Label>
<Label htmlFor="email">{t("application.form.email")}</Label>
<Input
id="email"
type="email"
placeholder="you@email.com"
placeholder={t("application.form.placeholders.email")}
value={formData.email}
onChange={(e) =>
handleInputChange("email", e.target.value)
@ -379,10 +508,10 @@ export default function JobApplicationPage({
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="phone">Phone / WhatsApp *</Label>
<Label htmlFor="phone">{t("application.form.phone")}</Label>
<Input
id="phone"
placeholder="(00) 00000-0000"
placeholder={t("application.form.placeholders.phone")}
value={formData.phone}
onChange={(e) =>
handleInputChange("phone", e.target.value)
@ -390,10 +519,10 @@ export default function JobApplicationPage({
/>
</div>
<div className="space-y-2">
<Label htmlFor="linkedin">LinkedIn (URL)</Label>
<Label htmlFor="linkedin">{t("application.form.linkedin")}</Label>
<Input
id="linkedin"
placeholder="linkedin.com/in/your-profile"
placeholder={t("application.form.placeholders.linkedin")}
value={formData.linkedin}
onChange={(e) =>
handleInputChange("linkedin", e.target.value)
@ -414,11 +543,11 @@ export default function JobApplicationPage({
htmlFor="privacy"
className="text-sm font-normal text-muted-foreground"
>
I have read and agree to the{" "}
{t("application.form.privacy.agree")}{" "}
<Link href="/privacy" className="text-primary underline">
Privacy Policy
{t("application.form.privacy.policy")}
</Link>{" "}
and authorize the processing of my data for recruitment purposes.
{t("application.form.privacy.authorize")}
</Label>
</div>
</div>
@ -428,18 +557,25 @@ export default function JobApplicationPage({
{currentStep === 2 && (
<div className="space-y-6">
<div className="space-y-3">
<Label>Resume (CV) *</Label>
<div className="border-2 border-dashed border-muted-foreground/25 rounded-lg p-8 text-center hover:bg-muted/50 transition-colors cursor-pointer">
<Label>{t("application.form.resume")}</Label>
<div className="border-2 border-dashed border-muted-foreground/25 rounded-lg p-8 text-center hover:bg-muted/50 transition-colors relative">
<input
type="file"
accept=".pdf,.doc,.docx,.txt"
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
onChange={handleResumeUpload}
disabled={isUploading}
/>
<div className="flex flex-col items-center gap-2">
<div className="p-3 bg-primary/10 rounded-full text-primary">
<Upload className="h-6 w-6" />
{isUploading ? <Loader2 className="h-6 w-6 animate-spin" /> : <Upload className="h-6 w-6" />}
</div>
<div className="space-y-1">
<p className="text-sm font-medium">
Click to upload or drag the file here
{formData.resumeName || t("application.form.upload.click")}
</p>
<p className="text-xs text-muted-foreground">
PDF, DOCX, or TXT (Max 5MB)
{formData.resumeName ? t("application.form.upload.change") : t("application.form.upload.formats")}
</p>
</div>
</div>
@ -448,11 +584,11 @@ export default function JobApplicationPage({
<div className="space-y-2">
<Label htmlFor="portfolio">
Portfolio / Personal Website (Optional)
{t("application.form.portfolio")}
</Label>
<Input
id="portfolio"
placeholder="https://..."
placeholder={t("application.form.placeholders.portfolio")}
value={formData.portfolioUrl}
onChange={(e) =>
handleInputChange("portfolioUrl", e.target.value)
@ -462,11 +598,11 @@ export default function JobApplicationPage({
<div className="space-y-2">
<Label htmlFor="coverLetter">
Cover Letter (Optional)
{t("application.form.coverLetter")}
</Label>
<Textarea
id="coverLetter"
placeholder="Write a short introduction about yourself..."
placeholder={t("application.form.placeholders.whyUs")}
className="min-h-[150px]"
value={formData.coverLetter}
onChange={(e) =>
@ -481,7 +617,7 @@ export default function JobApplicationPage({
{currentStep === 3 && (
<div className="space-y-6">
<div className="space-y-2">
<Label htmlFor="salary">Salary expectation *</Label>
<Label htmlFor="salary">{t("application.form.salary")}</Label>
<Select
value={formData.salaryExpectation}
onValueChange={(val) =>
@ -489,23 +625,23 @@ export default function JobApplicationPage({
}
>
<SelectTrigger>
<SelectValue placeholder="Select a range" />
<SelectValue placeholder={t("application.form.placeholders.select")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="up-to-3k">
Up to R$ 3,000
{t("application.form.salaryRanges.upTo3k")}
</SelectItem>
<SelectItem value="3k-5k">
R$ 3,000 - R$ 5,000
{t("application.form.salaryRanges.3k-5k")}
</SelectItem>
<SelectItem value="5k-8k">
R$ 5,000 - R$ 8,000
{t("application.form.salaryRanges.5k-8k")}
</SelectItem>
<SelectItem value="8k-12k">
R$ 8,000 - R$ 12,000
{t("application.form.salaryRanges.8k-12k")}
</SelectItem>
<SelectItem value="12k-plus">
Above R$ 12,000
{t("application.form.salaryRanges.12k-plus")}
</SelectItem>
</SelectContent>
</Select>
@ -513,8 +649,7 @@ export default function JobApplicationPage({
<div className="space-y-3">
<Label>
Do you have the minimum experience required for the
role? *
{t("application.form.hasExperience")}
</Label>
<div className="flex gap-4">
<div className="flex items-center space-x-2 border p-3 rounded-md flex-1 hover:bg-muted/50 cursor-pointer">
@ -532,7 +667,7 @@ export default function JobApplicationPage({
htmlFor="exp-yes"
className="cursor-pointer flex-1"
>
Yes, I do
{t("application.form.experience.yes")}
</Label>
</div>
<div className="flex items-center space-x-2 border p-3 rounded-md flex-1 hover:bg-muted/50 cursor-pointer">
@ -550,7 +685,7 @@ export default function JobApplicationPage({
htmlFor="exp-no"
className="cursor-pointer flex-1"
>
Not yet
{t("application.form.experience.no")}
</Label>
</div>
</div>
@ -563,11 +698,11 @@ export default function JobApplicationPage({
<div className="space-y-6">
<div className="space-y-2">
<Label htmlFor="whyUs">
Why do you want to work at {job.companyName || 'this company'}? *
{t("application.form.whyUs", { company: job.companyName || 'this company' })}
</Label>
<Textarea
id="whyUs"
placeholder="Tell us what attracts you to this company and role..."
placeholder={t("application.form.placeholders.whyUs")}
className="min-h-[150px]"
maxLength={1000}
value={formData.whyUs}
@ -581,13 +716,13 @@ export default function JobApplicationPage({
</div>
<div className="space-y-3">
<Label>Availability *</Label>
<Label>{t("application.form.availability")}</Label>
<div className="grid gap-2">
{[
"On-site work",
"Remote work",
"Travel",
"Immediate start",
"onsite",
"remote",
"travel",
"immediate",
].map((item) => (
<div
key={item}
@ -612,7 +747,7 @@ export default function JobApplicationPage({
}
}}
/>
<Label htmlFor={`avail-${item}`}>{item}</Label>
<Label htmlFor={`avail-${item}`}>{t(`application.form.availabilityOptions.${item}`)}</Label>
</div>
))}
</div>
@ -629,7 +764,7 @@ export default function JobApplicationPage({
className="w-full sm:w-auto order-2 sm:order-1"
>
<ChevronLeft className="mr-2 h-4 w-4" />
Back
{t("application.buttons.back")}
</Button>
<div className="flex gap-2 w-full sm:w-auto order-1 sm:order-2">
@ -640,7 +775,7 @@ export default function JobApplicationPage({
className="hidden sm:flex"
>
<Save className="mr-2 h-4 w-4" />
Save draft
{t("application.buttons.draft")}
</Button>
<Button
@ -649,15 +784,15 @@ export default function JobApplicationPage({
className="flex-1 sm:flex-none sm:min-w-[120px]"
>
{isSubmitting ? (
"Submitting..."
t("application.buttons.submitting")
) : currentStep === steps.length ? (
<>
Submit application{" "}
{t("application.buttons.submit")}{" "}
<CheckCircle2 className="ml-2 h-4 w-4" />
</>
) : (
<>
Next step{" "}
{t("application.buttons.next")}{" "}
<ChevronRight className="ml-2 h-4 w-4" />
</>
)}

View file

@ -35,6 +35,7 @@ import {
} from "lucide-react";
import Link from "next/link";
import { motion } from "framer-motion";
import { useTranslation } from "@/lib/i18n";
export const runtime = 'edge';
@ -44,6 +45,7 @@ export default function JobDetailPage({
}: {
params: Promise<{ id: string }>;
}) {
const { t } = useTranslation();
const { id } = use(params);
const router = useRouter();
const [isFavorited, setIsFavorited] = useState(false);
@ -129,21 +131,19 @@ export default function JobDetailPage({
const diffInMs = now.getTime() - date.getTime();
const diffInDays = Math.floor(diffInMs / (1000 * 60 * 60 * 24));
if (diffInDays === 0) return "Today";
if (diffInDays === 1) return "Yesterday";
if (diffInDays < 7) return `${diffInDays} days ago`;
if (diffInDays < 30) return `${Math.floor(diffInDays / 7)} weeks ago`;
return `${Math.floor(diffInDays / 30)} months ago`;
if (diffInDays === 0) return t("jobs.posted.today");
if (diffInDays === 1) return t("jobs.posted.yesterday");
if (diffInDays < 7) return t("jobs.posted.daysAgo", { count: diffInDays });
if (diffInDays < 30) return t("jobs.posted.weeksAgo", { count: Math.floor(diffInDays / 7) });
return t("jobs.posted.monthsAgo", { count: Math.floor(diffInDays / 30) });
};
const getTypeLabel = (type: string) => {
const typeLabels: { [key: string]: string } = {
"full-time": "Full time",
"part-time": "Part time",
contract: "Contract",
remote: "Remote",
};
return typeLabels[type] || type;
// Rely on t() for mapping if possible, or keep simple map if keys match
const key = `jobs.types.${type}`;
// We can try to use t(key), but if it doesn't exist we fail.
// The keys in pt-BR.json are "jobs.types.full-time" etc.
return t(`jobs.types.${type}`) || type;
};
const getSalaryDisplay = () => {
@ -182,7 +182,7 @@ export default function JobDetailPage({
<Link href="/jobs">
<Button variant="ghost" className="gap-2 hover:bg-muted">
<ArrowLeft className="h-4 w-4" />
Back to jobs
{t("jobs.details.back")}
</Button>
</Link>
</motion.div>
@ -336,7 +336,7 @@ export default function JobDetailPage({
className="w-full"
>
<Button size="lg" className="w-full cursor-pointer">
Apply now
{t("jobs.details.applyNow")}
</Button>
</Link>
</div>
@ -352,7 +352,7 @@ export default function JobDetailPage({
>
<Card>
<CardHeader>
<CardTitle className="text-xl">About the role</CardTitle>
<CardTitle className="text-xl">{t("jobs.details.aboutRole")}</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p className="text-muted-foreground leading-relaxed whitespace-pre-line">
@ -370,7 +370,7 @@ export default function JobDetailPage({
>
<Card>
<CardHeader>
<CardTitle className="text-xl">Requirements</CardTitle>
<CardTitle className="text-xl">{t("jobs.details.requirements")}</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-3">
@ -382,7 +382,7 @@ export default function JobDetailPage({
</div>
))
) : (
<p className="text-muted-foreground">No specific requirements listed.</p>
<p className="text-muted-foreground">{t("jobs.details.noRequirements")}</p>
)}
</div>
</CardContent>
@ -397,21 +397,18 @@ export default function JobDetailPage({
>
<Card>
<CardHeader>
<CardTitle className="text-xl">About the company</CardTitle>
<CardTitle className="text-xl">{t("jobs.details.aboutCompany")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-muted-foreground leading-relaxed">
{job.companyName || "Company"} is a market leader committed to creating
an inclusive and innovative workplace. We offer
competitive benefits and opportunities for professional
growth.
{t("jobs.details.companyDesc", { company: job.companyName || "Company" })}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 pt-4 border-t">
<div>
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
<Users className="h-4 w-4 shrink-0" />
<span>Size</span>
<span>{t("jobs.details.company.size")}</span>
</div>
<p className="font-medium text-sm">
{mockCompanyInfo.size}
@ -420,7 +417,7 @@ export default function JobDetailPage({
<div>
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
<Building2 className="h-4 w-4 shrink-0" />
<span>Industry</span>
<span>{t("jobs.details.company.industry")}</span>
</div>
<p className="font-medium text-sm">
{mockCompanyInfo.industry}
@ -429,7 +426,7 @@ export default function JobDetailPage({
<div>
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
<Calendar className="h-4 w-4 shrink-0" />
<span>Founded</span>
<span>{t("jobs.details.company.founded")}</span>
</div>
<p className="font-medium text-sm">
{mockCompanyInfo.founded}
@ -438,7 +435,7 @@ export default function JobDetailPage({
<div>
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
<Globe className="h-4 w-4 shrink-0" />
<span>Website</span>
<span>{t("jobs.details.company.website")}</span>
</div>
<a
href={`https://${mockCompanyInfo.website}`}
@ -466,10 +463,10 @@ export default function JobDetailPage({
<Card>
<CardHeader>
<CardTitle className="text-lg">
Interested in this role?
{t("jobs.details.interested")}
</CardTitle>
<CardDescription>
Apply now and join our team!
{t("jobs.details.applyCta")}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
@ -478,7 +475,7 @@ export default function JobDetailPage({
className="w-full"
>
<Button size="lg" className="w-full cursor-pointer">
Apply now
{t("jobs.details.applyNow")}
</Button>
</Link>
@ -487,7 +484,7 @@ export default function JobDetailPage({
<div className="space-y-3 text-sm">
<div className="flex items-start justify-between gap-2">
<span className="text-muted-foreground">
Job type:
{t("jobs.details.meta.type")}:
</span>
<Badge
variant="outline"
@ -498,7 +495,7 @@ export default function JobDetailPage({
</div>
<div className="flex items-start justify-between gap-2">
<span className="text-muted-foreground shrink-0">
Location:
{t("jobs.details.meta.location")}:
</span>
<span className="font-medium text-right">
{job.location}
@ -507,7 +504,7 @@ export default function JobDetailPage({
{salaryDisplay && (
<div className="flex items-start justify-between gap-2">
<span className="text-muted-foreground">
Salary:
{t("jobs.details.meta.salary")}:
</span>
<span className="font-medium text-right whitespace-nowrap">
{salaryDisplay}
@ -516,7 +513,7 @@ export default function JobDetailPage({
)}
<div className="flex items-start justify-between gap-2">
<span className="text-muted-foreground">
Posted:
{t("jobs.details.meta.posted")}:
</span>
<span className="font-medium text-right whitespace-nowrap">
{formatTimeAgo(job.createdAt)}
@ -535,15 +532,15 @@ export default function JobDetailPage({
>
<Card>
<CardHeader>
<CardTitle className="text-lg">Similar jobs</CardTitle>
<CardTitle className="text-lg">{t("jobs.details.similar")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
Find more opportunities like this one.
{t("jobs.details.similarDesc")}
</p>
<Link href="/jobs">
<Button variant="outline" size="sm" className="w-full">
View all jobs
{t("jobs.details.viewAll")}
</Button>
</Link>
</CardContent>

View file

@ -1,7 +1,7 @@
"use client";
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
import { Button } from "@/components/ui/button";
@ -53,12 +53,12 @@ const createCandidateSchema = (t: (key: string, params?: Record<string, string |
username: z.string().min(3, "Username must be at least 3 characters").regex(/^[a-zA-Z0-9_]+$/, "Username must only contain letters, numbers and underscores"),
password: z.string().min(6, t("register.candidate.validation.password")),
confirmPassword: z.string(),
phone: z.string().min(10, t("register.candidate.validation.phone")),
phone: z.string().min(10, t("register.candidate.validation.phone")).max(15, "Phone number is too long"),
birthDate: z.string().min(1, t("register.candidate.validation.birthDate")),
address: z.string().min(5, t("register.candidate.validation.address")),
city: z.string().min(2, t("register.candidate.validation.city")),
state: z.string().min(2, t("register.candidate.validation.state")),
zipCode: z.string().min(8, t("register.candidate.validation.zipCode")),
zipCode: z.string().min(8, t("register.candidate.validation.zipCode")).max(9, "CEP too long"),
education: z.string().min(1, t("register.candidate.validation.education")),
experience: z.string().min(1, t("register.candidate.validation.experience")),
skills: z.string().optional(),
@ -82,8 +82,11 @@ export default function CandidateRegisterPage() {
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [currentStep, setCurrentStep] = useState(1);
const [loadingCep, setLoadingCep] = useState(false);
const candidateSchema = useMemo(() => createCandidateSchema(t), [t]);
const searchParams = useSearchParams();
const {
register,
handleSubmit,
@ -92,10 +95,13 @@ export default function CandidateRegisterPage() {
setValue,
watch,
trigger,
getValues,
} = useForm<CandidateFormData>({
resolver: zodResolver(candidateSchema),
defaultValues: {
phone: "55", // Default Brazil
phone: searchParams.get("phone") || "55",
email: searchParams.get("email") || "",
fullName: searchParams.get("name") || "",
}
});
@ -112,6 +118,15 @@ export default function CandidateRegisterPage() {
password: data.password,
username: data.username,
phone: data.phone,
birthDate: data.birthDate,
address: data.address,
city: data.city,
state: data.state,
zipCode: data.zipCode,
education: data.education,
experience: data.experience,
skills: data.skills,
objective: data.objective,
});
router.push(`/login?message=${encodeURIComponent("Account created successfully! Please login.")}`);
@ -140,6 +155,41 @@ export default function CandidateRegisterPage() {
if (currentStep > 1) setCurrentStep(currentStep - 1);
};
const handleZipBlur = async () => {
const zip = getValues("zipCode");
if (!zip || zip.length < 8) return;
const cleanZip = zip.replace(/\D/g, "");
if (cleanZip.length !== 8) return;
setLoadingCep(true);
try {
const response = await fetch(`https://cep.awesomeapi.com.br/json/${cleanZip}?token=9426cdf7a6f36931f5afba5a3c4e7bf29974fec6d2662ebcb6d7a1b237ffacdc`);
if (response.ok) {
const data = await response.json();
// Map API response to fields
// API returns: address_name (Logradouro), district (Bairro), city (Cidade), state (UF)
// We map: address = address_name + ", " + district
if (data.address) { // Check if valid
const logradouro = data.address_name || data.address;
const bairro = data.district || "";
const fullAddress = `${logradouro}${bairro ? `, ${bairro}` : ""}`;
setValue("address", fullAddress);
setValue("city", data.city);
setValue("state", data.state);
// Clear errors if any
trigger(["address", "city", "state"]);
}
}
} catch (error) {
console.error("Error fetching CEP:", error);
} finally {
setLoadingCep(false);
}
};
const stepVariants = {
hidden: { opacity: 0, x: 20 },
visible: { opacity: 1, x: 0 },
@ -406,6 +456,27 @@ export default function CandidateRegisterPage() {
)}
</div>
<div className="space-y-2">
<Label htmlFor="zipCode">{t("register.candidate.fields.zipCode")}</Label>
<div className="relative">
<Input
id="zipCode"
type="text"
placeholder={t("register.candidate.placeholders.zipCode")}
{...register("zipCode")}
onBlur={handleZipBlur}
/>
{loadingCep && (
<span className="absolute right-3 top-3 text-xs text-muted-foreground animate-pulse">
Fetching address...
</span>
)}
</div>
{errors.zipCode && (
<span className="text-sm text-destructive">{errors.zipCode.message}</span>
)}
</div>
<div className="space-y-2">
<Label htmlFor="address">{t("register.candidate.fields.address")}</Label>
<div className="relative">
@ -439,7 +510,10 @@ export default function CandidateRegisterPage() {
<div className="space-y-2">
<Label htmlFor="state">{t("register.candidate.fields.state")}</Label>
<Select onValueChange={(value) => setValue("state", value)}>
<Select
onValueChange={(value) => setValue("state", value)}
value={watch("state")}
>
<SelectTrigger>
<SelectValue placeholder={t("register.candidate.placeholders.state")} />
</SelectTrigger>
@ -479,19 +553,6 @@ export default function CandidateRegisterPage() {
</div>
</div>
<div className="space-y-2">
<Label htmlFor="zipCode">{t("register.candidate.fields.zipCode")}</Label>
<Input
id="zipCode"
type="text"
placeholder={t("register.candidate.placeholders.zipCode")}
{...register("zipCode")}
/>
{errors.zipCode && (
<span className="text-sm text-destructive">{errors.zipCode.message}</span>
)}
</div>
<div className="flex gap-4">
<Button type="button" variant="outline" onClick={prevStep} className="flex-1">
{t("register.candidate.actions.back")}

View file

@ -13,7 +13,9 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table"
import { mockJobs, mockApplications, mockNotifications } from "@/lib/mock-data"
import { mockNotifications } from "@/lib/mock-data"
import { jobsApi, applicationsApi, transformApiJobToFrontend } from "@/lib/api"
import { Job, ApplicationWithDetails } from "@/lib/types"
import {
Bell,
FileText,
@ -26,13 +28,44 @@ import {
import { motion } from "framer-motion"
import { getCurrentUser } from "@/lib/auth"
import { useTranslation } from "@/lib/i18n"
import { useState, useEffect } from "react"
export function CandidateDashboardContent() {
const { t } = useTranslation()
const user = getCurrentUser()
const recommendedJobs = mockJobs.slice(0, 3)
const [jobs, setJobs] = useState<Job[]>([])
const [applications, setApplications] = useState<ApplicationWithDetails[]>([])
const [loading, setLoading] = useState(true)
const unreadNotifications = mockNotifications.filter((n) => !n.read)
useEffect(() => {
async function fetchData() {
try {
// Fetch recommended jobs (latest ones for now)
const jobsRes = await jobsApi.list({ limit: 3, sortBy: "created_at" });
const appsRes = await applicationsApi.listMine();
if (jobsRes && jobsRes.data) {
const mappedJobs = jobsRes.data.map(transformApiJobToFrontend);
setJobs(mappedJobs);
}
if (appsRes) {
setApplications(appsRes);
}
} catch (error) {
console.error("Failed to fetch dashboard data", error);
} finally {
setLoading(false);
}
}
fetchData();
}, []);
const recommendedJobs = jobs
const getStatusBadge = (status: string) => {
switch (status) {
case "pending":
@ -108,15 +141,15 @@ export function CandidateDashboardContent() {
>
<StatsCard
title={t('candidate.dashboard.stats.applications')}
value={mockApplications.length}
value={applications.length}
icon={FileText}
description={t('candidate.dashboard.stats.applications_desc')}
/>
<StatsCard
title={t('candidate.dashboard.stats.in_progress')}
value={
mockApplications.filter(
(a) => a.status === "reviewing" || a.status === "interview"
applications.filter(
(a) => ["reviewing", "interview", "pending"].includes(a.status)
).length
}
icon={Clock}
@ -144,9 +177,26 @@ export function CandidateDashboardContent() {
<CardTitle>{t('candidate.dashboard.recommended.title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{recommendedJobs.map((job) => (
<JobCard key={job.id} job={job} />
))}
{loading ? (
<div className="text-center py-4">Loading jobs...</div>
) : jobs.length > 0 ? (
recommendedJobs.map((job) => {
// Check if applied
const isApplied = applications.some(app => app.jobId === job.id);
return (
<JobCard
key={job.id}
job={job}
isApplied={isApplied}
applicationStatus={isApplied ? applications.find(app => app.jobId === job.id)?.status : undefined}
/>
);
})
) : (
<div className="text-center py-4 text-muted-foreground">
No recommended jobs found at this time.
</div>
)}
</CardContent>
</Card>
</motion.div>
@ -172,22 +222,28 @@ export function CandidateDashboardContent() {
</TableRow>
</TableHeader>
<TableBody>
{mockApplications.map((application) => (
{applications.length > 0 ? applications.map((application) => (
<TableRow key={application.id}>
<TableCell className="font-medium">
{application.jobTitle}
{application.job?.title || "Unknown Job"}
</TableCell>
<TableCell>{application.company}</TableCell>
<TableCell>{application.job?.companyName || "Unknown Company"}</TableCell>
<TableCell>
{getStatusBadge(application.status)}
</TableCell>
<TableCell className="text-muted-foreground">
{new Date(application.appliedAt).toLocaleDateString(
"en-US"
{new Date(application.createdAt).toLocaleDateString(
"pt-BR"
)}
</TableCell>
</TableRow>
))}
)) : (
<TableRow>
<TableCell colSpan={4} className="text-center text-muted-foreground py-4">
{t('candidate.dashboard.applications.empty')}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>

View file

@ -26,9 +26,11 @@ import { useTranslation } from "@/lib/i18n";
interface JobCardProps {
job: Job;
isApplied?: boolean;
applicationStatus?: string;
}
export function JobCard({ job }: JobCardProps) {
export function JobCard({ job, isApplied, applicationStatus }: JobCardProps) {
const { t } = useTranslation();
const [isFavorited, setIsFavorited] = useState(false);
const notify = useNotify();
@ -201,9 +203,18 @@ export function JobCard({ job }: JobCardProps) {
{t('jobs.card.viewDetails')}
</Button>
</Link>
{isApplied ? (
<Button className="flex-1 w-full cursor-default bg-emerald-600 hover:bg-emerald-700 text-white" variant="secondary">
{applicationStatus === 'pending' ? t('jobs.card.applied') :
applicationStatus === 'reviewing' ? t('jobs.card.reviewing') :
applicationStatus === 'interview' ? t('jobs.card.interview') :
t('jobs.card.applied')}
</Button>
) : (
<Link href={`/jobs/${job.id}/apply`} className="flex-1">
<Button className="w-full cursor-pointer">{t('jobs.card.apply')}</Button>
</Link>
)}
</div>
</CardFooter>
</Card>

View file

@ -37,6 +37,16 @@ export function PhoneInput({ className, value, onChangeValue, ...props }: PhoneI
const [countryCode, setCountryCode] = React.useState("55")
const [phoneNumber, setPhoneNumber] = React.useState("")
const maskPhone = (value: string, code: string) => {
if (code === "55") {
return value
.replace(/\D/g, "")
.replace(/^(\d{2})(\d)/, "($1) $2")
.replace(/(\d)(\d{4})$/, "$1-$2");
}
return value;
}
// Parse initial value
React.useEffect(() => {
if (value) {
@ -45,18 +55,27 @@ export function PhoneInput({ className, value, onChangeValue, ...props }: PhoneI
setCountryCode(country.value)
// Remove code and +, keep only numbers
const cleanNumber = value.replace(new RegExp(`^\\+?${country.value}`), "")
setPhoneNumber(cleanNumber)
const masked = maskPhone(cleanNumber, country.value)
setPhoneNumber(masked)
} else {
setPhoneNumber(value)
}
}
}, [value])
const handlePhoneChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newVal = e.target.value.replace(/\D/g, "")
setPhoneNumber(newVal)
const rawInput = e.target.value;
const onlyNums = rawInput.replace(/\D/g, "");
// Limit length for Brazil (11 digits max)
if (countryCode === "55" && onlyNums.length > 11) return;
const masked = maskPhone(onlyNums, countryCode);
setPhoneNumber(masked)
if (onChangeValue) {
onChangeValue(`${countryCode}${newVal}`)
onChangeValue(`${countryCode}${onlyNums}`)
}
}

View file

@ -133,7 +133,7 @@ export function useNotifications() {
export function useNotify() {
const { addNotification } = useNotifications();
return {
return React.useMemo(() => ({
success: (
title: string,
message: string,
@ -189,5 +189,5 @@ export function useNotify() {
actionUrl: options?.actionUrl,
actionLabel: options?.actionLabel,
}),
};
}), [addNotification]);
}

View file

@ -657,6 +657,143 @@
"retry": "Retry",
"noResults": "No results found"
},
"application": {
"title": "Application: {jobTitle}",
"back": "Back to job details",
"estimatedTime": "Estimated time: 5 min",
"steps": {
"personal": "Personal Details",
"documents": "Resume & Documents",
"experience": "Experience",
"additional": "Additional Questions"
},
"progress": {
"step": "Step {current} of {total}:"
},
"form": {
"description": "Fill in the information below to continue.",
"fullName": "Full name *",
"email": "Email *",
"phone": "Phone / WhatsApp *",
"linkedin": "LinkedIn (URL)",
"resume": "Resume (CV) *",
"portfolio": "Portfolio / Personal Website (Optional)",
"coverLetter": "Cover Letter (Optional)",
"salary": "Salary expectation *",
"hasExperience": "Do you have the minimum experience required for the role? *",
"whyUs": "Why do you want to work at {company}? *",
"availability": "Availability *",
"placeholders": {
"fullName": "Your full name",
"email": "you@email.com",
"phone": "(00) 00000-0000",
"linkedin": "linkedin.com/in/your-profile",
"select": "Select a range",
"whyUs": "Tell us what attracts you to this company and role...",
"portfolio": "https://..."
},
"privacy": {
"agree": "I have read and agree to the",
"policy": "Privacy Policy",
"authorize": "and authorize the processing of my data for recruitment purposes."
},
"upload": {
"click": "Click to upload or drag the file here",
"change": "Click to change",
"formats": "PDF, DOCX, or TXT (Max 5MB)"
},
"experience": {
"yes": "Yes, I do",
"no": "Not yet"
},
"salaryRanges": {
"upTo3k": "Up to R$ 3,000",
"3k-5k": "R$ 3,000 - R$ 5,000",
"5k-8k": "R$ 5.000 - R$ 8.000",
"8k-12k": "R$ 8.000 - R$ 12.000",
"12k-plus": "Above R$ 12,000"
},
"availabilityOptions": {
"onsite": "On-site work",
"remote": "Remote work",
"travel": "Travel",
"immediate": "Immediate start"
}
},
"buttons": {
"back": "Back",
"draft": "Save draft",
"next": "Next step",
"submit": "Submit application",
"submitting": "Submitting..."
},
"success": {
"title": "Application Submitted!",
"message": "We have received your application for",
"ctaTitle": "Create an account to track status",
"ctaDesc": "Don't lose track of your applications. Create a password now to access your candidate dashboard.",
"ctaButton": "Create Password & Track Status",
"backJobs": "Back to Jobs"
},
"toasts": {
"fileTooLarge": {
"title": "File too large",
"desc": "Max file size is 5MB"
},
"uploadComplete": {
"title": "Upload complete",
"desc": "Resume uploaded successfully"
},
"uploadFailed": {
"title": "Upload failed",
"desc": "Could not upload resume. Please try again."
},
"requiredFields": {
"title": "Required fields",
"desc": "Please fill out all required fields."
},
"invalidEmail": {
"title": "Invalid email",
"desc": "Please enter a valid email address."
},
"invalidPhone": {
"title": "Invalid phone",
"desc": "Please enter a valid phone number."
},
"privacyPolicy": {
"title": "Privacy policy",
"desc": "You must accept the privacy policy to continue."
},
"resumeRequired": {
"title": "Resume required",
"desc": "Please upload your resume."
},
"questionsRequired": {
"title": "Required fields",
"desc": "Please answer all questions."
},
"reasonRequired": {
"title": "Required fields",
"desc": "Please provide your reason and select at least one availability option."
},
"submitted": {
"title": "Application submitted!",
"desc": "Good luck! Your application for {jobTitle} has been received."
},
"submitError": {
"title": "Error submitting",
"default": "Please try again later."
},
"draftSaved": {
"title": "Draft saved",
"desc": "You can finish your application later."
},
"loadError": {
"title": "Error",
"desc": "Failed to load job details"
}
}
},
"faq": {
"title": "Frequently Asked Questions",
"subtitle": "Find answers to common questions about GoHorse Jobs.",

View file

@ -15,6 +15,39 @@
"my_applications": "Minhas Candidaturas",
"support": "Suporte"
},
"candidate": {
"dashboard": {
"welcome": "Olá, {name}!",
"edit_profile": "Editar perfil",
"stats": {
"applications": "Candidaturas",
"applications_desc": "Total de vagas aplicadas",
"in_progress": "Em andamento",
"in_progress_desc": "Aguardando resposta",
"notifications": "Notificações",
"notifications_desc": "Novas atualizações"
},
"recommended": {
"title": "Vagas recomendadas"
},
"status": {
"under_review": "Em análise",
"interview": "Entrevista",
"accepted": "Aprovado",
"rejected": "Não selecionado"
},
"applications": {
"title": "Minhas candidaturas",
"empty": "Você ainda não se candidatou a nenhuma vaga.",
"table": {
"role": "Vaga",
"company": "Empresa",
"status": "Status",
"date": "Data"
}
}
}
},
"nav": {
"jobs": "Vagas",
"about": "Sobre",
@ -178,6 +211,7 @@
"card": {
"viewDetails": "Ver detalhes",
"apply": "Candidatar-se",
"applied": "Candidatou-se",
"perMonth": "/mês",
"postedAgo": "Publicada há {time}"
},
@ -206,6 +240,32 @@
},
"action": "Ver favoritos"
},
"details": {
"back": "Voltar para vagas",
"interested": "Interessado nesta vaga?",
"applyCta": "Candidate-se agora e faça parte do time!",
"applyNow": "Candidatar-se agora",
"similar": "Vagas similares",
"similarDesc": "Encontre mais oportunidades como esta.",
"viewAll": "Ver todas as vagas",
"aboutRole": "Sobre a vaga",
"requirements": "Requisitos",
"aboutCompany": "Sobre a empresa",
"noRequirements": "Nenhum requisito específico listado.",
"companyDesc": "{company} é líder de mercado comprometida em criar um ambiente de trabalho inclusivo e inovador. Oferecemos benefícios competitivos e oportunidades de crescimento profissional.",
"company": {
"size": "Tamanho",
"industry": "Setor",
"founded": "Fundação",
"website": "Site"
},
"meta": {
"type": "Tipo",
"location": "Localização",
"salary": "Salário",
"posted": "Publicado"
}
},
"requirements": {
"more": "+{count} mais"
},
@ -657,6 +717,143 @@
"retry": "Tentar novamente",
"noResults": "Nenhum resultado encontrado"
},
"application": {
"title": "Candidatura: {jobTitle}",
"back": "Voltar para detalhes da vaga",
"estimatedTime": "Tempo estimado: 5 min",
"steps": {
"personal": "Dados Pessoais",
"documents": "Currículo e Documentos",
"experience": "Experiência",
"additional": "Perguntas Adicionais"
},
"progress": {
"step": "Etapa {current} de {total}:"
},
"form": {
"description": "Preencha as informações abaixo para continuar.",
"fullName": "Nome completo *",
"email": "E-mail *",
"phone": "Telefone / WhatsApp *",
"linkedin": "LinkedIn (URL)",
"resume": "Currículo (CV) *",
"portfolio": "Portfólio / Site Pessoal (Opcional)",
"coverLetter": "Carta de Apresentação (Opcional)",
"salary": "Pretensão Salarial *",
"hasExperience": "Você tem a experiência mínima exigida para a vaga? *",
"whyUs": "Por que você quer trabalhar na {company}? *",
"availability": "Disponibilidade *",
"placeholders": {
"fullName": "Seu nome completo",
"email": "voce@email.com",
"phone": "(00) 00000-0000",
"linkedin": "linkedin.com/in/seu-perfil",
"select": "Selecione uma faixa",
"whyUs": "Conte-nos o que te atrai nesta empresa e função...",
"portfolio": "https://..."
},
"privacy": {
"agree": "Li e concordo com a",
"policy": "Política de Privacidade",
"authorize": "e autorizo o processamento dos meus dados para fins de recrutamento."
},
"upload": {
"click": "Clique para enviar ou arraste o arquivo aqui",
"change": "Clique para alterar",
"formats": "PDF, DOCX ou TXT (Max 5MB)"
},
"experience": {
"yes": "Sim, eu tenho",
"no": "Ainda não"
},
"salaryRanges": {
"upTo3k": "Até R$ 3.000",
"3k-5k": "R$ 3.000 - R$ 5.000",
"5k-8k": "R$ 5.000 - R$ 8.000",
"8k-12k": "R$ 8.000 - R$ 12.000",
"12k-plus": "Acima de R$ 12.000"
},
"availabilityOptions": {
"onsite": "Trabalho presencial",
"remote": "Trabalho remoto",
"travel": "Viagens",
"immediate": "Início imediato"
}
},
"buttons": {
"back": "Voltar",
"draft": "Salvar rascunho",
"next": "Próxima etapa",
"submit": "Enviar candidatura",
"submitting": "Enviando..."
},
"success": {
"title": "Candidatura Enviada!",
"message": "Recebemos sua candidatura para",
"ctaTitle": "Crie uma conta para acompanhar o status",
"ctaDesc": "Não perca o rastro das suas candidaturas. Crie uma senha agora para acessar seu painel de candidato.",
"ctaButton": "Criar Senha e Acompanhar Status",
"backJobs": "Voltar para Vagas"
},
"toasts": {
"fileTooLarge": {
"title": "Arquivo muito grande",
"desc": "O tamanho máximo é 5MB"
},
"uploadComplete": {
"title": "Envio concluído",
"desc": "Currículo enviado com sucesso"
},
"uploadFailed": {
"title": "Falha no envio",
"desc": "Não foi possível enviar o currículo. Tente novamente."
},
"requiredFields": {
"title": "Campos obrigatórios",
"desc": "Por favor, preencha todos os campos obrigatórios."
},
"invalidEmail": {
"title": "Email inválido",
"desc": "Insira um endereço de email válido."
},
"invalidPhone": {
"title": "Telefone inválido",
"desc": "Insira um número de telefone válido."
},
"privacyPolicy": {
"title": "Política de privacidade",
"desc": "Você deve aceitar a política de privacidade para continuar."
},
"resumeRequired": {
"title": "Currículo obrigatório",
"desc": "Por favor, envie seu currículo."
},
"questionsRequired": {
"title": "Campos obrigatórios",
"desc": "Por favor, responda a todas as perguntas."
},
"reasonRequired": {
"title": "Campos obrigatórios",
"desc": "Forneça seu motivo e selecione pelo menos uma opção de disponibilidade."
},
"submitted": {
"title": "Candidatura enviada!",
"desc": "Boa sorte! Sua candidatura para {jobTitle} foi recebida."
},
"submitError": {
"title": "Erro ao enviar",
"default": "Por favor, tente novamente mais tarde."
},
"draftSaved": {
"title": "Rascunho salvo",
"desc": "Você pode finalizar sua candidatura mais tarde."
},
"loadError": {
"title": "Erro",
"desc": "Falha ao carregar detalhes da vaga"
}
}
},
"faq": {
"title": "Perguntas Frequentes",
"subtitle": "Encontre respostas para as perguntas mais comuns sobre o GoHorse Jobs.",
@ -844,38 +1041,6 @@
}
}
},
"candidate": {
"dashboard": {
"welcome": "Olá, {name}!",
"edit_profile": "Editar perfil",
"stats": {
"applications": "Candidaturas",
"applications_desc": "Total de vagas aplicadas",
"in_progress": "Em andamento",
"in_progress_desc": "Aguardando resposta",
"notifications": "Notificações",
"notifications_desc": "Novas atualizações"
},
"recommended": {
"title": "Vagas recomendadas"
},
"applications": {
"title": "Minhas candidaturas",
"table": {
"role": "Vaga",
"company": "Empresa",
"status": "Status",
"date": "Data"
}
},
"status": {
"under_review": "Em análise",
"interview": "Entrevista",
"accepted": "Aprovado",
"rejected": "Reprovado"
}
}
},
"ticketsPage": {
"title": "Tickets de Suporte (Admin)",
"description": "Gerencie todos os tickets de suporte dos usuários.",

View file

@ -401,6 +401,9 @@ export const applicationsApi = {
if (params.companyId) query.append("companyId", params.companyId);
return apiRequest<any[]>(`/api/v1/applications?${query.toString()}`);
},
listMine: () => {
return apiRequest<any[]>("/api/v1/applications/me");
},
delete: (id: string) => {
return apiRequest<void>(`/api/v1/applications/${id}`, {
method: "DELETE"
@ -731,6 +734,32 @@ export const storageApi = {
testConnection: () => apiRequest<{ message: string }>("/api/v1/admin/storage/test-connection", {
method: "POST"
}),
async uploadFile(file: File, folder = "uploads") {
await initConfig();
// Use backend proxy to avoid CORS/403
const formData = new FormData();
formData.append('file', file);
formData.append('folder', folder);
// We use the proxy route
const response = await fetch(`${getApiUrl()}/api/v1/storage/upload`, {
method: 'POST',
body: formData,
// Credentials include is important if we need cookies (though for guest it might not matter, but good practice)
credentials: 'include',
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to upload file to storage: ${errorText}`);
}
const data = await response.json();
return {
key: data.key,
publicUrl: data.publicUrl || data.url
};
},
};
// --- Email Templates & Settings ---

View file

@ -186,6 +186,15 @@ export interface RegisterCandidateData {
password: string;
username: string; // identifier
phone: string;
birthDate?: string;
address?: string;
city?: string;
state?: string;
zipCode?: string;
education?: string;
experience?: string;
skills?: string;
objective?: string;
}
export async function registerCandidate(data: RegisterCandidateData): Promise<void> {

View file

@ -12,6 +12,21 @@ export interface Job {
isFeatured?: boolean;
}
export interface ApplicationWithDetails {
id: string;
userId: string;
jobId: string;
job?: {
id: string;
title: string;
companyName: string;
companyId: string;
};
status: "pending" | "reviewing" | "interview" | "rejected" | "accepted";
createdAt: string;
updatedAt: string;
}
export interface Application {
id: string;
jobId: string;

View file

@ -4,3 +4,21 @@ import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function formatPhone(value: string): string {
// Remove non-numeric characters
const digits = value.replace(/\D/g, "");
// Limit to 11 digits
const limited = digits.substring(0, 11);
// Apply mask
if (limited.length <= 10) {
// (xx) xxxx-xxxx
return limited.replace(/(\d{2})(\d{4})(\d{0,4})/, "($1) $2-$3");
} else {
// (xx) xxxxx-xxxx
return limited.replace(/(\d{2})(\d{5})(\d{0,4})/, "($1) $2-$3");
}
}