feat: fix seeder password hashing, add custom questions, navbar/footer on register, payment handler

- fix(seeder): add PASSWORD_PEPPER to all bcrypt hashes (admin + candidates/recruiters)
- fix(seeder): add created_by field to jobs INSERT (was causing NOT NULL violation)
- feat(backend): add custom job questions support in applications
- feat(backend): add payment handler and Stripe routes
- feat(frontend): add navbar and footer to /register and /register/user pages
- feat(frontend): add custom question answers to job apply page
- feat(frontend): update home page hero section and navbar buttons
- feat(frontend): update auth/api lib with new endpoints
- chore(db): add migration 045 for application answers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tiago Yamamoto 2026-02-23 20:26:49 -06:00
parent 9f5725bf01
commit 8ee0d59a61
14 changed files with 1066 additions and 672 deletions

View file

@ -5,7 +5,7 @@ import "time"
type CreateCompanyRequest struct { type CreateCompanyRequest struct {
Name string `json:"name"` Name string `json:"name"`
CompanyName string `json:"companyName"` // Alternative field name CompanyName string `json:"companyName"` // Alternative field name
Document string `json:"document"` Document string `json:"document"` // Tax ID / Business registration (any country)
Contact string `json:"contact"` Contact string `json:"contact"`
AdminEmail string `json:"admin_email"` AdminEmail string `json:"admin_email"`
Email string `json:"email"` // Alternative field name Email string `json:"email"` // Alternative field name
@ -15,6 +15,7 @@ type CreateCompanyRequest struct {
Address *string `json:"address,omitempty"` Address *string `json:"address,omitempty"`
City *string `json:"city,omitempty"` City *string `json:"city,omitempty"`
State *string `json:"state,omitempty"` State *string `json:"state,omitempty"`
Country *string `json:"country,omitempty"` // ISO country name or code
ZipCode *string `json:"zip_code,omitempty"` ZipCode *string `json:"zip_code,omitempty"`
EmployeeCount *string `json:"employeeCount,omitempty"` EmployeeCount *string `json:"employeeCount,omitempty"`
FoundedYear *int `json:"foundedYear,omitempty"` FoundedYear *int `json:"foundedYear,omitempty"`

View file

@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"strings"
"time" "time"
"github.com/rede5/gohorsejobs/backend/internal/core/domain/entity" "github.com/rede5/gohorsejobs/backend/internal/core/domain/entity"
@ -82,8 +83,22 @@ func (uc *CreateCompanyUseCase) Execute(ctx context.Context, input dto.CreateCom
if input.Description != nil { if input.Description != nil {
company.Description = input.Description company.Description = input.Description
} }
if input.Address != nil { // Build address: combine provided address + city + country
company.Address = input.Address if input.Address != nil || input.City != nil || input.Country != nil {
parts := []string{}
if input.Address != nil && *input.Address != "" {
parts = append(parts, *input.Address)
}
if input.City != nil && *input.City != "" {
parts = append(parts, *input.City)
}
if input.Country != nil && *input.Country != "" {
parts = append(parts, *input.Country)
}
if len(parts) > 0 {
combined := strings.Join(parts, ", ")
company.Address = &combined
}
} }
if input.YearsInMarket != nil { if input.YearsInMarket != nil {
company.YearsInMarket = input.YearsInMarket company.YearsInMarket = input.YearsInMarket

View file

@ -217,6 +217,123 @@ func (h *PaymentHandler) handlePaymentFailed(_ map[string]interface{}) {
fmt.Println("Payment failed") fmt.Println("Payment failed")
} }
// SubscriptionCheckoutRequest represents a subscription checkout session request
type SubscriptionCheckoutRequest struct {
PriceID string `json:"priceId"` // Stripe Price ID for the plan
SuccessURL string `json:"successUrl"` // Redirect URL after payment success
CancelURL string `json:"cancelUrl"` // Redirect URL if payment is cancelled
}
// SubscriptionCheckout creates a Stripe checkout session for a subscription plan
// @Summary Create subscription checkout session
// @Description Create a Stripe checkout session for a subscription plan (no job required)
// @Tags Payments
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body SubscriptionCheckoutRequest true "Subscription checkout request"
// @Success 200 {object} CreateCheckoutResponse
// @Failure 400 {string} string "Bad Request"
// @Failure 500 {string} string "Internal Server Error"
// @Router /api/v1/payments/subscription-checkout [post]
func (h *PaymentHandler) SubscriptionCheckout(w http.ResponseWriter, r *http.Request) {
var req SubscriptionCheckoutRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.PriceID == "" {
http.Error(w, "priceId is required", http.StatusBadRequest)
return
}
// Get Stripe config from encrypted vault
var config StripeConfig
payload, err := h.credentialsService.GetDecryptedKey(r.Context(), "stripe")
if err == nil {
json.Unmarshal([]byte(payload), &config)
}
if config.SecretKey == "" {
config.SecretKey = os.Getenv("STRIPE_SECRET_KEY")
}
if config.SecretKey == "" {
http.Error(w, "Payment service not configured", http.StatusInternalServerError)
return
}
successURL := req.SuccessURL
if successURL == "" {
frontendURL := os.Getenv("FRONTEND_URL")
if frontendURL == "" {
frontendURL = "http://localhost:3000"
}
successURL = frontendURL + "/dashboard?payment=success"
}
cancelURL := req.CancelURL
if cancelURL == "" {
frontendURL := os.Getenv("FRONTEND_URL")
if frontendURL == "" {
frontendURL = "http://localhost:3000"
}
cancelURL = frontendURL + "/register?payment=cancelled"
}
// Build Stripe checkout session (subscription mode)
sessionID, checkoutURL, err := createStripeSubscriptionCheckout(config.SecretKey, req.PriceID, successURL, cancelURL)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to create checkout session: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(CreateCheckoutResponse{
SessionID: sessionID,
CheckoutURL: checkoutURL,
})
}
// createStripeSubscriptionCheckout creates a Stripe subscription checkout session
func createStripeSubscriptionCheckout(secretKey, priceID, successURL, cancelURL string) (string, string, error) {
data := fmt.Sprintf(
"mode=subscription&success_url=%s&cancel_url=%s&line_items[0][price]=%s&line_items[0][quantity]=1",
successURL, cancelURL, priceID,
)
req, err := http.NewRequest("POST", "https://api.stripe.com/v1/checkout/sessions",
strings.NewReader(data))
if err != nil {
return "", "", err
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", secretKey))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := (&http.Client{}).Do(req)
if err != nil {
return "", "", err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
return "", "", fmt.Errorf("stripe API error: %s", string(body))
}
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", "", err
}
sessionID, _ := result["id"].(string)
checkoutURL, _ := result["url"].(string)
return sessionID, checkoutURL, nil
}
// GetPaymentStatus returns the status of a payment // GetPaymentStatus returns the status of a payment
// @Summary Get payment status // @Summary Get payment status
// @Description Get the status of a job posting payment // @Description Get the status of a job posting payment

View file

@ -19,6 +19,7 @@ type Application struct {
Message *string `json:"message,omitempty" db:"message"` Message *string `json:"message,omitempty" db:"message"`
ResumeURL *string `json:"resumeUrl,omitempty" db:"resume_url"` ResumeURL *string `json:"resumeUrl,omitempty" db:"resume_url"`
Documents JSONMap `json:"documents,omitempty" db:"documents"` // Array of {type, url} Documents JSONMap `json:"documents,omitempty" db:"documents"` // Array of {type, url}
Answers JSONMap `json:"answers,omitempty" db:"answers"` // Map of form answers and custom question responses
// Status & Notes // Status & Notes
Status string `json:"status" db:"status"` // pending, reviewed, shortlisted, rejected, hired Status string `json:"status" db:"status"` // pending, reviewed, shortlisted, rejected, hired

View file

@ -335,6 +335,7 @@ func NewRouter() http.Handler {
// Payment Routes // Payment Routes
mux.Handle("POST /api/v1/payments/create-checkout", authMiddleware.HeaderAuthGuard(http.HandlerFunc(paymentHandler.CreateCheckout))) mux.Handle("POST /api/v1/payments/create-checkout", authMiddleware.HeaderAuthGuard(http.HandlerFunc(paymentHandler.CreateCheckout)))
mux.Handle("POST /api/v1/payments/subscription-checkout", authMiddleware.OptionalHeaderAuthGuard(http.HandlerFunc(paymentHandler.SubscriptionCheckout)))
mux.HandleFunc("POST /api/v1/payments/webhook", paymentHandler.HandleWebhook) mux.HandleFunc("POST /api/v1/payments/webhook", paymentHandler.HandleWebhook)
mux.HandleFunc("GET /api/v1/payments/status/{id}", paymentHandler.GetPaymentStatus) mux.HandleFunc("GET /api/v1/payments/status/{id}", paymentHandler.GetPaymentStatus)

View file

@ -25,8 +25,8 @@ func (s *ApplicationService) CreateApplication(req dto.CreateApplicationRequest)
query := ` query := `
INSERT INTO applications ( INSERT INTO applications (
job_id, user_id, name, phone, line_id, whatsapp, email, job_id, user_id, name, phone, line_id, whatsapp, email,
message, resume_url, documents, status, created_at, updated_at message, resume_url, documents, answers, status, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING id, created_at, updated_at RETURNING id, created_at, updated_at
` `
@ -41,6 +41,7 @@ func (s *ApplicationService) CreateApplication(req dto.CreateApplicationRequest)
Message: req.Message, Message: req.Message,
ResumeURL: req.ResumeURL, ResumeURL: req.ResumeURL,
Documents: req.Documents, Documents: req.Documents,
Answers: req.Answers,
Status: "pending", Status: "pending",
CreatedAt: time.Now(), CreatedAt: time.Now(),
UpdatedAt: time.Now(), UpdatedAt: time.Now(),
@ -49,7 +50,7 @@ func (s *ApplicationService) CreateApplication(req dto.CreateApplicationRequest)
err := s.DB.QueryRow( err := s.DB.QueryRow(
query, query,
app.JobID, app.UserID, app.Name, app.Phone, app.LineID, app.WhatsApp, app.Email, app.JobID, app.UserID, app.Name, app.Phone, app.LineID, app.WhatsApp, app.Email,
app.Message, app.ResumeURL, app.Documents, app.Status, app.CreatedAt, app.UpdatedAt, app.Message, app.ResumeURL, app.Documents, app.Answers, app.Status, app.CreatedAt, app.UpdatedAt,
).Scan(&app.ID, &app.CreatedAt, &app.UpdatedAt) ).Scan(&app.ID, &app.CreatedAt, &app.UpdatedAt)
if err != nil { if err != nil {
@ -86,7 +87,7 @@ func (s *ApplicationService) GetApplications(jobID string) ([]models.Application
// Simple get by Job ID // Simple get by Job ID
query := ` query := `
SELECT id, job_id, user_id, name, phone, line_id, whatsapp, email, SELECT id, job_id, user_id, name, phone, line_id, whatsapp, email,
message, resume_url, documents, status, created_at, updated_at message, resume_url, documents, answers, status, created_at, updated_at
FROM applications WHERE job_id = $1 FROM applications WHERE job_id = $1
` `
rows, err := s.DB.Query(query, jobID) rows, err := s.DB.Query(query, jobID)
@ -100,7 +101,7 @@ func (s *ApplicationService) GetApplications(jobID string) ([]models.Application
var a models.Application var a models.Application
if err := rows.Scan( if err := rows.Scan(
&a.ID, &a.JobID, &a.UserID, &a.Name, &a.Phone, &a.LineID, &a.WhatsApp, &a.Email, &a.ID, &a.JobID, &a.UserID, &a.Name, &a.Phone, &a.LineID, &a.WhatsApp, &a.Email,
&a.Message, &a.ResumeURL, &a.Documents, &a.Status, &a.CreatedAt, &a.UpdatedAt, &a.Message, &a.ResumeURL, &a.Documents, &a.Answers, &a.Status, &a.CreatedAt, &a.UpdatedAt,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -148,12 +149,12 @@ func (s *ApplicationService) GetApplicationByID(id string) (*models.Application,
var a models.Application var a models.Application
query := ` query := `
SELECT id, job_id, user_id, name, phone, line_id, whatsapp, email, SELECT id, job_id, user_id, name, phone, line_id, whatsapp, email,
message, resume_url, documents, status, created_at, updated_at message, resume_url, documents, answers, status, created_at, updated_at
FROM applications WHERE id = $1 FROM applications WHERE id = $1
` `
err := s.DB.QueryRow(query, id).Scan( err := s.DB.QueryRow(query, id).Scan(
&a.ID, &a.JobID, &a.UserID, &a.Name, &a.Phone, &a.LineID, &a.WhatsApp, &a.Email, &a.ID, &a.JobID, &a.UserID, &a.Name, &a.Phone, &a.LineID, &a.WhatsApp, &a.Email,
&a.Message, &a.ResumeURL, &a.Documents, &a.Status, &a.CreatedAt, &a.UpdatedAt, &a.Message, &a.ResumeURL, &a.Documents, &a.Answers, &a.Status, &a.CreatedAt, &a.UpdatedAt,
) )
if err != nil { if err != nil {
return nil, err return nil, err
@ -179,7 +180,7 @@ func (s *ApplicationService) UpdateApplicationStatus(id string, req dto.UpdateAp
func (s *ApplicationService) GetApplicationsByCompany(companyID string) ([]models.Application, error) { func (s *ApplicationService) GetApplicationsByCompany(companyID string) ([]models.Application, error) {
query := ` query := `
SELECT a.id, a.job_id, a.user_id, a.name, a.phone, a.line_id, a.whatsapp, a.email, SELECT a.id, a.job_id, a.user_id, a.name, a.phone, a.line_id, a.whatsapp, a.email,
a.message, a.resume_url, a.documents, a.status, a.created_at, a.updated_at a.message, a.resume_url, a.documents, a.answers, a.status, a.created_at, a.updated_at
FROM applications a FROM applications a
JOIN jobs j ON a.job_id = j.id JOIN jobs j ON a.job_id = j.id
WHERE j.company_id = $1 WHERE j.company_id = $1
@ -196,7 +197,7 @@ func (s *ApplicationService) GetApplicationsByCompany(companyID string) ([]model
var a models.Application var a models.Application
if err := rows.Scan( if err := rows.Scan(
&a.ID, &a.JobID, &a.UserID, &a.Name, &a.Phone, &a.LineID, &a.WhatsApp, &a.Email, &a.ID, &a.JobID, &a.UserID, &a.Name, &a.Phone, &a.LineID, &a.WhatsApp, &a.Email,
&a.Message, &a.ResumeURL, &a.Documents, &a.Status, &a.CreatedAt, &a.UpdatedAt, &a.Message, &a.ResumeURL, &a.Documents, &a.Answers, &a.Status, &a.CreatedAt, &a.UpdatedAt,
); err != nil { ); err != nil {
return nil, err return nil, err
} }

View file

@ -69,8 +69,9 @@ func (s *SeederService) Seed(ctx context.Context, logChan chan string) error {
if adminEmail != "" && adminPass != "" { if adminEmail != "" && adminPass != "" {
s.SendEvent(logChan, fmt.Sprintf("🛡️ Found ADMIN_EMAIL env. Creating Superadmin: %s", adminEmail)) s.SendEvent(logChan, fmt.Sprintf("🛡️ Found ADMIN_EMAIL env. Creating Superadmin: %s", adminEmail))
// Hash password // Hash password with pepper (must match VerifyPassword in JWT service)
hash, err := bcrypt.GenerateFromPassword([]byte(adminPass), bcrypt.DefaultCost) pepper := os.Getenv("PASSWORD_PEPPER")
hash, err := bcrypt.GenerateFromPassword([]byte(adminPass+pepper), bcrypt.DefaultCost)
if err == nil { if err == nil {
tx, err := s.DB.BeginTx(ctx, nil) tx, err := s.DB.BeginTx(ctx, nil)
if err == nil { if err == nil {
@ -110,7 +111,8 @@ func (s *SeederService) Seed(ctx context.Context, logChan chan string) error {
candidates := []string{"Alice Johnson", "Bob Smith", "Charlie Brown", "Diana Prince", "Evan Wright"} candidates := []string{"Alice Johnson", "Bob Smith", "Charlie Brown", "Diana Prince", "Evan Wright"}
var candidateIDs []string var candidateIDs []string
passwordHash, _ := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.DefaultCost) pepper := os.Getenv("PASSWORD_PEPPER")
passwordHash, _ := bcrypt.GenerateFromPassword([]byte("password123"+pepper), bcrypt.DefaultCost)
for _, name := range candidates { for _, name := range candidates {
id := uuid.New().String() id := uuid.New().String()
@ -137,6 +139,7 @@ func (s *SeederService) Seed(ctx context.Context, logChan chan string) error {
s.SendEvent(logChan, "🏢 Creating Companies...") s.SendEvent(logChan, "🏢 Creating Companies...")
companyNames := []string{"TechCorp", "InnovateX", "GlobalSolutions", "CodeFactory", "DesignStudio"} companyNames := []string{"TechCorp", "InnovateX", "GlobalSolutions", "CodeFactory", "DesignStudio"}
var companyIDs []string var companyIDs []string
var companyRecruiterIDs []string
for _, compName := range companyNames { for _, compName := range companyNames {
// Create Recruiter // Create Recruiter
@ -171,6 +174,7 @@ func (s *SeederService) Seed(ctx context.Context, logChan chan string) error {
continue continue
} }
companyIDs = append(companyIDs, compID) companyIDs = append(companyIDs, compID)
companyRecruiterIDs = append(companyRecruiterIDs, recruiterID)
// Link Recruiter - Use 'admin' as role (per schema constraint: 'admin', 'recruiter') // Link Recruiter - Use 'admin' as role (per schema constraint: 'admin', 'recruiter')
_, err = tx.ExecContext(ctx, ` _, err = tx.ExecContext(ctx, `
@ -203,6 +207,7 @@ func (s *SeederService) Seed(ctx context.Context, logChan chan string) error {
LanguageLevel string LanguageLevel string
VisaSupport bool VisaSupport bool
Status string Status string
Questions string // JSONB: {"items":[{id,label,type,required,options?}]}
} }
jobTemplates := []jobTemplate{ jobTemplates := []jobTemplate{
@ -214,6 +219,7 @@ func (s *SeederService) Seed(ctx context.Context, logChan chan string) error {
Location: "Remote — Worldwide", Location: "Remote — Worldwide",
SalaryMin: 8000, SalaryMax: 14000, SalaryType: "monthly", Currency: "USD", SalaryMin: 8000, SalaryMax: 14000, SalaryType: "monthly", Currency: "USD",
Negotiable: false, LanguageLevel: "none", VisaSupport: false, Status: "open", Negotiable: false, LanguageLevel: "none", VisaSupport: false, Status: "open",
Questions: `{"items":[{"id":"q1","label":"Describe a distributed system you have designed or maintained.","type":"textarea","required":true},{"id":"q2","label":"Which cloud provider are you most experienced with?","type":"select","required":true,"options":["AWS","GCP","Azure","Other"]}]}`,
}, },
{ {
Title: "Frontend Developer (React / Next.js)", Title: "Frontend Developer (React / Next.js)",
@ -223,6 +229,7 @@ func (s *SeederService) Seed(ctx context.Context, logChan chan string) error {
Location: "São Paulo, SP — Brasil", Location: "São Paulo, SP — Brasil",
SalaryMin: 6000, SalaryMax: 10000, SalaryType: "monthly", Currency: "BRL", SalaryMin: 6000, SalaryMax: 10000, SalaryType: "monthly", Currency: "BRL",
Negotiable: false, LanguageLevel: "none", VisaSupport: false, Status: "open", Negotiable: false, LanguageLevel: "none", VisaSupport: false, Status: "open",
Questions: `{"items":[{"id":"q1","label":"Link to a project or portfolio you are proud of:","type":"text","required":false},{"id":"q2","label":"Do you have experience with TypeScript?","type":"radio","required":true,"options":["Yes","No"]}]}`,
}, },
{ {
Title: "Backend Engineer — Go", Title: "Backend Engineer — Go",
@ -241,6 +248,7 @@ func (s *SeederService) Seed(ctx context.Context, logChan chan string) error {
Location: "Tokyo, Japan", Location: "Tokyo, Japan",
SalaryMin: 500000, SalaryMax: 800000, SalaryType: "monthly", Currency: "JPY", SalaryMin: 500000, SalaryMax: 800000, SalaryType: "monthly", Currency: "JPY",
Negotiable: false, LanguageLevel: "N3", VisaSupport: true, Status: "open", Negotiable: false, LanguageLevel: "N3", VisaSupport: true, Status: "open",
Questions: `{"items":[{"id":"q1","label":"Describe your experience with B2B SaaS product development.","type":"textarea","required":true},{"id":"q2","label":"What is your Japanese proficiency level?","type":"select","required":true,"options":["N1","N2","N3","N4","N5","Beginner"]},{"id":"q3","label":"Are you currently based in Japan?","type":"radio","required":true,"options":["Yes","No, but willing to relocate","No"]}]}`,
}, },
{ {
Title: "UX / Product Designer", Title: "UX / Product Designer",
@ -250,6 +258,7 @@ func (s *SeederService) Seed(ctx context.Context, logChan chan string) error {
Location: "Berlin, Germany", Location: "Berlin, Germany",
SalaryMin: 2500, SalaryMax: 4000, SalaryType: "monthly", Currency: "EUR", SalaryMin: 2500, SalaryMax: 4000, SalaryType: "monthly", Currency: "EUR",
Negotiable: false, LanguageLevel: "none", VisaSupport: true, Status: "open", Negotiable: false, LanguageLevel: "none", VisaSupport: true, Status: "open",
Questions: `{"items":[{"id":"q1","label":"Please share a link to your design portfolio:","type":"text","required":true},{"id":"q2","label":"Which design tools do you use regularly?","type":"checkbox","required":true,"options":["Figma","Sketch","Adobe XD","InVision","Framer"]}]}`,
}, },
{ {
Title: "DevOps / Platform Engineer", Title: "DevOps / Platform Engineer",
@ -268,6 +277,7 @@ func (s *SeederService) Seed(ctx context.Context, logChan chan string) error {
Location: "São Paulo, SP — Brasil", Location: "São Paulo, SP — Brasil",
SalaryMin: 5000, SalaryMax: 8000, SalaryType: "monthly", Currency: "BRL", SalaryMin: 5000, SalaryMax: 8000, SalaryType: "monthly", Currency: "BRL",
Negotiable: true, LanguageLevel: "none", VisaSupport: false, Status: "open", Negotiable: true, LanguageLevel: "none", VisaSupport: false, Status: "open",
Questions: `{"items":[{"id":"q1","label":"What SQL databases have you worked with?","type":"checkbox","required":true,"options":["PostgreSQL","MySQL","BigQuery","Snowflake","Other"]},{"id":"q2","label":"Describe a data analysis project that had business impact.","type":"textarea","required":true}]}`,
}, },
{ {
Title: "Mobile Developer (iOS / Swift)", Title: "Mobile Developer (iOS / Swift)",
@ -286,6 +296,7 @@ func (s *SeederService) Seed(ctx context.Context, logChan chan string) error {
Location: "Tokyo, Japan", Location: "Tokyo, Japan",
SalaryMin: 180000, SalaryMax: 220000, SalaryType: "monthly", Currency: "JPY", SalaryMin: 180000, SalaryMax: 220000, SalaryType: "monthly", Currency: "JPY",
Negotiable: false, LanguageLevel: "N4", VisaSupport: true, Status: "open", Negotiable: false, LanguageLevel: "N4", VisaSupport: true, Status: "open",
Questions: `{"items":[{"id":"q1","label":"Do you have a valid work visa for Japan?","type":"radio","required":true,"options":["Yes","No, I need sponsorship"]},{"id":"q2","label":"Can you start immediately?","type":"radio","required":true,"options":["Yes","Within 1 month","Within 3 months"]}]}`,
}, },
{ {
Title: "QA Engineer", Title: "QA Engineer",
@ -301,31 +312,37 @@ func (s *SeederService) Seed(ctx context.Context, logChan chan string) error {
var jobIDs []string var jobIDs []string
for i, compID := range companyIDs { for i, compID := range companyIDs {
recruiterID := companyRecruiterIDs[i]
numJobs := rnd.Intn(2) + 2 numJobs := rnd.Intn(2) + 2
for j := 0; j < numJobs; j++ { for j := 0; j < numJobs; j++ {
tmpl := jobTemplates[(i*3+j)%len(jobTemplates)] tmpl := jobTemplates[(i*3+j)%len(jobTemplates)]
jobID := uuid.New().String() jobID := uuid.New().String()
var questionsArg interface{}
if tmpl.Questions != "" {
questionsArg = tmpl.Questions
}
_, err := tx.ExecContext(ctx, ` _, err := tx.ExecContext(ctx, `
INSERT INTO jobs ( INSERT INTO jobs (
id, company_id, title, description, id, company_id, created_by, title, description,
employment_type, work_mode, location, employment_type, work_mode, location,
salary_min, salary_max, salary_type, currency, salary_negotiable, salary_min, salary_max, salary_type, currency, salary_negotiable,
language_level, visa_support, language_level, visa_support,
status, date_posted, created_at, updated_at questions, status, date_posted, created_at, updated_at
) VALUES ( ) VALUES (
$1, $2, $3, $4, $1, $2, $3, $4, $5,
$5, $6, $7, $6, $7, $8,
$8, $9, $10, $11, $12, $9, $10, $11, $12, $13,
$13, $14, $14, $15,
$15, NOW(), NOW(), NOW() $16::jsonb, $17, NOW(), NOW(), NOW()
) ON CONFLICT DO NOTHING ) ON CONFLICT DO NOTHING
`, `,
jobID, compID, tmpl.Title, tmpl.Description, jobID, compID, recruiterID, tmpl.Title, tmpl.Description,
tmpl.EmploymentType, tmpl.WorkMode, tmpl.Location, tmpl.EmploymentType, tmpl.WorkMode, tmpl.Location,
tmpl.SalaryMin, tmpl.SalaryMax, tmpl.SalaryType, tmpl.Currency, tmpl.Negotiable, tmpl.SalaryMin, tmpl.SalaryMax, tmpl.SalaryType, tmpl.Currency, tmpl.Negotiable,
tmpl.LanguageLevel, tmpl.VisaSupport, tmpl.LanguageLevel, tmpl.VisaSupport,
tmpl.Status, questionsArg, tmpl.Status,
) )
if err != nil { if err != nil {
s.SendEvent(logChan, fmt.Sprintf("❌ Error creating job %s: %v", tmpl.Title, err)) s.SendEvent(logChan, fmt.Sprintf("❌ Error creating job %s: %v", tmpl.Title, err))
@ -338,6 +355,16 @@ func (s *SeederService) Seed(ctx context.Context, logChan chan string) error {
// 4. Create Applications // 4. Create Applications
s.SendEvent(logChan, "📝 Creating Applications...") s.SendEvent(logChan, "📝 Creating Applications...")
sampleMessages := []string{
"I am very excited about this opportunity and believe my background is a great fit for the role.",
"I have been following your company for a while and would love to contribute to your team.",
"This position aligns perfectly with my career goals and technical expertise.",
"I am passionate about this field and eager to bring my skills to your organization.",
"I believe I can add significant value to your team with my experience and enthusiasm.",
}
appStatuses := []string{"pending", "reviewed", "shortlisted", "rejected"}
for _, candID := range candidateIDs { for _, candID := range candidateIDs {
// Apply to 1-3 random jobs // Apply to 1-3 random jobs
numApps := rnd.Intn(3) + 1 numApps := rnd.Intn(3) + 1
@ -347,15 +374,20 @@ func (s *SeederService) Seed(ctx context.Context, logChan chan string) error {
} }
jobID := jobIDs[rnd.Intn(len(jobIDs))] jobID := jobIDs[rnd.Intn(len(jobIDs))]
appID := uuid.New().String() appID := uuid.New().String()
status := appStatuses[rnd.Intn(len(appStatuses))]
message := sampleMessages[rnd.Intn(len(sampleMessages))]
answers := `{"salaryExpectation":"5k-8k","hasExperience":"yes","availability":["remote","immediate"]}`
_, err := tx.ExecContext(ctx, ` _, err := tx.ExecContext(ctx, `
INSERT INTO applications (id, job_id, user_id, status, created_at, updated_at) INSERT INTO applications (id, job_id, user_id, message, answers, status, created_at, updated_at)
VALUES ($1, $2, $3, 'applied', NOW(), NOW()) VALUES ($1, $2, $3, $4, $5::jsonb, $6, NOW(), NOW())
ON CONFLICT DO NOTHING ON CONFLICT DO NOTHING
`, appID, jobID, candID) `, appID, jobID, candID, message, answers, status)
if err == nil { if err == nil {
s.SendEvent(logChan, fmt.Sprintf(" - Candidate %s applied to Job %s", candID[:8], jobID[:8])) s.SendEvent(logChan, fmt.Sprintf(" - Candidate %s applied to Job %s (status: %s)", candID[:8], jobID[:8], status))
} else {
s.SendEvent(logChan, fmt.Sprintf(" ⚠️ Application error: %v", err))
} }
} }
} }

View file

@ -0,0 +1,6 @@
-- Migration: Add answers column to applications
-- Description: Stores applicant answers to job-specific questions and form extra fields
ALTER TABLE applications ADD COLUMN IF NOT EXISTS answers JSONB;
COMMENT ON COLUMN applications.answers IS 'JSON map of applicant answers to job questions and extra form fields (linkedin, portfolioUrl, salaryExpectation, availability, etc.)';

View file

@ -18,6 +18,7 @@ import {
Save, Save,
ArrowLeft, ArrowLeft,
Loader2, Loader2,
HelpCircle,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -44,7 +45,7 @@ import { Progress } from "@/components/ui/progress";
import { Navbar } from "@/components/navbar"; import { Navbar } from "@/components/navbar";
import { Footer } from "@/components/footer"; import { Footer } from "@/components/footer";
import { useNotify } from "@/contexts/notification-context"; import { useNotify } from "@/contexts/notification-context";
import { jobsApi, applicationsApi, storageApi, type ApiJob } from "@/lib/api"; import { jobsApi, applicationsApi, storageApi, type ApiJob, type JobQuestion } from "@/lib/api";
import { formatPhone } from "@/lib/utils"; import { formatPhone } from "@/lib/utils";
import { useTranslation } from "@/lib/i18n"; import { useTranslation } from "@/lib/i18n";
import { getCurrentUser } from "@/lib/auth"; import { getCurrentUser } from "@/lib/auth";
@ -56,14 +57,6 @@ export default function JobApplicationPage({
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const steps = [
{ id: 1, title: t("application.steps.personal"), icon: User },
{ id: 2, title: t("application.steps.documents"), icon: FileText },
{ id: 3, title: t("application.steps.experience"), icon: Briefcase },
{ id: 4, title: t("application.steps.additional"), icon: MessageSquare },
];
const { id } = use(params); const { id } = use(params);
const router = useRouter(); const router = useRouter();
const notify = useNotify(); const notify = useNotify();
@ -96,13 +89,35 @@ export default function JobApplicationPage({
availability: [] as string[], availability: [] as string[],
}); });
const handleInputChange = (field: string, value: any) => { // Custom answers from dynamic job questions (step 5)
const [customAnswers, setCustomAnswers] = useState<Record<string, string | string[]>>({});
// Derived: custom questions from job
const jobQuestions: JobQuestion[] = job?.questions?.items ?? [];
const hasCustomQuestions = jobQuestions.length > 0;
const baseSteps = [
{ id: 1, title: t("application.steps.personal"), icon: User },
{ id: 2, title: t("application.steps.documents"), icon: FileText },
{ id: 3, title: t("application.steps.experience"), icon: Briefcase },
{ id: 4, title: t("application.steps.additional"), icon: MessageSquare },
];
const steps = hasCustomQuestions
? [...baseSteps, { id: 5, title: t("application.steps.questions") || "Company Questions", icon: HelpCircle }]
: baseSteps;
const handleInputChange = (field: string, value: unknown) => {
if (field === "phone") { if (field === "phone") {
value = formatPhone(value); value = formatPhone(value as string);
} }
setFormData((prev) => ({ ...prev, [field]: value })); setFormData((prev) => ({ ...prev, [field]: value }));
}; };
const handleCustomAnswerChange = (questionId: string, value: string | string[]) => {
setCustomAnswers((prev) => ({ ...prev, [questionId]: value }));
};
const handleResumeUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleResumeUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
@ -140,7 +155,7 @@ export default function JobApplicationPage({
notify.error(t("application.toasts.invalidEmail.title"), t("application.toasts.invalidEmail.desc")); notify.error(t("application.toasts.invalidEmail.title"), t("application.toasts.invalidEmail.desc"));
return false; return false;
} }
if (formData.phone.length < 14) { // (11) 91234-5678 is 15 chars, (11) 1234-5678 is 14 chars if (formData.phone.length < 14) {
notify.error(t("application.toasts.invalidPhone.title"), t("application.toasts.invalidPhone.desc")); notify.error(t("application.toasts.invalidPhone.title"), t("application.toasts.invalidPhone.desc"));
return false; return false;
} }
@ -173,6 +188,24 @@ export default function JobApplicationPage({
return false; return false;
} }
return true; return true;
case 5: {
// Validate required custom questions
const missing = jobQuestions.filter(q => {
if (!q.required) return false;
const ans = customAnswers[q.id];
if (!ans) return true;
if (Array.isArray(ans)) return ans.length === 0;
return (ans as string).trim() === "";
});
if (missing.length > 0) {
notify.error(
t("application.toasts.questionsRequired.title") || "Required fields",
`Please answer: ${missing.map(q => q.label).join(", ")}`
);
return false;
}
return true;
}
default: default:
return true; return true;
} }
@ -229,23 +262,23 @@ export default function JobApplicationPage({
const handleSubmit = async () => { const handleSubmit = async () => {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
// 1. Resume is already uploaded via handleResumeUpload, so we use formData.resumeUrl
const resumeUrl = formData.resumeUrl;
// Note: If you want to enforce upload here, you'd need the File object, but we uploaded it earlier.
await applicationsApi.create({ await applicationsApi.create({
jobId: Number(id), // ID might need number conversion depending on API jobId: id, // UUID string — must NOT be Number(id)
name: formData.fullName, name: formData.fullName,
email: formData.email, email: formData.email,
phone: formData.phone, phone: formData.phone,
linkedin: formData.linkedin, message: formData.coverLetter,
coverLetter: formData.coverLetter || formData.whyUs, // Fallback resumeUrl: formData.resumeUrl,
resumeUrl: resumeUrl, answers: {
portfolioUrl: formData.portfolioUrl, linkedin: formData.linkedin || undefined,
salaryExpectation: formData.salaryExpectation, portfolioUrl: formData.portfolioUrl || undefined,
hasExperience: formData.hasExperience, salaryExpectation: formData.salaryExpectation,
whyUs: formData.whyUs, hasExperience: formData.hasExperience,
availability: formData.availability, whyUs: formData.whyUs,
availability: formData.availability,
// Custom job-specific answers
...customAnswers,
},
}); });
notify.success( notify.success(
@ -255,12 +288,10 @@ export default function JobApplicationPage({
setIsSubmitted(true); setIsSubmitted(true);
window.scrollTo(0, 0); window.scrollTo(0, 0);
} catch (error: any) { } catch (error: unknown) {
console.error("Submit error:", error); console.error("Submit error:", error);
notify.error( const message = error instanceof Error ? error.message : t("application.toasts.submitError.default");
t("application.toasts.submitError.title"), notify.error(t("application.toasts.submitError.title"), message);
error.message || t("application.toasts.submitError.default")
);
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
@ -276,18 +307,18 @@ export default function JobApplicationPage({
const progress = (currentStep / steps.length) * 100; const progress = (currentStep / steps.length) * 100;
if (loading) { if (loading) {
return ( return (
<div className="min-h-screen flex items-center justify-center"> <div className="min-h-screen flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin" /> <Loader2 className="h-8 w-8 animate-spin" />
</div> </div>
); );
} }
if (!job) { if (!job) {
return ( return (
<div className="min-h-screen flex items-center justify-center"> <div className="min-h-screen flex items-center justify-center">
<p>Job not found</p> <p>Job not found</p>
</div> </div>
); );
} }
@ -765,6 +796,101 @@ export default function JobApplicationPage({
</div> </div>
</div> </div>
)} )}
{/* Step 5: Custom Job Questions */}
{currentStep === 5 && hasCustomQuestions && (
<div className="space-y-6">
{jobQuestions.map((q) => (
<div key={q.id} className="space-y-2">
<Label htmlFor={`q-${q.id}`}>
{q.label}
{q.required && <span className="text-destructive ml-1">*</span>}
</Label>
{q.type === "text" && (
<Input
id={`q-${q.id}`}
value={(customAnswers[q.id] as string) ?? ""}
onChange={(e) => handleCustomAnswerChange(q.id, e.target.value)}
placeholder="Your answer..."
/>
)}
{q.type === "textarea" && (
<Textarea
id={`q-${q.id}`}
className="min-h-[120px]"
value={(customAnswers[q.id] as string) ?? ""}
onChange={(e) => handleCustomAnswerChange(q.id, e.target.value)}
placeholder="Your answer..."
/>
)}
{q.type === "radio" && q.options && (
<div className="flex flex-wrap gap-3">
{q.options.map((opt) => (
<div
key={opt}
className="flex items-center space-x-2 border p-3 rounded-md hover:bg-muted/50 cursor-pointer min-w-[120px]"
>
<input
type="radio"
name={`q-${q.id}`}
id={`q-${q.id}-${opt}`}
className="accent-primary h-4 w-4"
checked={customAnswers[q.id] === opt}
onChange={() => handleCustomAnswerChange(q.id, opt)}
/>
<Label htmlFor={`q-${q.id}-${opt}`} className="cursor-pointer">
{opt}
</Label>
</div>
))}
</div>
)}
{q.type === "select" && q.options && (
<Select
value={(customAnswers[q.id] as string) ?? ""}
onValueChange={(val) => handleCustomAnswerChange(q.id, val)}
>
<SelectTrigger>
<SelectValue placeholder="Select an option..." />
</SelectTrigger>
<SelectContent>
{q.options.map((opt) => (
<SelectItem key={opt} value={opt}>{opt}</SelectItem>
))}
</SelectContent>
</Select>
)}
{q.type === "checkbox" && q.options && (
<div className="grid gap-2">
{q.options.map((opt) => {
const current = (customAnswers[q.id] as string[]) ?? [];
return (
<div key={opt} className="flex items-center space-x-2">
<Checkbox
id={`q-${q.id}-${opt}`}
checked={current.includes(opt)}
onCheckedChange={(checked) => {
const updated = checked
? [...current, opt]
: current.filter((v) => v !== opt);
handleCustomAnswerChange(q.id, updated);
}}
/>
<Label htmlFor={`q-${q.id}-${opt}`}>{opt}</Label>
</div>
);
})}
</div>
)}
</div>
))}
</div>
)}
</CardContent> </CardContent>
<CardFooter className="flex flex-col sm:flex-row gap-3 sm:justify-between border-t pt-4 sm:pt-6"> <CardFooter className="flex flex-col sm:flex-row gap-3 sm:justify-between border-t pt-4 sm:pt-6">

View file

@ -19,6 +19,9 @@ export default function Home() {
const { t } = useTranslation() const { t } = useTranslation()
const [jobs, setJobs] = useState<Job[]>([]) const [jobs, setJobs] = useState<Job[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
const [emblaRef, emblaApi] = useEmblaCarousel({ const [emblaRef, emblaApi] = useEmblaCarousel({
align: "start", align: "start",
@ -27,33 +30,47 @@ export default function Home() {
dragFree: true dragFree: true
}) })
const [moreJobsEmblaRef, moreJobsEmblaApi] = useEmblaCarousel({
align: "start",
loop: false,
skipSnaps: false,
dragFree: true
})
const [prevBtnDisabled, setPrevBtnDisabled] = useState(true) const [prevBtnDisabled, setPrevBtnDisabled] = useState(true)
const [nextBtnDisabled, setNextBtnDisabled] = useState(true) const [nextBtnDisabled, setNextBtnDisabled] = useState(true)
const [moreJobsPrevBtnDisabled, setMoreJobsPrevBtnDisabled] = useState(true)
const [moreJobsNextBtnDisabled, setMoreJobsNextBtnDisabled] = useState(true) const fetchJobs = useCallback(async (pageNum: number, isLoadMore = false) => {
try {
if (isLoadMore) setLoadingMore(true)
else setLoading(true)
const limit = 8
const res = await jobsApi.list({ page: pageNum, limit })
if (res.data) {
const newJobs = res.data.map(transformApiJobToFrontend)
if (isLoadMore) {
setJobs(prev => [...prev, ...newJobs])
} else {
setJobs(newJobs)
}
// If we got fewer jobs than the limit, we've reached the end
if (newJobs.length < limit) {
setHasMore(false)
}
}
} catch (error) {
console.error("Failed to fetch jobs:", error)
} finally {
setLoading(false)
setLoadingMore(false)
}
}, [t])
useEffect(() => { useEffect(() => {
async function fetchJobs() { fetchJobs(1)
try { }, [fetchJobs])
const res = await jobsApi.list({ limit: 8 })
if (res.data) { const handleLoadMore = () => {
setJobs(res.data.map(transformApiJobToFrontend)) const nextPage = page + 1
} setPage(nextPage)
} catch (error) { fetchJobs(nextPage, true)
console.error("Failed to fetch jobs:", error) }
} finally {
setLoading(false)
}
}
fetchJobs()
}, [])
const scrollPrev = useCallback(() => { const scrollPrev = useCallback(() => {
if (emblaApi) emblaApi.scrollPrev() if (emblaApi) emblaApi.scrollPrev()
@ -63,24 +80,11 @@ export default function Home() {
if (emblaApi) emblaApi.scrollNext() if (emblaApi) emblaApi.scrollNext()
}, [emblaApi]) }, [emblaApi])
const scrollMoreJobsPrev = useCallback(() => {
if (moreJobsEmblaApi) moreJobsEmblaApi.scrollPrev()
}, [moreJobsEmblaApi])
const scrollMoreJobsNext = useCallback(() => {
if (moreJobsEmblaApi) moreJobsEmblaApi.scrollNext()
}, [moreJobsEmblaApi])
const onSelect = useCallback((emblaApi: any) => { const onSelect = useCallback((emblaApi: any) => {
setPrevBtnDisabled(!emblaApi.canScrollPrev()) setPrevBtnDisabled(!emblaApi.canScrollPrev())
setNextBtnDisabled(!emblaApi.canScrollNext()) setNextBtnDisabled(!emblaApi.canScrollNext())
}, []) }, [])
const onMoreJobsSelect = useCallback((emblaApi: any) => {
setMoreJobsPrevBtnDisabled(!emblaApi.canScrollPrev())
setMoreJobsNextBtnDisabled(!emblaApi.canScrollNext())
}, [])
useEffect(() => { useEffect(() => {
if (!emblaApi) return if (!emblaApi) return
onSelect(emblaApi) onSelect(emblaApi)
@ -88,13 +92,6 @@ export default function Home() {
emblaApi.on("select", onSelect) emblaApi.on("select", onSelect)
}, [emblaApi, onSelect]) }, [emblaApi, onSelect])
useEffect(() => {
if (!moreJobsEmblaApi) return
onMoreJobsSelect(moreJobsEmblaApi)
moreJobsEmblaApi.on("reInit", onMoreJobsSelect)
moreJobsEmblaApi.on("select", onMoreJobsSelect)
}, [moreJobsEmblaApi, onMoreJobsSelect])
return ( return (
<div className="min-h-screen bg-gray-50 flex flex-col font-sans"> <div className="min-h-screen bg-gray-50 flex flex-col font-sans">
<Navbar /> <Navbar />
@ -195,82 +192,74 @@ export default function Home() {
<h2 className="text-2xl md:text-3xl font-bold text-gray-900"> <h2 className="text-2xl md:text-3xl font-bold text-gray-900">
{t("home.moreJobs.title")} {t("home.moreJobs.title")}
</h2> </h2>
<div className="flex items-center gap-3"> <Link href="/jobs">
<Button <Button variant="outline" className="border-orange-500 text-orange-500 hover:bg-orange-50">
variant="outline" {t("home.moreJobs.viewAll")}
size="icon"
className="rounded-full border-gray-300 hover:border-orange-500 hover:text-orange-500 transition-all w-10 h-10"
onClick={scrollMoreJobsPrev}
disabled={moreJobsPrevBtnDisabled}
>
<ChevronLeft className="w-5 h-5" />
</Button> </Button>
<Button </Link>
variant="outline"
size="icon"
className="rounded-full border-gray-300 hover:border-orange-500 hover:text-orange-500 transition-all w-10 h-10"
onClick={scrollMoreJobsNext}
disabled={moreJobsNextBtnDisabled}
>
<ChevronRight className="w-5 h-5" />
</Button>
<Link href="/jobs">
<Button className="bg-orange-500 hover:bg-orange-600 text-white font-bold rounded-lg">
{t("home.moreJobs.viewAll")}
</Button>
</Link>
</div>
</div> </div>
<div className="overflow-hidden" ref={moreJobsEmblaRef}> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<div className="flex gap-6"> {loading && page === 1 ? (
{loading ? ( <div className="col-span-full text-center py-12">Carregando vagas...</div>
<div className="flex-[0_0_100%] text-center py-8">Carregando vagas...</div> ) : jobs.map((job, index) => (
) : jobs.slice(0, 8).map((job, index) => ( <div key={`more-${job.id}-${index}`} className="pb-1">
<div key={`more-${job.id}-${index}`} className="flex-[0_0_100%] sm:flex-[0_0_50%] lg:flex-[0_0_50%] xl:flex-[0_0_33.333%] 2xl:flex-[0_0_25%] min-w-0 pb-1"> <JobCard job={job} />
<JobCard job={job} />
</div>
))}
</div>
</div>
</div>
</section>
{/* Bottom CTA Section */}
<section className="py-16 bg-white">
<div className="container mx-auto px-4 sm:px-6">
<div className="bg-[#1F2F40] rounded-[2rem] p-8 md:p-16 relative overflow-hidden text-center md:text-left flex flex-col md:flex-row items-center justify-between min-h-[400px]">
<div className="relative z-10 max-w-xl">
<h2 className="text-3xl md:text-4xl font-bold text-white mb-4 leading-tight">
{t("home.cta.title")}
</h2>
<p className="text-base text-gray-300 mb-8">
{t("home.cta.subtitle")}
</p>
<Link href="/register/user">
<Button className="h-12 px-8 bg-white text-gray-900 hover:bg-gray-100 font-bold text-lg rounded-lg">
{t("home.cta.button")}
</Button>
</Link>
</div>
<div className="absolute inset-0 z-0">
<div className="absolute right-0 top-0 h-full w-full md:w-2/3 lg:w-1/2">
<Image
src="/muie.jpeg"
alt="Professional"
fill
className="object-cover object-center md:object-right opacity-40 md:opacity-100"
/>
<div className="absolute inset-0 bg-gradient-to-t md:bg-gradient-to-r from-[#1F2F40] via-[#1F2F40]/30 to-transparent" />
</div> </div>
</div> ))}
</div> </div>
{hasMore && (
<div className="mt-12 text-center">
<Button
onClick={handleLoadMore}
disabled={loadingMore}
className="bg-orange-500 hover:bg-orange-600 text-white font-bold px-8 py-6 rounded-xl text-lg transition-all hover:scale-105 active:scale-95 shadow-lg"
>
{loadingMore ? "Carregando..." : t("home.moreJobs.loadMore") || "Carregar Mais Vagas"}
</Button>
</div>
)}
</div> </div>
</section> </section>
</main>
<Footer />
</div> </div>
</section >
{/* Bottom CTA Section */ }
< section className = "py-16 bg-white" >
<div className="container mx-auto px-4 sm:px-6">
<div className="bg-[#1F2F40] rounded-[2rem] p-8 md:p-16 relative overflow-hidden text-center md:text-left flex flex-col md:flex-row items-center justify-between min-h-[400px]">
<div className="relative z-10 max-w-xl">
<h2 className="text-3xl md:text-4xl font-bold text-white mb-4 leading-tight">
{t("home.cta.title")}
</h2>
<p className="text-base text-gray-300 mb-8">
{t("home.cta.subtitle")}
</p>
<Link href="/register/user">
<Button className="h-12 px-8 bg-white text-gray-900 hover:bg-gray-100 font-bold text-lg rounded-lg">
{t("home.cta.button")}
</Button>
</Link>
</div>
<div className="absolute inset-0 z-0">
<div className="absolute right-0 top-0 h-full w-full md:w-2/3 lg:w-1/2">
<Image
src="/muie.jpeg"
alt="Professional"
fill
className="object-cover object-center md:object-right opacity-40 md:opacity-100"
/>
<div className="absolute inset-0 bg-gradient-to-t md:bg-gradient-to-r from-[#1F2F40] via-[#1F2F40]/30 to-transparent" />
</div>
</div>
</div>
</div>
</section >
</main >
<Footer />
</div >
) )
} }

File diff suppressed because it is too large Load diff

View file

@ -28,12 +28,13 @@ import { motion } from "framer-motion";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
import { useTranslation } from "@/lib/i18n"; import { Navbar } from "@/components/navbar";
import { Footer } from "@/components/footer";
type RegisterFormData = { type RegisterFormData = {
name: string; name: string;
email: string; email: string;
phone: string; phone?: string;
password: string; password: string;
confirmPassword: string; confirmPassword: string;
acceptTerms: boolean; acceptTerms: boolean;
@ -41,23 +42,22 @@ type RegisterFormData = {
export default function RegisterUserPage() { export default function RegisterUserPage() {
const router = useRouter(); const router = useRouter();
const { t } = useTranslation();
const [error, setError] = useState(""); const [error, setError] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false); const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const registerSchema = useMemo(() => z.object({ const registerSchema = useMemo(() => z.object({
name: z.string().min(3, "Nome deve ter no mínimo 3 caracteres"), name: z.string().min(3, "Name must be at least 3 characters"),
email: z.string().email("E-mail inválido"), email: z.string().email("Invalid email address"),
phone: z.string().min(10, "Telefone inválido"), phone: z.string().min(7, "Phone number too short").optional().or(z.literal("")),
password: z.string().min(6, "Senha deve ter no mínimo 6 caracteres"), password: z.string().min(6, "Password must be at least 6 characters"),
confirmPassword: z.string().min(6, "Confirmação de senha obrigatória"), confirmPassword: z.string().min(6, "Please confirm your password"),
acceptTerms: z.boolean().refine((val) => val === true, { acceptTerms: z.boolean().refine((val) => val === true, {
message: "Você deve aceitar os termos de uso" message: "You must accept the terms of use",
}), }),
}).refine((data) => data.password === data.confirmPassword, { }).refine((data) => data.password === data.confirmPassword, {
message: "As senhas não coincidem", message: "Passwords do not match",
path: ["confirmPassword"], path: ["confirmPassword"],
}), []); }), []);
@ -87,27 +87,24 @@ export default function RegisterUserPage() {
await registerCandidate({ await registerCandidate({
name: data.name, name: data.name,
email: data.email, email: data.email,
phone: data.phone, phone: data.phone || "",
password: data.password, password: data.password,
username: data.email.split('@')[0], username: data.email.split("@")[0],
}); });
router.push("/login?message=Conta criada com sucesso! Faça login."); router.push("/login?message=Account created successfully! Please log in.");
} catch (err: any) { } catch (err: any) {
console.error('🔥 [REGISTER FRONT] Erro no registro:', err); setError(err.message || "Failed to create account. Please try again.");
setError(err.message || "Erro ao criar conta. Tente novamente.");
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const onSubmit = (data: RegisterFormData) => {
handleRegister(data);
};
return ( return (
<div className="min-h-screen flex flex-col lg:flex-row"> <div className="min-h-screen flex flex-col">
<Navbar />
<div className="flex-1 flex flex-col lg:flex-row">
{/* Left Side - Branding */} {/* Left Side - Branding */}
<div className="lg:flex-1 bg-gradient-to-br from-primary to-primary/80 p-8 flex flex-col justify-center items-center text-primary-foreground"> <div className="lg:flex-1 bg-gradient-to-br from-primary to-primary/80 p-8 flex flex-col justify-center items-center text-primary-foreground">
<motion.div <motion.div
@ -126,11 +123,11 @@ export default function RegisterUserPage() {
</div> </div>
<h1 className="text-4xl font-bold mb-4"> <h1 className="text-4xl font-bold mb-4">
Comece sua jornada profissional Start your professional journey
</h1> </h1>
<p className="text-lg opacity-90 leading-relaxed"> <p className="text-lg opacity-90 leading-relaxed">
Conecte-se com as melhores oportunidades do mercado. Cadastre-se gratuitamente e encontre a vaga ideal para você! Connect with the best opportunities worldwide. Sign up for free and find the job that's right for you!
</p> </p>
<div className="mt-8 space-y-4"> <div className="mt-8 space-y-4">
@ -138,19 +135,19 @@ export default function RegisterUserPage() {
<div className="w-8 h-8 bg-white/20 rounded-full flex items-center justify-center"> <div className="w-8 h-8 bg-white/20 rounded-full flex items-center justify-center">
<UserIcon className="w-4 h-4" /> <UserIcon className="w-4 h-4" />
</div> </div>
<span>Crie seu perfil profissional completo</span> <span>Build your complete professional profile</span>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-8 h-8 bg-white/20 rounded-full flex items-center justify-center"> <div className="w-8 h-8 bg-white/20 rounded-full flex items-center justify-center">
<Building2 className="w-4 h-4" /> <Building2 className="w-4 h-4" />
</div> </div>
<span>Candidate-se às melhores vagas do mercado</span> <span>Apply to top jobs from companies worldwide</span>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-8 h-8 bg-white/20 rounded-full flex items-center justify-center"> <div className="w-8 h-8 bg-white/20 rounded-full flex items-center justify-center">
<Briefcase className="w-4 h-4" /> <Briefcase className="w-4 h-4" />
</div> </div>
<span>Acompanhe suas candidaturas em tempo real</span> <span>Track your applications in real time</span>
</div> </div>
</div> </div>
</motion.div> </motion.div>
@ -164,15 +161,15 @@ export default function RegisterUserPage() {
className="w-full max-w-md space-y-6" className="w-full max-w-md space-y-6"
> >
<div className="text-center space-y-2"> <div className="text-center space-y-2">
<h2 className="text-3xl font-bold">Criar Conta de Usuário</h2> <h2 className="text-3xl font-bold">Create your account</h2>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Preencha os dados abaixo para se cadastrar Fill in your details to get started
</p> </p>
</div> </div>
<Card className="border-0 shadow-lg"> <Card className="border-0 shadow-lg">
<CardContent className="pt-6"> <CardContent className="pt-6">
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4"> <form onSubmit={handleSubmit(handleRegister)} className="space-y-4">
{error && ( {error && (
<motion.div <motion.div
initial={{ opacity: 0, scale: 0.95 }} initial={{ opacity: 0, scale: 0.95 }}
@ -186,70 +183,66 @@ export default function RegisterUserPage() {
)} )}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="name">Nome Completo</Label> <Label htmlFor="name">Full Name</Label>
<div className="relative"> <div className="relative">
<UserIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <UserIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input <Input
id="name" id="name"
type="text" type="text"
placeholder="Seu nome completo" placeholder="Your full name"
className="pl-10" className="pl-10"
{...register("name")} {...register("name")}
/> />
</div> </div>
{errors.name && ( {errors.name && (
<p className="text-sm text-destructive"> <p className="text-sm text-destructive">{errors.name.message}</p>
{errors.name.message}
</p>
)} )}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="email">E-mail</Label> <Label htmlFor="email">Email</Label>
<div className="relative"> <div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input <Input
id="email" id="email"
type="email" type="email"
placeholder="seu@email.com" placeholder="you@example.com"
className="pl-10" className="pl-10"
{...register("email")} {...register("email")}
/> />
</div> </div>
{errors.email && ( {errors.email && (
<p className="text-sm text-destructive"> <p className="text-sm text-destructive">{errors.email.message}</p>
{errors.email.message}
</p>
)} )}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="phone">Telefone</Label> <Label htmlFor="phone">
Phone <span className="text-muted-foreground font-normal">(optional)</span>
</Label>
<div className="relative"> <div className="relative">
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Phone className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input <Input
id="phone" id="phone"
type="tel" type="tel"
placeholder="(00) 00000-0000" placeholder="+1 555 000 0000"
className="pl-10" className="pl-10"
{...register("phone")} {...register("phone")}
/> />
</div> </div>
{errors.phone && ( {errors.phone && (
<p className="text-sm text-destructive"> <p className="text-sm text-destructive">{errors.phone.message}</p>
{errors.phone.message}
</p>
)} )}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="password">Senha</Label> <Label htmlFor="password">Password</Label>
<div className="relative"> <div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input <Input
id="password" id="password"
type={showPassword ? "text" : "password"} type={showPassword ? "text" : "password"}
placeholder="Mínimo 6 caracteres" placeholder="At least 6 characters"
className="pl-10 pr-10" className="pl-10 pr-10"
{...register("password")} {...register("password")}
/> />
@ -268,20 +261,18 @@ export default function RegisterUserPage() {
</Button> </Button>
</div> </div>
{errors.password && ( {errors.password && (
<p className="text-sm text-destructive"> <p className="text-sm text-destructive">{errors.password.message}</p>
{errors.password.message}
</p>
)} )}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="confirmPassword">Confirmar Senha</Label> <Label htmlFor="confirmPassword">Confirm Password</Label>
<div className="relative"> <div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input <Input
id="confirmPassword" id="confirmPassword"
type={showConfirmPassword ? "text" : "password"} type={showConfirmPassword ? "text" : "password"}
placeholder="Digite a senha novamente" placeholder="Re-enter your password"
className="pl-10 pr-10" className="pl-10 pr-10"
{...register("confirmPassword")} {...register("confirmPassword")}
/> />
@ -300,9 +291,7 @@ export default function RegisterUserPage() {
</Button> </Button>
</div> </div>
{errors.confirmPassword && ( {errors.confirmPassword && (
<p className="text-sm text-destructive"> <p className="text-sm text-destructive">{errors.confirmPassword.message}</p>
{errors.confirmPassword.message}
</p>
)} )}
</div> </div>
@ -312,20 +301,18 @@ export default function RegisterUserPage() {
htmlFor="acceptTerms" htmlFor="acceptTerms"
className="text-sm font-normal cursor-pointer leading-relaxed" className="text-sm font-normal cursor-pointer leading-relaxed"
> >
Aceito os{" "} I agree to the{" "}
<Link href="/terms" className="text-primary hover:underline"> <Link href="/terms" className="text-primary hover:underline">
termos de uso Terms of Use
</Link> </Link>
{" "}e a{" "} {" "}and{" "}
<Link href="/privacy" className="text-primary hover:underline"> <Link href="/privacy" className="text-primary hover:underline">
política de privacidade Privacy Policy
</Link> </Link>
</Label> </Label>
</div> </div>
{errors.acceptTerms && ( {errors.acceptTerms && (
<p className="text-sm text-destructive"> <p className="text-sm text-destructive">{errors.acceptTerms.message}</p>
{errors.acceptTerms.message}
</p>
)} )}
<Button <Button
@ -333,15 +320,15 @@ export default function RegisterUserPage() {
className="w-full h-11 cursor-pointer bg-[#F0932B] hover:bg-[#d97d1a]" className="w-full h-11 cursor-pointer bg-[#F0932B] hover:bg-[#d97d1a]"
disabled={loading} disabled={loading}
> >
{loading ? "Criando conta..." : "Criar Conta"} {loading ? "Creating account..." : "Create Account"}
</Button> </Button>
</form> </form>
<div className="mt-6 text-center"> <div className="mt-6 text-center">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
tem uma conta?{" "} Already have an account?{" "}
<Link href="/login" className="text-primary hover:underline font-semibold"> <Link href="/login" className="text-primary hover:underline font-semibold">
Fazer login Log in
</Link> </Link>
</p> </p>
</div> </div>
@ -353,11 +340,14 @@ export default function RegisterUserPage() {
href="/" href="/"
className="text-sm text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-2" className="text-sm text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-2"
> >
Voltar para o início Back to home
</Link> </Link>
</div> </div>
</motion.div> </motion.div>
</div> </div>
</div>
<Footer />
</div> </div>
); );
} }

View file

@ -94,6 +94,7 @@ export interface ApiJob {
currency?: string; currency?: string;
description: string; description: string;
requirements?: unknown; requirements?: unknown;
questions?: { items?: JobQuestion[] }; // Custom application questions
status: string; status: string;
createdAt: string; createdAt: string;
datePosted?: string; datePosted?: string;
@ -101,6 +102,14 @@ export interface ApiJob {
applicationCount?: number; applicationCount?: number;
} }
export interface JobQuestion {
id: string;
label: string;
type: "text" | "textarea" | "radio" | "select" | "checkbox";
required: boolean;
options?: string[];
}
export interface ApiCompany { export interface ApiCompany {
id: string; id: string;
name: string; name: string;
@ -574,6 +583,15 @@ export const applicationsApi = {
}, },
}; };
// Payments API
export const paymentsApi = {
createSubscriptionCheckout: (data: { priceId: string; successUrl?: string; cancelUrl?: string }) =>
apiRequest<{ sessionId: string; checkoutUrl: string }>("/api/v1/payments/subscription-checkout", {
method: "POST",
body: JSON.stringify(data),
}),
};
// Storage API // Storage API
export const storageApi = { export const storageApi = {
getUploadUrl: (filename: string, contentType: string) => getUploadUrl: (filename: string, contentType: string) =>

View file

@ -230,40 +230,32 @@ export function getToken(): string | null {
export interface RegisterCompanyData { export interface RegisterCompanyData {
companyName: string; companyName: string;
email: string; email: string;
phone: string; phone?: string;
password?: string; password?: string;
confirmPassword?: string; document?: string; // Generic Tax ID / Business registration number
document?: string;
website?: string; website?: string;
yearsInMarket?: string; yearsInMarket?: string;
description?: string; description?: string;
zipCode?: string;
address?: string;
city?: string; city?: string;
state?: string; country?: string; // Country name or code
birthDate?: string;
cnpj?: string;
} }
export async function registerCompany(data: RegisterCompanyData): Promise<void> { export async function registerCompany(data: RegisterCompanyData): Promise<{ token?: string; id?: string; name?: string }> {
const payload = { const payload = {
name: data.companyName, name: data.companyName,
slug: data.companyName.toLowerCase().replace(/\s+/g, '-'), companyName: data.companyName,
document: data.document || data.cnpj, slug: data.companyName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''),
document: data.document,
phone: data.phone, phone: data.phone,
email: data.email, email: data.email,
admin_email: data.email,
password: data.password,
admin_password: data.password,
website: data.website, website: data.website,
address: data.address,
zip_code: data.zipCode,
city: data.city, city: data.city,
state: data.state, country: data.country,
description: data.description, description: data.description,
years_in_market: data.yearsInMarket, years_in_market: data.yearsInMarket,
admin_email: data.email,
admin_password: data.password,
password: data.password,
admin_name: data.companyName,
admin_birth_date: data.birthDate
}; };
const res = await fetch(`${getApiV1Url()}/auth/register/company`, { const res = await fetch(`${getApiV1Url()}/auth/register/company`, {