diff --git a/backend/internal/core/dto/company.go b/backend/internal/core/dto/company.go index b9900b2..6b37ad8 100644 --- a/backend/internal/core/dto/company.go +++ b/backend/internal/core/dto/company.go @@ -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"` diff --git a/backend/internal/core/usecases/tenant/create_company.go b/backend/internal/core/usecases/tenant/create_company.go index 21846ba..ad88142 100644 --- a/backend/internal/core/usecases/tenant/create_company.go +++ b/backend/internal/core/usecases/tenant/create_company.go @@ -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 diff --git a/backend/internal/handlers/payment_handler.go b/backend/internal/handlers/payment_handler.go index c55f20f..6b66d46 100644 --- a/backend/internal/handlers/payment_handler.go +++ b/backend/internal/handlers/payment_handler.go @@ -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 diff --git a/backend/internal/models/application.go b/backend/internal/models/application.go index eec4456..a33fca9 100755 --- a/backend/internal/models/application.go +++ b/backend/internal/models/application.go @@ -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 diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 276eada..2cc0b73 100755 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -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) diff --git a/backend/internal/services/application_service.go b/backend/internal/services/application_service.go index 568d235..a96eeae 100644 --- a/backend/internal/services/application_service.go +++ b/backend/internal/services/application_service.go @@ -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 } diff --git a/backend/internal/services/seeder_service.go b/backend/internal/services/seeder_service.go index 7883624..3b3516f 100644 --- a/backend/internal/services/seeder_service.go +++ b/backend/internal/services/seeder_service.go @@ -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)) } } } diff --git a/backend/migrations/045_add_answers_to_applications.sql b/backend/migrations/045_add_answers_to_applications.sql new file mode 100644 index 0000000..839a037 --- /dev/null +++ b/backend/migrations/045_add_answers_to_applications.sql @@ -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.)'; diff --git a/frontend/src/app/jobs/[id]/apply/page.tsx b/frontend/src/app/jobs/[id]/apply/page.tsx index aae8117..d49db87 100644 --- a/frontend/src/app/jobs/[id]/apply/page.tsx +++ b/frontend/src/app/jobs/[id]/apply/page.tsx @@ -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>({}); + + // 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) => { 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 ( -
- -
- ); + return ( +
+ +
+ ); } if (!job) { return ( -
-

Job not found

-
+
+

Job not found

+
); } @@ -765,6 +796,101 @@ export default function JobApplicationPage({ )} + + {/* Step 5: Custom Job Questions */} + {currentStep === 5 && hasCustomQuestions && ( +
+ {jobQuestions.map((q) => ( +
+ + + {q.type === "text" && ( + handleCustomAnswerChange(q.id, e.target.value)} + placeholder="Your answer..." + /> + )} + + {q.type === "textarea" && ( +