feat: fix seeder password hashing, add custom questions, navbar/footer on register, payment handler
- fix(seeder): add PASSWORD_PEPPER to all bcrypt hashes (admin + candidates/recruiters) - fix(seeder): add created_by field to jobs INSERT (was causing NOT NULL violation) - feat(backend): add custom job questions support in applications - feat(backend): add payment handler and Stripe routes - feat(frontend): add navbar and footer to /register and /register/user pages - feat(frontend): add custom question answers to job apply page - feat(frontend): update home page hero section and navbar buttons - feat(frontend): update auth/api lib with new endpoints - chore(db): add migration 045 for application answers Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9f5725bf01
commit
8ee0d59a61
14 changed files with 1066 additions and 672 deletions
|
|
@ -5,7 +5,7 @@ import "time"
|
|||
type CreateCompanyRequest struct {
|
||||
Name string `json:"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"`
|
||||
AdminEmail string `json:"admin_email"`
|
||||
Email string `json:"email"` // Alternative field name
|
||||
|
|
@ -15,6 +15,7 @@ type CreateCompanyRequest struct {
|
|||
Address *string `json:"address,omitempty"`
|
||||
City *string `json:"city,omitempty"`
|
||||
State *string `json:"state,omitempty"`
|
||||
Country *string `json:"country,omitempty"` // ISO country name or code
|
||||
ZipCode *string `json:"zip_code,omitempty"`
|
||||
EmployeeCount *string `json:"employeeCount,omitempty"`
|
||||
FoundedYear *int `json:"foundedYear,omitempty"`
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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 {
|
||||
company.Description = input.Description
|
||||
}
|
||||
if input.Address != nil {
|
||||
company.Address = input.Address
|
||||
// Build address: combine provided address + city + country
|
||||
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 {
|
||||
company.YearsInMarket = input.YearsInMarket
|
||||
|
|
|
|||
|
|
@ -217,6 +217,123 @@ func (h *PaymentHandler) handlePaymentFailed(_ map[string]interface{}) {
|
|||
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
|
||||
// @Summary Get payment status
|
||||
// @Description Get the status of a job posting payment
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ type Application struct {
|
|||
Message *string `json:"message,omitempty" db:"message"`
|
||||
ResumeURL *string `json:"resumeUrl,omitempty" db:"resume_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 string `json:"status" db:"status"` // pending, reviewed, shortlisted, rejected, hired
|
||||
|
|
|
|||
|
|
@ -335,6 +335,7 @@ func NewRouter() http.Handler {
|
|||
|
||||
// Payment Routes
|
||||
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("GET /api/v1/payments/status/{id}", paymentHandler.GetPaymentStatus)
|
||||
|
||||
|
|
|
|||
|
|
@ -25,8 +25,8 @@ func (s *ApplicationService) CreateApplication(req dto.CreateApplicationRequest)
|
|||
query := `
|
||||
INSERT INTO applications (
|
||||
job_id, user_id, name, phone, line_id, whatsapp, email,
|
||||
message, resume_url, documents, status, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
message, resume_url, documents, answers, status, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
RETURNING id, created_at, updated_at
|
||||
`
|
||||
|
||||
|
|
@ -41,6 +41,7 @@ func (s *ApplicationService) CreateApplication(req dto.CreateApplicationRequest)
|
|||
Message: req.Message,
|
||||
ResumeURL: req.ResumeURL,
|
||||
Documents: req.Documents,
|
||||
Answers: req.Answers,
|
||||
Status: "pending",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
|
|
@ -49,7 +50,7 @@ func (s *ApplicationService) CreateApplication(req dto.CreateApplicationRequest)
|
|||
err := s.DB.QueryRow(
|
||||
query,
|
||||
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)
|
||||
|
||||
if err != nil {
|
||||
|
|
@ -86,7 +87,7 @@ func (s *ApplicationService) GetApplications(jobID string) ([]models.Application
|
|||
// Simple get by Job ID
|
||||
query := `
|
||||
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
|
||||
`
|
||||
rows, err := s.DB.Query(query, jobID)
|
||||
|
|
@ -100,7 +101,7 @@ func (s *ApplicationService) GetApplications(jobID string) ([]models.Application
|
|||
var a models.Application
|
||||
if err := rows.Scan(
|
||||
&a.ID, &a.JobID, &a.UserID, &a.Name, &a.Phone, &a.LineID, &a.WhatsApp, &a.Email,
|
||||
&a.Message, &a.ResumeURL, &a.Documents, &a.Status, &a.CreatedAt, &a.UpdatedAt,
|
||||
&a.Message, &a.ResumeURL, &a.Documents, &a.Answers, &a.Status, &a.CreatedAt, &a.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -148,12 +149,12 @@ func (s *ApplicationService) GetApplicationByID(id string) (*models.Application,
|
|||
var a models.Application
|
||||
query := `
|
||||
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
|
||||
`
|
||||
err := s.DB.QueryRow(query, id).Scan(
|
||||
&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 {
|
||||
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) {
|
||||
query := `
|
||||
SELECT a.id, a.job_id, a.user_id, a.name, a.phone, a.line_id, a.whatsapp, a.email,
|
||||
a.message, a.resume_url, a.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
|
||||
JOIN jobs j ON a.job_id = j.id
|
||||
WHERE j.company_id = $1
|
||||
|
|
@ -196,7 +197,7 @@ func (s *ApplicationService) GetApplicationsByCompany(companyID string) ([]model
|
|||
var a models.Application
|
||||
if err := rows.Scan(
|
||||
&a.ID, &a.JobID, &a.UserID, &a.Name, &a.Phone, &a.LineID, &a.WhatsApp, &a.Email,
|
||||
&a.Message, &a.ResumeURL, &a.Documents, &a.Status, &a.CreatedAt, &a.UpdatedAt,
|
||||
&a.Message, &a.ResumeURL, &a.Documents, &a.Answers, &a.Status, &a.CreatedAt, &a.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,8 +69,9 @@ func (s *SeederService) Seed(ctx context.Context, logChan chan string) error {
|
|||
if adminEmail != "" && adminPass != "" {
|
||||
s.SendEvent(logChan, fmt.Sprintf("🛡️ Found ADMIN_EMAIL env. Creating Superadmin: %s", adminEmail))
|
||||
|
||||
// Hash password
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(adminPass), bcrypt.DefaultCost)
|
||||
// Hash password with pepper (must match VerifyPassword in JWT service)
|
||||
pepper := os.Getenv("PASSWORD_PEPPER")
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(adminPass+pepper), bcrypt.DefaultCost)
|
||||
if err == nil {
|
||||
tx, err := s.DB.BeginTx(ctx, 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"}
|
||||
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 {
|
||||
id := uuid.New().String()
|
||||
|
|
@ -137,6 +139,7 @@ func (s *SeederService) Seed(ctx context.Context, logChan chan string) error {
|
|||
s.SendEvent(logChan, "🏢 Creating Companies...")
|
||||
companyNames := []string{"TechCorp", "InnovateX", "GlobalSolutions", "CodeFactory", "DesignStudio"}
|
||||
var companyIDs []string
|
||||
var companyRecruiterIDs []string
|
||||
|
||||
for _, compName := range companyNames {
|
||||
// Create Recruiter
|
||||
|
|
@ -171,6 +174,7 @@ func (s *SeederService) Seed(ctx context.Context, logChan chan string) error {
|
|||
continue
|
||||
}
|
||||
companyIDs = append(companyIDs, compID)
|
||||
companyRecruiterIDs = append(companyRecruiterIDs, recruiterID)
|
||||
|
||||
// Link Recruiter - Use 'admin' as role (per schema constraint: 'admin', 'recruiter')
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
|
|
@ -203,6 +207,7 @@ func (s *SeederService) Seed(ctx context.Context, logChan chan string) error {
|
|||
LanguageLevel string
|
||||
VisaSupport bool
|
||||
Status string
|
||||
Questions string // JSONB: {"items":[{id,label,type,required,options?}]}
|
||||
}
|
||||
|
||||
jobTemplates := []jobTemplate{
|
||||
|
|
@ -214,6 +219,7 @@ func (s *SeederService) Seed(ctx context.Context, logChan chan string) error {
|
|||
Location: "Remote — Worldwide",
|
||||
SalaryMin: 8000, SalaryMax: 14000, SalaryType: "monthly", Currency: "USD",
|
||||
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)",
|
||||
|
|
@ -223,6 +229,7 @@ func (s *SeederService) Seed(ctx context.Context, logChan chan string) error {
|
|||
Location: "São Paulo, SP — Brasil",
|
||||
SalaryMin: 6000, SalaryMax: 10000, SalaryType: "monthly", Currency: "BRL",
|
||||
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",
|
||||
|
|
@ -241,6 +248,7 @@ func (s *SeederService) Seed(ctx context.Context, logChan chan string) error {
|
|||
Location: "Tokyo, Japan",
|
||||
SalaryMin: 500000, SalaryMax: 800000, SalaryType: "monthly", Currency: "JPY",
|
||||
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",
|
||||
|
|
@ -250,6 +258,7 @@ func (s *SeederService) Seed(ctx context.Context, logChan chan string) error {
|
|||
Location: "Berlin, Germany",
|
||||
SalaryMin: 2500, SalaryMax: 4000, SalaryType: "monthly", Currency: "EUR",
|
||||
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",
|
||||
|
|
@ -268,6 +277,7 @@ func (s *SeederService) Seed(ctx context.Context, logChan chan string) error {
|
|||
Location: "São Paulo, SP — Brasil",
|
||||
SalaryMin: 5000, SalaryMax: 8000, SalaryType: "monthly", Currency: "BRL",
|
||||
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)",
|
||||
|
|
@ -286,6 +296,7 @@ func (s *SeederService) Seed(ctx context.Context, logChan chan string) error {
|
|||
Location: "Tokyo, Japan",
|
||||
SalaryMin: 180000, SalaryMax: 220000, SalaryType: "monthly", Currency: "JPY",
|
||||
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",
|
||||
|
|
@ -301,31 +312,37 @@ func (s *SeederService) Seed(ctx context.Context, logChan chan string) error {
|
|||
var jobIDs []string
|
||||
|
||||
for i, compID := range companyIDs {
|
||||
recruiterID := companyRecruiterIDs[i]
|
||||
numJobs := rnd.Intn(2) + 2
|
||||
for j := 0; j < numJobs; j++ {
|
||||
tmpl := jobTemplates[(i*3+j)%len(jobTemplates)]
|
||||
jobID := uuid.New().String()
|
||||
|
||||
var questionsArg interface{}
|
||||
if tmpl.Questions != "" {
|
||||
questionsArg = tmpl.Questions
|
||||
}
|
||||
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO jobs (
|
||||
id, company_id, title, description,
|
||||
id, company_id, created_by, title, description,
|
||||
employment_type, work_mode, location,
|
||||
salary_min, salary_max, salary_type, currency, salary_negotiable,
|
||||
language_level, visa_support,
|
||||
status, date_posted, created_at, updated_at
|
||||
questions, status, date_posted, created_at, updated_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4,
|
||||
$5, $6, $7,
|
||||
$8, $9, $10, $11, $12,
|
||||
$13, $14,
|
||||
$15, NOW(), NOW(), NOW()
|
||||
$1, $2, $3, $4, $5,
|
||||
$6, $7, $8,
|
||||
$9, $10, $11, $12, $13,
|
||||
$14, $15,
|
||||
$16::jsonb, $17, NOW(), NOW(), NOW()
|
||||
) ON CONFLICT DO NOTHING
|
||||
`,
|
||||
jobID, compID, tmpl.Title, tmpl.Description,
|
||||
jobID, compID, recruiterID, tmpl.Title, tmpl.Description,
|
||||
tmpl.EmploymentType, tmpl.WorkMode, tmpl.Location,
|
||||
tmpl.SalaryMin, tmpl.SalaryMax, tmpl.SalaryType, tmpl.Currency, tmpl.Negotiable,
|
||||
tmpl.LanguageLevel, tmpl.VisaSupport,
|
||||
tmpl.Status,
|
||||
questionsArg, tmpl.Status,
|
||||
)
|
||||
if err != nil {
|
||||
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
|
||||
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 {
|
||||
// Apply to 1-3 random jobs
|
||||
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))]
|
||||
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, `
|
||||
INSERT INTO applications (id, job_id, user_id, status, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, 'applied', NOW(), NOW())
|
||||
INSERT INTO applications (id, job_id, user_id, message, answers, status, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5::jsonb, $6, NOW(), NOW())
|
||||
ON CONFLICT DO NOTHING
|
||||
`, appID, jobID, candID)
|
||||
`, appID, jobID, candID, message, answers, status)
|
||||
|
||||
if err == nil {
|
||||
s.SendEvent(logChan, fmt.Sprintf(" - Candidate %s applied to Job %s", candID[:8], jobID[:8]))
|
||||
s.SendEvent(logChan, fmt.Sprintf(" - Candidate %s applied to Job %s (status: %s)", candID[:8], jobID[:8], status))
|
||||
} else {
|
||||
s.SendEvent(logChan, fmt.Sprintf(" ⚠️ Application error: %v", err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
6
backend/migrations/045_add_answers_to_applications.sql
Normal file
6
backend/migrations/045_add_answers_to_applications.sql
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
-- Migration: Add answers column to applications
|
||||
-- Description: Stores applicant answers to job-specific questions and form extra fields
|
||||
|
||||
ALTER TABLE applications ADD COLUMN IF NOT EXISTS answers JSONB;
|
||||
|
||||
COMMENT ON COLUMN applications.answers IS 'JSON map of applicant answers to job questions and extra form fields (linkedin, portfolioUrl, salaryExpectation, availability, etc.)';
|
||||
|
|
@ -18,6 +18,7 @@ import {
|
|||
Save,
|
||||
ArrowLeft,
|
||||
Loader2,
|
||||
HelpCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -44,7 +45,7 @@ import { Progress } from "@/components/ui/progress";
|
|||
import { Navbar } from "@/components/navbar";
|
||||
import { Footer } from "@/components/footer";
|
||||
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 { useTranslation } from "@/lib/i18n";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
|
|
@ -56,14 +57,6 @@ export default function JobApplicationPage({
|
|||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const steps = [
|
||||
{ id: 1, title: t("application.steps.personal"), icon: User },
|
||||
{ id: 2, title: t("application.steps.documents"), icon: FileText },
|
||||
{ id: 3, title: t("application.steps.experience"), icon: Briefcase },
|
||||
{ id: 4, title: t("application.steps.additional"), icon: MessageSquare },
|
||||
];
|
||||
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const notify = useNotify();
|
||||
|
|
@ -96,13 +89,35 @@ export default function JobApplicationPage({
|
|||
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") {
|
||||
value = formatPhone(value);
|
||||
value = formatPhone(value as string);
|
||||
}
|
||||
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 file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
|
@ -140,7 +155,7 @@ export default function JobApplicationPage({
|
|||
notify.error(t("application.toasts.invalidEmail.title"), t("application.toasts.invalidEmail.desc"));
|
||||
return false;
|
||||
}
|
||||
if (formData.phone.length < 14) { // (11) 91234-5678 is 15 chars, (11) 1234-5678 is 14 chars
|
||||
if (formData.phone.length < 14) {
|
||||
notify.error(t("application.toasts.invalidPhone.title"), t("application.toasts.invalidPhone.desc"));
|
||||
return false;
|
||||
}
|
||||
|
|
@ -173,6 +188,24 @@ export default function JobApplicationPage({
|
|||
return false;
|
||||
}
|
||||
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:
|
||||
return true;
|
||||
}
|
||||
|
|
@ -229,23 +262,23 @@ export default function JobApplicationPage({
|
|||
const handleSubmit = async () => {
|
||||
setIsSubmitting(true);
|
||||
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({
|
||||
jobId: Number(id), // ID might need number conversion depending on API
|
||||
jobId: id, // UUID string — must NOT be Number(id)
|
||||
name: formData.fullName,
|
||||
email: formData.email,
|
||||
phone: formData.phone,
|
||||
linkedin: formData.linkedin,
|
||||
coverLetter: formData.coverLetter || formData.whyUs, // Fallback
|
||||
resumeUrl: resumeUrl,
|
||||
portfolioUrl: formData.portfolioUrl,
|
||||
message: formData.coverLetter,
|
||||
resumeUrl: formData.resumeUrl,
|
||||
answers: {
|
||||
linkedin: formData.linkedin || undefined,
|
||||
portfolioUrl: formData.portfolioUrl || undefined,
|
||||
salaryExpectation: formData.salaryExpectation,
|
||||
hasExperience: formData.hasExperience,
|
||||
whyUs: formData.whyUs,
|
||||
availability: formData.availability,
|
||||
// Custom job-specific answers
|
||||
...customAnswers,
|
||||
},
|
||||
});
|
||||
|
||||
notify.success(
|
||||
|
|
@ -255,12 +288,10 @@ export default function JobApplicationPage({
|
|||
|
||||
setIsSubmitted(true);
|
||||
window.scrollTo(0, 0);
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error("Submit error:", error);
|
||||
notify.error(
|
||||
t("application.toasts.submitError.title"),
|
||||
error.message || t("application.toasts.submitError.default")
|
||||
);
|
||||
const message = error instanceof Error ? error.message : t("application.toasts.submitError.default");
|
||||
notify.error(t("application.toasts.submitError.title"), message);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
|
|
@ -765,6 +796,101 @@ export default function JobApplicationPage({
|
|||
</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>
|
||||
|
||||
<CardFooter className="flex flex-col sm:flex-row gap-3 sm:justify-between border-t pt-4 sm:pt-6">
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@ export default function Home() {
|
|||
const { t } = useTranslation()
|
||||
const [jobs, setJobs] = useState<Job[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({
|
||||
align: "start",
|
||||
|
|
@ -27,33 +30,47 @@ export default function Home() {
|
|||
dragFree: true
|
||||
})
|
||||
|
||||
const [moreJobsEmblaRef, moreJobsEmblaApi] = useEmblaCarousel({
|
||||
align: "start",
|
||||
loop: false,
|
||||
skipSnaps: false,
|
||||
dragFree: true
|
||||
})
|
||||
|
||||
const [prevBtnDisabled, setPrevBtnDisabled] = useState(true)
|
||||
const [nextBtnDisabled, setNextBtnDisabled] = useState(true)
|
||||
const [moreJobsPrevBtnDisabled, setMoreJobsPrevBtnDisabled] = useState(true)
|
||||
const [moreJobsNextBtnDisabled, setMoreJobsNextBtnDisabled] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchJobs() {
|
||||
const fetchJobs = useCallback(async (pageNum: number, isLoadMore = false) => {
|
||||
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) {
|
||||
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) {
|
||||
console.error("Failed to fetch jobs:", error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoadingMore(false)
|
||||
}
|
||||
}, [t])
|
||||
|
||||
useEffect(() => {
|
||||
fetchJobs(1)
|
||||
}, [fetchJobs])
|
||||
|
||||
const handleLoadMore = () => {
|
||||
const nextPage = page + 1
|
||||
setPage(nextPage)
|
||||
fetchJobs(nextPage, true)
|
||||
}
|
||||
fetchJobs()
|
||||
}, [])
|
||||
|
||||
const scrollPrev = useCallback(() => {
|
||||
if (emblaApi) emblaApi.scrollPrev()
|
||||
|
|
@ -63,24 +80,11 @@ export default function Home() {
|
|||
if (emblaApi) emblaApi.scrollNext()
|
||||
}, [emblaApi])
|
||||
|
||||
const scrollMoreJobsPrev = useCallback(() => {
|
||||
if (moreJobsEmblaApi) moreJobsEmblaApi.scrollPrev()
|
||||
}, [moreJobsEmblaApi])
|
||||
|
||||
const scrollMoreJobsNext = useCallback(() => {
|
||||
if (moreJobsEmblaApi) moreJobsEmblaApi.scrollNext()
|
||||
}, [moreJobsEmblaApi])
|
||||
|
||||
const onSelect = useCallback((emblaApi: any) => {
|
||||
setPrevBtnDisabled(!emblaApi.canScrollPrev())
|
||||
setNextBtnDisabled(!emblaApi.canScrollNext())
|
||||
}, [])
|
||||
|
||||
const onMoreJobsSelect = useCallback((emblaApi: any) => {
|
||||
setMoreJobsPrevBtnDisabled(!emblaApi.canScrollPrev())
|
||||
setMoreJobsNextBtnDisabled(!emblaApi.canScrollNext())
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!emblaApi) return
|
||||
onSelect(emblaApi)
|
||||
|
|
@ -88,13 +92,6 @@ export default function Home() {
|
|||
emblaApi.on("select", onSelect)
|
||||
}, [emblaApi, onSelect])
|
||||
|
||||
useEffect(() => {
|
||||
if (!moreJobsEmblaApi) return
|
||||
onMoreJobsSelect(moreJobsEmblaApi)
|
||||
moreJobsEmblaApi.on("reInit", onMoreJobsSelect)
|
||||
moreJobsEmblaApi.on("select", onMoreJobsSelect)
|
||||
}, [moreJobsEmblaApi, onMoreJobsSelect])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col font-sans">
|
||||
<Navbar />
|
||||
|
|
@ -195,49 +192,41 @@ export default function Home() {
|
|||
<h2 className="text-2xl md:text-3xl font-bold text-gray-900">
|
||||
{t("home.moreJobs.title")}
|
||||
</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">
|
||||
<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")}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden" ref={moreJobsEmblaRef}>
|
||||
<div className="flex gap-6">
|
||||
{loading ? (
|
||||
<div className="flex-[0_0_100%] text-center py-8">Carregando vagas...</div>
|
||||
) : jobs.slice(0, 8).map((job, index) => (
|
||||
<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">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{loading && page === 1 ? (
|
||||
<div className="col-span-full text-center py-12">Carregando vagas...</div>
|
||||
) : jobs.map((job, index) => (
|
||||
<div key={`more-${job.id}-${index}`} className="pb-1">
|
||||
<JobCard job={job} />
|
||||
</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>
|
||||
</section>
|
||||
</div>
|
||||
</section >
|
||||
|
||||
{/* Bottom CTA Section */}
|
||||
<section className="py-16 bg-white">
|
||||
{/* Bottom CTA Section */ }
|
||||
< section className = "py-16 bg-white" >
|
||||
<div className="container mx-auto px-4 sm:px-6">
|
||||
<div className="bg-[#1F2F40] rounded-[2rem] p-8 md:p-16 relative overflow-hidden text-center md:text-left flex flex-col md:flex-row items-center justify-between min-h-[400px]">
|
||||
<div className="relative z-10 max-w-xl">
|
||||
|
|
@ -267,10 +256,10 @@ export default function Home() {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</section >
|
||||
</main >
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
</div >
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,7 @@
|
|||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -24,61 +22,135 @@ import {
|
|||
Eye,
|
||||
EyeOff,
|
||||
Phone,
|
||||
MapPin,
|
||||
Globe,
|
||||
FileText,
|
||||
MapPin,
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
Calendar,
|
||||
Briefcase
|
||||
Briefcase,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
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 router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [selectedPlan, setSelectedPlan] = useState<string | null>(null);
|
||||
const COUNTRIES = [
|
||||
"Argentina", "Australia", "Austria", "Belgium", "Brazil", "Canada", "Chile",
|
||||
"China", "Colombia", "Czech Republic", "Denmark", "Egypt", "Finland", "France",
|
||||
"Germany", "Greece", "Hong Kong", "Hungary", "India", "Indonesia", "Ireland",
|
||||
"Israel", "Italy", "Japan", "Malaysia", "Mexico", "Netherlands", "New Zealand",
|
||||
"Nigeria", "Norway", "Pakistan", "Peru", "Philippines", "Poland", "Portugal",
|
||||
"Romania", "Saudi Arabia", "Singapore", "South Africa", "South Korea", "Spain",
|
||||
"Sweden", "Switzerland", "Taiwan", "Thailand", "Turkey", "Ukraine",
|
||||
"United Arab Emirates", "United Kingdom", "United States", "Vietnam",
|
||||
];
|
||||
|
||||
// Schema Validation
|
||||
const companySchema = z.object({
|
||||
// User fields
|
||||
companyName: z.string().min(2, "Nome da empresa é obrigatório"),
|
||||
email: z.string().email("E-mail inválido"),
|
||||
password: z.string().min(6, "Senha deve ter pelo menos 6 caracteres"),
|
||||
const YEARS_IN_MARKET = [
|
||||
{ value: "<1", label: "Less than 1 year" },
|
||||
{ value: "1-3", label: "1 – 3 years" },
|
||||
{ value: "3-5", label: "3 – 5 years" },
|
||||
{ value: "5-10", label: "5 – 10 years" },
|
||||
{ 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(),
|
||||
phone: z.string().min(10, "Telefone inválido"),
|
||||
birthDate: z.string().min(1, "Data de nascimento é obrigatória"), // New field
|
||||
|
||||
// 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(),
|
||||
phone: z.string().optional(),
|
||||
country: z.string().min(1, "Country is required"),
|
||||
city: z.string().optional(),
|
||||
state: z.string().optional(),
|
||||
|
||||
acceptTerms: z.boolean().refine(val => val === true, "Você deve aceitar os termos"),
|
||||
}).refine(data => data.password === data.confirmPassword, {
|
||||
message: "Senhas não coincidem",
|
||||
website: z.string().url("Invalid URL").optional().or(z.literal("")),
|
||||
yearsInMarket: z.string().min(1, "Years in market is required"),
|
||||
description: z.string().min(20, "Description must be at least 20 characters"),
|
||||
taxId: z.string().optional(),
|
||||
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"],
|
||||
});
|
||||
|
||||
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 {
|
||||
register,
|
||||
|
|
@ -86,224 +158,193 @@ export default function RegisterPage() {
|
|||
formState: { errors },
|
||||
setValue,
|
||||
trigger,
|
||||
watch
|
||||
watch,
|
||||
} = useForm<CompanyFormData>({
|
||||
resolver: zodResolver(companySchema),
|
||||
defaultValues: {
|
||||
yearsInMarket: "",
|
||||
cnpj: "",
|
||||
}
|
||||
defaultValues: { yearsInMarket: "", country: "" },
|
||||
});
|
||||
|
||||
const acceptTerms = watch("acceptTerms");
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
const watchedCountry = watch("country");
|
||||
|
||||
const plans = [
|
||||
{
|
||||
id: "free",
|
||||
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) => {
|
||||
const nextStep = async () => {
|
||||
setErrorMsg(null);
|
||||
if (step === 1) {
|
||||
if (!selectedPlan) {
|
||||
setErrorMsg("Selecione um plano para continuar.");
|
||||
setErrorMsg("Please select a plan to continue.");
|
||||
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);
|
||||
setErrorMsg(null);
|
||||
|
||||
try {
|
||||
const { registerCompany } = await import("@/lib/auth");
|
||||
|
||||
// Mapping form data to API expectation
|
||||
// We pass the "company_name" as the primary name
|
||||
const res: any = await registerCompany({
|
||||
const res = await registerCompany({
|
||||
companyName: data.companyName,
|
||||
email: data.email,
|
||||
phone: data.phone,
|
||||
password: data.password,
|
||||
// Extra fields
|
||||
document: data.cnpj,
|
||||
document: data.taxId,
|
||||
website: data.website,
|
||||
yearsInMarket: data.yearsInMarket,
|
||||
description: data.companySummary,
|
||||
zipCode: data.zipCode,
|
||||
address: data.address,
|
||||
description: data.description,
|
||||
city: data.city,
|
||||
state: data.state,
|
||||
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.
|
||||
country: data.country,
|
||||
});
|
||||
|
||||
// Auto-login if token is present
|
||||
if (res && res.token) {
|
||||
localStorage.setItem("token", res.token);
|
||||
// Auto-login if token returned
|
||||
if (res?.token) {
|
||||
localStorage.setItem("auth_token", res.token);
|
||||
localStorage.setItem("token", res.token);
|
||||
localStorage.setItem("user", JSON.stringify({
|
||||
name: data.companyName,
|
||||
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') {
|
||||
router.push("/dashboard?payment=pending");
|
||||
} else {
|
||||
// Free plan → go straight to dashboard
|
||||
if (!selectedPlan.priceId) {
|
||||
router.push("/dashboard");
|
||||
}
|
||||
} else {
|
||||
if (selectedPlan !== 'free') {
|
||||
router.push("/dashboard?payment=pending");
|
||||
} else {
|
||||
router.push("/login?message=Cadastro realizado! Faça login.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("Registration error:", error);
|
||||
setErrorMsg(error.message || "Erro ao registrar. Tente novamente.");
|
||||
// Paid plan → create Stripe checkout session
|
||||
try {
|
||||
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 {
|
||||
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 (
|
||||
<div className="min-h-screen bg-background flex flex-col">
|
||||
{/* Header Simple */}
|
||||
<header className="py-6 px-8 border-b">
|
||||
<div className="max-w-7xl mx-auto flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Image src="/logohorse.png" alt="GoHorse Jobs" width={40} height={40} className="rounded" />
|
||||
<span className="font-bold text-xl tracking-tight">GoHorse Jobs</span>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Já tem conta? <Link href="/login" className="text-primary font-medium hover:underline">Entrar</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<Navbar />
|
||||
|
||||
<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="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" />
|
||||
|
||||
{[1, 2, 3, 4].map((step) => (
|
||||
<div key={step} className={`w-10 h-10 rounded-full flex items-center justify-center font-bold text-sm transition-colors ${
|
||||
currentStep >= step ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"
|
||||
}`}>
|
||||
{step}
|
||||
<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) => (
|
||||
<div key={label} className="flex flex-col items-center gap-1">
|
||||
<div
|
||||
className={`w-9 h-9 rounded-full flex items-center justify-center font-bold text-sm transition-colors z-10 ${
|
||||
step > i + 1
|
||||
? "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 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>
|
||||
|
||||
{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>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
{/* STEP 1: PLANS */}
|
||||
{currentStep === 1 && (
|
||||
{/* STEP 1: PLAN SELECTION */}
|
||||
{step === 1 && (
|
||||
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className="space-y-8">
|
||||
<div className="text-center space-y-2">
|
||||
<h1 className="text-3xl font-bold">Escolha o plano ideal para sua empresa</h1>
|
||||
<p className="text-muted-foreground">Comece a recrutar os melhores talentos hoje mesmo.</p>
|
||||
<h1 className="text-3xl font-bold">Choose your plan</h1>
|
||||
<p className="text-muted-foreground">Start hiring the best talent worldwide today.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-6">
|
||||
{plans.map((plan) => (
|
||||
<Card key={plan.id}
|
||||
className={`relative cursor-pointer transition-all hover:shadow-lg ${selectedPlan === plan.id ? 'border-primary ring-2 ring-primary/20' : ''}`}
|
||||
onClick={() => handlePlanSelect(plan.id)}
|
||||
{PLANS.map((plan) => (
|
||||
<Card
|
||||
key={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 && (
|
||||
<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">
|
||||
Recomendado
|
||||
<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">
|
||||
Recommended
|
||||
</div>
|
||||
)}
|
||||
<CardHeader>
|
||||
<CardTitle>{plan.name}</CardTitle>
|
||||
<div className="mt-2">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-lg">{plan.name}</CardTitle>
|
||||
<div className="mt-1">
|
||||
<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>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-3 text-sm">
|
||||
{plan.features.map((feat, i) => (
|
||||
<li key={i} className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
||||
{feat}
|
||||
<ul className="space-y-2 text-sm">
|
||||
{plan.features.map((f) => (
|
||||
<li key={f} className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-500 shrink-0" />
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<div className={`w-full h-4 rounded-full border-2 border-muted flex items-center justify-center ${selectedPlan === plan.id ? 'border-primary' : ''}`}>
|
||||
{selectedPlan === plan.id && <div className="w-2.5 h-2.5 bg-primary rounded-full" />}
|
||||
<div
|
||||
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>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
|
@ -312,204 +353,268 @@ export default function RegisterPage() {
|
|||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* STEP 2: FORM */}
|
||||
{currentStep === 2 && (
|
||||
<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-6">
|
||||
<h2 className="text-2xl font-bold">Dados da Empresa e Responsável</h2>
|
||||
<p className="text-muted-foreground">Preencha as informações para criar sua conta.</p>
|
||||
{/* STEP 2: COMPANY DETAILS */}
|
||||
{step === 2 && (
|
||||
<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-1 mb-4">
|
||||
<h2 className="text-2xl font-bold">Company details</h2>
|
||||
<p className="text-muted-foreground">Fill in your company and account information.</p>
|
||||
</div>
|
||||
|
||||
{/* Company name + Years in market */}
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Nome da Empresa *</Label>
|
||||
<Label>Company name *</Label>
|
||||
<div className="relative">
|
||||
<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>
|
||||
{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 className="space-y-2">
|
||||
<Label>Tempo de Mercado *</Label>
|
||||
<Select onValueChange={(val) => setValue("yearsInMarket", val)}>
|
||||
<Label>Years in business *</Label>
|
||||
<Select onValueChange={(v) => { setValue("yearsInMarket", v); trigger("yearsInMarket"); }}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione" />
|
||||
<SelectValue placeholder="Select…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="<1">Menos de 1 ano</SelectItem>
|
||||
<SelectItem value="1-3">1 a 3 anos</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>
|
||||
{YEARS_IN_MARKET.map((y) => (
|
||||
<SelectItem key={y.value} value={y.value}>{y.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</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>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-2">
|
||||
<Label>Resumo da Empresa *</Label>
|
||||
<Textarea {...register("companySummary")} placeholder="Breve descrição sobre o que a empresa faz, missão e valores..." className="min-h-[100px]" />
|
||||
{errors.companySummary && <span className="text-xs text-destructive">{errors.companySummary.message}</span>}
|
||||
<Label>Company description *</Label>
|
||||
<Textarea
|
||||
{...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>
|
||||
|
||||
{/* Email + Phone */}
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>E-mail *</Label>
|
||||
<Label>Email *</Label>
|
||||
<div className="relative">
|
||||
<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>
|
||||
{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 className="space-y-2">
|
||||
<Label>Telefone *</Label>
|
||||
<Label>Phone <span className="text-muted-foreground text-xs">(optional)</span></Label>
|
||||
<div className="relative">
|
||||
<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>
|
||||
{errors.phone && <span className="text-xs text-destructive">{errors.phone.message}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password + Confirm */}
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Senha *</Label>
|
||||
<Label>Password *</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input type={showPassword ? "text" : "password"} {...register("password")} className="pl-10" />
|
||||
<button type="button" onClick={() => setShowPassword(!showPassword)} className="absolute right-3 top-3 text-muted-foreground">
|
||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
<Input
|
||||
type={showPw ? "text" : "password"}
|
||||
{...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>
|
||||
</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 className="space-y-2">
|
||||
<Label>Confirmar Senha *</Label>
|
||||
<Label>Confirm password *</Label>
|
||||
<div className="relative">
|
||||
<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>
|
||||
{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>
|
||||
|
||||
{/* Country + City */}
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Data de Nascimento (Resp.) *</Label>
|
||||
<div className="relative">
|
||||
<Calendar className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input type="date" {...register("birthDate")} className="pl-10" />
|
||||
</div>
|
||||
{errors.birthDate && <span className="text-xs text-destructive">{errors.birthDate.message}</span>}
|
||||
<Label>Country *</Label>
|
||||
<Select
|
||||
value={watchedCountry}
|
||||
onValueChange={(v) => { setValue("country", v); trigger("country"); }}
|
||||
>
|
||||
<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 className="space-y-2">
|
||||
<Label>CEP *</Label>
|
||||
<Label>City <span className="text-muted-foreground text-xs">(optional)</span></Label>
|
||||
<div className="relative">
|
||||
<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>
|
||||
{errors.zipCode && <span className="text-xs text-destructive">{errors.zipCode.message}</span>}
|
||||
</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">
|
||||
<Label>CNPJ (Opcional)</Label>
|
||||
<Input {...register("cnpj")} placeholder="00.000.000/0000-00" />
|
||||
<Label>Tax ID / Business number <span className="text-muted-foreground text-xs">(optional)</span></Label>
|
||||
<Input {...register("taxId")} placeholder="EIN, CNPJ, ABN, CRN, etc." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
<div className="text-center space-y-2 mb-4">
|
||||
<h2 className="text-2xl font-bold">Termos e Condições</h2>
|
||||
<p className="text-muted-foreground">Leia atentamente para prosseguir.</p>
|
||||
<div className="text-center space-y-1 mb-4">
|
||||
<h2 className="text-2xl font-bold">Terms & Conditions</h2>
|
||||
<p className="text-muted-foreground">Please read carefully before proceeding.</p>
|
||||
</div>
|
||||
|
||||
<div className="h-64 overflow-y-auto border rounded-md p-4 bg-muted/20 text-sm leading-relaxed" onScroll={(e) => {
|
||||
const target = e.currentTarget;
|
||||
const reachedBottom = target.scrollHeight - target.scrollTop <= target.clientHeight + 20;
|
||||
if(reachedBottom) {
|
||||
// Can enable checkbox logic here if strictly enforcing read
|
||||
}
|
||||
}}>
|
||||
<h4 className="font-bold mb-2">1. Aceitação</h4>
|
||||
<p className="mb-2">Ao criar uma conta na GoHorse Jobs, você concorda com os termos...</p>
|
||||
<h4 className="font-bold mb-2">2. Uso da Plataforma</h4>
|
||||
<p className="mb-2">A plataforma destina-se a conectar empresas e candidatos...</p>
|
||||
<h4 className="font-bold mb-2">3. Pagamentos</h4>
|
||||
<p className="mb-2">Os planos pagos são renovados automaticamente...</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 className="h-64 overflow-y-auto border rounded-md p-4 bg-muted/20 text-sm leading-relaxed space-y-3">
|
||||
<h4 className="font-bold">1. Acceptance</h4>
|
||||
<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>
|
||||
<h4 className="font-bold">2. Platform Use</h4>
|
||||
<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">4. Data & Privacy</h4>
|
||||
<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">5. Prohibited Conduct</h4>
|
||||
<p>You must not post fraudulent, misleading, or discriminatory job listings. Violations may result in immediate account suspension.</p>
|
||||
<h4 className="font-bold">6. Liability</h4>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-2 pt-4">
|
||||
<Checkbox id="terms" onCheckedChange={(checked) => setValue("acceptTerms", checked as boolean)} />
|
||||
<label htmlFor="terms" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer">
|
||||
Li e aceito os Termos de Uso e Política de Privacidade da GoHorse Jobs.
|
||||
<div className="flex items-start space-x-2 pt-2">
|
||||
<Checkbox id="terms" onCheckedChange={(v) => setValue("acceptTerms", v as boolean)} />
|
||||
<label htmlFor="terms" className="text-sm leading-relaxed cursor-pointer">
|
||||
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>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* STEP 4: PAYMENT (Simple Confirmation for now) */}
|
||||
{currentStep === 4 && (
|
||||
{/* STEP 4: CONFIRM & SUBMIT */}
|
||||
{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">
|
||||
<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" />
|
||||
) : selectedPlan?.id === "enterprise" ? (
|
||||
<Briefcase className="w-10 h-10" />
|
||||
) : (
|
||||
<Globe className="w-10 h-10" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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">
|
||||
Você escolheu o plano <strong>{plans.find(p => p.id === selectedPlan)?.name}</strong>.
|
||||
You selected the <strong>{selectedPlan?.name}</strong> plan.
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
Ao clicar em "Finalizar", sua conta será criada e você será redirecionado para {selectedPlan === 'free' ? 'o dashboard' : 'o pagamento'}.
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{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>
|
||||
</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">
|
||||
<span className="text-muted-foreground">Plano</span>
|
||||
<span className="font-medium">{plans.find(p => p.id === selectedPlan)?.name}</span>
|
||||
<span className="text-muted-foreground">Plan</span>
|
||||
<span className="font-medium">{selectedPlan?.name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Total</span>
|
||||
<span className="font-bold">{plans.find(p => p.id === selectedPlan)?.price}</span>
|
||||
<span className="text-muted-foreground">Billing</span>
|
||||
<span className="font-bold">
|
||||
{selectedPlan?.price}{selectedPlan?.period ? ` ${selectedPlan.period}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Navigation Actions */}
|
||||
<div className="mt-8 flex justify-between max-w-2xl mx-auto px-4">
|
||||
{currentStep > 1 && (
|
||||
<Button type="button" variant="outline" onClick={prevStep} disabled={loading}>
|
||||
Voltar
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{currentStep < 4 ? (
|
||||
<Button type="button" onClick={nextStep} className="ml-auto min-w-[120px]">
|
||||
Próximo
|
||||
{/* Navigation */}
|
||||
<div className="mt-8 flex justify-between max-w-2xl mx-auto px-0">
|
||||
{step > 1 ? (
|
||||
<Button type="button" variant="outline" onClick={() => setStep(step - 1)} disabled={loading}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" /> Back
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="submit" disabled={loading} className="ml-auto min-w-[120px]">
|
||||
{loading ? "Processando..." : (selectedPlan === 'free' ? "Finalizar Cadastro" : "Ir para Pagamento")}
|
||||
<div />
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,12 +28,13 @@ import { motion } from "framer-motion";
|
|||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { Navbar } from "@/components/navbar";
|
||||
import { Footer } from "@/components/footer";
|
||||
|
||||
type RegisterFormData = {
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
phone?: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
acceptTerms: boolean;
|
||||
|
|
@ -41,23 +42,22 @@ type RegisterFormData = {
|
|||
|
||||
export default function RegisterUserPage() {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
|
||||
const registerSchema = useMemo(() => z.object({
|
||||
name: z.string().min(3, "Nome deve ter no mínimo 3 caracteres"),
|
||||
email: z.string().email("E-mail inválido"),
|
||||
phone: z.string().min(10, "Telefone inválido"),
|
||||
password: z.string().min(6, "Senha deve ter no mínimo 6 caracteres"),
|
||||
confirmPassword: z.string().min(6, "Confirmação de senha obrigatória"),
|
||||
name: z.string().min(3, "Name must be at least 3 characters"),
|
||||
email: z.string().email("Invalid email address"),
|
||||
phone: z.string().min(7, "Phone number too short").optional().or(z.literal("")),
|
||||
password: z.string().min(6, "Password must be at least 6 characters"),
|
||||
confirmPassword: z.string().min(6, "Please confirm your password"),
|
||||
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, {
|
||||
message: "As senhas não coincidem",
|
||||
message: "Passwords do not match",
|
||||
path: ["confirmPassword"],
|
||||
}), []);
|
||||
|
||||
|
|
@ -87,27 +87,24 @@ export default function RegisterUserPage() {
|
|||
await registerCandidate({
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
phone: data.phone,
|
||||
phone: data.phone || "",
|
||||
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) {
|
||||
console.error('🔥 [REGISTER FRONT] Erro no registro:', err);
|
||||
setError(err.message || "Erro ao criar conta. Tente novamente.");
|
||||
setError(err.message || "Failed to create account. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = (data: RegisterFormData) => {
|
||||
handleRegister(data);
|
||||
};
|
||||
|
||||
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 */}
|
||||
<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
|
||||
|
|
@ -126,11 +123,11 @@ export default function RegisterUserPage() {
|
|||
</div>
|
||||
|
||||
<h1 className="text-4xl font-bold mb-4">
|
||||
Comece sua jornada profissional
|
||||
Start your professional journey
|
||||
</h1>
|
||||
|
||||
<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>
|
||||
|
||||
<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">
|
||||
<UserIcon className="w-4 h-4" />
|
||||
</div>
|
||||
<span>Crie seu perfil profissional completo</span>
|
||||
<span>Build your complete professional profile</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-white/20 rounded-full flex items-center justify-center">
|
||||
<Building2 className="w-4 h-4" />
|
||||
</div>
|
||||
<span>Candidate-se às melhores vagas do mercado</span>
|
||||
<span>Apply to top jobs from companies worldwide</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-white/20 rounded-full flex items-center justify-center">
|
||||
<Briefcase className="w-4 h-4" />
|
||||
</div>
|
||||
<span>Acompanhe suas candidaturas em tempo real</span>
|
||||
<span>Track your applications in real time</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
|
@ -164,15 +161,15 @@ export default function RegisterUserPage() {
|
|||
className="w-full max-w-md space-y-6"
|
||||
>
|
||||
<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">
|
||||
Preencha os dados abaixo para se cadastrar
|
||||
Fill in your details to get started
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card className="border-0 shadow-lg">
|
||||
<CardContent className="pt-6">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<form onSubmit={handleSubmit(handleRegister)} className="space-y-4">
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
|
|
@ -186,70 +183,66 @@ export default function RegisterUserPage() {
|
|||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Nome Completo</Label>
|
||||
<Label htmlFor="name">Full Name</Label>
|
||||
<div className="relative">
|
||||
<UserIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
placeholder="Seu nome completo"
|
||||
placeholder="Your full name"
|
||||
className="pl-10"
|
||||
{...register("name")}
|
||||
/>
|
||||
</div>
|
||||
{errors.name && (
|
||||
<p className="text-sm text-destructive">
|
||||
{errors.name.message}
|
||||
</p>
|
||||
<p className="text-sm text-destructive">{errors.name.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">E-mail</Label>
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="seu@email.com"
|
||||
placeholder="you@example.com"
|
||||
className="pl-10"
|
||||
{...register("email")}
|
||||
/>
|
||||
</div>
|
||||
{errors.email && (
|
||||
<p className="text-sm text-destructive">
|
||||
{errors.email.message}
|
||||
</p>
|
||||
<p className="text-sm text-destructive">{errors.email.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="phone"
|
||||
type="tel"
|
||||
placeholder="(00) 00000-0000"
|
||||
placeholder="+1 555 000 0000"
|
||||
className="pl-10"
|
||||
{...register("phone")}
|
||||
/>
|
||||
</div>
|
||||
{errors.phone && (
|
||||
<p className="text-sm text-destructive">
|
||||
{errors.phone.message}
|
||||
</p>
|
||||
<p className="text-sm text-destructive">{errors.phone.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Senha</Label>
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="Mínimo 6 caracteres"
|
||||
placeholder="At least 6 characters"
|
||||
className="pl-10 pr-10"
|
||||
{...register("password")}
|
||||
/>
|
||||
|
|
@ -268,20 +261,18 @@ export default function RegisterUserPage() {
|
|||
</Button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p className="text-sm text-destructive">
|
||||
{errors.password.message}
|
||||
</p>
|
||||
<p className="text-sm text-destructive">{errors.password.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">Confirmar Senha</Label>
|
||||
<Label htmlFor="confirmPassword">Confirm Password</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
placeholder="Digite a senha novamente"
|
||||
placeholder="Re-enter your password"
|
||||
className="pl-10 pr-10"
|
||||
{...register("confirmPassword")}
|
||||
/>
|
||||
|
|
@ -300,9 +291,7 @@ export default function RegisterUserPage() {
|
|||
</Button>
|
||||
</div>
|
||||
{errors.confirmPassword && (
|
||||
<p className="text-sm text-destructive">
|
||||
{errors.confirmPassword.message}
|
||||
</p>
|
||||
<p className="text-sm text-destructive">{errors.confirmPassword.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -312,20 +301,18 @@ export default function RegisterUserPage() {
|
|||
htmlFor="acceptTerms"
|
||||
className="text-sm font-normal cursor-pointer leading-relaxed"
|
||||
>
|
||||
Aceito os{" "}
|
||||
I agree to the{" "}
|
||||
<Link href="/terms" className="text-primary hover:underline">
|
||||
termos de uso
|
||||
Terms of Use
|
||||
</Link>
|
||||
{" "}e a{" "}
|
||||
{" "}and{" "}
|
||||
<Link href="/privacy" className="text-primary hover:underline">
|
||||
política de privacidade
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</Label>
|
||||
</div>
|
||||
{errors.acceptTerms && (
|
||||
<p className="text-sm text-destructive">
|
||||
{errors.acceptTerms.message}
|
||||
</p>
|
||||
<p className="text-sm text-destructive">{errors.acceptTerms.message}</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
|
|
@ -333,15 +320,15 @@ export default function RegisterUserPage() {
|
|||
className="w-full h-11 cursor-pointer bg-[#F0932B] hover:bg-[#d97d1a]"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "Criando conta..." : "Criar Conta"}
|
||||
{loading ? "Creating account..." : "Create Account"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Já tem uma conta?{" "}
|
||||
Already have an account?{" "}
|
||||
<Link href="/login" className="text-primary hover:underline font-semibold">
|
||||
Fazer login
|
||||
Log in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -353,11 +340,14 @@ export default function RegisterUserPage() {
|
|||
href="/"
|
||||
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>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ export interface ApiJob {
|
|||
currency?: string;
|
||||
description: string;
|
||||
requirements?: unknown;
|
||||
questions?: { items?: JobQuestion[] }; // Custom application questions
|
||||
status: string;
|
||||
createdAt: string;
|
||||
datePosted?: string;
|
||||
|
|
@ -101,6 +102,14 @@ export interface ApiJob {
|
|||
applicationCount?: number;
|
||||
}
|
||||
|
||||
export interface JobQuestion {
|
||||
id: string;
|
||||
label: string;
|
||||
type: "text" | "textarea" | "radio" | "select" | "checkbox";
|
||||
required: boolean;
|
||||
options?: string[];
|
||||
}
|
||||
|
||||
export interface ApiCompany {
|
||||
id: 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
|
||||
export const storageApi = {
|
||||
getUploadUrl: (filename: string, contentType: string) =>
|
||||
|
|
|
|||
|
|
@ -230,40 +230,32 @@ export function getToken(): string | null {
|
|||
export interface RegisterCompanyData {
|
||||
companyName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
phone?: string;
|
||||
password?: string;
|
||||
confirmPassword?: string;
|
||||
document?: string;
|
||||
document?: string; // Generic Tax ID / Business registration number
|
||||
website?: string;
|
||||
yearsInMarket?: string;
|
||||
description?: string;
|
||||
zipCode?: string;
|
||||
address?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
birthDate?: string;
|
||||
cnpj?: string;
|
||||
country?: string; // Country name or code
|
||||
}
|
||||
|
||||
export async function registerCompany(data: RegisterCompanyData): Promise<void> {
|
||||
export async function registerCompany(data: RegisterCompanyData): Promise<{ token?: string; id?: string; name?: string }> {
|
||||
const payload = {
|
||||
name: data.companyName,
|
||||
slug: data.companyName.toLowerCase().replace(/\s+/g, '-'),
|
||||
document: data.document || data.cnpj,
|
||||
companyName: data.companyName,
|
||||
slug: data.companyName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''),
|
||||
document: data.document,
|
||||
phone: data.phone,
|
||||
email: data.email,
|
||||
admin_email: data.email,
|
||||
password: data.password,
|
||||
admin_password: data.password,
|
||||
website: data.website,
|
||||
address: data.address,
|
||||
zip_code: data.zipCode,
|
||||
city: data.city,
|
||||
state: data.state,
|
||||
country: data.country,
|
||||
description: data.description,
|
||||
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`, {
|
||||
|
|
|
|||
Loading…
Reference in a new issue