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:
parent
ac1b72be44
commit
ddc2f5dd03
38 changed files with 1719 additions and 260 deletions
77
backend/cmd/debug_s3/main.go
Normal file
77
backend/cmd/debug_s3/main.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 == "" {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -51,11 +51,20 @@ type UpdatePasswordRequest struct {
|
|||
}
|
||||
|
||||
type RegisterCandidateRequest struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Username string `json:"username"`
|
||||
Phone string `json:"phone"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
38
backend/internal/handlers/application_handler_ext.go
Normal file
38
backend/internal/handlers/application_handler_ext.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,8 +49,9 @@ type Job struct {
|
|||
// JobWithCompany includes company information
|
||||
type JobWithCompany struct {
|
||||
Job
|
||||
CompanyName string `json:"companyName"`
|
||||
CompanyLogoURL *string `json:"companyLogoUrl,omitempty"`
|
||||
RegionName *string `json:"regionName,omitempty"`
|
||||
CityName *string `json:"cityName,omitempty"`
|
||||
CompanyName string `json:"companyName"`
|
||||
CompanyLogoURL *string `json:"companyLogoUrl,omitempty"`
|
||||
RegionName *string `json:"regionName,omitempty"`
|
||||
CityName *string `json:"cityName,omitempty"`
|
||||
ApplicationsCount int `json:"applicationsCount"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
if err := json.Unmarshal([]byte(payload), &uCfg); err != nil {
|
||||
return UploadConfig{}, fmt.Errorf("failed to parse storage credentials: %w", err)
|
||||
|
||||
// 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)")
|
||||
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"
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 == "" {
|
||||
|
|
|
|||
97
docs/GUIA_MIGRACAO_FORGEJO.md
Normal file
97
docs/GUIA_MIGRACAO_FORGEJO.md
Normal 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
|
||||
```
|
||||
179
frontend/src/app/dashboard/my-applications/page.tsx
Normal file
179
frontend/src/app/dashboard/my-applications/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -278,10 +407,10 @@ export default function JobApplicationPage({
|
|||
<div
|
||||
key={step.id}
|
||||
className={`flex-1 h-1.5 rounded-full transition-colors ${isCompleted
|
||||
? "bg-primary"
|
||||
: isActive
|
||||
? "bg-primary/60"
|
||||
: "bg-muted"
|
||||
? "bg-primary"
|
||||
: isActive
|
||||
? "bg-primary/60"
|
||||
: "bg-muted"
|
||||
}`}
|
||||
/>
|
||||
);
|
||||
|
|
@ -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" />
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -553,9 +550,9 @@ export default function JobDetailPage({
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</main >
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
</div >
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<Link href={`/jobs/${job.id}/apply`} className="flex-1">
|
||||
<Button className="w-full cursor-pointer">{t('jobs.card.apply')}</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>
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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 ---
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue