feat: fix seeder password hashing, add custom questions, navbar/footer on register, payment handler
- fix(seeder): add PASSWORD_PEPPER to all bcrypt hashes (admin + candidates/recruiters) - fix(seeder): add created_by field to jobs INSERT (was causing NOT NULL violation) - feat(backend): add custom job questions support in applications - feat(backend): add payment handler and Stripe routes - feat(frontend): add navbar and footer to /register and /register/user pages - feat(frontend): add custom question answers to job apply page - feat(frontend): update home page hero section and navbar buttons - feat(frontend): update auth/api lib with new endpoints - chore(db): add migration 045 for application answers Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9f5725bf01
commit
8ee0d59a61
14 changed files with 1066 additions and 672 deletions
|
|
@ -5,7 +5,7 @@ import "time"
|
||||||
type CreateCompanyRequest struct {
|
type CreateCompanyRequest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
CompanyName string `json:"companyName"` // Alternative field name
|
CompanyName string `json:"companyName"` // Alternative field name
|
||||||
Document string `json:"document"`
|
Document string `json:"document"` // Tax ID / Business registration (any country)
|
||||||
Contact string `json:"contact"`
|
Contact string `json:"contact"`
|
||||||
AdminEmail string `json:"admin_email"`
|
AdminEmail string `json:"admin_email"`
|
||||||
Email string `json:"email"` // Alternative field name
|
Email string `json:"email"` // Alternative field name
|
||||||
|
|
@ -15,6 +15,7 @@ type CreateCompanyRequest struct {
|
||||||
Address *string `json:"address,omitempty"`
|
Address *string `json:"address,omitempty"`
|
||||||
City *string `json:"city,omitempty"`
|
City *string `json:"city,omitempty"`
|
||||||
State *string `json:"state,omitempty"`
|
State *string `json:"state,omitempty"`
|
||||||
|
Country *string `json:"country,omitempty"` // ISO country name or code
|
||||||
ZipCode *string `json:"zip_code,omitempty"`
|
ZipCode *string `json:"zip_code,omitempty"`
|
||||||
EmployeeCount *string `json:"employeeCount,omitempty"`
|
EmployeeCount *string `json:"employeeCount,omitempty"`
|
||||||
FoundedYear *int `json:"foundedYear,omitempty"`
|
FoundedYear *int `json:"foundedYear,omitempty"`
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/rede5/gohorsejobs/backend/internal/core/domain/entity"
|
"github.com/rede5/gohorsejobs/backend/internal/core/domain/entity"
|
||||||
|
|
@ -82,8 +83,22 @@ func (uc *CreateCompanyUseCase) Execute(ctx context.Context, input dto.CreateCom
|
||||||
if input.Description != nil {
|
if input.Description != nil {
|
||||||
company.Description = input.Description
|
company.Description = input.Description
|
||||||
}
|
}
|
||||||
if input.Address != nil {
|
// Build address: combine provided address + city + country
|
||||||
company.Address = input.Address
|
if input.Address != nil || input.City != nil || input.Country != nil {
|
||||||
|
parts := []string{}
|
||||||
|
if input.Address != nil && *input.Address != "" {
|
||||||
|
parts = append(parts, *input.Address)
|
||||||
|
}
|
||||||
|
if input.City != nil && *input.City != "" {
|
||||||
|
parts = append(parts, *input.City)
|
||||||
|
}
|
||||||
|
if input.Country != nil && *input.Country != "" {
|
||||||
|
parts = append(parts, *input.Country)
|
||||||
|
}
|
||||||
|
if len(parts) > 0 {
|
||||||
|
combined := strings.Join(parts, ", ")
|
||||||
|
company.Address = &combined
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if input.YearsInMarket != nil {
|
if input.YearsInMarket != nil {
|
||||||
company.YearsInMarket = input.YearsInMarket
|
company.YearsInMarket = input.YearsInMarket
|
||||||
|
|
|
||||||
|
|
@ -217,6 +217,123 @@ func (h *PaymentHandler) handlePaymentFailed(_ map[string]interface{}) {
|
||||||
fmt.Println("Payment failed")
|
fmt.Println("Payment failed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SubscriptionCheckoutRequest represents a subscription checkout session request
|
||||||
|
type SubscriptionCheckoutRequest struct {
|
||||||
|
PriceID string `json:"priceId"` // Stripe Price ID for the plan
|
||||||
|
SuccessURL string `json:"successUrl"` // Redirect URL after payment success
|
||||||
|
CancelURL string `json:"cancelUrl"` // Redirect URL if payment is cancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubscriptionCheckout creates a Stripe checkout session for a subscription plan
|
||||||
|
// @Summary Create subscription checkout session
|
||||||
|
// @Description Create a Stripe checkout session for a subscription plan (no job required)
|
||||||
|
// @Tags Payments
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Param request body SubscriptionCheckoutRequest true "Subscription checkout request"
|
||||||
|
// @Success 200 {object} CreateCheckoutResponse
|
||||||
|
// @Failure 400 {string} string "Bad Request"
|
||||||
|
// @Failure 500 {string} string "Internal Server Error"
|
||||||
|
// @Router /api/v1/payments/subscription-checkout [post]
|
||||||
|
func (h *PaymentHandler) SubscriptionCheckout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req SubscriptionCheckoutRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.PriceID == "" {
|
||||||
|
http.Error(w, "priceId is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get Stripe config from encrypted vault
|
||||||
|
var config StripeConfig
|
||||||
|
payload, err := h.credentialsService.GetDecryptedKey(r.Context(), "stripe")
|
||||||
|
if err == nil {
|
||||||
|
json.Unmarshal([]byte(payload), &config)
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.SecretKey == "" {
|
||||||
|
config.SecretKey = os.Getenv("STRIPE_SECRET_KEY")
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.SecretKey == "" {
|
||||||
|
http.Error(w, "Payment service not configured", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
successURL := req.SuccessURL
|
||||||
|
if successURL == "" {
|
||||||
|
frontendURL := os.Getenv("FRONTEND_URL")
|
||||||
|
if frontendURL == "" {
|
||||||
|
frontendURL = "http://localhost:3000"
|
||||||
|
}
|
||||||
|
successURL = frontendURL + "/dashboard?payment=success"
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelURL := req.CancelURL
|
||||||
|
if cancelURL == "" {
|
||||||
|
frontendURL := os.Getenv("FRONTEND_URL")
|
||||||
|
if frontendURL == "" {
|
||||||
|
frontendURL = "http://localhost:3000"
|
||||||
|
}
|
||||||
|
cancelURL = frontendURL + "/register?payment=cancelled"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build Stripe checkout session (subscription mode)
|
||||||
|
sessionID, checkoutURL, err := createStripeSubscriptionCheckout(config.SecretKey, req.PriceID, successURL, cancelURL)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to create checkout session: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(CreateCheckoutResponse{
|
||||||
|
SessionID: sessionID,
|
||||||
|
CheckoutURL: checkoutURL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// createStripeSubscriptionCheckout creates a Stripe subscription checkout session
|
||||||
|
func createStripeSubscriptionCheckout(secretKey, priceID, successURL, cancelURL string) (string, string, error) {
|
||||||
|
data := fmt.Sprintf(
|
||||||
|
"mode=subscription&success_url=%s&cancel_url=%s&line_items[0][price]=%s&line_items[0][quantity]=1",
|
||||||
|
successURL, cancelURL, priceID,
|
||||||
|
)
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", "https://api.stripe.com/v1/checkout/sessions",
|
||||||
|
strings.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", secretKey))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
|
||||||
|
resp, err := (&http.Client{}).Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return "", "", fmt.Errorf("stripe API error: %s", string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var result map[string]interface{}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionID, _ := result["id"].(string)
|
||||||
|
checkoutURL, _ := result["url"].(string)
|
||||||
|
|
||||||
|
return sessionID, checkoutURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetPaymentStatus returns the status of a payment
|
// GetPaymentStatus returns the status of a payment
|
||||||
// @Summary Get payment status
|
// @Summary Get payment status
|
||||||
// @Description Get the status of a job posting payment
|
// @Description Get the status of a job posting payment
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ type Application struct {
|
||||||
Message *string `json:"message,omitempty" db:"message"`
|
Message *string `json:"message,omitempty" db:"message"`
|
||||||
ResumeURL *string `json:"resumeUrl,omitempty" db:"resume_url"`
|
ResumeURL *string `json:"resumeUrl,omitempty" db:"resume_url"`
|
||||||
Documents JSONMap `json:"documents,omitempty" db:"documents"` // Array of {type, url}
|
Documents JSONMap `json:"documents,omitempty" db:"documents"` // Array of {type, url}
|
||||||
|
Answers JSONMap `json:"answers,omitempty" db:"answers"` // Map of form answers and custom question responses
|
||||||
|
|
||||||
// Status & Notes
|
// Status & Notes
|
||||||
Status string `json:"status" db:"status"` // pending, reviewed, shortlisted, rejected, hired
|
Status string `json:"status" db:"status"` // pending, reviewed, shortlisted, rejected, hired
|
||||||
|
|
|
||||||
|
|
@ -335,6 +335,7 @@ func NewRouter() http.Handler {
|
||||||
|
|
||||||
// Payment Routes
|
// Payment Routes
|
||||||
mux.Handle("POST /api/v1/payments/create-checkout", authMiddleware.HeaderAuthGuard(http.HandlerFunc(paymentHandler.CreateCheckout)))
|
mux.Handle("POST /api/v1/payments/create-checkout", authMiddleware.HeaderAuthGuard(http.HandlerFunc(paymentHandler.CreateCheckout)))
|
||||||
|
mux.Handle("POST /api/v1/payments/subscription-checkout", authMiddleware.OptionalHeaderAuthGuard(http.HandlerFunc(paymentHandler.SubscriptionCheckout)))
|
||||||
mux.HandleFunc("POST /api/v1/payments/webhook", paymentHandler.HandleWebhook)
|
mux.HandleFunc("POST /api/v1/payments/webhook", paymentHandler.HandleWebhook)
|
||||||
mux.HandleFunc("GET /api/v1/payments/status/{id}", paymentHandler.GetPaymentStatus)
|
mux.HandleFunc("GET /api/v1/payments/status/{id}", paymentHandler.GetPaymentStatus)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,8 @@ func (s *ApplicationService) CreateApplication(req dto.CreateApplicationRequest)
|
||||||
query := `
|
query := `
|
||||||
INSERT INTO applications (
|
INSERT INTO applications (
|
||||||
job_id, user_id, name, phone, line_id, whatsapp, email,
|
job_id, user_id, name, phone, line_id, whatsapp, email,
|
||||||
message, resume_url, documents, status, created_at, updated_at
|
message, resume_url, documents, answers, status, created_at, updated_at
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||||
RETURNING id, created_at, updated_at
|
RETURNING id, created_at, updated_at
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|
@ -41,6 +41,7 @@ func (s *ApplicationService) CreateApplication(req dto.CreateApplicationRequest)
|
||||||
Message: req.Message,
|
Message: req.Message,
|
||||||
ResumeURL: req.ResumeURL,
|
ResumeURL: req.ResumeURL,
|
||||||
Documents: req.Documents,
|
Documents: req.Documents,
|
||||||
|
Answers: req.Answers,
|
||||||
Status: "pending",
|
Status: "pending",
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
UpdatedAt: time.Now(),
|
UpdatedAt: time.Now(),
|
||||||
|
|
@ -49,7 +50,7 @@ func (s *ApplicationService) CreateApplication(req dto.CreateApplicationRequest)
|
||||||
err := s.DB.QueryRow(
|
err := s.DB.QueryRow(
|
||||||
query,
|
query,
|
||||||
app.JobID, app.UserID, app.Name, app.Phone, app.LineID, app.WhatsApp, app.Email,
|
app.JobID, app.UserID, app.Name, app.Phone, app.LineID, app.WhatsApp, app.Email,
|
||||||
app.Message, app.ResumeURL, app.Documents, app.Status, app.CreatedAt, app.UpdatedAt,
|
app.Message, app.ResumeURL, app.Documents, app.Answers, app.Status, app.CreatedAt, app.UpdatedAt,
|
||||||
).Scan(&app.ID, &app.CreatedAt, &app.UpdatedAt)
|
).Scan(&app.ID, &app.CreatedAt, &app.UpdatedAt)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -86,7 +87,7 @@ func (s *ApplicationService) GetApplications(jobID string) ([]models.Application
|
||||||
// Simple get by Job ID
|
// Simple get by Job ID
|
||||||
query := `
|
query := `
|
||||||
SELECT id, job_id, user_id, name, phone, line_id, whatsapp, email,
|
SELECT id, job_id, user_id, name, phone, line_id, whatsapp, email,
|
||||||
message, resume_url, documents, status, created_at, updated_at
|
message, resume_url, documents, answers, status, created_at, updated_at
|
||||||
FROM applications WHERE job_id = $1
|
FROM applications WHERE job_id = $1
|
||||||
`
|
`
|
||||||
rows, err := s.DB.Query(query, jobID)
|
rows, err := s.DB.Query(query, jobID)
|
||||||
|
|
@ -100,7 +101,7 @@ func (s *ApplicationService) GetApplications(jobID string) ([]models.Application
|
||||||
var a models.Application
|
var a models.Application
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&a.ID, &a.JobID, &a.UserID, &a.Name, &a.Phone, &a.LineID, &a.WhatsApp, &a.Email,
|
&a.ID, &a.JobID, &a.UserID, &a.Name, &a.Phone, &a.LineID, &a.WhatsApp, &a.Email,
|
||||||
&a.Message, &a.ResumeURL, &a.Documents, &a.Status, &a.CreatedAt, &a.UpdatedAt,
|
&a.Message, &a.ResumeURL, &a.Documents, &a.Answers, &a.Status, &a.CreatedAt, &a.UpdatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -148,12 +149,12 @@ func (s *ApplicationService) GetApplicationByID(id string) (*models.Application,
|
||||||
var a models.Application
|
var a models.Application
|
||||||
query := `
|
query := `
|
||||||
SELECT id, job_id, user_id, name, phone, line_id, whatsapp, email,
|
SELECT id, job_id, user_id, name, phone, line_id, whatsapp, email,
|
||||||
message, resume_url, documents, status, created_at, updated_at
|
message, resume_url, documents, answers, status, created_at, updated_at
|
||||||
FROM applications WHERE id = $1
|
FROM applications WHERE id = $1
|
||||||
`
|
`
|
||||||
err := s.DB.QueryRow(query, id).Scan(
|
err := s.DB.QueryRow(query, id).Scan(
|
||||||
&a.ID, &a.JobID, &a.UserID, &a.Name, &a.Phone, &a.LineID, &a.WhatsApp, &a.Email,
|
&a.ID, &a.JobID, &a.UserID, &a.Name, &a.Phone, &a.LineID, &a.WhatsApp, &a.Email,
|
||||||
&a.Message, &a.ResumeURL, &a.Documents, &a.Status, &a.CreatedAt, &a.UpdatedAt,
|
&a.Message, &a.ResumeURL, &a.Documents, &a.Answers, &a.Status, &a.CreatedAt, &a.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -179,7 +180,7 @@ func (s *ApplicationService) UpdateApplicationStatus(id string, req dto.UpdateAp
|
||||||
func (s *ApplicationService) GetApplicationsByCompany(companyID string) ([]models.Application, error) {
|
func (s *ApplicationService) GetApplicationsByCompany(companyID string) ([]models.Application, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT a.id, a.job_id, a.user_id, a.name, a.phone, a.line_id, a.whatsapp, a.email,
|
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.documents, a.status, a.created_at, a.updated_at
|
a.message, a.resume_url, a.documents, a.answers, a.status, a.created_at, a.updated_at
|
||||||
FROM applications a
|
FROM applications a
|
||||||
JOIN jobs j ON a.job_id = j.id
|
JOIN jobs j ON a.job_id = j.id
|
||||||
WHERE j.company_id = $1
|
WHERE j.company_id = $1
|
||||||
|
|
@ -196,7 +197,7 @@ func (s *ApplicationService) GetApplicationsByCompany(companyID string) ([]model
|
||||||
var a models.Application
|
var a models.Application
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&a.ID, &a.JobID, &a.UserID, &a.Name, &a.Phone, &a.LineID, &a.WhatsApp, &a.Email,
|
&a.ID, &a.JobID, &a.UserID, &a.Name, &a.Phone, &a.LineID, &a.WhatsApp, &a.Email,
|
||||||
&a.Message, &a.ResumeURL, &a.Documents, &a.Status, &a.CreatedAt, &a.UpdatedAt,
|
&a.Message, &a.ResumeURL, &a.Documents, &a.Answers, &a.Status, &a.CreatedAt, &a.UpdatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -69,8 +69,9 @@ func (s *SeederService) Seed(ctx context.Context, logChan chan string) error {
|
||||||
if adminEmail != "" && adminPass != "" {
|
if adminEmail != "" && adminPass != "" {
|
||||||
s.SendEvent(logChan, fmt.Sprintf("🛡️ Found ADMIN_EMAIL env. Creating Superadmin: %s", adminEmail))
|
s.SendEvent(logChan, fmt.Sprintf("🛡️ Found ADMIN_EMAIL env. Creating Superadmin: %s", adminEmail))
|
||||||
|
|
||||||
// Hash password
|
// Hash password with pepper (must match VerifyPassword in JWT service)
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte(adminPass), bcrypt.DefaultCost)
|
pepper := os.Getenv("PASSWORD_PEPPER")
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(adminPass+pepper), bcrypt.DefaultCost)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
tx, err := s.DB.BeginTx(ctx, nil)
|
tx, err := s.DB.BeginTx(ctx, nil)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|
@ -110,7 +111,8 @@ func (s *SeederService) Seed(ctx context.Context, logChan chan string) error {
|
||||||
candidates := []string{"Alice Johnson", "Bob Smith", "Charlie Brown", "Diana Prince", "Evan Wright"}
|
candidates := []string{"Alice Johnson", "Bob Smith", "Charlie Brown", "Diana Prince", "Evan Wright"}
|
||||||
var candidateIDs []string
|
var candidateIDs []string
|
||||||
|
|
||||||
passwordHash, _ := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.DefaultCost)
|
pepper := os.Getenv("PASSWORD_PEPPER")
|
||||||
|
passwordHash, _ := bcrypt.GenerateFromPassword([]byte("password123"+pepper), bcrypt.DefaultCost)
|
||||||
|
|
||||||
for _, name := range candidates {
|
for _, name := range candidates {
|
||||||
id := uuid.New().String()
|
id := uuid.New().String()
|
||||||
|
|
@ -137,6 +139,7 @@ func (s *SeederService) Seed(ctx context.Context, logChan chan string) error {
|
||||||
s.SendEvent(logChan, "🏢 Creating Companies...")
|
s.SendEvent(logChan, "🏢 Creating Companies...")
|
||||||
companyNames := []string{"TechCorp", "InnovateX", "GlobalSolutions", "CodeFactory", "DesignStudio"}
|
companyNames := []string{"TechCorp", "InnovateX", "GlobalSolutions", "CodeFactory", "DesignStudio"}
|
||||||
var companyIDs []string
|
var companyIDs []string
|
||||||
|
var companyRecruiterIDs []string
|
||||||
|
|
||||||
for _, compName := range companyNames {
|
for _, compName := range companyNames {
|
||||||
// Create Recruiter
|
// Create Recruiter
|
||||||
|
|
@ -171,6 +174,7 @@ func (s *SeederService) Seed(ctx context.Context, logChan chan string) error {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
companyIDs = append(companyIDs, compID)
|
companyIDs = append(companyIDs, compID)
|
||||||
|
companyRecruiterIDs = append(companyRecruiterIDs, recruiterID)
|
||||||
|
|
||||||
// Link Recruiter - Use 'admin' as role (per schema constraint: 'admin', 'recruiter')
|
// Link Recruiter - Use 'admin' as role (per schema constraint: 'admin', 'recruiter')
|
||||||
_, err = tx.ExecContext(ctx, `
|
_, err = tx.ExecContext(ctx, `
|
||||||
|
|
@ -203,6 +207,7 @@ func (s *SeederService) Seed(ctx context.Context, logChan chan string) error {
|
||||||
LanguageLevel string
|
LanguageLevel string
|
||||||
VisaSupport bool
|
VisaSupport bool
|
||||||
Status string
|
Status string
|
||||||
|
Questions string // JSONB: {"items":[{id,label,type,required,options?}]}
|
||||||
}
|
}
|
||||||
|
|
||||||
jobTemplates := []jobTemplate{
|
jobTemplates := []jobTemplate{
|
||||||
|
|
@ -214,6 +219,7 @@ func (s *SeederService) Seed(ctx context.Context, logChan chan string) error {
|
||||||
Location: "Remote — Worldwide",
|
Location: "Remote — Worldwide",
|
||||||
SalaryMin: 8000, SalaryMax: 14000, SalaryType: "monthly", Currency: "USD",
|
SalaryMin: 8000, SalaryMax: 14000, SalaryType: "monthly", Currency: "USD",
|
||||||
Negotiable: false, LanguageLevel: "none", VisaSupport: false, Status: "open",
|
Negotiable: false, LanguageLevel: "none", VisaSupport: false, Status: "open",
|
||||||
|
Questions: `{"items":[{"id":"q1","label":"Describe a distributed system you have designed or maintained.","type":"textarea","required":true},{"id":"q2","label":"Which cloud provider are you most experienced with?","type":"select","required":true,"options":["AWS","GCP","Azure","Other"]}]}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Title: "Frontend Developer (React / Next.js)",
|
Title: "Frontend Developer (React / Next.js)",
|
||||||
|
|
@ -223,6 +229,7 @@ func (s *SeederService) Seed(ctx context.Context, logChan chan string) error {
|
||||||
Location: "São Paulo, SP — Brasil",
|
Location: "São Paulo, SP — Brasil",
|
||||||
SalaryMin: 6000, SalaryMax: 10000, SalaryType: "monthly", Currency: "BRL",
|
SalaryMin: 6000, SalaryMax: 10000, SalaryType: "monthly", Currency: "BRL",
|
||||||
Negotiable: false, LanguageLevel: "none", VisaSupport: false, Status: "open",
|
Negotiable: false, LanguageLevel: "none", VisaSupport: false, Status: "open",
|
||||||
|
Questions: `{"items":[{"id":"q1","label":"Link to a project or portfolio you are proud of:","type":"text","required":false},{"id":"q2","label":"Do you have experience with TypeScript?","type":"radio","required":true,"options":["Yes","No"]}]}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Title: "Backend Engineer — Go",
|
Title: "Backend Engineer — Go",
|
||||||
|
|
@ -241,6 +248,7 @@ func (s *SeederService) Seed(ctx context.Context, logChan chan string) error {
|
||||||
Location: "Tokyo, Japan",
|
Location: "Tokyo, Japan",
|
||||||
SalaryMin: 500000, SalaryMax: 800000, SalaryType: "monthly", Currency: "JPY",
|
SalaryMin: 500000, SalaryMax: 800000, SalaryType: "monthly", Currency: "JPY",
|
||||||
Negotiable: false, LanguageLevel: "N3", VisaSupport: true, Status: "open",
|
Negotiable: false, LanguageLevel: "N3", VisaSupport: true, Status: "open",
|
||||||
|
Questions: `{"items":[{"id":"q1","label":"Describe your experience with B2B SaaS product development.","type":"textarea","required":true},{"id":"q2","label":"What is your Japanese proficiency level?","type":"select","required":true,"options":["N1","N2","N3","N4","N5","Beginner"]},{"id":"q3","label":"Are you currently based in Japan?","type":"radio","required":true,"options":["Yes","No, but willing to relocate","No"]}]}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Title: "UX / Product Designer",
|
Title: "UX / Product Designer",
|
||||||
|
|
@ -250,6 +258,7 @@ func (s *SeederService) Seed(ctx context.Context, logChan chan string) error {
|
||||||
Location: "Berlin, Germany",
|
Location: "Berlin, Germany",
|
||||||
SalaryMin: 2500, SalaryMax: 4000, SalaryType: "monthly", Currency: "EUR",
|
SalaryMin: 2500, SalaryMax: 4000, SalaryType: "monthly", Currency: "EUR",
|
||||||
Negotiable: false, LanguageLevel: "none", VisaSupport: true, Status: "open",
|
Negotiable: false, LanguageLevel: "none", VisaSupport: true, Status: "open",
|
||||||
|
Questions: `{"items":[{"id":"q1","label":"Please share a link to your design portfolio:","type":"text","required":true},{"id":"q2","label":"Which design tools do you use regularly?","type":"checkbox","required":true,"options":["Figma","Sketch","Adobe XD","InVision","Framer"]}]}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Title: "DevOps / Platform Engineer",
|
Title: "DevOps / Platform Engineer",
|
||||||
|
|
@ -268,6 +277,7 @@ func (s *SeederService) Seed(ctx context.Context, logChan chan string) error {
|
||||||
Location: "São Paulo, SP — Brasil",
|
Location: "São Paulo, SP — Brasil",
|
||||||
SalaryMin: 5000, SalaryMax: 8000, SalaryType: "monthly", Currency: "BRL",
|
SalaryMin: 5000, SalaryMax: 8000, SalaryType: "monthly", Currency: "BRL",
|
||||||
Negotiable: true, LanguageLevel: "none", VisaSupport: false, Status: "open",
|
Negotiable: true, LanguageLevel: "none", VisaSupport: false, Status: "open",
|
||||||
|
Questions: `{"items":[{"id":"q1","label":"What SQL databases have you worked with?","type":"checkbox","required":true,"options":["PostgreSQL","MySQL","BigQuery","Snowflake","Other"]},{"id":"q2","label":"Describe a data analysis project that had business impact.","type":"textarea","required":true}]}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Title: "Mobile Developer (iOS / Swift)",
|
Title: "Mobile Developer (iOS / Swift)",
|
||||||
|
|
@ -286,6 +296,7 @@ func (s *SeederService) Seed(ctx context.Context, logChan chan string) error {
|
||||||
Location: "Tokyo, Japan",
|
Location: "Tokyo, Japan",
|
||||||
SalaryMin: 180000, SalaryMax: 220000, SalaryType: "monthly", Currency: "JPY",
|
SalaryMin: 180000, SalaryMax: 220000, SalaryType: "monthly", Currency: "JPY",
|
||||||
Negotiable: false, LanguageLevel: "N4", VisaSupport: true, Status: "open",
|
Negotiable: false, LanguageLevel: "N4", VisaSupport: true, Status: "open",
|
||||||
|
Questions: `{"items":[{"id":"q1","label":"Do you have a valid work visa for Japan?","type":"radio","required":true,"options":["Yes","No, I need sponsorship"]},{"id":"q2","label":"Can you start immediately?","type":"radio","required":true,"options":["Yes","Within 1 month","Within 3 months"]}]}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Title: "QA Engineer",
|
Title: "QA Engineer",
|
||||||
|
|
@ -301,31 +312,37 @@ func (s *SeederService) Seed(ctx context.Context, logChan chan string) error {
|
||||||
var jobIDs []string
|
var jobIDs []string
|
||||||
|
|
||||||
for i, compID := range companyIDs {
|
for i, compID := range companyIDs {
|
||||||
|
recruiterID := companyRecruiterIDs[i]
|
||||||
numJobs := rnd.Intn(2) + 2
|
numJobs := rnd.Intn(2) + 2
|
||||||
for j := 0; j < numJobs; j++ {
|
for j := 0; j < numJobs; j++ {
|
||||||
tmpl := jobTemplates[(i*3+j)%len(jobTemplates)]
|
tmpl := jobTemplates[(i*3+j)%len(jobTemplates)]
|
||||||
jobID := uuid.New().String()
|
jobID := uuid.New().String()
|
||||||
|
|
||||||
|
var questionsArg interface{}
|
||||||
|
if tmpl.Questions != "" {
|
||||||
|
questionsArg = tmpl.Questions
|
||||||
|
}
|
||||||
|
|
||||||
_, err := tx.ExecContext(ctx, `
|
_, err := tx.ExecContext(ctx, `
|
||||||
INSERT INTO jobs (
|
INSERT INTO jobs (
|
||||||
id, company_id, title, description,
|
id, company_id, created_by, title, description,
|
||||||
employment_type, work_mode, location,
|
employment_type, work_mode, location,
|
||||||
salary_min, salary_max, salary_type, currency, salary_negotiable,
|
salary_min, salary_max, salary_type, currency, salary_negotiable,
|
||||||
language_level, visa_support,
|
language_level, visa_support,
|
||||||
status, date_posted, created_at, updated_at
|
questions, status, date_posted, created_at, updated_at
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1, $2, $3, $4,
|
$1, $2, $3, $4, $5,
|
||||||
$5, $6, $7,
|
$6, $7, $8,
|
||||||
$8, $9, $10, $11, $12,
|
$9, $10, $11, $12, $13,
|
||||||
$13, $14,
|
$14, $15,
|
||||||
$15, NOW(), NOW(), NOW()
|
$16::jsonb, $17, NOW(), NOW(), NOW()
|
||||||
) ON CONFLICT DO NOTHING
|
) ON CONFLICT DO NOTHING
|
||||||
`,
|
`,
|
||||||
jobID, compID, tmpl.Title, tmpl.Description,
|
jobID, compID, recruiterID, tmpl.Title, tmpl.Description,
|
||||||
tmpl.EmploymentType, tmpl.WorkMode, tmpl.Location,
|
tmpl.EmploymentType, tmpl.WorkMode, tmpl.Location,
|
||||||
tmpl.SalaryMin, tmpl.SalaryMax, tmpl.SalaryType, tmpl.Currency, tmpl.Negotiable,
|
tmpl.SalaryMin, tmpl.SalaryMax, tmpl.SalaryType, tmpl.Currency, tmpl.Negotiable,
|
||||||
tmpl.LanguageLevel, tmpl.VisaSupport,
|
tmpl.LanguageLevel, tmpl.VisaSupport,
|
||||||
tmpl.Status,
|
questionsArg, tmpl.Status,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.SendEvent(logChan, fmt.Sprintf("❌ Error creating job %s: %v", tmpl.Title, err))
|
s.SendEvent(logChan, fmt.Sprintf("❌ Error creating job %s: %v", tmpl.Title, err))
|
||||||
|
|
@ -338,6 +355,16 @@ func (s *SeederService) Seed(ctx context.Context, logChan chan string) error {
|
||||||
|
|
||||||
// 4. Create Applications
|
// 4. Create Applications
|
||||||
s.SendEvent(logChan, "📝 Creating Applications...")
|
s.SendEvent(logChan, "📝 Creating Applications...")
|
||||||
|
|
||||||
|
sampleMessages := []string{
|
||||||
|
"I am very excited about this opportunity and believe my background is a great fit for the role.",
|
||||||
|
"I have been following your company for a while and would love to contribute to your team.",
|
||||||
|
"This position aligns perfectly with my career goals and technical expertise.",
|
||||||
|
"I am passionate about this field and eager to bring my skills to your organization.",
|
||||||
|
"I believe I can add significant value to your team with my experience and enthusiasm.",
|
||||||
|
}
|
||||||
|
appStatuses := []string{"pending", "reviewed", "shortlisted", "rejected"}
|
||||||
|
|
||||||
for _, candID := range candidateIDs {
|
for _, candID := range candidateIDs {
|
||||||
// Apply to 1-3 random jobs
|
// Apply to 1-3 random jobs
|
||||||
numApps := rnd.Intn(3) + 1
|
numApps := rnd.Intn(3) + 1
|
||||||
|
|
@ -347,15 +374,20 @@ func (s *SeederService) Seed(ctx context.Context, logChan chan string) error {
|
||||||
}
|
}
|
||||||
jobID := jobIDs[rnd.Intn(len(jobIDs))]
|
jobID := jobIDs[rnd.Intn(len(jobIDs))]
|
||||||
appID := uuid.New().String()
|
appID := uuid.New().String()
|
||||||
|
status := appStatuses[rnd.Intn(len(appStatuses))]
|
||||||
|
message := sampleMessages[rnd.Intn(len(sampleMessages))]
|
||||||
|
answers := `{"salaryExpectation":"5k-8k","hasExperience":"yes","availability":["remote","immediate"]}`
|
||||||
|
|
||||||
_, err := tx.ExecContext(ctx, `
|
_, err := tx.ExecContext(ctx, `
|
||||||
INSERT INTO applications (id, job_id, user_id, status, created_at, updated_at)
|
INSERT INTO applications (id, job_id, user_id, message, answers, status, created_at, updated_at)
|
||||||
VALUES ($1, $2, $3, 'applied', NOW(), NOW())
|
VALUES ($1, $2, $3, $4, $5::jsonb, $6, NOW(), NOW())
|
||||||
ON CONFLICT DO NOTHING
|
ON CONFLICT DO NOTHING
|
||||||
`, appID, jobID, candID)
|
`, appID, jobID, candID, message, answers, status)
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
s.SendEvent(logChan, fmt.Sprintf(" - Candidate %s applied to Job %s", candID[:8], jobID[:8]))
|
s.SendEvent(logChan, fmt.Sprintf(" - Candidate %s applied to Job %s (status: %s)", candID[:8], jobID[:8], status))
|
||||||
|
} else {
|
||||||
|
s.SendEvent(logChan, fmt.Sprintf(" ⚠️ Application error: %v", err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
6
backend/migrations/045_add_answers_to_applications.sql
Normal file
6
backend/migrations/045_add_answers_to_applications.sql
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
-- Migration: Add answers column to applications
|
||||||
|
-- Description: Stores applicant answers to job-specific questions and form extra fields
|
||||||
|
|
||||||
|
ALTER TABLE applications ADD COLUMN IF NOT EXISTS answers JSONB;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN applications.answers IS 'JSON map of applicant answers to job questions and extra form fields (linkedin, portfolioUrl, salaryExpectation, availability, etc.)';
|
||||||
|
|
@ -18,6 +18,7 @@ import {
|
||||||
Save,
|
Save,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
HelpCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -44,7 +45,7 @@ import { Progress } from "@/components/ui/progress";
|
||||||
import { Navbar } from "@/components/navbar";
|
import { Navbar } from "@/components/navbar";
|
||||||
import { Footer } from "@/components/footer";
|
import { Footer } from "@/components/footer";
|
||||||
import { useNotify } from "@/contexts/notification-context";
|
import { useNotify } from "@/contexts/notification-context";
|
||||||
import { jobsApi, applicationsApi, storageApi, type ApiJob } from "@/lib/api";
|
import { jobsApi, applicationsApi, storageApi, type ApiJob, type JobQuestion } from "@/lib/api";
|
||||||
import { formatPhone } from "@/lib/utils";
|
import { formatPhone } from "@/lib/utils";
|
||||||
import { useTranslation } from "@/lib/i18n";
|
import { useTranslation } from "@/lib/i18n";
|
||||||
import { getCurrentUser } from "@/lib/auth";
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
|
|
@ -56,14 +57,6 @@ export default function JobApplicationPage({
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
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 { id } = use(params);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const notify = useNotify();
|
const notify = useNotify();
|
||||||
|
|
@ -96,13 +89,35 @@ export default function JobApplicationPage({
|
||||||
availability: [] as string[],
|
availability: [] as string[],
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleInputChange = (field: string, value: any) => {
|
// Custom answers from dynamic job questions (step 5)
|
||||||
|
const [customAnswers, setCustomAnswers] = useState<Record<string, string | string[]>>({});
|
||||||
|
|
||||||
|
// Derived: custom questions from job
|
||||||
|
const jobQuestions: JobQuestion[] = job?.questions?.items ?? [];
|
||||||
|
const hasCustomQuestions = jobQuestions.length > 0;
|
||||||
|
|
||||||
|
const baseSteps = [
|
||||||
|
{ 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 steps = hasCustomQuestions
|
||||||
|
? [...baseSteps, { id: 5, title: t("application.steps.questions") || "Company Questions", icon: HelpCircle }]
|
||||||
|
: baseSteps;
|
||||||
|
|
||||||
|
const handleInputChange = (field: string, value: unknown) => {
|
||||||
if (field === "phone") {
|
if (field === "phone") {
|
||||||
value = formatPhone(value);
|
value = formatPhone(value as string);
|
||||||
}
|
}
|
||||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCustomAnswerChange = (questionId: string, value: string | string[]) => {
|
||||||
|
setCustomAnswers((prev) => ({ ...prev, [questionId]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
const handleResumeUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleResumeUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
@ -140,7 +155,7 @@ export default function JobApplicationPage({
|
||||||
notify.error(t("application.toasts.invalidEmail.title"), t("application.toasts.invalidEmail.desc"));
|
notify.error(t("application.toasts.invalidEmail.title"), t("application.toasts.invalidEmail.desc"));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (formData.phone.length < 14) { // (11) 91234-5678 is 15 chars, (11) 1234-5678 is 14 chars
|
if (formData.phone.length < 14) {
|
||||||
notify.error(t("application.toasts.invalidPhone.title"), t("application.toasts.invalidPhone.desc"));
|
notify.error(t("application.toasts.invalidPhone.title"), t("application.toasts.invalidPhone.desc"));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -173,6 +188,24 @@ export default function JobApplicationPage({
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
case 5: {
|
||||||
|
// Validate required custom questions
|
||||||
|
const missing = jobQuestions.filter(q => {
|
||||||
|
if (!q.required) return false;
|
||||||
|
const ans = customAnswers[q.id];
|
||||||
|
if (!ans) return true;
|
||||||
|
if (Array.isArray(ans)) return ans.length === 0;
|
||||||
|
return (ans as string).trim() === "";
|
||||||
|
});
|
||||||
|
if (missing.length > 0) {
|
||||||
|
notify.error(
|
||||||
|
t("application.toasts.questionsRequired.title") || "Required fields",
|
||||||
|
`Please answer: ${missing.map(q => q.label).join(", ")}`
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -229,23 +262,23 @@ export default function JobApplicationPage({
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
// 1. Resume is already uploaded via handleResumeUpload, so we use formData.resumeUrl
|
|
||||||
const resumeUrl = formData.resumeUrl;
|
|
||||||
// Note: If you want to enforce upload here, you'd need the File object, but we uploaded it earlier.
|
|
||||||
|
|
||||||
await applicationsApi.create({
|
await applicationsApi.create({
|
||||||
jobId: Number(id), // ID might need number conversion depending on API
|
jobId: id, // UUID string — must NOT be Number(id)
|
||||||
name: formData.fullName,
|
name: formData.fullName,
|
||||||
email: formData.email,
|
email: formData.email,
|
||||||
phone: formData.phone,
|
phone: formData.phone,
|
||||||
linkedin: formData.linkedin,
|
message: formData.coverLetter,
|
||||||
coverLetter: formData.coverLetter || formData.whyUs, // Fallback
|
resumeUrl: formData.resumeUrl,
|
||||||
resumeUrl: resumeUrl,
|
answers: {
|
||||||
portfolioUrl: formData.portfolioUrl,
|
linkedin: formData.linkedin || undefined,
|
||||||
salaryExpectation: formData.salaryExpectation,
|
portfolioUrl: formData.portfolioUrl || undefined,
|
||||||
hasExperience: formData.hasExperience,
|
salaryExpectation: formData.salaryExpectation,
|
||||||
whyUs: formData.whyUs,
|
hasExperience: formData.hasExperience,
|
||||||
availability: formData.availability,
|
whyUs: formData.whyUs,
|
||||||
|
availability: formData.availability,
|
||||||
|
// Custom job-specific answers
|
||||||
|
...customAnswers,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
notify.success(
|
notify.success(
|
||||||
|
|
@ -255,12 +288,10 @@ export default function JobApplicationPage({
|
||||||
|
|
||||||
setIsSubmitted(true);
|
setIsSubmitted(true);
|
||||||
window.scrollTo(0, 0);
|
window.scrollTo(0, 0);
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
console.error("Submit error:", error);
|
console.error("Submit error:", error);
|
||||||
notify.error(
|
const message = error instanceof Error ? error.message : t("application.toasts.submitError.default");
|
||||||
t("application.toasts.submitError.title"),
|
notify.error(t("application.toasts.submitError.title"), message);
|
||||||
error.message || t("application.toasts.submitError.default")
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
|
|
@ -276,18 +307,18 @@ export default function JobApplicationPage({
|
||||||
const progress = (currentStep / steps.length) * 100;
|
const progress = (currentStep / steps.length) * 100;
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
<Loader2 className="h-8 w-8 animate-spin" />
|
<Loader2 className="h-8 w-8 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!job) {
|
if (!job) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
<p>Job not found</p>
|
<p>Job not found</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -765,6 +796,101 @@ export default function JobApplicationPage({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Step 5: Custom Job Questions */}
|
||||||
|
{currentStep === 5 && hasCustomQuestions && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{jobQuestions.map((q) => (
|
||||||
|
<div key={q.id} className="space-y-2">
|
||||||
|
<Label htmlFor={`q-${q.id}`}>
|
||||||
|
{q.label}
|
||||||
|
{q.required && <span className="text-destructive ml-1">*</span>}
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
{q.type === "text" && (
|
||||||
|
<Input
|
||||||
|
id={`q-${q.id}`}
|
||||||
|
value={(customAnswers[q.id] as string) ?? ""}
|
||||||
|
onChange={(e) => handleCustomAnswerChange(q.id, e.target.value)}
|
||||||
|
placeholder="Your answer..."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{q.type === "textarea" && (
|
||||||
|
<Textarea
|
||||||
|
id={`q-${q.id}`}
|
||||||
|
className="min-h-[120px]"
|
||||||
|
value={(customAnswers[q.id] as string) ?? ""}
|
||||||
|
onChange={(e) => handleCustomAnswerChange(q.id, e.target.value)}
|
||||||
|
placeholder="Your answer..."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{q.type === "radio" && q.options && (
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{q.options.map((opt) => (
|
||||||
|
<div
|
||||||
|
key={opt}
|
||||||
|
className="flex items-center space-x-2 border p-3 rounded-md hover:bg-muted/50 cursor-pointer min-w-[120px]"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={`q-${q.id}`}
|
||||||
|
id={`q-${q.id}-${opt}`}
|
||||||
|
className="accent-primary h-4 w-4"
|
||||||
|
checked={customAnswers[q.id] === opt}
|
||||||
|
onChange={() => handleCustomAnswerChange(q.id, opt)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor={`q-${q.id}-${opt}`} className="cursor-pointer">
|
||||||
|
{opt}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{q.type === "select" && q.options && (
|
||||||
|
<Select
|
||||||
|
value={(customAnswers[q.id] as string) ?? ""}
|
||||||
|
onValueChange={(val) => handleCustomAnswerChange(q.id, val)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select an option..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{q.options.map((opt) => (
|
||||||
|
<SelectItem key={opt} value={opt}>{opt}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{q.type === "checkbox" && q.options && (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{q.options.map((opt) => {
|
||||||
|
const current = (customAnswers[q.id] as string[]) ?? [];
|
||||||
|
return (
|
||||||
|
<div key={opt} className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`q-${q.id}-${opt}`}
|
||||||
|
checked={current.includes(opt)}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
const updated = checked
|
||||||
|
? [...current, opt]
|
||||||
|
: current.filter((v) => v !== opt);
|
||||||
|
handleCustomAnswerChange(q.id, updated);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Label htmlFor={`q-${q.id}-${opt}`}>{opt}</Label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
<CardFooter className="flex flex-col sm:flex-row gap-3 sm:justify-between border-t pt-4 sm:pt-6">
|
<CardFooter className="flex flex-col sm:flex-row gap-3 sm:justify-between border-t pt-4 sm:pt-6">
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,9 @@ export default function Home() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [jobs, setJobs] = useState<Job[]>([])
|
const [jobs, setJobs] = useState<Job[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [hasMore, setHasMore] = useState(true)
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false)
|
||||||
|
|
||||||
const [emblaRef, emblaApi] = useEmblaCarousel({
|
const [emblaRef, emblaApi] = useEmblaCarousel({
|
||||||
align: "start",
|
align: "start",
|
||||||
|
|
@ -27,33 +30,47 @@ export default function Home() {
|
||||||
dragFree: true
|
dragFree: true
|
||||||
})
|
})
|
||||||
|
|
||||||
const [moreJobsEmblaRef, moreJobsEmblaApi] = useEmblaCarousel({
|
|
||||||
align: "start",
|
|
||||||
loop: false,
|
|
||||||
skipSnaps: false,
|
|
||||||
dragFree: true
|
|
||||||
})
|
|
||||||
|
|
||||||
const [prevBtnDisabled, setPrevBtnDisabled] = useState(true)
|
const [prevBtnDisabled, setPrevBtnDisabled] = useState(true)
|
||||||
const [nextBtnDisabled, setNextBtnDisabled] = useState(true)
|
const [nextBtnDisabled, setNextBtnDisabled] = useState(true)
|
||||||
const [moreJobsPrevBtnDisabled, setMoreJobsPrevBtnDisabled] = useState(true)
|
|
||||||
const [moreJobsNextBtnDisabled, setMoreJobsNextBtnDisabled] = useState(true)
|
const fetchJobs = useCallback(async (pageNum: number, isLoadMore = false) => {
|
||||||
|
try {
|
||||||
|
if (isLoadMore) setLoadingMore(true)
|
||||||
|
else setLoading(true)
|
||||||
|
|
||||||
|
const limit = 8
|
||||||
|
const res = await jobsApi.list({ page: pageNum, limit })
|
||||||
|
|
||||||
|
if (res.data) {
|
||||||
|
const newJobs = res.data.map(transformApiJobToFrontend)
|
||||||
|
if (isLoadMore) {
|
||||||
|
setJobs(prev => [...prev, ...newJobs])
|
||||||
|
} else {
|
||||||
|
setJobs(newJobs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we got fewer jobs than the limit, we've reached the end
|
||||||
|
if (newJobs.length < limit) {
|
||||||
|
setHasMore(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch jobs:", error)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
setLoadingMore(false)
|
||||||
|
}
|
||||||
|
}, [t])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchJobs() {
|
fetchJobs(1)
|
||||||
try {
|
}, [fetchJobs])
|
||||||
const res = await jobsApi.list({ limit: 8 })
|
|
||||||
if (res.data) {
|
const handleLoadMore = () => {
|
||||||
setJobs(res.data.map(transformApiJobToFrontend))
|
const nextPage = page + 1
|
||||||
}
|
setPage(nextPage)
|
||||||
} catch (error) {
|
fetchJobs(nextPage, true)
|
||||||
console.error("Failed to fetch jobs:", error)
|
}
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fetchJobs()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const scrollPrev = useCallback(() => {
|
const scrollPrev = useCallback(() => {
|
||||||
if (emblaApi) emblaApi.scrollPrev()
|
if (emblaApi) emblaApi.scrollPrev()
|
||||||
|
|
@ -63,24 +80,11 @@ export default function Home() {
|
||||||
if (emblaApi) emblaApi.scrollNext()
|
if (emblaApi) emblaApi.scrollNext()
|
||||||
}, [emblaApi])
|
}, [emblaApi])
|
||||||
|
|
||||||
const scrollMoreJobsPrev = useCallback(() => {
|
|
||||||
if (moreJobsEmblaApi) moreJobsEmblaApi.scrollPrev()
|
|
||||||
}, [moreJobsEmblaApi])
|
|
||||||
|
|
||||||
const scrollMoreJobsNext = useCallback(() => {
|
|
||||||
if (moreJobsEmblaApi) moreJobsEmblaApi.scrollNext()
|
|
||||||
}, [moreJobsEmblaApi])
|
|
||||||
|
|
||||||
const onSelect = useCallback((emblaApi: any) => {
|
const onSelect = useCallback((emblaApi: any) => {
|
||||||
setPrevBtnDisabled(!emblaApi.canScrollPrev())
|
setPrevBtnDisabled(!emblaApi.canScrollPrev())
|
||||||
setNextBtnDisabled(!emblaApi.canScrollNext())
|
setNextBtnDisabled(!emblaApi.canScrollNext())
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const onMoreJobsSelect = useCallback((emblaApi: any) => {
|
|
||||||
setMoreJobsPrevBtnDisabled(!emblaApi.canScrollPrev())
|
|
||||||
setMoreJobsNextBtnDisabled(!emblaApi.canScrollNext())
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!emblaApi) return
|
if (!emblaApi) return
|
||||||
onSelect(emblaApi)
|
onSelect(emblaApi)
|
||||||
|
|
@ -88,13 +92,6 @@ export default function Home() {
|
||||||
emblaApi.on("select", onSelect)
|
emblaApi.on("select", onSelect)
|
||||||
}, [emblaApi, onSelect])
|
}, [emblaApi, onSelect])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!moreJobsEmblaApi) return
|
|
||||||
onMoreJobsSelect(moreJobsEmblaApi)
|
|
||||||
moreJobsEmblaApi.on("reInit", onMoreJobsSelect)
|
|
||||||
moreJobsEmblaApi.on("select", onMoreJobsSelect)
|
|
||||||
}, [moreJobsEmblaApi, onMoreJobsSelect])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 flex flex-col font-sans">
|
<div className="min-h-screen bg-gray-50 flex flex-col font-sans">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
@ -195,82 +192,74 @@ export default function Home() {
|
||||||
<h2 className="text-2xl md:text-3xl font-bold text-gray-900">
|
<h2 className="text-2xl md:text-3xl font-bold text-gray-900">
|
||||||
{t("home.moreJobs.title")}
|
{t("home.moreJobs.title")}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex items-center gap-3">
|
<Link href="/jobs">
|
||||||
<Button
|
<Button variant="outline" className="border-orange-500 text-orange-500 hover:bg-orange-50">
|
||||||
variant="outline"
|
{t("home.moreJobs.viewAll")}
|
||||||
size="icon"
|
|
||||||
className="rounded-full border-gray-300 hover:border-orange-500 hover:text-orange-500 transition-all w-10 h-10"
|
|
||||||
onClick={scrollMoreJobsPrev}
|
|
||||||
disabled={moreJobsPrevBtnDisabled}
|
|
||||||
>
|
|
||||||
<ChevronLeft className="w-5 h-5" />
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
</Link>
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
className="rounded-full border-gray-300 hover:border-orange-500 hover:text-orange-500 transition-all w-10 h-10"
|
|
||||||
onClick={scrollMoreJobsNext}
|
|
||||||
disabled={moreJobsNextBtnDisabled}
|
|
||||||
>
|
|
||||||
<ChevronRight className="w-5 h-5" />
|
|
||||||
</Button>
|
|
||||||
<Link href="/jobs">
|
|
||||||
<Button className="bg-orange-500 hover:bg-orange-600 text-white font-bold rounded-lg">
|
|
||||||
{t("home.moreJobs.viewAll")}
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-hidden" ref={moreJobsEmblaRef}>
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||||
<div className="flex gap-6">
|
{loading && page === 1 ? (
|
||||||
{loading ? (
|
<div className="col-span-full text-center py-12">Carregando vagas...</div>
|
||||||
<div className="flex-[0_0_100%] text-center py-8">Carregando vagas...</div>
|
) : jobs.map((job, index) => (
|
||||||
) : jobs.slice(0, 8).map((job, index) => (
|
<div key={`more-${job.id}-${index}`} className="pb-1">
|
||||||
<div key={`more-${job.id}-${index}`} className="flex-[0_0_100%] sm:flex-[0_0_50%] lg:flex-[0_0_50%] xl:flex-[0_0_33.333%] 2xl:flex-[0_0_25%] min-w-0 pb-1">
|
<JobCard job={job} />
|
||||||
<JobCard job={job} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Bottom CTA Section */}
|
|
||||||
<section className="py-16 bg-white">
|
|
||||||
<div className="container mx-auto px-4 sm:px-6">
|
|
||||||
<div className="bg-[#1F2F40] rounded-[2rem] p-8 md:p-16 relative overflow-hidden text-center md:text-left flex flex-col md:flex-row items-center justify-between min-h-[400px]">
|
|
||||||
<div className="relative z-10 max-w-xl">
|
|
||||||
<h2 className="text-3xl md:text-4xl font-bold text-white mb-4 leading-tight">
|
|
||||||
{t("home.cta.title")}
|
|
||||||
</h2>
|
|
||||||
<p className="text-base text-gray-300 mb-8">
|
|
||||||
{t("home.cta.subtitle")}
|
|
||||||
</p>
|
|
||||||
<Link href="/register/user">
|
|
||||||
<Button className="h-12 px-8 bg-white text-gray-900 hover:bg-gray-100 font-bold text-lg rounded-lg">
|
|
||||||
{t("home.cta.button")}
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="absolute inset-0 z-0">
|
|
||||||
<div className="absolute right-0 top-0 h-full w-full md:w-2/3 lg:w-1/2">
|
|
||||||
<Image
|
|
||||||
src="/muie.jpeg"
|
|
||||||
alt="Professional"
|
|
||||||
fill
|
|
||||||
className="object-cover object-center md:object-right opacity-40 md:opacity-100"
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-t md:bg-gradient-to-r from-[#1F2F40] via-[#1F2F40]/30 to-transparent" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{hasMore && (
|
||||||
|
<div className="mt-12 text-center">
|
||||||
|
<Button
|
||||||
|
onClick={handleLoadMore}
|
||||||
|
disabled={loadingMore}
|
||||||
|
className="bg-orange-500 hover:bg-orange-600 text-white font-bold px-8 py-6 rounded-xl text-lg transition-all hover:scale-105 active:scale-95 shadow-lg"
|
||||||
|
>
|
||||||
|
{loadingMore ? "Carregando..." : t("home.moreJobs.loadMore") || "Carregar Mais Vagas"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
|
||||||
|
|
||||||
<Footer />
|
|
||||||
</div>
|
</div>
|
||||||
|
</section >
|
||||||
|
|
||||||
|
{/* Bottom CTA Section */ }
|
||||||
|
< section className = "py-16 bg-white" >
|
||||||
|
<div className="container mx-auto px-4 sm:px-6">
|
||||||
|
<div className="bg-[#1F2F40] rounded-[2rem] p-8 md:p-16 relative overflow-hidden text-center md:text-left flex flex-col md:flex-row items-center justify-between min-h-[400px]">
|
||||||
|
<div className="relative z-10 max-w-xl">
|
||||||
|
<h2 className="text-3xl md:text-4xl font-bold text-white mb-4 leading-tight">
|
||||||
|
{t("home.cta.title")}
|
||||||
|
</h2>
|
||||||
|
<p className="text-base text-gray-300 mb-8">
|
||||||
|
{t("home.cta.subtitle")}
|
||||||
|
</p>
|
||||||
|
<Link href="/register/user">
|
||||||
|
<Button className="h-12 px-8 bg-white text-gray-900 hover:bg-gray-100 font-bold text-lg rounded-lg">
|
||||||
|
{t("home.cta.button")}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute inset-0 z-0">
|
||||||
|
<div className="absolute right-0 top-0 h-full w-full md:w-2/3 lg:w-1/2">
|
||||||
|
<Image
|
||||||
|
src="/muie.jpeg"
|
||||||
|
alt="Professional"
|
||||||
|
fill
|
||||||
|
className="object-cover object-center md:object-right opacity-40 md:opacity-100"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t md:bg-gradient-to-r from-[#1F2F40] via-[#1F2F40]/30 to-transparent" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section >
|
||||||
|
</main >
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</div >
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -28,12 +28,13 @@ import { motion } from "framer-motion";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { useTranslation } from "@/lib/i18n";
|
import { Navbar } from "@/components/navbar";
|
||||||
|
import { Footer } from "@/components/footer";
|
||||||
|
|
||||||
type RegisterFormData = {
|
type RegisterFormData = {
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
phone: string;
|
phone?: string;
|
||||||
password: string;
|
password: string;
|
||||||
confirmPassword: string;
|
confirmPassword: string;
|
||||||
acceptTerms: boolean;
|
acceptTerms: boolean;
|
||||||
|
|
@ -41,23 +42,22 @@ type RegisterFormData = {
|
||||||
|
|
||||||
export default function RegisterUserPage() {
|
export default function RegisterUserPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t } = useTranslation();
|
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||||
|
|
||||||
const registerSchema = useMemo(() => z.object({
|
const registerSchema = useMemo(() => z.object({
|
||||||
name: z.string().min(3, "Nome deve ter no mínimo 3 caracteres"),
|
name: z.string().min(3, "Name must be at least 3 characters"),
|
||||||
email: z.string().email("E-mail inválido"),
|
email: z.string().email("Invalid email address"),
|
||||||
phone: z.string().min(10, "Telefone inválido"),
|
phone: z.string().min(7, "Phone number too short").optional().or(z.literal("")),
|
||||||
password: z.string().min(6, "Senha deve ter no mínimo 6 caracteres"),
|
password: z.string().min(6, "Password must be at least 6 characters"),
|
||||||
confirmPassword: z.string().min(6, "Confirmação de senha obrigatória"),
|
confirmPassword: z.string().min(6, "Please confirm your password"),
|
||||||
acceptTerms: z.boolean().refine((val) => val === true, {
|
acceptTerms: z.boolean().refine((val) => val === true, {
|
||||||
message: "Você deve aceitar os termos de uso"
|
message: "You must accept the terms of use",
|
||||||
}),
|
}),
|
||||||
}).refine((data) => data.password === data.confirmPassword, {
|
}).refine((data) => data.password === data.confirmPassword, {
|
||||||
message: "As senhas não coincidem",
|
message: "Passwords do not match",
|
||||||
path: ["confirmPassword"],
|
path: ["confirmPassword"],
|
||||||
}), []);
|
}), []);
|
||||||
|
|
||||||
|
|
@ -83,31 +83,28 @@ export default function RegisterUserPage() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { registerCandidate } = await import("@/lib/auth");
|
const { registerCandidate } = await import("@/lib/auth");
|
||||||
|
|
||||||
await registerCandidate({
|
await registerCandidate({
|
||||||
name: data.name,
|
name: data.name,
|
||||||
email: data.email,
|
email: data.email,
|
||||||
phone: data.phone,
|
phone: data.phone || "",
|
||||||
password: data.password,
|
password: data.password,
|
||||||
username: data.email.split('@')[0],
|
username: data.email.split("@")[0],
|
||||||
});
|
});
|
||||||
|
|
||||||
router.push("/login?message=Conta criada com sucesso! Faça login.");
|
router.push("/login?message=Account created successfully! Please log in.");
|
||||||
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('🔥 [REGISTER FRONT] Erro no registro:', err);
|
setError(err.message || "Failed to create account. Please try again.");
|
||||||
setError(err.message || "Erro ao criar conta. Tente novamente.");
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = (data: RegisterFormData) => {
|
|
||||||
handleRegister(data);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col lg:flex-row">
|
<div className="min-h-screen flex flex-col">
|
||||||
|
<Navbar />
|
||||||
|
|
||||||
|
<div className="flex-1 flex flex-col lg:flex-row">
|
||||||
{/* Left Side - Branding */}
|
{/* Left Side - Branding */}
|
||||||
<div className="lg:flex-1 bg-gradient-to-br from-primary to-primary/80 p-8 flex flex-col justify-center items-center text-primary-foreground">
|
<div className="lg:flex-1 bg-gradient-to-br from-primary to-primary/80 p-8 flex flex-col justify-center items-center text-primary-foreground">
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|
@ -126,11 +123,11 @@ export default function RegisterUserPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className="text-4xl font-bold mb-4">
|
<h1 className="text-4xl font-bold mb-4">
|
||||||
Comece sua jornada profissional
|
Start your professional journey
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className="text-lg opacity-90 leading-relaxed">
|
<p className="text-lg opacity-90 leading-relaxed">
|
||||||
Conecte-se com as melhores oportunidades do mercado. Cadastre-se gratuitamente e encontre a vaga ideal para você!
|
Connect with the best opportunities worldwide. Sign up for free and find the job that's right for you!
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-8 space-y-4">
|
<div className="mt-8 space-y-4">
|
||||||
|
|
@ -138,19 +135,19 @@ export default function RegisterUserPage() {
|
||||||
<div className="w-8 h-8 bg-white/20 rounded-full flex items-center justify-center">
|
<div className="w-8 h-8 bg-white/20 rounded-full flex items-center justify-center">
|
||||||
<UserIcon className="w-4 h-4" />
|
<UserIcon className="w-4 h-4" />
|
||||||
</div>
|
</div>
|
||||||
<span>Crie seu perfil profissional completo</span>
|
<span>Build your complete professional profile</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-8 h-8 bg-white/20 rounded-full flex items-center justify-center">
|
<div className="w-8 h-8 bg-white/20 rounded-full flex items-center justify-center">
|
||||||
<Building2 className="w-4 h-4" />
|
<Building2 className="w-4 h-4" />
|
||||||
</div>
|
</div>
|
||||||
<span>Candidate-se às melhores vagas do mercado</span>
|
<span>Apply to top jobs from companies worldwide</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-8 h-8 bg-white/20 rounded-full flex items-center justify-center">
|
<div className="w-8 h-8 bg-white/20 rounded-full flex items-center justify-center">
|
||||||
<Briefcase className="w-4 h-4" />
|
<Briefcase className="w-4 h-4" />
|
||||||
</div>
|
</div>
|
||||||
<span>Acompanhe suas candidaturas em tempo real</span>
|
<span>Track your applications in real time</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
@ -164,15 +161,15 @@ export default function RegisterUserPage() {
|
||||||
className="w-full max-w-md space-y-6"
|
className="w-full max-w-md space-y-6"
|
||||||
>
|
>
|
||||||
<div className="text-center space-y-2">
|
<div className="text-center space-y-2">
|
||||||
<h2 className="text-3xl font-bold">Criar Conta de Usuário</h2>
|
<h2 className="text-3xl font-bold">Create your account</h2>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Preencha os dados abaixo para se cadastrar
|
Fill in your details to get started
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="border-0 shadow-lg">
|
<Card className="border-0 shadow-lg">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
<form onSubmit={handleSubmit(handleRegister)} className="space-y-4">
|
||||||
{error && (
|
{error && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, scale: 0.95 }}
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
|
@ -186,70 +183,66 @@ export default function RegisterUserPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="name">Nome Completo</Label>
|
<Label htmlFor="name">Full Name</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<UserIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<UserIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Seu nome completo"
|
placeholder="Your full name"
|
||||||
className="pl-10"
|
className="pl-10"
|
||||||
{...register("name")}
|
{...register("name")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{errors.name && (
|
{errors.name && (
|
||||||
<p className="text-sm text-destructive">
|
<p className="text-sm text-destructive">{errors.name.message}</p>
|
||||||
{errors.name.message}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="email">E-mail</Label>
|
<Label htmlFor="email">Email</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="seu@email.com"
|
placeholder="you@example.com"
|
||||||
className="pl-10"
|
className="pl-10"
|
||||||
{...register("email")}
|
{...register("email")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{errors.email && (
|
{errors.email && (
|
||||||
<p className="text-sm text-destructive">
|
<p className="text-sm text-destructive">{errors.email.message}</p>
|
||||||
{errors.email.message}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="phone">Telefone</Label>
|
<Label htmlFor="phone">
|
||||||
|
Phone <span className="text-muted-foreground font-normal">(optional)</span>
|
||||||
|
</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
id="phone"
|
id="phone"
|
||||||
type="tel"
|
type="tel"
|
||||||
placeholder="(00) 00000-0000"
|
placeholder="+1 555 000 0000"
|
||||||
className="pl-10"
|
className="pl-10"
|
||||||
{...register("phone")}
|
{...register("phone")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{errors.phone && (
|
{errors.phone && (
|
||||||
<p className="text-sm text-destructive">
|
<p className="text-sm text-destructive">{errors.phone.message}</p>
|
||||||
{errors.phone.message}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="password">Senha</Label>
|
<Label htmlFor="password">Password</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
type={showPassword ? "text" : "password"}
|
type={showPassword ? "text" : "password"}
|
||||||
placeholder="Mínimo 6 caracteres"
|
placeholder="At least 6 characters"
|
||||||
className="pl-10 pr-10"
|
className="pl-10 pr-10"
|
||||||
{...register("password")}
|
{...register("password")}
|
||||||
/>
|
/>
|
||||||
|
|
@ -268,20 +261,18 @@ export default function RegisterUserPage() {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{errors.password && (
|
{errors.password && (
|
||||||
<p className="text-sm text-destructive">
|
<p className="text-sm text-destructive">{errors.password.message}</p>
|
||||||
{errors.password.message}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="confirmPassword">Confirmar Senha</Label>
|
<Label htmlFor="confirmPassword">Confirm Password</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
id="confirmPassword"
|
id="confirmPassword"
|
||||||
type={showConfirmPassword ? "text" : "password"}
|
type={showConfirmPassword ? "text" : "password"}
|
||||||
placeholder="Digite a senha novamente"
|
placeholder="Re-enter your password"
|
||||||
className="pl-10 pr-10"
|
className="pl-10 pr-10"
|
||||||
{...register("confirmPassword")}
|
{...register("confirmPassword")}
|
||||||
/>
|
/>
|
||||||
|
|
@ -300,9 +291,7 @@ export default function RegisterUserPage() {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{errors.confirmPassword && (
|
{errors.confirmPassword && (
|
||||||
<p className="text-sm text-destructive">
|
<p className="text-sm text-destructive">{errors.confirmPassword.message}</p>
|
||||||
{errors.confirmPassword.message}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -312,20 +301,18 @@ export default function RegisterUserPage() {
|
||||||
htmlFor="acceptTerms"
|
htmlFor="acceptTerms"
|
||||||
className="text-sm font-normal cursor-pointer leading-relaxed"
|
className="text-sm font-normal cursor-pointer leading-relaxed"
|
||||||
>
|
>
|
||||||
Aceito os{" "}
|
I agree to the{" "}
|
||||||
<Link href="/terms" className="text-primary hover:underline">
|
<Link href="/terms" className="text-primary hover:underline">
|
||||||
termos de uso
|
Terms of Use
|
||||||
</Link>
|
</Link>
|
||||||
{" "}e a{" "}
|
{" "}and{" "}
|
||||||
<Link href="/privacy" className="text-primary hover:underline">
|
<Link href="/privacy" className="text-primary hover:underline">
|
||||||
política de privacidade
|
Privacy Policy
|
||||||
</Link>
|
</Link>
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
{errors.acceptTerms && (
|
{errors.acceptTerms && (
|
||||||
<p className="text-sm text-destructive">
|
<p className="text-sm text-destructive">{errors.acceptTerms.message}</p>
|
||||||
{errors.acceptTerms.message}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -333,15 +320,15 @@ export default function RegisterUserPage() {
|
||||||
className="w-full h-11 cursor-pointer bg-[#F0932B] hover:bg-[#d97d1a]"
|
className="w-full h-11 cursor-pointer bg-[#F0932B] hover:bg-[#d97d1a]"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
{loading ? "Criando conta..." : "Criar Conta"}
|
{loading ? "Creating account..." : "Create Account"}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="mt-6 text-center">
|
<div className="mt-6 text-center">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Já tem uma conta?{" "}
|
Already have an account?{" "}
|
||||||
<Link href="/login" className="text-primary hover:underline font-semibold">
|
<Link href="/login" className="text-primary hover:underline font-semibold">
|
||||||
Fazer login
|
Log in
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -353,11 +340,14 @@ export default function RegisterUserPage() {
|
||||||
href="/"
|
href="/"
|
||||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-2"
|
className="text-sm text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-2"
|
||||||
>
|
>
|
||||||
Voltar para o início
|
Back to home
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,7 @@ export interface ApiJob {
|
||||||
currency?: string;
|
currency?: string;
|
||||||
description: string;
|
description: string;
|
||||||
requirements?: unknown;
|
requirements?: unknown;
|
||||||
|
questions?: { items?: JobQuestion[] }; // Custom application questions
|
||||||
status: string;
|
status: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
datePosted?: string;
|
datePosted?: string;
|
||||||
|
|
@ -101,6 +102,14 @@ export interface ApiJob {
|
||||||
applicationCount?: number;
|
applicationCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface JobQuestion {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
type: "text" | "textarea" | "radio" | "select" | "checkbox";
|
||||||
|
required: boolean;
|
||||||
|
options?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface ApiCompany {
|
export interface ApiCompany {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -574,6 +583,15 @@ export const applicationsApi = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Payments API
|
||||||
|
export const paymentsApi = {
|
||||||
|
createSubscriptionCheckout: (data: { priceId: string; successUrl?: string; cancelUrl?: string }) =>
|
||||||
|
apiRequest<{ sessionId: string; checkoutUrl: string }>("/api/v1/payments/subscription-checkout", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
// Storage API
|
// Storage API
|
||||||
export const storageApi = {
|
export const storageApi = {
|
||||||
getUploadUrl: (filename: string, contentType: string) =>
|
getUploadUrl: (filename: string, contentType: string) =>
|
||||||
|
|
|
||||||
|
|
@ -230,40 +230,32 @@ export function getToken(): string | null {
|
||||||
export interface RegisterCompanyData {
|
export interface RegisterCompanyData {
|
||||||
companyName: string;
|
companyName: string;
|
||||||
email: string;
|
email: string;
|
||||||
phone: string;
|
phone?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
confirmPassword?: string;
|
document?: string; // Generic Tax ID / Business registration number
|
||||||
document?: string;
|
|
||||||
website?: string;
|
website?: string;
|
||||||
yearsInMarket?: string;
|
yearsInMarket?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
zipCode?: string;
|
|
||||||
address?: string;
|
|
||||||
city?: string;
|
city?: string;
|
||||||
state?: string;
|
country?: string; // Country name or code
|
||||||
birthDate?: string;
|
|
||||||
cnpj?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function registerCompany(data: RegisterCompanyData): Promise<void> {
|
export async function registerCompany(data: RegisterCompanyData): Promise<{ token?: string; id?: string; name?: string }> {
|
||||||
const payload = {
|
const payload = {
|
||||||
name: data.companyName,
|
name: data.companyName,
|
||||||
slug: data.companyName.toLowerCase().replace(/\s+/g, '-'),
|
companyName: data.companyName,
|
||||||
document: data.document || data.cnpj,
|
slug: data.companyName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''),
|
||||||
|
document: data.document,
|
||||||
phone: data.phone,
|
phone: data.phone,
|
||||||
email: data.email,
|
email: data.email,
|
||||||
|
admin_email: data.email,
|
||||||
|
password: data.password,
|
||||||
|
admin_password: data.password,
|
||||||
website: data.website,
|
website: data.website,
|
||||||
address: data.address,
|
|
||||||
zip_code: data.zipCode,
|
|
||||||
city: data.city,
|
city: data.city,
|
||||||
state: data.state,
|
country: data.country,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
years_in_market: data.yearsInMarket,
|
years_in_market: data.yearsInMarket,
|
||||||
admin_email: data.email,
|
|
||||||
admin_password: data.password,
|
|
||||||
password: data.password,
|
|
||||||
admin_name: data.companyName,
|
|
||||||
admin_birth_date: data.birthDate
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const res = await fetch(`${getApiV1Url()}/auth/register/company`, {
|
const res = await fetch(`${getApiV1Url()}/auth/register/company`, {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue