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,
|
||||
salaryExpectation: formData.salaryExpectation,
|
||||
hasExperience: formData.hasExperience,
|
||||
whyUs: formData.whyUs,
|
||||
availability: formData.availability,
|
||||
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);
|
||||
}
|
||||
|
|
@ -276,18 +307,18 @@ export default function JobApplicationPage({
|
|||
const progress = (currentStep / steps.length) * 100;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!job) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<p>Job not found</p>
|
||||
</div>
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<p>Job not found</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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)
|
||||
|
||||
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(() => {
|
||||
async function fetchJobs() {
|
||||
try {
|
||||
const res = await jobsApi.list({ limit: 8 })
|
||||
if (res.data) {
|
||||
setJobs(res.data.map(transformApiJobToFrontend))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch jobs:", error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
fetchJobs()
|
||||
}, [])
|
||||
fetchJobs(1)
|
||||
}, [fetchJobs])
|
||||
|
||||
const handleLoadMore = () => {
|
||||
const nextPage = page + 1
|
||||
setPage(nextPage)
|
||||
fetchJobs(nextPage, true)
|
||||
}
|
||||
|
||||
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,82 +192,74 @@ 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" />
|
||||
<Link href="/jobs">
|
||||
<Button variant="outline" className="border-orange-500 text-orange-500 hover:bg-orange-50">
|
||||
{t("home.moreJobs.viewAll")}
|
||||
</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">
|
||||
{t("home.moreJobs.viewAll")}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</Link>
|
||||
</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">
|
||||
<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 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>
|
||||
))}
|
||||
</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>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
</section >
|
||||
|
||||
{/* Bottom CTA Section */ }
|
||||
< section className = "py-16 bg-white" >
|
||||
<div className="container mx-auto px-4 sm:px-6">
|
||||
<div className="bg-[#1F2F40] rounded-[2rem] p-8 md:p-16 relative overflow-hidden text-center md:text-left flex flex-col md:flex-row items-center justify-between min-h-[400px]">
|
||||
<div className="relative z-10 max-w-xl">
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-white mb-4 leading-tight">
|
||||
{t("home.cta.title")}
|
||||
</h2>
|
||||
<p className="text-base text-gray-300 mb-8">
|
||||
{t("home.cta.subtitle")}
|
||||
</p>
|
||||
<Link href="/register/user">
|
||||
<Button className="h-12 px-8 bg-white text-gray-900 hover:bg-gray-100 font-bold text-lg rounded-lg">
|
||||
{t("home.cta.button")}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 z-0">
|
||||
<div className="absolute right-0 top-0 h-full w-full md:w-2/3 lg:w-1/2">
|
||||
<Image
|
||||
src="/muie.jpeg"
|
||||
alt="Professional"
|
||||
fill
|
||||
className="object-cover object-center md:object-right opacity-40 md:opacity-100"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t md:bg-gradient-to-r from-[#1F2F40] via-[#1F2F40]/30 to-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section >
|
||||
</main >
|
||||
|
||||
<Footer />
|
||||
</div >
|
||||
)
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -28,12 +28,13 @@ import { motion } from "framer-motion";
|
|||
import { useForm } from "react-hook-form";
|
||||
import { 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"],
|
||||
}), []);
|
||||
|
||||
|
|
@ -83,31 +83,28 @@ export default function RegisterUserPage() {
|
|||
|
||||
try {
|
||||
const { registerCandidate } = await import("@/lib/auth");
|
||||
|
||||
|
||||
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