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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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