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,
|
||||||
|
portfolioUrl: formData.portfolioUrl || undefined,
|
||||||
salaryExpectation: formData.salaryExpectation,
|
salaryExpectation: formData.salaryExpectation,
|
||||||
hasExperience: formData.hasExperience,
|
hasExperience: formData.hasExperience,
|
||||||
whyUs: formData.whyUs,
|
whyUs: formData.whyUs,
|
||||||
availability: formData.availability,
|
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);
|
||||||
}
|
}
|
||||||
|
|
@ -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)
|
|
||||||
|
|
||||||
useEffect(() => {
|
const fetchJobs = useCallback(async (pageNum: number, isLoadMore = false) => {
|
||||||
async function fetchJobs() {
|
|
||||||
try {
|
try {
|
||||||
const res = await jobsApi.list({ limit: 8 })
|
if (isLoadMore) setLoadingMore(true)
|
||||||
|
else setLoading(true)
|
||||||
|
|
||||||
|
const limit = 8
|
||||||
|
const res = await jobsApi.list({ page: pageNum, limit })
|
||||||
|
|
||||||
if (res.data) {
|
if (res.data) {
|
||||||
setJobs(res.data.map(transformApiJobToFrontend))
|
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) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch jobs:", error)
|
console.error("Failed to fetch jobs:", error)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
setLoadingMore(false)
|
||||||
}
|
}
|
||||||
|
}, [t])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchJobs(1)
|
||||||
|
}, [fetchJobs])
|
||||||
|
|
||||||
|
const handleLoadMore = () => {
|
||||||
|
const nextPage = page + 1
|
||||||
|
setPage(nextPage)
|
||||||
|
fetchJobs(nextPage, true)
|
||||||
}
|
}
|
||||||
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,49 +192,41 @@ 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">
|
|
||||||
<Button
|
|
||||||
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={scrollMoreJobsPrev}
|
|
||||||
disabled={moreJobsPrevBtnDisabled}
|
|
||||||
>
|
|
||||||
<ChevronLeft className="w-5 h-5" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
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">
|
<Link href="/jobs">
|
||||||
<Button className="bg-orange-500 hover:bg-orange-600 text-white font-bold rounded-lg">
|
<Button variant="outline" className="border-orange-500 text-orange-500 hover:bg-orange-50">
|
||||||
{t("home.moreJobs.viewAll")}
|
{t("home.moreJobs.viewAll")}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</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>
|
||||||
|
|
||||||
|
{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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
|
</section >
|
||||||
|
|
||||||
{/* Bottom CTA Section */}
|
{/* Bottom CTA Section */ }
|
||||||
<section className="py-16 bg-white">
|
< section className = "py-16 bg-white" >
|
||||||
<div className="container mx-auto px-4 sm:px-6">
|
<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="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">
|
<div className="relative z-10 max-w-xl">
|
||||||
|
|
@ -267,10 +256,10 @@ export default function Home() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section >
|
||||||
</main>
|
</main >
|
||||||
|
|
||||||
<Footer />
|
<Footer />
|
||||||
</div>
|
</div >
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,7 @@
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { toast } from "sonner";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
@ -24,61 +22,135 @@ import {
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
Phone,
|
Phone,
|
||||||
MapPin,
|
|
||||||
Globe,
|
Globe,
|
||||||
FileText,
|
MapPin,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Calendar,
|
Briefcase,
|
||||||
Briefcase
|
Loader2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { motion } from "framer-motion";
|
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 { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
|
||||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { paymentsApi } from "@/lib/api";
|
||||||
|
import { Navbar } from "@/components/navbar";
|
||||||
|
import { Footer } from "@/components/footer";
|
||||||
|
|
||||||
export default function RegisterPage() {
|
const COUNTRIES = [
|
||||||
const router = useRouter();
|
"Argentina", "Australia", "Austria", "Belgium", "Brazil", "Canada", "Chile",
|
||||||
const { t } = useTranslation();
|
"China", "Colombia", "Czech Republic", "Denmark", "Egypt", "Finland", "France",
|
||||||
const [loading, setLoading] = useState(false);
|
"Germany", "Greece", "Hong Kong", "Hungary", "India", "Indonesia", "Ireland",
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
"Israel", "Italy", "Japan", "Malaysia", "Mexico", "Netherlands", "New Zealand",
|
||||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
"Nigeria", "Norway", "Pakistan", "Peru", "Philippines", "Poland", "Portugal",
|
||||||
const [currentStep, setCurrentStep] = useState(1);
|
"Romania", "Saudi Arabia", "Singapore", "South Africa", "South Korea", "Spain",
|
||||||
const [selectedPlan, setSelectedPlan] = useState<string | null>(null);
|
"Sweden", "Switzerland", "Taiwan", "Thailand", "Turkey", "Ukraine",
|
||||||
|
"United Arab Emirates", "United Kingdom", "United States", "Vietnam",
|
||||||
|
];
|
||||||
|
|
||||||
// Schema Validation
|
const YEARS_IN_MARKET = [
|
||||||
const companySchema = z.object({
|
{ value: "<1", label: "Less than 1 year" },
|
||||||
// User fields
|
{ value: "1-3", label: "1 – 3 years" },
|
||||||
companyName: z.string().min(2, "Nome da empresa é obrigatório"),
|
{ value: "3-5", label: "3 – 5 years" },
|
||||||
email: z.string().email("E-mail inválido"),
|
{ value: "5-10", label: "5 – 10 years" },
|
||||||
password: z.string().min(6, "Senha deve ter pelo menos 6 caracteres"),
|
{ value: "10+", label: "10+ years" },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Plan {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
price: string;
|
||||||
|
period: string;
|
||||||
|
priceId: string | null; // Stripe Price ID — null for free/enterprise
|
||||||
|
features: string[];
|
||||||
|
recommended: boolean;
|
||||||
|
cta: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLANS: Plan[] = [
|
||||||
|
{
|
||||||
|
id: "free",
|
||||||
|
name: "Starter",
|
||||||
|
price: "$0",
|
||||||
|
period: "/ month",
|
||||||
|
priceId: null,
|
||||||
|
features: [
|
||||||
|
"1 active job posting",
|
||||||
|
"Resume collection",
|
||||||
|
"Basic applicant dashboard",
|
||||||
|
"Email notifications",
|
||||||
|
],
|
||||||
|
recommended: false,
|
||||||
|
cta: "Get started free",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pro",
|
||||||
|
name: "Pro",
|
||||||
|
price: "$49",
|
||||||
|
period: "/ month",
|
||||||
|
priceId: process.env.NEXT_PUBLIC_STRIPE_PRO_PRICE_ID || null,
|
||||||
|
features: [
|
||||||
|
"Unlimited job postings",
|
||||||
|
"Priority in search results",
|
||||||
|
"Talent pool access",
|
||||||
|
"Priority support",
|
||||||
|
"Analytics dashboard",
|
||||||
|
],
|
||||||
|
recommended: true,
|
||||||
|
cta: "Start Pro",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "enterprise",
|
||||||
|
name: "Enterprise",
|
||||||
|
price: "Custom",
|
||||||
|
period: "",
|
||||||
|
priceId: null,
|
||||||
|
features: [
|
||||||
|
"Everything in Pro",
|
||||||
|
"API integration",
|
||||||
|
"Dedicated account manager",
|
||||||
|
"Advanced reporting",
|
||||||
|
"White-label option",
|
||||||
|
],
|
||||||
|
recommended: false,
|
||||||
|
cta: "Contact sales",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const companySchema = z
|
||||||
|
.object({
|
||||||
|
companyName: z.string().min(2, "Company name is required"),
|
||||||
|
email: z.string().email("Invalid email address"),
|
||||||
|
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||||
confirmPassword: z.string(),
|
confirmPassword: z.string(),
|
||||||
phone: z.string().min(10, "Telefone inválido"),
|
phone: z.string().optional(),
|
||||||
birthDate: z.string().min(1, "Data de nascimento é obrigatória"), // New field
|
country: z.string().min(1, "Country is required"),
|
||||||
|
|
||||||
// Company fields
|
|
||||||
cnpj: z.string().optional(), // Made optional for now or keep strict? Assuming optional for initial MVP flex
|
|
||||||
website: z.string().url("URL inválida").optional().or(z.literal("")),
|
|
||||||
yearsInMarket: z.string().min(1, "Tempo de mercado é obrigatório"), // Replaces Education
|
|
||||||
companySummary: z.string().min(20, "Resumo da empresa deve ter pelo menos 20 caracteres"), // Replaces Skills
|
|
||||||
|
|
||||||
// Address
|
|
||||||
zipCode: z.string().min(8, "CEP inválido"),
|
|
||||||
address: z.string().optional(),
|
|
||||||
city: z.string().optional(),
|
city: z.string().optional(),
|
||||||
state: z.string().optional(),
|
website: z.string().url("Invalid URL").optional().or(z.literal("")),
|
||||||
|
yearsInMarket: z.string().min(1, "Years in market is required"),
|
||||||
acceptTerms: z.boolean().refine(val => val === true, "Você deve aceitar os termos"),
|
description: z.string().min(20, "Description must be at least 20 characters"),
|
||||||
}).refine(data => data.password === data.confirmPassword, {
|
taxId: z.string().optional(),
|
||||||
message: "Senhas não coincidem",
|
acceptTerms: z.boolean().refine((v) => v === true, "You must accept the terms"),
|
||||||
|
})
|
||||||
|
.refine((d) => d.password === d.confirmPassword, {
|
||||||
|
message: "Passwords do not match",
|
||||||
path: ["confirmPassword"],
|
path: ["confirmPassword"],
|
||||||
});
|
});
|
||||||
|
|
||||||
type CompanyFormData = z.infer<typeof companySchema>;
|
type CompanyFormData = z.infer<typeof companySchema>;
|
||||||
|
|
||||||
|
const STEPS = ["Plan", "Details", "Terms", "Confirm"];
|
||||||
|
|
||||||
|
export default function RegisterPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [step, setStep] = useState(1);
|
||||||
|
const [selectedPlan, setSelectedPlan] = useState<Plan | null>(null);
|
||||||
|
const [showPw, setShowPw] = useState(false);
|
||||||
|
const [showConfirmPw, setShowConfirmPw] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
|
|
@ -86,224 +158,193 @@ export default function RegisterPage() {
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
setValue,
|
setValue,
|
||||||
trigger,
|
trigger,
|
||||||
watch
|
watch,
|
||||||
} = useForm<CompanyFormData>({
|
} = useForm<CompanyFormData>({
|
||||||
resolver: zodResolver(companySchema),
|
resolver: zodResolver(companySchema),
|
||||||
defaultValues: {
|
defaultValues: { yearsInMarket: "", country: "" },
|
||||||
yearsInMarket: "",
|
|
||||||
cnpj: "",
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const acceptTerms = watch("acceptTerms");
|
const watchedCountry = watch("country");
|
||||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const plans = [
|
const nextStep = async () => {
|
||||||
{
|
setErrorMsg(null);
|
||||||
id: "free",
|
if (step === 1) {
|
||||||
name: "Start",
|
|
||||||
price: "R$ 0",
|
|
||||||
period: "mês",
|
|
||||||
features: ["1 Vaga ativa", "Recebimento de currículos", "Painel básico"],
|
|
||||||
recommended: false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "pro",
|
|
||||||
name: "Pro",
|
|
||||||
price: "R$ 199",
|
|
||||||
period: "mês",
|
|
||||||
features: ["Vagas ilimitadas", "Destaque nas buscas", "Banco de talentos", "Suporte prioritário"],
|
|
||||||
recommended: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "enterprise",
|
|
||||||
name: "Enterprise",
|
|
||||||
price: "Sob consulta",
|
|
||||||
period: "",
|
|
||||||
features: ["API de integração", "Gerente de conta", "Relatórios avançados", "Marca branca"],
|
|
||||||
recommended: false
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
const onSubmit = async (data: CompanyFormData) => {
|
|
||||||
if (!selectedPlan) {
|
if (!selectedPlan) {
|
||||||
setErrorMsg("Selecione um plano para continuar.");
|
setErrorMsg("Please select a plan to continue.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setStep(2);
|
||||||
|
} else if (step === 2) {
|
||||||
|
const ok = await trigger([
|
||||||
|
"companyName", "email", "password", "confirmPassword",
|
||||||
|
"country", "yearsInMarket", "description",
|
||||||
|
]);
|
||||||
|
if (ok) setStep(3);
|
||||||
|
} else if (step === 3) {
|
||||||
|
const ok = await trigger("acceptTerms");
|
||||||
|
if (ok) setStep(4);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async (data: CompanyFormData) => {
|
||||||
|
if (!selectedPlan) return;
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setErrorMsg(null);
|
setErrorMsg(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { registerCompany } = await import("@/lib/auth");
|
const { registerCompany } = await import("@/lib/auth");
|
||||||
|
|
||||||
// Mapping form data to API expectation
|
const res = await registerCompany({
|
||||||
// We pass the "company_name" as the primary name
|
|
||||||
const res: any = await registerCompany({
|
|
||||||
companyName: data.companyName,
|
companyName: data.companyName,
|
||||||
email: data.email,
|
email: data.email,
|
||||||
phone: data.phone,
|
phone: data.phone,
|
||||||
password: data.password,
|
password: data.password,
|
||||||
// Extra fields
|
document: data.taxId,
|
||||||
document: data.cnpj,
|
|
||||||
website: data.website,
|
website: data.website,
|
||||||
yearsInMarket: data.yearsInMarket,
|
yearsInMarket: data.yearsInMarket,
|
||||||
description: data.companySummary,
|
description: data.description,
|
||||||
zipCode: data.zipCode,
|
|
||||||
address: data.address,
|
|
||||||
city: data.city,
|
city: data.city,
|
||||||
state: data.state,
|
country: data.country,
|
||||||
birthDate: data.birthDate,
|
|
||||||
// Plan info could be passed here if backend supports it immediately,
|
|
||||||
// or we handle payment in next step.
|
|
||||||
// For now, let's assume registration creates the account and redirect to payment if needed.
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-login if token is present
|
// Auto-login if token returned
|
||||||
if (res && res.token) {
|
if (res?.token) {
|
||||||
localStorage.setItem("token", res.token);
|
|
||||||
localStorage.setItem("auth_token", res.token);
|
localStorage.setItem("auth_token", res.token);
|
||||||
|
localStorage.setItem("token", res.token);
|
||||||
localStorage.setItem("user", JSON.stringify({
|
localStorage.setItem("user", JSON.stringify({
|
||||||
name: data.companyName,
|
name: data.companyName,
|
||||||
email: data.email,
|
email: data.email,
|
||||||
role: 'company'
|
role: "recruiter",
|
||||||
}));
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
toast.success("Cadastro realizado com sucesso! Redirecionando...");
|
// Enterprise → redirect to contact / dashboard
|
||||||
|
if (selectedPlan.id === "enterprise") {
|
||||||
|
router.push("/dashboard?plan=enterprise");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (selectedPlan !== 'free') {
|
// Free plan → go straight to dashboard
|
||||||
router.push("/dashboard?payment=pending");
|
if (!selectedPlan.priceId) {
|
||||||
} else {
|
|
||||||
router.push("/dashboard");
|
router.push("/dashboard");
|
||||||
}
|
return;
|
||||||
} else {
|
|
||||||
if (selectedPlan !== 'free') {
|
|
||||||
router.push("/dashboard?payment=pending");
|
|
||||||
} else {
|
|
||||||
router.push("/login?message=Cadastro realizado! Faça login.");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error: any) {
|
// Paid plan → create Stripe checkout session
|
||||||
console.error("Registration error:", error);
|
try {
|
||||||
setErrorMsg(error.message || "Erro ao registrar. Tente novamente.");
|
const origin = window.location.origin;
|
||||||
|
const checkout = await paymentsApi.createSubscriptionCheckout({
|
||||||
|
priceId: selectedPlan.priceId,
|
||||||
|
successUrl: `${origin}/dashboard?payment=success`,
|
||||||
|
cancelUrl: `${origin}/register?payment=cancelled`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (checkout.checkoutUrl) {
|
||||||
|
window.location.href = checkout.checkoutUrl;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (stripeError) {
|
||||||
|
console.warn("Stripe checkout failed, falling back:", stripeError);
|
||||||
|
// Fallback: redirect to dashboard with a pending payment notice
|
||||||
|
router.push("/dashboard?payment=pending");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push("/dashboard");
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const msg = error instanceof Error ? error.message : "Registration failed. Please try again.";
|
||||||
|
setErrorMsg(msg);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const nextStep = async () => {
|
|
||||||
let valid = false;
|
|
||||||
if (currentStep === 1) {
|
|
||||||
if (!selectedPlan) {
|
|
||||||
setErrorMsg("Por favor, selecione um plano.");
|
|
||||||
valid = false;
|
|
||||||
} else {
|
|
||||||
setErrorMsg(null);
|
|
||||||
valid = true;
|
|
||||||
}
|
|
||||||
} else if (currentStep === 2) {
|
|
||||||
// Validate Data Form
|
|
||||||
valid = await trigger(["companyName", "email", "password", "confirmPassword", "phone", "birthDate", "yearsInMarket", "companySummary", "zipCode"]);
|
|
||||||
} else if (currentStep === 3) {
|
|
||||||
// Validate Terms
|
|
||||||
valid = await trigger("acceptTerms");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (valid && currentStep < 4) setCurrentStep(currentStep + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const prevStep = () => {
|
|
||||||
if (currentStep > 1) setCurrentStep(currentStep - 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePlanSelect = (planId: string) => {
|
|
||||||
setSelectedPlan(planId);
|
|
||||||
setErrorMsg(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background flex flex-col">
|
<div className="min-h-screen bg-background flex flex-col">
|
||||||
{/* Header Simple */}
|
<Navbar />
|
||||||
<header className="py-6 px-8 border-b">
|
|
||||||
<div className="max-w-7xl mx-auto flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Image src="/logohorse.png" alt="GoHorse Jobs" width={40} height={40} className="rounded" />
|
|
||||||
<span className="font-bold text-xl tracking-tight">GoHorse Jobs</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
Já tem conta? <Link href="/login" className="text-primary font-medium hover:underline">Entrar</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main className="flex-1 container mx-auto py-10 px-4 max-w-5xl">
|
<main className="flex-1 container mx-auto py-10 px-4 max-w-5xl">
|
||||||
{/* Progress */}
|
{/* Step indicator */}
|
||||||
<div className="mb-10 max-w-3xl mx-auto">
|
<div className="mb-10 max-w-3xl mx-auto">
|
||||||
<div className="flex items-center justify-between relative">
|
<div className="flex items-center justify-between relative">
|
||||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-full h-1 bg-muted -z-10" />
|
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-full h-0.5 bg-muted -z-10" />
|
||||||
|
{STEPS.map((label, i) => (
|
||||||
{[1, 2, 3, 4].map((step) => (
|
<div key={label} className="flex flex-col items-center gap-1">
|
||||||
<div key={step} className={`w-10 h-10 rounded-full flex items-center justify-center font-bold text-sm transition-colors ${
|
<div
|
||||||
currentStep >= step ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"
|
className={`w-9 h-9 rounded-full flex items-center justify-center font-bold text-sm transition-colors z-10 ${
|
||||||
}`}>
|
step > i + 1
|
||||||
{step}
|
? "bg-green-500 text-white"
|
||||||
|
: step === i + 1
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-muted text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{step > i + 1 ? <CheckCircle2 className="w-4 h-4" /> : i + 1}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground hidden sm:block">{label}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between mt-2 text-xs text-muted-foreground px-1">
|
|
||||||
<span>Planos</span>
|
|
||||||
<span>Dados</span>
|
|
||||||
<span>Termos</span>
|
|
||||||
<span>Pagamento</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{errorMsg && (
|
{errorMsg && (
|
||||||
<Alert variant="destructive" className="mb-6 max-w-xl mx-auto">
|
<Alert variant="destructive" className="mb-6 max-w-2xl mx-auto">
|
||||||
<AlertDescription>{errorMsg}</AlertDescription>
|
<AlertDescription>{errorMsg}</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
{/* STEP 1: PLANS */}
|
{/* STEP 1: PLAN SELECTION */}
|
||||||
{currentStep === 1 && (
|
{step === 1 && (
|
||||||
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className="space-y-8">
|
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className="space-y-8">
|
||||||
<div className="text-center space-y-2">
|
<div className="text-center space-y-2">
|
||||||
<h1 className="text-3xl font-bold">Escolha o plano ideal para sua empresa</h1>
|
<h1 className="text-3xl font-bold">Choose your plan</h1>
|
||||||
<p className="text-muted-foreground">Comece a recrutar os melhores talentos hoje mesmo.</p>
|
<p className="text-muted-foreground">Start hiring the best talent worldwide today.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid md:grid-cols-3 gap-6">
|
<div className="grid md:grid-cols-3 gap-6">
|
||||||
{plans.map((plan) => (
|
{PLANS.map((plan) => (
|
||||||
<Card key={plan.id}
|
<Card
|
||||||
className={`relative cursor-pointer transition-all hover:shadow-lg ${selectedPlan === plan.id ? 'border-primary ring-2 ring-primary/20' : ''}`}
|
key={plan.id}
|
||||||
onClick={() => handlePlanSelect(plan.id)}
|
className={`relative cursor-pointer transition-all hover:shadow-lg ${
|
||||||
|
selectedPlan?.id === plan.id ? "border-primary ring-2 ring-primary/20" : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => { setSelectedPlan(plan); setErrorMsg(null); }}
|
||||||
>
|
>
|
||||||
{plan.recommended && (
|
{plan.recommended && (
|
||||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2 bg-primary text-primary-foreground text-xs px-3 py-1 rounded-full">
|
<div className="absolute -top-3 left-1/2 -translate-x-1/2 bg-primary text-primary-foreground text-xs px-3 py-1 rounded-full whitespace-nowrap">
|
||||||
Recomendado
|
Recommended
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<CardHeader>
|
<CardHeader className="pb-2">
|
||||||
<CardTitle>{plan.name}</CardTitle>
|
<CardTitle className="text-lg">{plan.name}</CardTitle>
|
||||||
<div className="mt-2">
|
<div className="mt-1">
|
||||||
<span className="text-4xl font-bold">{plan.price}</span>
|
<span className="text-4xl font-bold">{plan.price}</span>
|
||||||
{plan.period && <span className="text-muted-foreground">/{plan.period}</span>}
|
{plan.period && (
|
||||||
|
<span className="text-muted-foreground text-sm ml-1">{plan.period}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<ul className="space-y-3 text-sm">
|
<ul className="space-y-2 text-sm">
|
||||||
{plan.features.map((feat, i) => (
|
{plan.features.map((f) => (
|
||||||
<li key={i} className="flex items-center gap-2">
|
<li key={f} className="flex items-center gap-2">
|
||||||
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
<CheckCircle2 className="w-4 h-4 text-green-500 shrink-0" />
|
||||||
{feat}
|
{f}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter>
|
<CardFooter>
|
||||||
<div className={`w-full h-4 rounded-full border-2 border-muted flex items-center justify-center ${selectedPlan === plan.id ? 'border-primary' : ''}`}>
|
<div
|
||||||
{selectedPlan === plan.id && <div className="w-2.5 h-2.5 bg-primary rounded-full" />}
|
className={`w-full h-4 rounded-full border-2 flex items-center justify-center transition-colors ${
|
||||||
|
selectedPlan?.id === plan.id ? "border-primary" : "border-muted"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{selectedPlan?.id === plan.id && (
|
||||||
|
<div className="w-2.5 h-2.5 bg-primary rounded-full" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -312,204 +353,268 @@ export default function RegisterPage() {
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* STEP 2: FORM */}
|
{/* STEP 2: COMPANY DETAILS */}
|
||||||
{currentStep === 2 && (
|
{step === 2 && (
|
||||||
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className="max-w-2xl mx-auto space-y-6">
|
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className="max-w-2xl mx-auto space-y-5">
|
||||||
<div className="text-center space-y-2 mb-6">
|
<div className="text-center space-y-1 mb-4">
|
||||||
<h2 className="text-2xl font-bold">Dados da Empresa e Responsável</h2>
|
<h2 className="text-2xl font-bold">Company details</h2>
|
||||||
<p className="text-muted-foreground">Preencha as informações para criar sua conta.</p>
|
<p className="text-muted-foreground">Fill in your company and account information.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Company name + Years in market */}
|
||||||
<div className="grid md:grid-cols-2 gap-4">
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Nome da Empresa *</Label>
|
<Label>Company name *</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Building2 className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
<Building2 className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||||
<Input {...register("companyName")} className="pl-10" placeholder="Tech Solutions Ltda" />
|
<Input {...register("companyName")} className="pl-10" placeholder="Acme Inc." />
|
||||||
</div>
|
</div>
|
||||||
{errors.companyName && <span className="text-xs text-destructive">{errors.companyName.message}</span>}
|
{errors.companyName && <p className="text-xs text-destructive">{errors.companyName.message}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Tempo de Mercado *</Label>
|
<Label>Years in business *</Label>
|
||||||
<Select onValueChange={(val) => setValue("yearsInMarket", val)}>
|
<Select onValueChange={(v) => { setValue("yearsInMarket", v); trigger("yearsInMarket"); }}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Selecione" />
|
<SelectValue placeholder="Select…" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="<1">Menos de 1 ano</SelectItem>
|
{YEARS_IN_MARKET.map((y) => (
|
||||||
<SelectItem value="1-3">1 a 3 anos</SelectItem>
|
<SelectItem key={y.value} value={y.value}>{y.label}</SelectItem>
|
||||||
<SelectItem value="3-5">3 a 5 anos</SelectItem>
|
))}
|
||||||
<SelectItem value="5-10">5 a 10 anos</SelectItem>
|
|
||||||
<SelectItem value="10+">Mais de 10 anos</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
{errors.yearsInMarket && <span className="text-xs text-destructive">{errors.yearsInMarket.message}</span>}
|
{errors.yearsInMarket && <p className="text-xs text-destructive">{errors.yearsInMarket.message}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Resumo da Empresa *</Label>
|
<Label>Company description *</Label>
|
||||||
<Textarea {...register("companySummary")} placeholder="Breve descrição sobre o que a empresa faz, missão e valores..." className="min-h-[100px]" />
|
<Textarea
|
||||||
{errors.companySummary && <span className="text-xs text-destructive">{errors.companySummary.message}</span>}
|
{...register("description")}
|
||||||
|
placeholder="Brief description of what your company does, its mission and values…"
|
||||||
|
className="min-h-[90px]"
|
||||||
|
/>
|
||||||
|
{errors.description && <p className="text-xs text-destructive">{errors.description.message}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Email + Phone */}
|
||||||
<div className="grid md:grid-cols-2 gap-4">
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>E-mail *</Label>
|
<Label>Email *</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Mail className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
<Mail className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||||
<Input type="email" {...register("email")} className="pl-10" placeholder="rh@empresa.com" />
|
<Input type="email" {...register("email")} className="pl-10" placeholder="hr@acme.com" />
|
||||||
</div>
|
</div>
|
||||||
{errors.email && <span className="text-xs text-destructive">{errors.email.message}</span>}
|
{errors.email && <p className="text-xs text-destructive">{errors.email.message}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Telefone *</Label>
|
<Label>Phone <span className="text-muted-foreground text-xs">(optional)</span></Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Phone className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
<Phone className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||||
<Input {...register("phone")} className="pl-10" placeholder="(11) 99999-9999" />
|
<Input {...register("phone")} className="pl-10" placeholder="+1 555 000 0000" />
|
||||||
</div>
|
</div>
|
||||||
{errors.phone && <span className="text-xs text-destructive">{errors.phone.message}</span>}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Password + Confirm */}
|
||||||
<div className="grid md:grid-cols-2 gap-4">
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Senha *</Label>
|
<Label>Password *</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||||
<Input type={showPassword ? "text" : "password"} {...register("password")} className="pl-10" />
|
<Input
|
||||||
<button type="button" onClick={() => setShowPassword(!showPassword)} className="absolute right-3 top-3 text-muted-foreground">
|
type={showPw ? "text" : "password"}
|
||||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
{...register("password")}
|
||||||
|
className="pl-10 pr-10"
|
||||||
|
placeholder="Min. 8 characters"
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={() => setShowPw(!showPw)} className="absolute right-3 top-3 text-muted-foreground">
|
||||||
|
{showPw ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{errors.password && <span className="text-xs text-destructive">{errors.password.message}</span>}
|
{errors.password && <p className="text-xs text-destructive">{errors.password.message}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Confirmar Senha *</Label>
|
<Label>Confirm password *</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||||
<Input type={showConfirmPassword ? "text" : "password"} {...register("confirmPassword")} className="pl-10" />
|
<Input
|
||||||
|
type={showConfirmPw ? "text" : "password"}
|
||||||
|
{...register("confirmPassword")}
|
||||||
|
className="pl-10 pr-10"
|
||||||
|
placeholder="Repeat password"
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={() => setShowConfirmPw(!showConfirmPw)} className="absolute right-3 top-3 text-muted-foreground">
|
||||||
|
{showConfirmPw ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{errors.confirmPassword && <span className="text-xs text-destructive">{errors.confirmPassword.message}</span>}
|
{errors.confirmPassword && <p className="text-xs text-destructive">{errors.confirmPassword.message}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Country + City */}
|
||||||
<div className="grid md:grid-cols-2 gap-4">
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Data de Nascimento (Resp.) *</Label>
|
<Label>Country *</Label>
|
||||||
<div className="relative">
|
<Select
|
||||||
<Calendar className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
value={watchedCountry}
|
||||||
<Input type="date" {...register("birthDate")} className="pl-10" />
|
onValueChange={(v) => { setValue("country", v); trigger("country"); }}
|
||||||
</div>
|
>
|
||||||
{errors.birthDate && <span className="text-xs text-destructive">{errors.birthDate.message}</span>}
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select country…" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-60">
|
||||||
|
{COUNTRIES.map((c) => (
|
||||||
|
<SelectItem key={c} value={c}>{c}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{errors.country && <p className="text-xs text-destructive">{errors.country.message}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>CEP *</Label>
|
<Label>City <span className="text-muted-foreground text-xs">(optional)</span></Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<MapPin className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
<MapPin className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||||
<Input {...register("zipCode")} className="pl-10" placeholder="00000-000" />
|
<Input {...register("city")} className="pl-10" placeholder="San Francisco" />
|
||||||
</div>
|
</div>
|
||||||
{errors.zipCode && <span className="text-xs text-destructive">{errors.zipCode.message}</span>}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Website + Tax ID */}
|
||||||
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Website <span className="text-muted-foreground text-xs">(optional)</span></Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Globe className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input {...register("website")} className="pl-10" placeholder="https://acme.com" />
|
||||||
|
</div>
|
||||||
|
{errors.website && <p className="text-xs text-destructive">{errors.website.message}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>CNPJ (Opcional)</Label>
|
<Label>Tax ID / Business number <span className="text-muted-foreground text-xs">(optional)</span></Label>
|
||||||
<Input {...register("cnpj")} placeholder="00.000.000/0000-00" />
|
<Input {...register("taxId")} placeholder="EIN, CNPJ, ABN, CRN, etc." />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* STEP 3: TERMS */}
|
{/* STEP 3: TERMS */}
|
||||||
{currentStep === 3 && (
|
{step === 3 && (
|
||||||
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className="max-w-2xl mx-auto space-y-6">
|
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className="max-w-2xl mx-auto space-y-6">
|
||||||
<div className="text-center space-y-2 mb-4">
|
<div className="text-center space-y-1 mb-4">
|
||||||
<h2 className="text-2xl font-bold">Termos e Condições</h2>
|
<h2 className="text-2xl font-bold">Terms & Conditions</h2>
|
||||||
<p className="text-muted-foreground">Leia atentamente para prosseguir.</p>
|
<p className="text-muted-foreground">Please read carefully before proceeding.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="h-64 overflow-y-auto border rounded-md p-4 bg-muted/20 text-sm leading-relaxed" onScroll={(e) => {
|
<div className="h-64 overflow-y-auto border rounded-md p-4 bg-muted/20 text-sm leading-relaxed space-y-3">
|
||||||
const target = e.currentTarget;
|
<h4 className="font-bold">1. Acceptance</h4>
|
||||||
const reachedBottom = target.scrollHeight - target.scrollTop <= target.clientHeight + 20;
|
<p>By creating an account on GoHorse Jobs, you agree to these terms and our Privacy Policy. If you do not agree, please do not use the platform.</p>
|
||||||
if(reachedBottom) {
|
<h4 className="font-bold">2. Platform Use</h4>
|
||||||
// Can enable checkbox logic here if strictly enforcing read
|
<p>The platform is designed to connect companies with candidates globally. You agree to post only accurate, lawful, and non-discriminatory job listings.</p>
|
||||||
}
|
<h4 className="font-bold">3. Payments & Subscriptions</h4>
|
||||||
}}>
|
<p>Paid plans are billed on a recurring basis. You may cancel at any time. Refunds are subject to our refund policy. All prices are in USD unless stated otherwise.</p>
|
||||||
<h4 className="font-bold mb-2">1. Aceitação</h4>
|
<h4 className="font-bold">4. Data & Privacy</h4>
|
||||||
<p className="mb-2">Ao criar uma conta na GoHorse Jobs, você concorda com os termos...</p>
|
<p>We process personal data in accordance with applicable laws (GDPR, LGPD, etc.). Candidate data is shared only with the posting employer. You are responsible for handling applicant data lawfully.</p>
|
||||||
<h4 className="font-bold mb-2">2. Uso da Plataforma</h4>
|
<h4 className="font-bold">5. Prohibited Conduct</h4>
|
||||||
<p className="mb-2">A plataforma destina-se a conectar empresas e candidatos...</p>
|
<p>You must not post fraudulent, misleading, or discriminatory job listings. Violations may result in immediate account suspension.</p>
|
||||||
<h4 className="font-bold mb-2">3. Pagamentos</h4>
|
<h4 className="font-bold">6. Liability</h4>
|
||||||
<p className="mb-2">Os planos pagos são renovados automaticamente...</p>
|
<p>GoHorse Jobs is not liable for any employment decisions made based on platform use. The platform is provided "as is" with no warranties of any kind.</p>
|
||||||
<p className="mb-2">Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.</p>
|
|
||||||
<p className="mb-2">Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p>
|
|
||||||
<p className="mb-2">Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo.</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-start space-x-2 pt-4">
|
<div className="flex items-start space-x-2 pt-2">
|
||||||
<Checkbox id="terms" onCheckedChange={(checked) => setValue("acceptTerms", checked as boolean)} />
|
<Checkbox id="terms" onCheckedChange={(v) => setValue("acceptTerms", v as boolean)} />
|
||||||
<label htmlFor="terms" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer">
|
<label htmlFor="terms" className="text-sm leading-relaxed cursor-pointer">
|
||||||
Li e aceito os Termos de Uso e Política de Privacidade da GoHorse Jobs.
|
I have read and agree to the{" "}
|
||||||
|
<Link href="/terms" className="text-primary underline">Terms of Use</Link>
|
||||||
|
{" "}and{" "}
|
||||||
|
<Link href="/privacy" className="text-primary underline">Privacy Policy</Link>
|
||||||
|
{" "}of GoHorse Jobs.
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{errors.acceptTerms && <span className="text-xs text-destructive block">{errors.acceptTerms.message}</span>}
|
{errors.acceptTerms && <p className="text-xs text-destructive">{errors.acceptTerms.message}</p>}
|
||||||
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* STEP 4: PAYMENT (Simple Confirmation for now) */}
|
{/* STEP 4: CONFIRM & SUBMIT */}
|
||||||
{currentStep === 4 && (
|
{step === 4 && (
|
||||||
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className="max-w-md mx-auto space-y-8 text-center">
|
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className="max-w-md mx-auto space-y-8 text-center">
|
||||||
<div className="w-20 h-20 bg-primary/10 rounded-full flex items-center justify-center mx-auto text-primary">
|
<div className="w-20 h-20 bg-primary/10 rounded-full flex items-center justify-center mx-auto text-primary">
|
||||||
|
{selectedPlan?.id === "free" ? (
|
||||||
<CheckCircle2 className="w-10 h-10" />
|
<CheckCircle2 className="w-10 h-10" />
|
||||||
|
) : selectedPlan?.id === "enterprise" ? (
|
||||||
|
<Briefcase className="w-10 h-10" />
|
||||||
|
) : (
|
||||||
|
<Globe className="w-10 h-10" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h2 className="text-2xl font-bold">Tudo pronto!</h2>
|
<h2 className="text-2xl font-bold">Almost there!</h2>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Você escolheu o plano <strong>{plans.find(p => p.id === selectedPlan)?.name}</strong>.
|
You selected the <strong>{selectedPlan?.name}</strong> plan.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm">
|
<p className="text-sm text-muted-foreground">
|
||||||
Ao clicar em "Finalizar", sua conta será criada e você será redirecionado para {selectedPlan === 'free' ? 'o dashboard' : 'o pagamento'}.
|
{selectedPlan?.id === "free"
|
||||||
|
? "Your account will be created and you'll be redirected to the dashboard."
|
||||||
|
: selectedPlan?.id === "enterprise"
|
||||||
|
? "Your account will be created and our team will reach out to discuss your needs."
|
||||||
|
: "Your account will be created, then you'll be redirected to Stripe to complete payment."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-muted p-4 rounded-lg text-left space-y-3">
|
<div className="bg-muted p-4 rounded-lg text-left space-y-2">
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="text-muted-foreground">Plano</span>
|
<span className="text-muted-foreground">Plan</span>
|
||||||
<span className="font-medium">{plans.find(p => p.id === selectedPlan)?.name}</span>
|
<span className="font-medium">{selectedPlan?.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="text-muted-foreground">Total</span>
|
<span className="text-muted-foreground">Billing</span>
|
||||||
<span className="font-bold">{plans.find(p => p.id === selectedPlan)?.price}</span>
|
<span className="font-bold">
|
||||||
|
{selectedPlan?.price}{selectedPlan?.period ? ` ${selectedPlan.period}` : ""}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Navigation Actions */}
|
{/* Navigation */}
|
||||||
<div className="mt-8 flex justify-between max-w-2xl mx-auto px-4">
|
<div className="mt-8 flex justify-between max-w-2xl mx-auto px-0">
|
||||||
{currentStep > 1 && (
|
{step > 1 ? (
|
||||||
<Button type="button" variant="outline" onClick={prevStep} disabled={loading}>
|
<Button type="button" variant="outline" onClick={() => setStep(step - 1)} disabled={loading}>
|
||||||
Voltar
|
<ArrowLeft className="w-4 h-4 mr-2" /> Back
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{currentStep < 4 ? (
|
|
||||||
<Button type="button" onClick={nextStep} className="ml-auto min-w-[120px]">
|
|
||||||
Próximo
|
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button type="submit" disabled={loading} className="ml-auto min-w-[120px]">
|
<div />
|
||||||
{loading ? "Processando..." : (selectedPlan === 'free' ? "Finalizar Cadastro" : "Ir para Pagamento")}
|
)}
|
||||||
|
|
||||||
|
{step < 4 ? (
|
||||||
|
<Button type="button" onClick={nextStep} className="min-w-[120px]">
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button type="submit" disabled={loading} className="min-w-[160px]">
|
||||||
|
{loading ? (
|
||||||
|
<><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Processing…</>
|
||||||
|
) : selectedPlan?.id === "free" ? (
|
||||||
|
"Create account"
|
||||||
|
) : selectedPlan?.id === "enterprise" ? (
|
||||||
|
"Create account"
|
||||||
|
) : (
|
||||||
|
"Create account & pay"
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"],
|
||||||
}), []);
|
}), []);
|
||||||
|
|
||||||
|
|
@ -87,27 +87,24 @@ export default function RegisterUserPage() {
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</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