gohorsejobs/frontend/src/app/register/page.tsx
Tiago Yamamoto 8ee0d59a61 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>
2026-02-23 20:26:49 -06:00

620 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import {
Building2,
Mail,
Lock,
Eye,
EyeOff,
Phone,
Globe,
MapPin,
ArrowLeft,
CheckCircle2,
Briefcase,
Loader2,
} from "lucide-react";
import { motion } from "framer-motion";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { paymentsApi } from "@/lib/api";
import { Navbar } from "@/components/navbar";
import { Footer } from "@/components/footer";
const COUNTRIES = [
"Argentina", "Australia", "Austria", "Belgium", "Brazil", "Canada", "Chile",
"China", "Colombia", "Czech Republic", "Denmark", "Egypt", "Finland", "France",
"Germany", "Greece", "Hong Kong", "Hungary", "India", "Indonesia", "Ireland",
"Israel", "Italy", "Japan", "Malaysia", "Mexico", "Netherlands", "New Zealand",
"Nigeria", "Norway", "Pakistan", "Peru", "Philippines", "Poland", "Portugal",
"Romania", "Saudi Arabia", "Singapore", "South Africa", "South Korea", "Spain",
"Sweden", "Switzerland", "Taiwan", "Thailand", "Turkey", "Ukraine",
"United Arab Emirates", "United Kingdom", "United States", "Vietnam",
];
const YEARS_IN_MARKET = [
{ value: "<1", label: "Less than 1 year" },
{ value: "1-3", label: "1 3 years" },
{ value: "3-5", label: "3 5 years" },
{ value: "5-10", label: "5 10 years" },
{ value: "10+", label: "10+ years" },
];
interface Plan {
id: string;
name: string;
price: string;
period: string;
priceId: string | null; // Stripe Price ID — null for free/enterprise
features: string[];
recommended: boolean;
cta: string;
}
const PLANS: Plan[] = [
{
id: "free",
name: "Starter",
price: "$0",
period: "/ month",
priceId: null,
features: [
"1 active job posting",
"Resume collection",
"Basic applicant dashboard",
"Email notifications",
],
recommended: false,
cta: "Get started free",
},
{
id: "pro",
name: "Pro",
price: "$49",
period: "/ month",
priceId: process.env.NEXT_PUBLIC_STRIPE_PRO_PRICE_ID || null,
features: [
"Unlimited job postings",
"Priority in search results",
"Talent pool access",
"Priority support",
"Analytics dashboard",
],
recommended: true,
cta: "Start Pro",
},
{
id: "enterprise",
name: "Enterprise",
price: "Custom",
period: "",
priceId: null,
features: [
"Everything in Pro",
"API integration",
"Dedicated account manager",
"Advanced reporting",
"White-label option",
],
recommended: false,
cta: "Contact sales",
},
];
const companySchema = z
.object({
companyName: z.string().min(2, "Company name is required"),
email: z.string().email("Invalid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
confirmPassword: z.string(),
phone: z.string().optional(),
country: z.string().min(1, "Country is required"),
city: z.string().optional(),
website: z.string().url("Invalid URL").optional().or(z.literal("")),
yearsInMarket: z.string().min(1, "Years in market is required"),
description: z.string().min(20, "Description must be at least 20 characters"),
taxId: z.string().optional(),
acceptTerms: z.boolean().refine((v) => v === true, "You must accept the terms"),
})
.refine((d) => d.password === d.confirmPassword, {
message: "Passwords do not match",
path: ["confirmPassword"],
});
type CompanyFormData = z.infer<typeof companySchema>;
const STEPS = ["Plan", "Details", "Terms", "Confirm"];
export default function RegisterPage() {
const router = useRouter();
const [step, setStep] = useState(1);
const [selectedPlan, setSelectedPlan] = useState<Plan | null>(null);
const [showPw, setShowPw] = useState(false);
const [showConfirmPw, setShowConfirmPw] = useState(false);
const [loading, setLoading] = useState(false);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const {
register,
handleSubmit,
formState: { errors },
setValue,
trigger,
watch,
} = useForm<CompanyFormData>({
resolver: zodResolver(companySchema),
defaultValues: { yearsInMarket: "", country: "" },
});
const watchedCountry = watch("country");
const nextStep = async () => {
setErrorMsg(null);
if (step === 1) {
if (!selectedPlan) {
setErrorMsg("Please select a plan to continue.");
return;
}
setStep(2);
} else if (step === 2) {
const ok = await trigger([
"companyName", "email", "password", "confirmPassword",
"country", "yearsInMarket", "description",
]);
if (ok) setStep(3);
} else if (step === 3) {
const ok = await trigger("acceptTerms");
if (ok) setStep(4);
}
};
const onSubmit = async (data: CompanyFormData) => {
if (!selectedPlan) return;
setLoading(true);
setErrorMsg(null);
try {
const { registerCompany } = await import("@/lib/auth");
const res = await registerCompany({
companyName: data.companyName,
email: data.email,
phone: data.phone,
password: data.password,
document: data.taxId,
website: data.website,
yearsInMarket: data.yearsInMarket,
description: data.description,
city: data.city,
country: data.country,
});
// Auto-login if token returned
if (res?.token) {
localStorage.setItem("auth_token", res.token);
localStorage.setItem("token", res.token);
localStorage.setItem("user", JSON.stringify({
name: data.companyName,
email: data.email,
role: "recruiter",
}));
}
// Enterprise → redirect to contact / dashboard
if (selectedPlan.id === "enterprise") {
router.push("/dashboard?plan=enterprise");
return;
}
// Free plan → go straight to dashboard
if (!selectedPlan.priceId) {
router.push("/dashboard");
return;
}
// Paid plan → create Stripe checkout session
try {
const origin = window.location.origin;
const checkout = await paymentsApi.createSubscriptionCheckout({
priceId: selectedPlan.priceId,
successUrl: `${origin}/dashboard?payment=success`,
cancelUrl: `${origin}/register?payment=cancelled`,
});
if (checkout.checkoutUrl) {
window.location.href = checkout.checkoutUrl;
return;
}
} catch (stripeError) {
console.warn("Stripe checkout failed, falling back:", stripeError);
// Fallback: redirect to dashboard with a pending payment notice
router.push("/dashboard?payment=pending");
return;
}
router.push("/dashboard");
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Registration failed. Please try again.";
setErrorMsg(msg);
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-background flex flex-col">
<Navbar />
<main className="flex-1 container mx-auto py-10 px-4 max-w-5xl">
{/* Step indicator */}
<div className="mb-10 max-w-3xl mx-auto">
<div className="flex items-center justify-between relative">
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-full h-0.5 bg-muted -z-10" />
{STEPS.map((label, i) => (
<div key={label} className="flex flex-col items-center gap-1">
<div
className={`w-9 h-9 rounded-full flex items-center justify-center font-bold text-sm transition-colors z-10 ${
step > i + 1
? "bg-green-500 text-white"
: step === i + 1
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
}`}
>
{step > i + 1 ? <CheckCircle2 className="w-4 h-4" /> : i + 1}
</div>
<span className="text-xs text-muted-foreground hidden sm:block">{label}</span>
</div>
))}
</div>
</div>
{errorMsg && (
<Alert variant="destructive" className="mb-6 max-w-2xl mx-auto">
<AlertDescription>{errorMsg}</AlertDescription>
</Alert>
)}
<form onSubmit={handleSubmit(onSubmit)}>
{/* STEP 1: PLAN SELECTION */}
{step === 1 && (
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className="space-y-8">
<div className="text-center space-y-2">
<h1 className="text-3xl font-bold">Choose your plan</h1>
<p className="text-muted-foreground">Start hiring the best talent worldwide today.</p>
</div>
<div className="grid md:grid-cols-3 gap-6">
{PLANS.map((plan) => (
<Card
key={plan.id}
className={`relative cursor-pointer transition-all hover:shadow-lg ${
selectedPlan?.id === plan.id ? "border-primary ring-2 ring-primary/20" : ""
}`}
onClick={() => { setSelectedPlan(plan); setErrorMsg(null); }}
>
{plan.recommended && (
<div className="absolute -top-3 left-1/2 -translate-x-1/2 bg-primary text-primary-foreground text-xs px-3 py-1 rounded-full whitespace-nowrap">
Recommended
</div>
)}
<CardHeader className="pb-2">
<CardTitle className="text-lg">{plan.name}</CardTitle>
<div className="mt-1">
<span className="text-4xl font-bold">{plan.price}</span>
{plan.period && (
<span className="text-muted-foreground text-sm ml-1">{plan.period}</span>
)}
</div>
</CardHeader>
<CardContent>
<ul className="space-y-2 text-sm">
{plan.features.map((f) => (
<li key={f} className="flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-green-500 shrink-0" />
{f}
</li>
))}
</ul>
</CardContent>
<CardFooter>
<div
className={`w-full h-4 rounded-full border-2 flex items-center justify-center transition-colors ${
selectedPlan?.id === plan.id ? "border-primary" : "border-muted"
}`}
>
{selectedPlan?.id === plan.id && (
<div className="w-2.5 h-2.5 bg-primary rounded-full" />
)}
</div>
</CardFooter>
</Card>
))}
</div>
</motion.div>
)}
{/* STEP 2: COMPANY DETAILS */}
{step === 2 && (
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className="max-w-2xl mx-auto space-y-5">
<div className="text-center space-y-1 mb-4">
<h2 className="text-2xl font-bold">Company details</h2>
<p className="text-muted-foreground">Fill in your company and account information.</p>
</div>
{/* Company name + Years in market */}
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Company name *</Label>
<div className="relative">
<Building2 className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input {...register("companyName")} className="pl-10" placeholder="Acme Inc." />
</div>
{errors.companyName && <p className="text-xs text-destructive">{errors.companyName.message}</p>}
</div>
<div className="space-y-2">
<Label>Years in business *</Label>
<Select onValueChange={(v) => { setValue("yearsInMarket", v); trigger("yearsInMarket"); }}>
<SelectTrigger>
<SelectValue placeholder="Select…" />
</SelectTrigger>
<SelectContent>
{YEARS_IN_MARKET.map((y) => (
<SelectItem key={y.value} value={y.value}>{y.label}</SelectItem>
))}
</SelectContent>
</Select>
{errors.yearsInMarket && <p className="text-xs text-destructive">{errors.yearsInMarket.message}</p>}
</div>
</div>
{/* Description */}
<div className="space-y-2">
<Label>Company description *</Label>
<Textarea
{...register("description")}
placeholder="Brief description of what your company does, its mission and values…"
className="min-h-[90px]"
/>
{errors.description && <p className="text-xs text-destructive">{errors.description.message}</p>}
</div>
{/* Email + Phone */}
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Email *</Label>
<div className="relative">
<Mail className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input type="email" {...register("email")} className="pl-10" placeholder="hr@acme.com" />
</div>
{errors.email && <p className="text-xs text-destructive">{errors.email.message}</p>}
</div>
<div className="space-y-2">
<Label>Phone <span className="text-muted-foreground text-xs">(optional)</span></Label>
<div className="relative">
<Phone className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input {...register("phone")} className="pl-10" placeholder="+1 555 000 0000" />
</div>
</div>
</div>
{/* Password + Confirm */}
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Password *</Label>
<div className="relative">
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
type={showPw ? "text" : "password"}
{...register("password")}
className="pl-10 pr-10"
placeholder="Min. 8 characters"
/>
<button type="button" onClick={() => setShowPw(!showPw)} className="absolute right-3 top-3 text-muted-foreground">
{showPw ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
{errors.password && <p className="text-xs text-destructive">{errors.password.message}</p>}
</div>
<div className="space-y-2">
<Label>Confirm password *</Label>
<div className="relative">
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
type={showConfirmPw ? "text" : "password"}
{...register("confirmPassword")}
className="pl-10 pr-10"
placeholder="Repeat password"
/>
<button type="button" onClick={() => setShowConfirmPw(!showConfirmPw)} className="absolute right-3 top-3 text-muted-foreground">
{showConfirmPw ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
{errors.confirmPassword && <p className="text-xs text-destructive">{errors.confirmPassword.message}</p>}
</div>
</div>
{/* Country + City */}
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Country *</Label>
<Select
value={watchedCountry}
onValueChange={(v) => { setValue("country", v); trigger("country"); }}
>
<SelectTrigger>
<SelectValue placeholder="Select country…" />
</SelectTrigger>
<SelectContent className="max-h-60">
{COUNTRIES.map((c) => (
<SelectItem key={c} value={c}>{c}</SelectItem>
))}
</SelectContent>
</Select>
{errors.country && <p className="text-xs text-destructive">{errors.country.message}</p>}
</div>
<div className="space-y-2">
<Label>City <span className="text-muted-foreground text-xs">(optional)</span></Label>
<div className="relative">
<MapPin className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input {...register("city")} className="pl-10" placeholder="San Francisco" />
</div>
</div>
</div>
{/* Website + Tax ID */}
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Website <span className="text-muted-foreground text-xs">(optional)</span></Label>
<div className="relative">
<Globe className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input {...register("website")} className="pl-10" placeholder="https://acme.com" />
</div>
{errors.website && <p className="text-xs text-destructive">{errors.website.message}</p>}
</div>
<div className="space-y-2">
<Label>Tax ID / Business number <span className="text-muted-foreground text-xs">(optional)</span></Label>
<Input {...register("taxId")} placeholder="EIN, CNPJ, ABN, CRN, etc." />
</div>
</div>
</motion.div>
)}
{/* STEP 3: TERMS */}
{step === 3 && (
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className="max-w-2xl mx-auto space-y-6">
<div className="text-center space-y-1 mb-4">
<h2 className="text-2xl font-bold">Terms & Conditions</h2>
<p className="text-muted-foreground">Please read carefully before proceeding.</p>
</div>
<div className="h-64 overflow-y-auto border rounded-md p-4 bg-muted/20 text-sm leading-relaxed space-y-3">
<h4 className="font-bold">1. Acceptance</h4>
<p>By creating an account on GoHorse Jobs, you agree to these terms and our Privacy Policy. If you do not agree, please do not use the platform.</p>
<h4 className="font-bold">2. Platform Use</h4>
<p>The platform is designed to connect companies with candidates globally. You agree to post only accurate, lawful, and non-discriminatory job listings.</p>
<h4 className="font-bold">3. Payments & Subscriptions</h4>
<p>Paid plans are billed on a recurring basis. You may cancel at any time. Refunds are subject to our refund policy. All prices are in USD unless stated otherwise.</p>
<h4 className="font-bold">4. Data & Privacy</h4>
<p>We process personal data in accordance with applicable laws (GDPR, LGPD, etc.). Candidate data is shared only with the posting employer. You are responsible for handling applicant data lawfully.</p>
<h4 className="font-bold">5. Prohibited Conduct</h4>
<p>You must not post fraudulent, misleading, or discriminatory job listings. Violations may result in immediate account suspension.</p>
<h4 className="font-bold">6. Liability</h4>
<p>GoHorse Jobs is not liable for any employment decisions made based on platform use. The platform is provided "as is" with no warranties of any kind.</p>
</div>
<div className="flex items-start space-x-2 pt-2">
<Checkbox id="terms" onCheckedChange={(v) => setValue("acceptTerms", v as boolean)} />
<label htmlFor="terms" className="text-sm leading-relaxed cursor-pointer">
I have read and agree to the{" "}
<Link href="/terms" className="text-primary underline">Terms of Use</Link>
{" "}and{" "}
<Link href="/privacy" className="text-primary underline">Privacy Policy</Link>
{" "}of GoHorse Jobs.
</label>
</div>
{errors.acceptTerms && <p className="text-xs text-destructive">{errors.acceptTerms.message}</p>}
</motion.div>
)}
{/* STEP 4: CONFIRM & SUBMIT */}
{step === 4 && (
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className="max-w-md mx-auto space-y-8 text-center">
<div className="w-20 h-20 bg-primary/10 rounded-full flex items-center justify-center mx-auto text-primary">
{selectedPlan?.id === "free" ? (
<CheckCircle2 className="w-10 h-10" />
) : selectedPlan?.id === "enterprise" ? (
<Briefcase className="w-10 h-10" />
) : (
<Globe className="w-10 h-10" />
)}
</div>
<div className="space-y-2">
<h2 className="text-2xl font-bold">Almost there!</h2>
<p className="text-muted-foreground">
You selected the <strong>{selectedPlan?.name}</strong> plan.
</p>
<p className="text-sm text-muted-foreground">
{selectedPlan?.id === "free"
? "Your account will be created and you'll be redirected to the dashboard."
: selectedPlan?.id === "enterprise"
? "Your account will be created and our team will reach out to discuss your needs."
: "Your account will be created, then you'll be redirected to Stripe to complete payment."}
</p>
</div>
<div className="bg-muted p-4 rounded-lg text-left space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Plan</span>
<span className="font-medium">{selectedPlan?.name}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Billing</span>
<span className="font-bold">
{selectedPlan?.price}{selectedPlan?.period ? ` ${selectedPlan.period}` : ""}
</span>
</div>
</div>
</motion.div>
)}
{/* Navigation */}
<div className="mt-8 flex justify-between max-w-2xl mx-auto px-0">
{step > 1 ? (
<Button type="button" variant="outline" onClick={() => setStep(step - 1)} disabled={loading}>
<ArrowLeft className="w-4 h-4 mr-2" /> Back
</Button>
) : (
<div />
)}
{step < 4 ? (
<Button type="button" onClick={nextStep} className="min-w-[120px]">
Next
</Button>
) : (
<Button type="submit" disabled={loading} className="min-w-[160px]">
{loading ? (
<><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Processing</>
) : selectedPlan?.id === "free" ? (
"Create account"
) : selectedPlan?.id === "enterprise" ? (
"Create account"
) : (
"Create account & pay"
)}
</Button>
)}
</div>
</form>
</main>
<Footer />
</div>
);
}