feat: Enhance platform with funny jobs, FAQ, Skeleton UI, and Tests

This commit is contained in:
Tiago Yamamoto 2025-12-22 23:48:56 -03:00
parent 407979c6dc
commit 743b2842c0
23 changed files with 2057 additions and 255 deletions

View file

@ -16,24 +16,26 @@ import (
)
type CoreHandlers struct {
loginUC *auth.LoginUseCase
createCompanyUC *tenant.CreateCompanyUseCase
createUserUC *user.CreateUserUseCase
listUsersUC *user.ListUsersUseCase
deleteUserUC *user.DeleteUserUseCase
listCompaniesUC *tenant.ListCompaniesUseCase
auditService *services.AuditService
loginUC *auth.LoginUseCase
registerCandidateUC *auth.RegisterCandidateUseCase
createCompanyUC *tenant.CreateCompanyUseCase
createUserUC *user.CreateUserUseCase
listUsersUC *user.ListUsersUseCase
deleteUserUC *user.DeleteUserUseCase
listCompaniesUC *tenant.ListCompaniesUseCase
auditService *services.AuditService
}
func NewCoreHandlers(l *auth.LoginUseCase, c *tenant.CreateCompanyUseCase, u *user.CreateUserUseCase, list *user.ListUsersUseCase, del *user.DeleteUserUseCase, lc *tenant.ListCompaniesUseCase, auditService *services.AuditService) *CoreHandlers {
func NewCoreHandlers(l *auth.LoginUseCase, reg *auth.RegisterCandidateUseCase, c *tenant.CreateCompanyUseCase, u *user.CreateUserUseCase, list *user.ListUsersUseCase, del *user.DeleteUserUseCase, lc *tenant.ListCompaniesUseCase, auditService *services.AuditService) *CoreHandlers {
return &CoreHandlers{
loginUC: l,
createCompanyUC: c,
createUserUC: u,
listUsersUC: list,
deleteUserUC: del,
listCompaniesUC: lc,
auditService: auditService,
loginUC: l,
registerCandidateUC: reg,
createCompanyUC: c,
createUserUC: u,
listUsersUC: list,
deleteUserUC: del,
listCompaniesUC: lc,
auditService: auditService,
}
}
@ -82,6 +84,31 @@ func (h *CoreHandlers) Login(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(resp)
}
// RegisterCandidate handles public registration for candidates
func (h *CoreHandlers) RegisterCandidate(w http.ResponseWriter, r *http.Request) {
var req dto.RegisterCandidateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request payload", http.StatusBadRequest)
return
}
if req.Email == "" || req.Password == "" || req.Name == "" {
http.Error(w, "Name, Email and Password are required", http.StatusBadRequest)
return
}
resp, err := h.registerCandidateUC.Execute(r.Context(), req)
if err != nil {
// Log removed to fix compilation error (LogAction missing)
http.Error(w, err.Error(), http.StatusConflict)
return
}
w.WriteHeader(http.StatusCreated)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
// CreateCompany registers a new tenant (Company) and its admin.
// @Summary Create Company (Tenant)
// @Description Registers a new company and creates an initial admin user.

View file

@ -4,15 +4,16 @@ import "time"
// User represents a user within a specific Tenant (Company).
type User struct {
ID string `json:"id"`
TenantID string `json:"tenant_id"` // Link to Company
Name string `json:"name"`
Email string `json:"email"`
PasswordHash string `json:"-"`
Roles []Role `json:"roles"`
Status string `json:"status"` // "ACTIVE", "INACTIVE"
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID string `json:"id"`
TenantID string `json:"tenant_id"` // Link to Company
Name string `json:"name"`
Email string `json:"email"`
PasswordHash string `json:"-"`
Roles []Role `json:"roles"`
Status string `json:"status"` // "ACTIVE", "INACTIVE"
Metadata map[string]interface{} `json:"metadata"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// NewUser creates a new User instance.

View file

@ -27,3 +27,11 @@ type UserResponse struct {
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
}
type RegisterCandidateRequest struct {
Name string `json:"name"`
Email string `json:"email"`
Password string `json:"password"`
Username string `json:"username"`
Phone string `json:"phone"`
}

View file

@ -0,0 +1,81 @@
package auth
import (
"context"
"errors"
"github.com/google/uuid"
"github.com/rede5/gohorsejobs/backend/internal/core/domain/entity"
"github.com/rede5/gohorsejobs/backend/internal/core/dto"
"github.com/rede5/gohorsejobs/backend/internal/core/ports"
)
type RegisterCandidateUseCase struct {
userRepo ports.UserRepository
authService ports.AuthService
}
func NewRegisterCandidateUseCase(uRepo ports.UserRepository, auth ports.AuthService) *RegisterCandidateUseCase {
return &RegisterCandidateUseCase{
userRepo: uRepo,
authService: auth,
}
}
func (uc *RegisterCandidateUseCase) Execute(ctx context.Context, input dto.RegisterCandidateRequest) (*dto.AuthResponse, error) {
// 1. Check if email exists
exists, _ := uc.userRepo.FindByEmail(ctx, input.Email)
if exists != nil {
return nil, errors.New("email already registered")
}
// 2. Hash Password
hashed, err := uc.authService.HashPassword(input.Password)
if err != nil {
return nil, err
}
// 3. Create Entity
// Candidates belong to their own tenant/workspace in this model logic
candidateTenantID := uuid.New().String()
user := entity.NewUser(uuid.New().String(), candidateTenantID, input.Name, input.Email)
user.PasswordHash = hashed
// Set Metadata
user.Metadata = map[string]interface{}{
"phone": input.Phone,
"username": input.Username,
}
// Assign Role
user.AssignRole(entity.Role{Name: "CANDIDATE"})
saved, err := uc.userRepo.Save(ctx, user)
if err != nil {
return nil, err
}
roles := make([]string, len(saved.Roles))
for i, r := range saved.Roles {
roles[i] = r.Name
}
// 4. Generate Token (Auto-login)
token, err := uc.authService.GenerateToken(saved.ID, saved.TenantID, roles)
if err != nil {
return nil, err
}
return &dto.AuthResponse{
Token: token,
User: dto.UserResponse{
ID: saved.ID,
Name: saved.Name,
Email: saved.Email,
Roles: roles,
Status: saved.Status,
CreatedAt: saved.CreatedAt,
},
}, nil
}

View file

@ -0,0 +1,148 @@
package auth_test
import (
"context"
"testing"
"github.com/rede5/gohorsejobs/backend/internal/core/domain/entity"
"github.com/rede5/gohorsejobs/backend/internal/core/dto"
"github.com/rede5/gohorsejobs/backend/internal/core/usecases/auth"
)
// --- Mocks ---
type MockUserRepo struct {
SaveFunc func(ctx context.Context, user *entity.User) (*entity.User, error)
FindByEmailFunc func(ctx context.Context, email string) (*entity.User, error)
}
func (m *MockUserRepo) Save(ctx context.Context, user *entity.User) (*entity.User, error) {
if m.SaveFunc != nil {
return m.SaveFunc(ctx, user)
}
return user, nil
}
func (m *MockUserRepo) FindByEmail(ctx context.Context, email string) (*entity.User, error) {
if m.FindByEmailFunc != nil {
return m.FindByEmailFunc(ctx, email)
}
return nil, nil // Not found by default
}
func (m *MockUserRepo) FindByID(ctx context.Context, id string) (*entity.User, error) {
return nil, nil
}
func (m *MockUserRepo) FindAllByTenant(ctx context.Context, tenantID string, l, o int) ([]*entity.User, int, error) {
return nil, 0, nil
}
func (m *MockUserRepo) Update(ctx context.Context, user *entity.User) (*entity.User, error) {
return nil, nil
}
func (m *MockUserRepo) Delete(ctx context.Context, id string) error { return nil }
type MockAuthService struct {
HashPasswordFunc func(password string) (string, error)
GenerateTokenFunc func(userID, tenantID string, roles []string) (string, error)
}
func (m *MockAuthService) HashPassword(password string) (string, error) {
if m.HashPasswordFunc != nil {
return m.HashPasswordFunc(password)
}
return "hashed_" + password, nil
}
func (m *MockAuthService) GenerateToken(userID, tenantID string, roles []string) (string, error) {
if m.GenerateTokenFunc != nil {
return m.GenerateTokenFunc(userID, tenantID, roles)
}
return "mock_token", nil
}
func (m *MockAuthService) VerifyPassword(hash, password string) bool { return true }
func (m *MockAuthService) ValidateToken(token string) (map[string]interface{}, error) {
return nil, nil
}
// --- Tests ---
func TestRegisterCandidateUseCase_Execute(t *testing.T) {
t.Run("Success", func(t *testing.T) {
repo := &MockUserRepo{
FindByEmailFunc: func(ctx context.Context, email string) (*entity.User, error) {
return nil, nil // Email not found
},
SaveFunc: func(ctx context.Context, user *entity.User) (*entity.User, error) {
user.ID = "new-user-id"
return user, nil
},
}
authSvc := &MockAuthService{}
uc := auth.NewRegisterCandidateUseCase(repo, authSvc)
input := dto.RegisterCandidateRequest{
Name: "John Doe",
Email: "john@example.com",
Password: "password123",
Username: "johndoe",
Phone: "+1234567890",
}
resp, err := uc.Execute(context.Background(), input)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if resp == nil {
t.Fatal("Expected response, got nil")
}
if resp.User.Email != input.Email {
t.Errorf("Expected email %s, got %s", input.Email, resp.User.Email)
}
if resp.Token != "mock_token" {
t.Errorf("Expected token mock_token, got %s", resp.Token)
}
})
t.Run("EmailAlreadyExists", func(t *testing.T) {
repo := &MockUserRepo{
FindByEmailFunc: func(ctx context.Context, email string) (*entity.User, error) {
return &entity.User{ID: "existing"}, nil // Found
},
}
authSvc := &MockAuthService{}
uc := auth.NewRegisterCandidateUseCase(repo, authSvc)
_, err := uc.Execute(context.Background(), dto.RegisterCandidateRequest{Email: "exists@example.com", Password: "123"})
if err == nil {
t.Error("Expected error, got nil")
}
if err.Error() != "email already registered" {
t.Errorf("Expected 'email already registered', got %v", err)
}
})
t.Run("MetadataSaved", func(t *testing.T) {
// Verify if username/phone ends up in metadata
var capturedUser *entity.User
repo := &MockUserRepo{
SaveFunc: func(ctx context.Context, user *entity.User) (*entity.User, error) {
capturedUser = user
return user, nil
},
}
authSvc := &MockAuthService{}
uc := auth.NewRegisterCandidateUseCase(repo, authSvc)
uc.Execute(context.Background(), dto.RegisterCandidateRequest{Username: "coder", Phone: "999"})
if capturedUser == nil {
t.Fatal("User not saved")
}
if capturedUser.Metadata["username"] != "coder" {
t.Errorf("Expected metadata username 'coder', got %v", capturedUser.Metadata["username"])
}
if capturedUser.Metadata["phone"] != "999" {
t.Errorf("Expected metadata phone '999', got %v", capturedUser.Metadata["phone"])
}
})
}

View file

@ -3,6 +3,7 @@ package postgres
import (
"context"
"database/sql"
"encoding/json"
"time"
"github.com/google/uuid"
@ -28,10 +29,16 @@ func (r *UserRepository) Save(ctx context.Context, user *entity.User) (*entity.U
}
defer tx.Rollback()
// Serialize metadata
metadata, err := json.Marshal(user.Metadata)
if err != nil {
return nil, err
}
// 1. Insert User
query := `
INSERT INTO core_users (id, tenant_id, name, email, password_hash, status, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
INSERT INTO core_users (id, tenant_id, name, email, password_hash, status, metadata, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
`
_, err = tx.ExecContext(ctx, query,
user.ID,
@ -40,6 +47,7 @@ func (r *UserRepository) Save(ctx context.Context, user *entity.User) (*entity.U
user.Email,
user.PasswordHash,
user.Status,
metadata,
user.CreatedAt,
user.UpdatedAt,
)

View file

@ -50,6 +50,7 @@ func NewRouter() http.Handler {
// UseCases
loginUC := authUC.NewLoginUseCase(userRepo, authService)
registerCandidateUC := authUC.NewRegisterCandidateUseCase(userRepo, authService)
createCompanyUC := tenantUC.NewCreateCompanyUseCase(companyRepo, userRepo, authService)
listCompaniesUC := tenantUC.NewListCompaniesUseCase(companyRepo)
createUserUC := userUC.NewCreateUserUseCase(userRepo, authService)
@ -58,7 +59,7 @@ func NewRouter() http.Handler {
// Handlers & Middleware
auditService := services.NewAuditService(database.DB)
coreHandlers := apiHandlers.NewCoreHandlers(loginUC, createCompanyUC, createUserUC, listUsersUC, deleteUserUC, listCompaniesUC, auditService)
coreHandlers := apiHandlers.NewCoreHandlers(loginUC, registerCandidateUC, createCompanyUC, createUserUC, listUsersUC, deleteUserUC, listCompaniesUC, auditService)
authMiddleware := middleware.NewMiddleware(authService)
adminService := services.NewAdminService(database.DB)
adminHandlers := apiHandlers.NewAdminHandlers(adminService, auditService, jobService)
@ -123,6 +124,7 @@ func NewRouter() http.Handler {
// --- CORE ROUTES ---
// Public
mux.HandleFunc("POST /api/v1/auth/login", coreHandlers.Login)
mux.HandleFunc("POST /api/v1/auth/register", coreHandlers.RegisterCandidate)
mux.HandleFunc("POST /api/v1/companies", coreHandlers.CreateCompany)
mux.HandleFunc("GET /api/v1/companies", coreHandlers.ListCompanies)

View file

@ -12,15 +12,21 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Label } from "@/components/ui/label"
import { Mail, MessageSquare, Phone, MapPin } from "lucide-react"
import { useTranslation } from "@/lib/i18n"
import Link from "next/link"
export default function ContactPage() {
const [submitted, setSubmitted] = useState(false)
const { t } = useTranslation()
const [ticketId, setTicketId] = useState<string | null>(null)
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
setSubmitted(true)
setTimeout(() => setSubmitted(false), 3000)
// Simulate API call
setTimeout(() => {
const id = `TKT-${Math.floor(Math.random() * 9000) + 1000}`
setTicketId(id)
setSubmitted(true)
}, 1000)
}
return (
@ -51,36 +57,57 @@ export default function ContactPage() {
<CardDescription>{t("contact.form.description")}</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">{t("contact.form.fields.name.label")}</Label>
<Input id="name" placeholder={t("contact.form.fields.name.placeholder")} required />
{submitted ? (
<div className="text-center py-8 space-y-4 animate-in fade-in zoom-in">
<div className="mx-auto w-12 h-12 bg-green-100 text-green-600 rounded-full flex items-center justify-center">
<MessageSquare className="h-6 w-6" />
</div>
<h3 className="text-xl font-bold">{t("contact.form.actions.success")}</h3>
<p className="text-muted-foreground">
{t("contact.form.ticket_label", { defaultValue: "Your Ticket ID:" })}
</p>
<div className="text-2xl font-mono font-bold text-primary bg-primary/10 py-2 rounded">
{ticketId}
</div>
<p className="text-sm text-muted-foreground">
{t("contact.form.ticket_desc", { defaultValue: "Save this ID for future reference." })}
</p>
<Button variant="outline" onClick={() => { setSubmitted(false); setTicketId(null); }}>
{t("contact.form.send_another", { defaultValue: "Send another message" })}
</Button>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">{t("contact.form.fields.name.label")}</Label>
<Input id="name" placeholder={t("contact.form.fields.name.placeholder")} required />
</div>
<div className="space-y-2">
<Label htmlFor="email">{t("contact.form.fields.email.label")}</Label>
<Input id="email" type="email" placeholder={t("contact.form.fields.email.placeholder")} required />
</div>
<div className="space-y-2">
<Label htmlFor="email">{t("contact.form.fields.email.label")}</Label>
<Input id="email" type="email" placeholder={t("contact.form.fields.email.placeholder")} required />
</div>
<div className="space-y-2">
<Label htmlFor="subject">{t("contact.form.fields.subject.label")}</Label>
<Input id="subject" placeholder={t("contact.form.fields.subject.placeholder")} required />
</div>
<div className="space-y-2">
<Label htmlFor="subject">{t("contact.form.fields.subject.label")}</Label>
<Input id="subject" placeholder={t("contact.form.fields.subject.placeholder")} required />
</div>
<div className="space-y-2">
<Label htmlFor="message">{t("contact.form.fields.message.label")}</Label>
<Textarea
id="message"
placeholder={t("contact.form.fields.message.placeholder")}
rows={5}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="message">{t("contact.form.fields.message.label")}</Label>
<Textarea
id="message"
placeholder={t("contact.form.fields.message.placeholder")}
rows={5}
required
/>
</div>
<Button type="submit" className="w-full cursor-pointer" disabled={submitted}>
{submitted ? t("contact.form.actions.success") : t("contact.form.actions.submit")}
</Button>
</form>
<Button type="submit" className="w-full cursor-pointer" disabled={submitted}>
{t("contact.form.actions.submit")}
</Button>
</form>
)}
</CardContent>
</Card>
@ -139,9 +166,11 @@ export default function ContactPage() {
<CardContent className="pt-6">
<h3 className="font-semibold mb-2">{t("contact.faq.title")}</h3>
<p className="text-sm text-muted-foreground mb-4">{t("contact.faq.description")}</p>
<Button variant="outline" className="w-full cursor-pointer bg-transparent">
{t("contact.faq.button")}
</Button>
<Link href="/faq">
<Button variant="outline" className="w-full cursor-pointer bg-transparent">
{t("contact.faq.button")}
</Button>
</Link>
</CardContent>
</Card>
</div>

View file

@ -0,0 +1,348 @@
"use client"
import { useState, useEffect } from "react"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Input } from "@/components/ui/input"
import {
Search,
Filter,
Mail,
Phone,
FileText,
Calendar,
MessageSquare,
MoreVertical,
Eye,
Check,
X,
Clock,
Star,
} from "lucide-react"
// Mock data - in production this would come from API
const mockApplications = [
{
id: "1",
candidateName: "Ana Silva",
email: "ana.silva@email.com",
phone: "+55 11 99999-1111",
jobTitle: "Senior Full Stack Developer",
appliedAt: "2 hours ago",
status: "pending",
resumeUrl: "https://cdn.gohorsejobs.com/docs/ana_silva_cv.pdf",
message: "Tenho 5 anos de experiência como desenvolvedora Full Stack...",
},
{
id: "2",
candidateName: "Carlos Santos",
email: "carlos.santos@email.com",
phone: "+55 11 98888-2222",
jobTitle: "Designer UX/UI",
appliedAt: "5 hours ago",
status: "reviewed",
resumeUrl: "https://cdn.gohorsejobs.com/docs/carlos_santos_portfolio.pdf",
message: "Designer UX/UI com 3 anos de experiência...",
},
{
id: "3",
candidateName: "Maria Oliveira",
email: "maria.oliveira@email.com",
phone: "+55 21 97777-3333",
jobTitle: "Product Manager",
appliedAt: "1 day ago",
status: "shortlisted",
resumeUrl: "https://cdn.gohorsejobs.com/docs/maria_oliveira_resume.pdf",
message: "Product Manager com 4 anos de experiência...",
},
{
id: "4",
candidateName: "Pedro Costa",
email: "pedro.costa@email.com",
phone: "+55 11 96666-4444",
jobTitle: "Backend Developer",
appliedAt: "2 days ago",
status: "hired",
resumeUrl: "https://cdn.gohorsejobs.com/docs/pedro_costa_cv.pdf",
message: "Desenvolvedor Backend com experiência em Go...",
},
{
id: "5",
candidateName: "Juliana Ferreira",
email: "juliana.ferreira@email.com",
phone: "+55 11 95555-5555",
jobTitle: "Data Scientist",
appliedAt: "3 days ago",
status: "rejected",
resumeUrl: "https://cdn.gohorsejobs.com/docs/juliana_ferreira_cv.pdf",
message: "Data Scientist com mestrado em Machine Learning...",
},
]
const statusConfig = {
pending: { label: "Pending", color: "bg-yellow-100 text-yellow-800 border-yellow-200", icon: Clock },
reviewed: { label: "Reviewed", color: "bg-blue-100 text-blue-800 border-blue-200", icon: Eye },
shortlisted: { label: "Shortlisted", color: "bg-purple-100 text-purple-800 border-purple-200", icon: Star },
hired: { label: "Hired", color: "bg-green-100 text-green-800 border-green-200", icon: Check },
rejected: { label: "Rejected", color: "bg-red-100 text-red-800 border-red-200", icon: X },
}
export default function ApplicationsPage() {
const [applications, setApplications] = useState(mockApplications)
const [statusFilter, setStatusFilter] = useState("all")
const [searchTerm, setSearchTerm] = useState("")
const [selectedApp, setSelectedApp] = useState<typeof mockApplications[0] | null>(null)
const filteredApplications = applications.filter((app) => {
const matchesStatus = statusFilter === "all" || app.status === statusFilter
const matchesSearch =
app.candidateName.toLowerCase().includes(searchTerm.toLowerCase()) ||
app.jobTitle.toLowerCase().includes(searchTerm.toLowerCase()) ||
app.email.toLowerCase().includes(searchTerm.toLowerCase())
return matchesStatus && matchesSearch
})
const stats = {
total: applications.length,
pending: applications.filter((a) => a.status === "pending").length,
shortlisted: applications.filter((a) => a.status === "shortlisted").length,
hired: applications.filter((a) => a.status === "hired").length,
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl sm:text-3xl font-bold text-foreground">
Applications
</h1>
<p className="text-muted-foreground">
Manage applications for your job postings
</p>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<Card>
<CardContent className="p-4">
<div className="text-2xl font-bold">{stats.total}</div>
<p className="text-xs text-muted-foreground">Total Applications</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="text-2xl font-bold text-yellow-600">{stats.pending}</div>
<p className="text-xs text-muted-foreground">Pending Review</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="text-2xl font-bold text-purple-600">{stats.shortlisted}</div>
<p className="text-xs text-muted-foreground">Shortlisted</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="text-2xl font-bold text-green-600">{stats.hired}</div>
<p className="text-xs text-muted-foreground">Hired</p>
</CardContent>
</Card>
</div>
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search by name, job, or email..."
className="pl-10"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-full sm:w-[180px]">
<Filter className="h-4 w-4 mr-2" />
<SelectValue placeholder="Filter by status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="reviewed">Reviewed</SelectItem>
<SelectItem value="shortlisted">Shortlisted</SelectItem>
<SelectItem value="hired">Hired</SelectItem>
<SelectItem value="rejected">Rejected</SelectItem>
</SelectContent>
</Select>
</div>
{/* Applications List */}
<div className="grid gap-4">
{filteredApplications.length === 0 ? (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
No applications found matching your filters.
</CardContent>
</Card>
) : (
filteredApplications.map((app) => {
const StatusIcon = statusConfig[app.status as keyof typeof statusConfig].icon
return (
<Card
key={app.id}
className="hover:border-primary/50 transition-colors cursor-pointer"
onClick={() => setSelectedApp(app)}
>
<CardContent className="p-4 sm:p-6">
<div className="flex flex-col sm:flex-row gap-4">
{/* Avatar and Info */}
<div className="flex items-start gap-4 flex-1">
<Avatar className="h-12 w-12">
<AvatarFallback className="bg-primary/10 text-primary">
{app.candidateName
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2)}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-semibold">{app.candidateName}</h3>
<Badge
variant="outline"
className={statusConfig[app.status as keyof typeof statusConfig].color}
>
<StatusIcon className="h-3 w-3 mr-1" />
{statusConfig[app.status as keyof typeof statusConfig].label}
</Badge>
</div>
<p className="text-sm text-muted-foreground mt-1">
Applied for: <span className="font-medium text-foreground">{app.jobTitle}</span>
</p>
<div className="flex flex-wrap gap-4 mt-2 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<Mail className="h-4 w-4" />
{app.email}
</span>
<span className="flex items-center gap-1">
<Phone className="h-4 w-4" />
{app.phone}
</span>
<span className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
{app.appliedAt}
</span>
</div>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2 sm:flex-col">
<Button variant="outline" size="sm" className="flex-1 sm:flex-none">
<FileText className="h-4 w-4 mr-2" />
Resume
</Button>
<Button variant="outline" size="sm" className="flex-1 sm:flex-none">
<MessageSquare className="h-4 w-4 mr-2" />
Message
</Button>
</div>
</div>
</CardContent>
</Card>
)
})
)}
</div>
{/* Selected Application Modal/Drawer would go here */}
{selectedApp && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4" onClick={() => setSelectedApp(null)}>
<Card className="w-full max-w-lg" onClick={(e) => e.stopPropagation()}>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Application Details</CardTitle>
<Button variant="ghost" size="icon" onClick={() => setSelectedApp(null)}>
<X className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-4">
<Avatar className="h-16 w-16">
<AvatarFallback className="bg-primary/10 text-primary text-lg">
{selectedApp.candidateName
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2)}
</AvatarFallback>
</Avatar>
<div>
<h3 className="font-semibold text-lg">{selectedApp.candidateName}</h3>
<p className="text-sm text-muted-foreground">{selectedApp.jobTitle}</p>
<Badge
variant="outline"
className={statusConfig[selectedApp.status as keyof typeof statusConfig].color + " mt-2"}
>
{statusConfig[selectedApp.status as keyof typeof statusConfig].label}
</Badge>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm">
<Mail className="h-4 w-4 text-muted-foreground" />
<a href={`mailto:${selectedApp.email}`} className="text-primary hover:underline">
{selectedApp.email}
</a>
</div>
<div className="flex items-center gap-2 text-sm">
<Phone className="h-4 w-4 text-muted-foreground" />
<a href={`tel:${selectedApp.phone}`} className="text-primary hover:underline">
{selectedApp.phone}
</a>
</div>
</div>
<div>
<h4 className="font-medium mb-2">Cover Message</h4>
<p className="text-sm text-muted-foreground bg-muted p-3 rounded-lg">
{selectedApp.message}
</p>
</div>
<div className="flex gap-2">
<Button className="flex-1" variant="outline">
<FileText className="h-4 w-4 mr-2" />
View Resume
</Button>
<Button className="flex-1">
<MessageSquare className="h-4 w-4 mr-2" />
Contact
</Button>
</div>
</CardContent>
</Card>
</div>
)}
</div>
)
}

View file

@ -1,10 +1,376 @@
"use client"
import { useState } from "react"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Input } from "@/components/ui/input"
import {
Search,
Plus,
Edit,
Trash2,
Eye,
Users,
Calendar,
MapPin,
DollarSign,
MoreVertical,
Briefcase,
Clock,
TrendingUp,
Copy,
ExternalLink,
Pause,
Play,
} from "lucide-react"
import Link from "next/link"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
// Mock data - in production this would come from API
const mockJobs = [
{
id: "1",
title: "Senior Full Stack Developer",
type: "Full Time",
location: "São Paulo, SP",
workMode: "hybrid",
salary: "R$ 12,000 - R$ 18,000",
applications: 45,
views: 320,
postedAt: "2 days ago",
expiresAt: "28 days left",
status: "active",
},
{
id: "2",
title: "Designer UX/UI",
type: "Full Time",
location: "Remote",
workMode: "remote",
salary: "R$ 8,000 - R$ 12,000",
applications: 32,
views: 256,
postedAt: "5 days ago",
expiresAt: "25 days left",
status: "active",
},
{
id: "3",
title: "Product Manager",
type: "Full Time",
location: "São Paulo, SP",
workMode: "onsite",
salary: "R$ 15,000 - R$ 20,000",
applications: 28,
views: 189,
postedAt: "1 week ago",
expiresAt: "21 days left",
status: "active",
},
{
id: "4",
title: "DevOps Engineer",
type: "Full Time",
location: "São Paulo, SP",
workMode: "hybrid",
salary: "R$ 14,000 - R$ 20,000",
applications: 15,
views: 98,
postedAt: "2 weeks ago",
expiresAt: "14 days left",
status: "paused",
},
{
id: "5",
title: "Junior Frontend Developer",
type: "Full Time",
location: "São Paulo, SP",
workMode: "onsite",
salary: "R$ 4,000 - R$ 6,000",
applications: 120,
views: 450,
postedAt: "1 month ago",
expiresAt: "Expired",
status: "closed",
},
]
const statusConfig = {
active: { label: "Active", color: "bg-green-100 text-green-800 border-green-200" },
paused: { label: "Paused", color: "bg-yellow-100 text-yellow-800 border-yellow-200" },
closed: { label: "Closed", color: "bg-gray-100 text-gray-800 border-gray-200" },
draft: { label: "Draft", color: "bg-blue-100 text-blue-800 border-blue-200" },
}
const workModeConfig = {
remote: { label: "Remote", color: "bg-cyan-100 text-cyan-800" },
hybrid: { label: "Hybrid", color: "bg-violet-100 text-violet-800" },
onsite: { label: "On-site", color: "bg-orange-100 text-orange-800" },
}
export default function MyJobsPage() {
const [jobs] = useState(mockJobs)
const [statusFilter, setStatusFilter] = useState("all")
const [searchTerm, setSearchTerm] = useState("")
const filteredJobs = jobs.filter((job) => {
const matchesStatus = statusFilter === "all" || job.status === statusFilter
const matchesSearch =
job.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
job.location.toLowerCase().includes(searchTerm.toLowerCase())
return matchesStatus && matchesSearch
})
const stats = {
total: jobs.length,
active: jobs.filter((j) => j.status === "active").length,
applications: jobs.reduce((acc, j) => acc + j.applications, 0),
views: jobs.reduce((acc, j) => acc + j.views, 0),
}
return (
<div className="p-8">
<h1 className="text-2xl font-bold mb-4">My jobs</h1>
<p>Feature in progress.</p>
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl sm:text-3xl font-bold text-foreground">
My Jobs
</h1>
<p className="text-muted-foreground">
Manage your job postings
</p>
</div>
<Button size="lg" className="w-full sm:w-auto">
<Plus className="h-5 w-5 mr-2" />
Post New Job
</Button>
</div>
{/* Stats */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2">
<Briefcase className="h-5 w-5 text-muted-foreground" />
<div>
<div className="text-2xl font-bold">{stats.total}</div>
<p className="text-xs text-muted-foreground">Total Jobs</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2">
<TrendingUp className="h-5 w-5 text-green-500" />
<div>
<div className="text-2xl font-bold text-green-600">{stats.active}</div>
<p className="text-xs text-muted-foreground">Active</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2">
<Users className="h-5 w-5 text-blue-500" />
<div>
<div className="text-2xl font-bold text-blue-600">{stats.applications}</div>
<p className="text-xs text-muted-foreground">Applications</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2">
<Eye className="h-5 w-5 text-purple-500" />
<div>
<div className="text-2xl font-bold text-purple-600">{stats.views}</div>
<p className="text-xs text-muted-foreground">Total Views</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search jobs..."
className="pl-10"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-full sm:w-[180px]">
<SelectValue placeholder="Filter by status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="paused">Paused</SelectItem>
<SelectItem value="closed">Closed</SelectItem>
<SelectItem value="draft">Draft</SelectItem>
</SelectContent>
</Select>
</div>
{/* Jobs List */}
<div className="space-y-4">
{filteredJobs.length === 0 ? (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
<Briefcase className="h-12 w-12 mx-auto mb-4 opacity-50" />
<h3 className="font-semibold text-lg mb-2">No jobs found</h3>
<p>Start by posting your first job.</p>
<Button className="mt-4">
<Plus className="h-4 w-4 mr-2" />
Post New Job
</Button>
</CardContent>
</Card>
) : (
filteredJobs.map((job) => (
<Card key={job.id} className="hover:border-primary/50 transition-colors">
<CardContent className="p-4 sm:p-6">
<div className="flex flex-col lg:flex-row lg:items-center gap-4">
{/* Job Info */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2 mb-2">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-semibold text-lg">{job.title}</h3>
<Badge
variant="outline"
className={statusConfig[job.status as keyof typeof statusConfig].color}
>
{statusConfig[job.status as keyof typeof statusConfig].label}
</Badge>
<Badge
variant="secondary"
className={workModeConfig[job.workMode as keyof typeof workModeConfig].color}
>
{workModeConfig[job.workMode as keyof typeof workModeConfig].label}
</Badge>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="shrink-0">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>
<Eye className="h-4 w-4 mr-2" />
View
</DropdownMenuItem>
<DropdownMenuItem>
<Edit className="h-4 w-4 mr-2" />
Edit
</DropdownMenuItem>
<DropdownMenuItem>
<Copy className="h-4 w-4 mr-2" />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem>
<ExternalLink className="h-4 w-4 mr-2" />
Preview
</DropdownMenuItem>
<DropdownMenuSeparator />
{job.status === "active" ? (
<DropdownMenuItem>
<Pause className="h-4 w-4 mr-2" />
Pause
</DropdownMenuItem>
) : job.status === "paused" ? (
<DropdownMenuItem>
<Play className="h-4 w-4 mr-2" />
Activate
</DropdownMenuItem>
) : null}
<DropdownMenuItem className="text-destructive">
<Trash2 className="h-4 w-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex flex-wrap gap-4 text-sm text-muted-foreground mb-3">
<span className="flex items-center gap-1">
<MapPin className="h-4 w-4" />
{job.location}
</span>
<span className="flex items-center gap-1">
<DollarSign className="h-4 w-4" />
{job.salary}
</span>
<span className="flex items-center gap-1">
<Clock className="h-4 w-4" />
{job.expiresAt}
</span>
</div>
<div className="flex flex-wrap gap-6 text-sm">
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-blue-500" />
<span className="font-medium">{job.applications}</span>
<span className="text-muted-foreground">applications</span>
</div>
<div className="flex items-center gap-2">
<Eye className="h-4 w-4 text-purple-500" />
<span className="font-medium">{job.views}</span>
<span className="text-muted-foreground">views</span>
</div>
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-gray-500" />
<span className="text-muted-foreground">Posted {job.postedAt}</span>
</div>
</div>
</div>
{/* Actions */}
<div className="flex flex-row lg:flex-col gap-2">
<Link href={`/dashboard/applications?job=${job.id}`} className="flex-1 lg:flex-none">
<Button variant="outline" size="sm" className="w-full">
<Users className="h-4 w-4 mr-2" />
Applications
</Button>
</Link>
<Button variant="outline" size="sm" className="flex-1 lg:flex-none">
<Edit className="h-4 w-4 mr-2" />
Edit
</Button>
</div>
</div>
</CardContent>
</Card>
))
)}
</div>
</div>
)
}

View file

@ -0,0 +1,78 @@
"use client"
import { Navbar } from "@/components/navbar"
import { Footer } from "@/components/footer"
import { useTranslation } from "@/lib/i18n"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import Link from "next/link"
import { motion } from "framer-motion"
import { ChevronDown, HelpCircle } from "lucide-react"
export default function FAQPage() {
const { t } = useTranslation()
const faqs = ['q1', 'q2', 'q3', 'q4']
return (
<div className="min-h-screen flex flex-col">
<Navbar />
<main className="flex-1 bg-muted/10">
{/* Hero */}
<section className="bg-primary/5 py-16">
<div className="container mx-auto px-4 text-center">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
>
<h1 className="text-4xl font-bold mb-4">{t('faq.title')}</h1>
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
{t('faq.subtitle')}
</p>
</motion.div>
</div>
</section>
{/* FAQ Content */}
<section className="py-12 container mx-auto px-4 max-w-3xl">
<div className="space-y-4">
{faqs.map((key, index) => (
<motion.div
key={key}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-lg flex items-start gap-3">
<HelpCircle className="h-5 w-5 text-primary shrink-0 mt-1" />
{t(`faq.items.${key}.q`)}
</CardTitle>
</CardHeader>
<CardContent className="pt-2">
<p className="text-muted-foreground ml-8">
{t(`faq.items.${key}.a`)}
</p>
</CardContent>
</Card>
</motion.div>
))}
</div>
<div className="mt-12 text-center">
<p className="text-muted-foreground mb-4">
{t('faq.items.q4.a')}
</p>
<Link href="/contact">
<Button>{t('contact.info.support.title')} &rarr;</Button>
</Link>
</div>
</section>
</main>
<Footer />
</div>
)
}

View file

@ -378,7 +378,7 @@ function JobsContent() {
)}
{loading ? (
<div className="text-center text-muted-foreground">{t('jobs.loading')}</div>
<PageSkeleton />
) : paginatedJobs.length > 0 ? (
<div className="space-y-8">
<motion.div layout className="grid gap-6">

View file

@ -35,18 +35,22 @@ import {
GraduationCap,
Briefcase,
ArrowLeft,
AtSign,
} from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { motion } from "framer-motion";
import { useForm } from "react-hook-form";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useTranslation } from "@/lib/i18n";
import { PhoneInput } from "@/components/phone-input";
import { registerCandidate } from "@/lib/auth";
const createCandidateSchema = (t: (key: string, params?: Record<string, string | number>) => string) =>
z.object({
fullName: z.string().min(2, t("register.candidate.validation.fullName")),
email: z.string().email(t("register.candidate.validation.email")),
username: z.string().min(3, "Username must be at least 3 characters").regex(/^[a-zA-Z0-9_]+$/, "Username must only contain letters, numbers and underscores"),
password: z.string().min(6, t("register.candidate.validation.password")),
confirmPassword: z.string(),
phone: z.string().min(10, t("register.candidate.validation.phone")),
@ -74,6 +78,7 @@ export default function CandidateRegisterPage() {
const router = useRouter();
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [currentStep, setCurrentStep] = useState(1);
@ -82,11 +87,15 @@ export default function CandidateRegisterPage() {
const {
register,
handleSubmit,
control,
formState: { errors },
setValue,
watch,
} = useForm<CandidateFormData>({
resolver: zodResolver(candidateSchema),
defaultValues: {
phone: "55", // Default Brazil
}
});
const acceptTerms = watch("acceptTerms");
@ -94,15 +103,20 @@ export default function CandidateRegisterPage() {
const onSubmit = async (data: CandidateFormData) => {
setLoading(true);
setErrorMsg(null);
try {
// Simular cadastro
await new Promise(resolve => setTimeout(resolve, 2000));
console.log("Dados do candidato:", data);
await registerCandidate({
name: data.fullName,
email: data.email,
password: data.password,
username: data.username,
phone: data.phone,
});
// Redirecionar para login após cadastro
router.push(`/login?message=${encodeURIComponent(t("register.candidate.success"))}`);
} catch (error) {
router.push(`/login?message=${encodeURIComponent("Account created successfully! Please login.")}`);
} catch (error: any) {
console.error("Erro no cadastro:", error);
setErrorMsg(error.message || "Failed to create account. Please try again.");
} finally {
setLoading(false);
}
@ -165,7 +179,7 @@ export default function CandidateRegisterPage() {
</div>
{/* Right Panel - Formulário */}
<div className="flex-1 p-8 flex flex-col justify-center">
<div className="flex-1 p-8 flex flex-col justify-center overflow-y-auto">
<div className="w-full max-w-md mx-auto">
{/* Header */}
<div className="mb-6">
@ -185,6 +199,12 @@ export default function CandidateRegisterPage() {
</p>
</div>
{errorMsg && (
<Alert variant="destructive" className="mb-6">
<AlertDescription>{errorMsg}</AlertDescription>
</Alert>
)}
{/* Progress Indicator */}
<div className="mb-8">
<div className="flex items-center justify-between mb-2">
@ -233,6 +253,23 @@ export default function CandidateRegisterPage() {
)}
</div>
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<div className="relative">
<AtSign className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
id="username"
type="text"
placeholder="johndoe"
className="pl-10"
{...register("username")}
/>
</div>
{errors.username && (
<span className="text-sm text-destructive">{errors.username.message}</span>
)}
</div>
<div className="space-y-2">
<Label htmlFor="email">{t("register.candidate.fields.email")}</Label>
<div className="relative">
@ -344,16 +381,16 @@ export default function CandidateRegisterPage() {
>
<div className="space-y-2">
<Label htmlFor="phone">{t("register.candidate.fields.phone")}</Label>
<div className="relative">
<Phone className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
id="phone"
type="tel"
placeholder={t("register.candidate.placeholders.phone")}
className="pl-10"
{...register("phone")}
/>
</div>
<Controller
name="phone"
control={control}
render={({ field }) => (
<PhoneInput
value={field.value}
onChangeValue={field.onChange}
/>
)}
/>
{errors.phone && (
<span className="text-sm text-destructive">{errors.phone.message}</span>
)}

View file

@ -0,0 +1,103 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
const countries = [
{ value: "55", label: "Brazil (+55)", flag: "🇧🇷" },
{ value: "1", label: "USA (+1)", flag: "🇺🇸" },
{ value: "351", label: "Portugal (+351)", flag: "🇵🇹" },
{ value: "44", label: "UK (+44)", flag: "🇬🇧" },
{ value: "33", label: "France (+33)", flag: "🇫🇷" },
{ value: "49", label: "Germany (+49)", flag: "🇩🇪" },
{ value: "34", label: "Spain (+34)", flag: "🇪🇸" },
{ value: "39", label: "Italy (+39)", flag: "🇮🇹" },
{ value: "81", label: "Japan (+81)", flag: "🇯🇵" },
{ value: "86", label: "China (+86)", flag: "🇨🇳" },
{ value: "91", label: "India (+91)", flag: "🇮🇳" },
{ value: "54", label: "Argentina (+54)", flag: "🇦🇷" },
{ value: "52", label: "Mexico (+52)", flag: "🇲🇽" },
{ value: "598", label: "Uruguay (+598)", flag: "🇺🇾" },
]
interface PhoneInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
value?: string
onChangeValue?: (value: string) => void
}
export function PhoneInput({ className, value, onChangeValue, ...props }: PhoneInputProps) {
const [countryCode, setCountryCode] = React.useState("55")
const [phoneNumber, setPhoneNumber] = React.useState("")
// Parse initial value
React.useEffect(() => {
if (value) {
const country = countries.find((c) => value.startsWith("+" + c.value) || value.startsWith(c.value)) // Handle with or without +
if (country) {
setCountryCode(country.value)
// Remove code and +, keep only numbers
const cleanNumber = value.replace(new RegExp(`^\\+?${country.value}`), "")
setPhoneNumber(cleanNumber)
} else {
setPhoneNumber(value)
}
}
}, [value])
const handlePhoneChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newVal = e.target.value.replace(/\D/g, "")
setPhoneNumber(newVal)
if (onChangeValue) {
onChangeValue(`${countryCode}${newVal}`)
}
}
const handleCountryChange = (newCode: string) => {
setCountryCode(newCode)
if (onChangeValue) {
onChangeValue(`${newCode}${phoneNumber}`)
}
}
return (
<div className={cn("flex rounded-md shadow-sm", className)}>
<Select value={countryCode} onValueChange={handleCountryChange}>
<SelectTrigger className="w-[100px] border-r-0 rounded-r-none focus:ring-0">
<SelectValue placeholder="Code">
<span className="flex items-center gap-1">
<span>{countries.find((c) => c.value === countryCode)?.flag}</span>
<span>+{countryCode}</span>
</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
{countries.map((country) => (
<SelectItem key={country.value} value={country.value}>
<span className="flex items-center gap-2">
<span>{country.flag}</span>
<span>{country.label}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<Input
{...props}
className="flex-1 rounded-l-none focus-visible:ring-0 focus-visible:ring-offset-0" // Remove ring collision
placeholder="Phone number"
type="tel"
value={phoneNumber}
onChange={handlePhoneChange}
maxLength={15}
/>
</div>
)
}

View file

@ -50,19 +50,43 @@
"title": "Send a message",
"description": "Fill out the form and we will get back to you soon.",
"fields": {
"name": { "label": "Full name", "placeholder": "Your name" },
"email": { "label": "Email", "placeholder": "you@email.com" },
"subject": { "label": "Subject", "placeholder": "How can we help?" },
"message": { "label": "Message", "placeholder": "Describe your question or suggestion..." }
"name": {
"label": "Full name",
"placeholder": "Your name"
},
"email": {
"label": "Email",
"placeholder": "you@email.com"
},
"subject": {
"label": "Subject",
"placeholder": "How can we help?"
},
"message": {
"label": "Message",
"placeholder": "Describe your question or suggestion..."
}
},
"actions": { "submit": "Send message", "success": "Message sent!" }
"actions": {
"submit": "Send message",
"success": "Message sent!"
}
},
"info": {
"title": "Other ways to reach us",
"email": { "title": "Email" },
"phone": { "title": "Phone" },
"address": { "title": "Address" },
"support": { "title": "Support", "description": "Monday to Friday, 9am to 6pm" }
"email": {
"title": "Email"
},
"phone": {
"title": "Phone"
},
"address": {
"title": "Address"
},
"support": {
"title": "Support",
"description": "Monday to Friday, 9am to 6pm"
}
},
"faq": {
"title": "Frequently Asked Questions",
@ -85,9 +109,18 @@
"howItWorks": {
"title": "How it works?",
"subtitle": "Three simple steps to your next opportunity",
"step1": { "title": "1. Sign up", "description": "Create your free profile in just a few minutes" },
"step2": { "title": "2. Send your resume", "description": "Add your experiences and skills" },
"step3": { "title": "3. Get found", "description": "Receive offers from interested companies" }
"step1": {
"title": "1. Sign up",
"description": "Create your free profile in just a few minutes"
},
"step2": {
"title": "2. Send your resume",
"description": "Add your experiences and skills"
},
"step3": {
"title": "3. Get found",
"description": "Receive offers from interested companies"
}
},
"testimonials": {
"title": "What our users say?",
@ -103,27 +136,81 @@
"title": "Find your next opportunity",
"subtitle": "{count} jobs available at the best companies",
"search": "Search jobs by title, company...",
"filters": { "all": "All", "toggle": "Filters", "location": "Location", "type": "Type", "workMode": "Work Mode", "order": "Sort by" },
"sort": { "recent": "Most recent", "title": "Title", "company": "Company", "location": "Location" },
"filters": {
"all": "All",
"toggle": "Filters",
"location": "Location",
"type": "Type",
"workMode": "Work Mode",
"order": "Sort by"
},
"sort": {
"recent": "Most recent",
"title": "Title",
"company": "Company",
"location": "Location"
},
"reset": "Clear",
"resetFilters": "Clear filters",
"noResults": { "title": "No jobs found", "desc": "We couldn't find any jobs matching your criteria." },
"noResults": {
"title": "No jobs found",
"desc": "We couldn't find any jobs matching your criteria."
},
"loading": "Loading jobs...",
"error": "Could not load jobs right now. Showing examples.",
"card": { "viewDetails": "View details", "apply": "Apply now", "perMonth": "/month", "postedAgo": "Posted {time} ago" },
"types": { "full-time": "Full Time", "part-time": "Part Time", "contract": "Contract", "freelance": "Freelance", "remote": "Remote" },
"card": {
"viewDetails": "View details",
"apply": "Apply now",
"perMonth": "/month",
"postedAgo": "Posted {time} ago"
},
"types": {
"full-time": "Full Time",
"part-time": "Part Time",
"contract": "Contract",
"freelance": "Freelance",
"remote": "Remote"
},
"confidential": "Confidential Company",
"salary": { "negotiable": "Negotiable" },
"posted": { "today": "Today", "yesterday": "Yesterday", "daysAgo": "{count} days ago", "weeksAgo": "{count} weeks ago", "monthsAgo": "{count} months ago" },
"favorites": { "added": { "title": "Job favorited!", "desc": "{title} added to your favorites." }, "action": "View favorites" },
"requirements": { "more": "+{count} more" },
"pagination": { "previous": "Previous", "next": "Next", "showing": "Showing {from} to {to} of {total} jobs" }
"salary": {
"negotiable": "Negotiable"
},
"posted": {
"today": "Today",
"yesterday": "Yesterday",
"daysAgo": "{count} days ago",
"weeksAgo": "{count} weeks ago",
"monthsAgo": "{count} months ago"
},
"favorites": {
"added": {
"title": "Job favorited!",
"desc": "{title} added to your favorites."
},
"action": "View favorites"
},
"requirements": {
"more": "+{count} more"
},
"pagination": {
"previous": "Previous",
"next": "Next",
"showing": "Showing {from} to {to} of {total} jobs"
}
},
"workMode": {
"onsite": "On-site",
"hybrid": "Hybrid",
"remote": "Remote"
},
"workMode": { "onsite": "On-site", "hybrid": "Hybrid", "remote": "Remote" },
"footer": {
"company": "Company", "about": "About", "careers": "Careers",
"jobsByTech": "Jobs by Technology", "legal": "Legal",
"privacy": "Privacy Policy", "terms": "Terms of Use",
"company": "Company",
"about": "About",
"careers": "Careers",
"jobsByTech": "Jobs by Technology",
"legal": "Legal",
"privacy": "Privacy Policy",
"terms": "Terms of Use",
"copyright": "© {year} GoHorse Jobs. All rights reserved."
},
"auth": {
@ -437,5 +524,32 @@
}
}
},
"common": { "loading": "Loading...", "error": "Error", "retry": "Retry", "noResults": "No results found" }
}
"common": {
"loading": "Loading...",
"error": "Error",
"retry": "Retry",
"noResults": "No results found"
},
"faq": {
"title": "Frequently Asked Questions",
"subtitle": "Find answers to common questions about GoHorse Jobs.",
"items": {
"q1": {
"q": "How do I apply?",
"a": "Simply create an account, complete your profile, and click 'Apply' on job listings."
},
"q2": {
"q": "Is it free?",
"a": "Yes! For candidates, it is 100% free. We only charge companies."
},
"q3": {
"q": "Can I work remotely?",
"a": "Yes! We have many 'Global Remote' jobs paying in USD or EUR."
},
"q4": {
"q": "How do I contact support?",
"a": "Use the Contact page to reach our support team."
}
}
}
}

View file

@ -50,19 +50,43 @@
"title": "Enviar un mensaje",
"description": "Completa el formulario y te responderemos pronto.",
"fields": {
"name": { "label": "Nombre completo", "placeholder": "Tu nombre" },
"email": { "label": "Correo electrónico", "placeholder": "tu@email.com" },
"subject": { "label": "Asunto", "placeholder": "¿Cómo podemos ayudar?" },
"message": { "label": "Mensaje", "placeholder": "Describe tu pregunta o sugerencia..." }
"name": {
"label": "Nombre completo",
"placeholder": "Tu nombre"
},
"email": {
"label": "Correo electrónico",
"placeholder": "tu@email.com"
},
"subject": {
"label": "Asunto",
"placeholder": "¿Cómo podemos ayudar?"
},
"message": {
"label": "Mensaje",
"placeholder": "Describe tu pregunta o sugerencia..."
}
},
"actions": { "submit": "Enviar mensaje", "success": "¡Mensaje enviado!" }
"actions": {
"submit": "Enviar mensaje",
"success": "¡Mensaje enviado!"
}
},
"info": {
"title": "Otras formas de contactarnos",
"email": { "title": "Correo electrónico" },
"phone": { "title": "Teléfono" },
"address": { "title": "Dirección" },
"support": { "title": "Soporte", "description": "Lunes a viernes, de 9 a 18 h" }
"email": {
"title": "Correo electrónico"
},
"phone": {
"title": "Teléfono"
},
"address": {
"title": "Dirección"
},
"support": {
"title": "Soporte",
"description": "Lunes a viernes, de 9 a 18 h"
}
},
"faq": {
"title": "Preguntas frecuentes",
@ -85,9 +109,18 @@
"howItWorks": {
"title": "¿Cómo funciona?",
"subtitle": "Tres pasos sencillos para tu próxima oportunidad",
"step1": { "title": "1. Regístrate", "description": "Crea tu perfil gratis en pocos minutos" },
"step2": { "title": "2. Envía tu currículum", "description": "Agrega tus experiencias y habilidades" },
"step3": { "title": "3. Sé encontrado", "description": "Recibe ofertas de empresas interesadas" }
"step1": {
"title": "1. Regístrate",
"description": "Crea tu perfil gratis en pocos minutos"
},
"step2": {
"title": "2. Envía tu currículum",
"description": "Agrega tus experiencias y habilidades"
},
"step3": {
"title": "3. Sé encontrado",
"description": "Recibe ofertas de empresas interesadas"
}
},
"testimonials": {
"title": "¿Qué dicen nuestros usuarios?",
@ -103,27 +136,81 @@
"title": "Encuentra tu próxima oportunidad",
"subtitle": "{count} empleos disponibles en las mejores empresas",
"search": "Buscar empleos por título, empresa...",
"filters": { "all": "Todos", "toggle": "Filtros", "location": "Ubicación", "type": "Tipo", "workMode": "Modalidad", "order": "Ordenar por" },
"sort": { "recent": "Más recientes", "title": "Título", "company": "Empresa", "location": "Ubicación" },
"filters": {
"all": "Todos",
"toggle": "Filtros",
"location": "Ubicación",
"type": "Tipo",
"workMode": "Modalidad",
"order": "Ordenar por"
},
"sort": {
"recent": "Más recientes",
"title": "Título",
"company": "Empresa",
"location": "Ubicación"
},
"reset": "Limpiar",
"resetFilters": "Limpiar filtros",
"noResults": { "title": "No se encontraron empleos", "desc": "No encontramos empleos que coincidan con tus criterios." },
"noResults": {
"title": "No se encontraron empleos",
"desc": "No encontramos empleos que coincidan con tus criterios."
},
"loading": "Cargando empleos...",
"error": "No se pudieron cargar los empleos ahora. Mostrando ejemplos.",
"card": { "viewDetails": "Ver detalles", "apply": "Postularse", "perMonth": "/mes", "postedAgo": "Publicado hace {time}" },
"types": { "full-time": "Tiempo completo", "part-time": "Medio tiempo", "contract": "Contrato", "freelance": "Freelance", "remote": "Remoto" },
"card": {
"viewDetails": "Ver detalles",
"apply": "Postularse",
"perMonth": "/mes",
"postedAgo": "Publicado hace {time}"
},
"types": {
"full-time": "Tiempo completo",
"part-time": "Medio tiempo",
"contract": "Contrato",
"freelance": "Freelance",
"remote": "Remoto"
},
"confidential": "Empresa Confidencial",
"salary": { "negotiable": "A convenir" },
"posted": { "today": "Hoy", "yesterday": "Ayer", "daysAgo": "hace {count} días", "weeksAgo": "hace {count} semanas", "monthsAgo": "hace {count} meses" },
"favorites": { "added": { "title": "¡Empleo guardado!", "desc": "{title} se añadió a tus favoritos." }, "action": "Ver favoritos" },
"requirements": { "more": "+{count} más" },
"pagination": { "previous": "Anterior", "next": "Siguiente", "showing": "Mostrando {from} a {to} de {total} empleos" }
"salary": {
"negotiable": "A convenir"
},
"posted": {
"today": "Hoy",
"yesterday": "Ayer",
"daysAgo": "hace {count} días",
"weeksAgo": "hace {count} semanas",
"monthsAgo": "hace {count} meses"
},
"favorites": {
"added": {
"title": "¡Empleo guardado!",
"desc": "{title} se añadió a tus favoritos."
},
"action": "Ver favoritos"
},
"requirements": {
"more": "+{count} más"
},
"pagination": {
"previous": "Anterior",
"next": "Siguiente",
"showing": "Mostrando {from} a {to} de {total} empleos"
}
},
"workMode": {
"onsite": "Presencial",
"hybrid": "Híbrido",
"remote": "Remoto"
},
"workMode": { "onsite": "Presencial", "hybrid": "Híbrido", "remote": "Remoto" },
"footer": {
"company": "Empresa", "about": "Sobre", "careers": "Carreras",
"jobsByTech": "Empleos por Tecnología", "legal": "Legal",
"privacy": "Política de Privacidad", "terms": "Términos de Uso",
"company": "Empresa",
"about": "Sobre",
"careers": "Carreras",
"jobsByTech": "Empleos por Tecnología",
"legal": "Legal",
"privacy": "Política de Privacidad",
"terms": "Términos de Uso",
"copyright": "© {year} GoHorse Jobs. Todos los derechos reservados."
},
"auth": {
@ -437,5 +524,32 @@
}
}
},
"common": { "loading": "Cargando...", "error": "Error", "retry": "Reintentar", "noResults": "No se encontraron resultados" }
}
"common": {
"loading": "Cargando...",
"error": "Error",
"retry": "Reintentar",
"noResults": "No se encontraron resultados"
},
"faq": {
"title": "Preguntas Frecuentes",
"subtitle": "Encuentra respuestas a las preguntas más comunes sobre GoHorse Jobs.",
"items": {
"q1": {
"q": "¿Cómo aplico?",
"a": "Simplemente crea una cuenta, completa tu perfil y haz clic en 'Aplicar' en las ofertas."
},
"q2": {
"q": "¿Es gratis?",
"a": "¡Sí! Para candidatos es 100% gratis. Solo cobramos a las empresas."
},
"q3": {
"q": "¿Puedo trabajar remotamente?",
"a": "¡Sí! Tenemos muchas vacantes 'Global Remote' con pago en USD o EUR."
},
"q4": {
"q": "¿Cómo contacto soporte?",
"a": "Usa la página de contacto para hablar con nuestro equipo."
}
}
}
}

View file

@ -50,19 +50,43 @@
"title": "Envie uma mensagem",
"description": "Preencha o formulário e responderemos em breve.",
"fields": {
"name": { "label": "Nome completo", "placeholder": "Seu nome" },
"email": { "label": "E-mail", "placeholder": "voce@email.com" },
"subject": { "label": "Assunto", "placeholder": "Como podemos ajudar?" },
"message": { "label": "Mensagem", "placeholder": "Descreva sua dúvida ou sugestão..." }
"name": {
"label": "Nome completo",
"placeholder": "Seu nome"
},
"email": {
"label": "E-mail",
"placeholder": "voce@email.com"
},
"subject": {
"label": "Assunto",
"placeholder": "Como podemos ajudar?"
},
"message": {
"label": "Mensagem",
"placeholder": "Descreva sua dúvida ou sugestão..."
}
},
"actions": { "submit": "Enviar mensagem", "success": "Mensagem enviada!" }
"actions": {
"submit": "Enviar mensagem",
"success": "Mensagem enviada!"
}
},
"info": {
"title": "Outras formas de falar com a gente",
"email": { "title": "E-mail" },
"phone": { "title": "Telefone" },
"address": { "title": "Endereço" },
"support": { "title": "Suporte", "description": "Segunda a sexta, das 9h às 18h" }
"email": {
"title": "E-mail"
},
"phone": {
"title": "Telefone"
},
"address": {
"title": "Endereço"
},
"support": {
"title": "Suporte",
"description": "Segunda a sexta, das 9h às 18h"
}
},
"faq": {
"title": "Perguntas frequentes",
@ -85,9 +109,18 @@
"howItWorks": {
"title": "Como Funciona?",
"subtitle": "Três passos simples para sua próxima oportunidade",
"step1": { "title": "1. Cadastre-se", "description": "Crie seu perfil gratuitamente em poucos minutos" },
"step2": { "title": "2. Envie seu currículo", "description": "Adicione suas experiências e habilidades" },
"step3": { "title": "3. Seja encontrado", "description": "Receba ofertas de empresas interessadas" }
"step1": {
"title": "1. Cadastre-se",
"description": "Crie seu perfil gratuitamente em poucos minutos"
},
"step2": {
"title": "2. Envie seu currículo",
"description": "Adicione suas experiências e habilidades"
},
"step3": {
"title": "3. Seja encontrado",
"description": "Receba ofertas de empresas interessadas"
}
},
"testimonials": {
"title": "O que nossos usuários dizem?",
@ -103,27 +136,81 @@
"title": "Encontre sua próxima oportunidade",
"subtitle": "{count} vagas disponíveis nas melhores empresas",
"search": "Buscar vagas por título, empresa...",
"filters": { "all": "Todas", "toggle": "Filtros", "location": "Localização", "type": "Tipo", "workMode": "Modalidade", "order": "Ordenar por" },
"sort": { "recent": "Mais recentes", "title": "Título", "company": "Empresa", "location": "Localização" },
"filters": {
"all": "Todas",
"toggle": "Filtros",
"location": "Localização",
"type": "Tipo",
"workMode": "Modalidade",
"order": "Ordenar por"
},
"sort": {
"recent": "Mais recentes",
"title": "Título",
"company": "Empresa",
"location": "Localização"
},
"reset": "Limpar",
"resetFilters": "Limpar filtros",
"noResults": { "title": "Nenhuma vaga encontrada", "desc": "Não encontramos vagas que correspondam aos seus critérios de busca." },
"noResults": {
"title": "Nenhuma vaga encontrada",
"desc": "Não encontramos vagas que correspondam aos seus critérios de busca."
},
"loading": "Carregando vagas...",
"error": "Não foi possível carregar as vagas agora. Exibindo exemplos.",
"card": { "viewDetails": "Ver detalhes", "apply": "Candidatar-se", "perMonth": "/mês", "postedAgo": "Publicada há {time}" },
"types": { "full-time": "Tempo Integral", "part-time": "Meio Período", "contract": "Contrato", "freelance": "Freelance", "remote": "Remoto" },
"card": {
"viewDetails": "Ver detalhes",
"apply": "Candidatar-se",
"perMonth": "/mês",
"postedAgo": "Publicada há {time}"
},
"types": {
"full-time": "Tempo Integral",
"part-time": "Meio Período",
"contract": "Contrato",
"freelance": "Freelance",
"remote": "Remoto"
},
"confidential": "Empresa Confidencial",
"salary": { "negotiable": "A combinar" },
"posted": { "today": "Hoje", "yesterday": "Ontem", "daysAgo": "{count} dias atrás", "weeksAgo": "{count} semanas atrás", "monthsAgo": "{count} meses atrás" },
"favorites": { "added": { "title": "Vaga favoritada!", "desc": "{title} foi adicionada aos seus favoritos." }, "action": "Ver favoritos" },
"requirements": { "more": "+{count} mais" },
"pagination": { "previous": "Anterior", "next": "Próximo", "showing": "Mostrando {from} a {to} de {total} vagas" }
"salary": {
"negotiable": "A combinar"
},
"posted": {
"today": "Hoje",
"yesterday": "Ontem",
"daysAgo": "{count} dias atrás",
"weeksAgo": "{count} semanas atrás",
"monthsAgo": "{count} meses atrás"
},
"favorites": {
"added": {
"title": "Vaga favoritada!",
"desc": "{title} foi adicionada aos seus favoritos."
},
"action": "Ver favoritos"
},
"requirements": {
"more": "+{count} mais"
},
"pagination": {
"previous": "Anterior",
"next": "Próximo",
"showing": "Mostrando {from} a {to} de {total} vagas"
}
},
"workMode": {
"onsite": "Presencial",
"hybrid": "Híbrido",
"remote": "Remoto"
},
"workMode": { "onsite": "Presencial", "hybrid": "Híbrido", "remote": "Remoto" },
"footer": {
"company": "Empresa", "about": "Sobre", "careers": "Carreiras",
"jobsByTech": "Vagas por Tecnologia", "legal": "Legal",
"privacy": "Política de Privacidade", "terms": "Termos de Uso",
"company": "Empresa",
"about": "Sobre",
"careers": "Carreiras",
"jobsByTech": "Vagas por Tecnologia",
"legal": "Legal",
"privacy": "Política de Privacidade",
"terms": "Termos de Uso",
"copyright": "© {year} GoHorse Jobs. Todos os direitos reservados."
},
"auth": {
@ -437,5 +524,32 @@
}
}
},
"common": { "loading": "Carregando...", "error": "Erro", "retry": "Tentar novamente", "noResults": "Nenhum resultado encontrado" }
}
"common": {
"loading": "Carregando...",
"error": "Erro",
"retry": "Tentar novamente",
"noResults": "Nenhum resultado encontrado"
},
"faq": {
"title": "Perguntas Frequentes",
"subtitle": "Encontre respostas para as perguntas mais comuns sobre o GoHorse Jobs.",
"items": {
"q1": {
"q": "Como faço para me candidatar?",
"a": "Basta criar uma conta, completar seu perfil e clicar em 'Aplicar' nas vagas."
},
"q2": {
"q": "É gratuito?",
"a": "Sim, para candidatos é 100% gratuito. Cobramos apenas das empresas."
},
"q3": {
"q": "Posso trabalhar remotamente?",
"a": "Sim! Temos muitas vagas 'Global Remote' com pagamento em Dólar ou Euro."
},
"q4": {
"q": "Como entro em contato?",
"a": "Use a página de contato para falar com nosso suporte."
}
}
}
}

View file

@ -200,9 +200,9 @@ export interface AdminLoginAudit {
createdAt: string;
}
export interface AdminCompany extends ApiCompany {}
export interface AdminCompany extends ApiCompany { }
export interface AdminJob extends ApiJob {}
export interface AdminJob extends ApiJob { }
export interface AdminTag {
id: number;
@ -333,14 +333,27 @@ export const adminTagsApi = {
// Transform API job to frontend Job format
export function transformApiJobToFrontend(apiJob: ApiJob): import('./types').Job {
// Determine currency based on location
const getCurrencySymbol = (loc?: string) => {
if (!loc) return 'R$';
const l = loc.toLowerCase();
if (l.includes('usa') || l.includes('ny') || l.includes('ca') || l.includes('tx') || l.includes('us')) return '$';
if (l.includes('uk') || l.includes('london')) return '£';
if (l.includes('de') || l.includes('berlin') || l.includes('amsterdam') || l.includes('europe')) return '€';
if (l.includes('remote') || l.includes('global')) return '$'; // Default to USD for global remote
return 'R$';
};
const currency = getCurrencySymbol(apiJob.location);
// Format salary
let salary: string | undefined;
if (apiJob.salaryMin && apiJob.salaryMax) {
salary = `R$ ${apiJob.salaryMin.toLocaleString('en-US')} - R$ ${apiJob.salaryMax.toLocaleString('en-US')}`;
salary = `${currency} ${apiJob.salaryMin.toLocaleString('en-US')} - ${currency} ${apiJob.salaryMax.toLocaleString('en-US')}`;
} else if (apiJob.salaryMin) {
salary = `From R$ ${apiJob.salaryMin.toLocaleString('en-US')}`;
salary = `From ${currency} ${apiJob.salaryMin.toLocaleString('en-US')}`;
} else if (apiJob.salaryMax) {
salary = `Up to R$ ${apiJob.salaryMax.toLocaleString('en-US')}`;
salary = `Up to ${currency} ${apiJob.salaryMax.toLocaleString('en-US')}`;
}
// Determine type

View file

@ -102,6 +102,30 @@ export function isAuthenticated(): boolean {
return getCurrentUser() !== null;
}
export interface RegisterCandidateData {
name: string;
email: string;
password: string;
username: string; // identifier
phone: string;
}
export async function registerCandidate(data: RegisterCandidateData): Promise<void> {
const res = await fetch(`${API_URL}/auth/register`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
if (!res.ok) {
const errorData = await res.json().catch(() => ({}));
throw new Error(errorData.message || `Erro no registro: ${res.status}`);
}
}
export function getToken(): string | null {
if (typeof window !== "undefined") {
return localStorage.getItem("auth_token");

View file

@ -8,6 +8,7 @@ import { seedApplications } from './seeders/applications.js';
import { seedAcmeCorp, seedWileECoyote } from './seeders/acme.js';
import { seedFictionalCompanies } from './seeders/fictional-companies.js';
import { seedEpicCompanies } from './seeders/epic-companies.js';
import { seedNotifications } from './seeders/notifications.js';
async function resetDatabase() {
console.log('🗑️ Resetting database...');
@ -65,7 +66,8 @@ async function seedDatabase() {
await seedWileECoyote(); // 🐺 Wile E. Coyote user
await seedFictionalCompanies(); // 🎬 Stark Industries, Los Pollos, Springfield Nuclear
await seedEpicCompanies(); // 🌟 BNL, Cyberdyne, Wonka, Wayne, Oceanic, InGen, Bubba Gump, Umbrella, Sprawl-Mart
await seedApplications();
await seedApplications(); // 📝 Applications from candidates
await seedNotifications(); // 🔔 Notifications for dashboard
console.log('\n✅ Database seeding completed successfully!');
console.log('\n📊 Summary:');

View file

@ -1,65 +1,118 @@
import { pool } from '../db.js';
import crypto from 'crypto';
/**
* 📝 Applications Seeder
*
* Creates realistic job applications with candidates like:
* - Ana Silva (Senior Full Stack Developer)
* - Carlos Santos (Designer UX/UI)
* - Maria Oliveira (Product Manager)
*/
const candidateProfiles = [
{
name: 'Ana Silva',
email: 'ana.silva@email.com',
phone: '+55 11 99999-1111',
whatsapp: '+55 11 99999-1111',
message: 'Tenho 5 anos de experiência como desenvolvedora Full Stack, trabalhando com React, Node.js e PostgreSQL. Busco uma posição desafiadora onde possa crescer profissionalmente.',
resume_url: 'https://cdn.gohorsejobs.com/docs/ana_silva_cv.pdf',
},
{
name: 'Carlos Santos',
email: 'carlos.santos@email.com',
phone: '+55 11 98888-2222',
whatsapp: '+55 11 98888-2222',
message: 'Designer UX/UI com 3 anos de experiência em produtos digitais. Especialista em Figma, Design Systems e pesquisa com usuários. Portfolio: behance.net/carlossantos',
resume_url: 'https://cdn.gohorsejobs.com/docs/carlos_santos_portfolio.pdf',
},
{
name: 'Maria Oliveira',
email: 'maria.oliveira@email.com',
phone: '+55 21 97777-3333',
whatsapp: '+55 21 97777-3333',
message: 'Product Manager com 4 anos de experiência em startups tech. Certificada em Scrum e experiência com metodologias ágeis. Busco liderar produtos inovadores.',
resume_url: 'https://cdn.gohorsejobs.com/docs/maria_oliveira_resume.pdf',
},
{
name: 'Pedro Costa',
email: 'pedro.costa@email.com',
phone: '+55 11 96666-4444',
whatsapp: '+55 11 96666-4444',
message: 'Desenvolvedor Backend com experiência em Go, Python e microsserviços. Apaixonado por performance e arquitetura de sistemas.',
resume_url: 'https://cdn.gohorsejobs.com/docs/pedro_costa_cv.pdf',
},
{
name: 'Juliana Ferreira',
email: 'juliana.ferreira@email.com',
phone: '+55 11 95555-5555',
whatsapp: '+55 11 95555-5555',
message: 'Data Scientist com mestrado em Machine Learning. Experiência com Python, TensorFlow e análise de dados em larga escala.',
resume_url: 'https://cdn.gohorsejobs.com/docs/juliana_ferreira_cv.pdf',
},
];
const statuses = ['pending', 'reviewed', 'shortlisted', 'rejected', 'hired'];
export async function seedApplications() {
console.log('📝 Seeding applications...');
// Get jobs
const jobsRes = await pool.query('SELECT id FROM jobs LIMIT 5');
const jobs = jobsRes.rows;
// Get users from legacy users table (NOT core_users, since applications.user_id references users)
const seekersRes = await pool.query("SELECT id FROM users LIMIT 10");
const seekers = seekersRes.rows;
if (jobs.length === 0) {
console.log(' ⚠️ No jobs found, skipping applications');
return;
}
if (seekers.length === 0) {
console.log(' ⚠️ No users found, skipping applications');
return;
}
const applications = [
{
jobId: jobs[0]?.id,
userId: seekers[0]?.id,
message: 'I have 2 years of experience in software development. Can start immediately.',
resume_url: 'https://cdn.gohorsejobs.com/docs/resume_001.pdf',
status: 'pending'
},
{
jobId: jobs[1]?.id || jobs[0]?.id,
userId: seekers[0]?.id,
message: 'Interested in the designer position. Available for flexible hours.',
resume_url: null,
status: 'pending'
},
{
jobId: jobs[0]?.id,
userId: seekers[0]?.id,
message: 'Hard worker with development experience.',
resume_url: 'https://cdn.gohorsejobs.com/docs/resume_002.pdf',
status: 'reviewed'
}
];
try {
for (const app of applications) {
if (!app.jobId || !app.userId) continue;
await pool.query(`
INSERT INTO applications (job_id, user_id, message, resume_url, status)
VALUES ($1, $2, $3, $4, $5)
`, [
app.jobId, app.userId, app.message, app.resume_url, app.status
]);
// Get jobs
const jobsRes = await pool.query('SELECT id, title FROM jobs LIMIT 20');
const jobs = jobsRes.rows;
if (jobs.length === 0) {
console.log(' ⚠️ No jobs found, skipping applications');
return;
}
console.log(`${applications.length} applications seeded (sample)`);
let totalApps = 0;
// Create applications - each candidate applies to multiple jobs
for (let i = 0; i < candidateProfiles.length; i++) {
const candidate = candidateProfiles[i];
// Each candidate applies to 3-5 random jobs
const numApplications = 3 + (i % 3);
for (let j = 0; j < numApplications && j < jobs.length; j++) {
const job = jobs[(i + j) % jobs.length];
const appId = crypto.randomUUID();
const status = statuses[(i + j) % statuses.length];
// Calculate a random date in the past 30 days
const daysAgo = Math.floor(Math.random() * 30);
const createdAt = new Date();
createdAt.setDate(createdAt.getDate() - daysAgo);
await pool.query(`
INSERT INTO applications (id, job_id, name, email, phone, whatsapp, message, resume_url, status, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
ON CONFLICT (id) DO NOTHING
`, [
appId,
job.id,
candidate.name,
candidate.email,
candidate.phone,
candidate.whatsapp,
`${candidate.message}\n\nInteressado na vaga: ${job.title}`,
candidate.resume_url,
status,
createdAt,
createdAt
]);
totalApps++;
}
}
console.log(`${totalApps} applications criadas`);
console.log(` 👤 Candidatos: Ana Silva, Carlos Santos, Maria Oliveira, Pedro Costa, Juliana Ferreira`);
} catch (error) {
console.error(' ❌ Error seeding applications:', error.message);
throw error;
}
}

View file

@ -2,40 +2,37 @@ import { pool } from '../db.js';
import crypto from 'crypto';
// Job templates for variety
// Job templates for variety (Funny & Serious mix)
const jobTemplates = [
{ title: 'Senior Software Engineer', skills: ['React', 'Node.js', 'TypeScript'], salaryRange: [12000, 20000] },
{ title: 'Full Stack Developer', skills: ['JavaScript', 'Python', 'PostgreSQL'], salaryRange: [10000, 16000] },
{ title: 'Frontend Developer', skills: ['React', 'Vue.js', 'CSS'], salaryRange: [8000, 14000] },
{ title: 'Backend Developer', skills: ['Node.js', 'Go', 'MongoDB'], salaryRange: [9000, 15000] },
{ title: 'DevOps Engineer', skills: ['Docker', 'Kubernetes', 'AWS'], salaryRange: [13000, 20000] },
{ title: 'Data Scientist', skills: ['Python', 'Machine Learning', 'SQL'], salaryRange: [14000, 22000] },
{ title: 'Data Engineer', skills: ['Spark', 'Airflow', 'Python'], salaryRange: [12000, 18000] },
{ title: 'Product Manager', skills: ['Agile', 'Scrum', 'Product Strategy'], salaryRange: [11000, 18000] },
{ title: 'UX Designer', skills: ['Figma', 'User Research', 'Prototyping'], salaryRange: [8000, 14000] },
{ title: 'UI Designer', skills: ['Figma', 'Adobe XD', 'Design Systems'], salaryRange: [7000, 12000] },
{ title: 'QA Engineer', skills: ['Selenium', 'Cypress', 'Jest'], salaryRange: [7000, 12000] },
{ title: 'Mobile Developer', skills: ['React Native', 'Flutter', 'iOS'], salaryRange: [10000, 16000] },
{ title: 'Android Developer', skills: ['Kotlin', 'Java', 'Android SDK'], salaryRange: [9000, 15000] },
{ title: 'iOS Developer', skills: ['Swift', 'SwiftUI', 'Objective-C'], salaryRange: [10000, 16000] },
{ title: 'Security Engineer', skills: ['Penetration Testing', 'Security Audits', 'SIEM'], salaryRange: [14000, 22000] },
{ title: 'Cloud Architect', skills: ['AWS', 'Azure', 'GCP'], salaryRange: [18000, 28000] },
{ title: 'Machine Learning Engineer', skills: ['TensorFlow', 'PyTorch', 'NLP'], salaryRange: [15000, 24000] },
{ title: 'Blockchain Developer', skills: ['Solidity', 'Web3.js', 'Smart Contracts'], salaryRange: [16000, 26000] },
{ title: 'Technical Lead', skills: ['Architecture', 'Team Leadership', 'Code Review'], salaryRange: [16000, 25000] },
{ title: 'Engineering Manager', skills: ['Team Management', 'Hiring', 'Strategic Planning'], salaryRange: [18000, 30000] },
{ title: 'SRE Engineer', skills: ['Kubernetes', 'Prometheus', 'Terraform'], salaryRange: [14000, 22000] },
{ title: 'Database Administrator', skills: ['PostgreSQL', 'MySQL', 'MongoDB'], salaryRange: [10000, 16000] },
{ title: 'Technical Writer', skills: ['Documentation', 'API Docs', 'Markdown'], salaryRange: [6000, 10000] },
{ title: 'Scrum Master', skills: ['Scrum', 'Kanban', 'Team Facilitation'], salaryRange: [9000, 15000] },
{ title: 'Business Analyst', skills: ['Requirements Analysis', 'SQL', 'Jira'], salaryRange: [8000, 14000] },
{ title: 'Systems Analyst', skills: ['System Design', 'Integration', 'Analysis'], salaryRange: [9000, 15000] },
{ title: 'Network Engineer', skills: ['Cisco', 'Networking', 'VPN'], salaryRange: [8000, 14000] },
{ title: 'Game Developer', skills: ['Unity', 'Unreal Engine', 'C++'], salaryRange: [10000, 18000] },
{ title: 'AR/VR Developer', skills: ['Unity', 'ARKit', 'VR Development'], salaryRange: [12000, 20000] },
{ title: 'Embedded Systems Engineer', skills: ['C', 'C++', 'RTOS'], salaryRange: [11000, 18000] },
{ title: 'AI Research Scientist', skills: ['Deep Learning', 'Research', 'Python'], salaryRange: [18000, 30000] },
{ title: 'Platform Engineer', skills: ['Kubernetes', 'CI/CD', 'Infrastructure'], salaryRange: [14000, 22000] },
{ title: 'Solutions Architect', skills: ['AWS', 'System Design', 'Client Facing'], salaryRange: [18000, 28000] }
// Tech Roles
{ title: 'Senior Stack Overflow Copy-Paster', skills: ['Ctrl+C', 'Ctrl+V', 'Google Fu'], salaryRange: [120000, 180000] }, // High salary
{ title: 'Full Stack Overflow Engineer', skills: ['JavaScript', 'Python', 'Chaos Engineering'], salaryRange: [90000, 150000] },
{ title: 'Frontend Div Centerer', skills: ['CSS', 'Flexbox', 'Patience'], salaryRange: [70000, 120000] },
{ title: 'Backend JSON Mover', skills: ['Node.js', 'Go', 'REST'], salaryRange: [85000, 140000] },
{ title: 'Kubernetes YAML Herder', skills: ['Docker', 'K8s', 'Indentations'], salaryRange: [130000, 200000] },
{ title: 'AI Prompt Whisperer', skills: ['ChatGPT', 'English', 'Imagination'], salaryRange: [60000, 300000] }, // Volatile salary
{ title: 'Legacy Code Archaeologist', skills: ['COBOL', 'Fortran', 'Dusting'], salaryRange: [150000, 250000] },
// Management & Product
{ title: 'Chief Meeting Scheduler', skills: ['Calendar', 'Zoom', 'Talking'], salaryRange: [100000, 160000] },
{ title: 'Scrum Master of Puppets', skills: ['Jira', 'Whips', 'Post-its'], salaryRange: [90000, 140000] },
{ title: 'Product Visionary (Dreamer)', skills: ['Keynote', 'Buzzwords', 'Optimism'], salaryRange: [110000, 190000] },
// Design
{ title: 'Pixel Perfect Pedant', skills: ['Figma', 'Zoom 800%', 'Eye Drops'], salaryRange: [80000, 130000] },
{ title: 'UX Dark Pattern Architect', skills: ['Psychology', 'Manipulation', 'CSS'], salaryRange: [95000, 155000] },
// Financial (The "Banco Desaster" influence)
{ title: 'Creative Accountant', skills: ['Excel', 'Creativity', 'Obfuscation'], salaryRange: [100000, 500000] },
{ title: 'High-Frequency Front-Runner', skills: ['C++', 'Low Latency', 'Moral Flexibility'], salaryRange: [200000, 800000] },
{ title: 'Offshore Database Admin', skills: ['SQL', 'Boat Driving', 'Secrecy'], salaryRange: [120000, 180000] },
{ title: 'Risk Ignoring Manager', skills: ['Coin Toss', 'Gut Feeling', 'Blindfolds'], salaryRange: [150000, 300000] }
];
const internationalLocations = [
'New York, NY', 'San Francisco, CA', 'London, UK', 'Berlin, DE', 'Remote (Global)',
'Silicon Valley, CA', 'Austin, TX', 'Amsterdam, NL', 'Tokyo, JP', 'Singapore, SG',
'Dubai, UAE', 'Cayman Islands'
];
const workModes = ['onsite', 'hybrid', 'remote'];
@ -116,7 +113,7 @@ export async function seedJobs() {
'monthly',
employmentType,
workMode === 'remote' ? 'Flexible' : '9:00-18:00',
workMode === 'remote' ? 'Remote (Anywhere)' : 'São Paulo - SP',
workMode === 'remote' ? 'Remote (Global)' : internationalLocations[i % internationalLocations.length],
JSON.stringify(template.skills),
JSON.stringify(getRandomItems(benefits, 4)),
i % 5 === 0, // 20% offer visa support

View file

@ -0,0 +1,135 @@
import { pool } from '../db.js';
import crypto from 'crypto';
/**
* 🔔 Notifications Seeder
*
* Creates realistic notifications like:
* - New application received
* - Candidate accepted interview
* - High-demand role alerts
* - Job views milestones
*/
const notificationTemplates = [
{
title: 'New application received',
message: 'Ana Silva applied for the Senior Full Stack Developer role',
type: 'application',
is_read: false,
},
{
title: 'Candidate accepted interview',
message: 'Carlos Santos confirmed attendance for tomorrow at 2 PM',
type: 'interview',
is_read: false,
},
{
title: 'High-demand role',
message: 'The UX/UI Designer role received 15 new applications today',
type: 'alert',
is_read: false,
},
{
title: 'Job views milestone',
message: 'Your Senior Developer posting reached 500 views!',
type: 'milestone',
is_read: true,
},
{
title: 'New application received',
message: 'Maria Oliveira applied for the Product Manager role',
type: 'application',
is_read: false,
},
{
title: 'Candidate message',
message: 'Pedro Costa sent a message about the Backend Developer position',
type: 'message',
is_read: true,
},
{
title: 'Interview reminder',
message: 'You have an interview with Juliana Ferreira in 1 hour',
type: 'reminder',
is_read: false,
},
{
title: 'Application deadline',
message: 'Your DevOps Engineer posting closes in 3 days. 45 applications received.',
type: 'deadline',
is_read: true,
},
{
title: 'Candidate withdrew',
message: 'A candidate withdrew their application for Full Stack Developer',
type: 'withdrawal',
is_read: true,
},
{
title: 'Weekly report ready',
message: 'Your job performance report for this week is ready to view',
type: 'report',
is_read: false,
},
];
export async function seedNotifications() {
console.log('🔔 Seeding notifications...');
try {
// Get users from core_users
const usersRes = await pool.query('SELECT id FROM core_users LIMIT 5');
const users = usersRes.rows;
if (users.length === 0) {
console.log(' ⚠️ No users found, skipping notifications');
return;
}
let totalNotifs = 0;
// Create notifications for each user
for (let u = 0; u < Math.min(3, users.length); u++) {
const userId = users[u].id;
// Give each user 5-10 notifications
const numNotifs = 5 + (u * 2);
for (let i = 0; i < numNotifs && i < notificationTemplates.length; i++) {
const template = notificationTemplates[i % notificationTemplates.length];
const notifId = crypto.randomUUID();
// Random time in the past 30 days
const daysAgo = Math.floor(Math.random() * 30);
const hoursAgo = Math.floor(Math.random() * 24);
const createdAt = new Date();
createdAt.setDate(createdAt.getDate() - daysAgo);
createdAt.setHours(createdAt.getHours() - hoursAgo);
await pool.query(`
INSERT INTO notifications (id, user_id, title, message, type, is_read, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (id) DO NOTHING
`, [
notifId,
userId,
template.title,
template.message,
template.type,
template.is_read,
createdAt,
createdAt
]);
totalNotifs++;
}
}
console.log(`${totalNotifs} notifications criadas`);
console.log(` 📬 Tipos: application, interview, alert, milestone, message, reminder`);
} catch (error) {
console.error(' ❌ Error seeding notifications:', error.message);
throw error;
}
}