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:
Tiago Yamamoto 2026-02-23 20:26:49 -06:00
parent 9f5725bf01
commit 8ee0d59a61
14 changed files with 1066 additions and 672 deletions

View file

@ -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"`

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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
} }

View file

@ -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))
} }
} }
} }

View 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.)';

View file

@ -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">

View file

@ -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,44 +192,36 @@ 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>
</section>
</div> </div>
</section > </section >

View file

@ -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,286 +22,329 @@ 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,
handleSubmit, handleSubmit,
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">
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>
); );
} }

View file

@ -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">
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>
); );
} }

View file

@ -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) =>

View file

@ -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`, {