feat: Enhance platform with funny jobs, FAQ, Skeleton UI, and Tests
This commit is contained in:
parent
407979c6dc
commit
743b2842c0
23 changed files with 2057 additions and 255 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
81
backend/internal/core/usecases/auth/register_candidate.go
Normal file
81
backend/internal/core/usecases/auth/register_candidate.go
Normal 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
|
||||
}
|
||||
148
backend/internal/core/usecases/auth/register_candidate_test.go
Normal file
148
backend/internal/core/usecases/auth/register_candidate_test.go
Normal 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"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
348
frontend/src/app/dashboard/applications/page.tsx
Normal file
348
frontend/src/app/dashboard/applications/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
78
frontend/src/app/faq/page.tsx
Normal file
78
frontend/src/app/faq/page.tsx
Normal 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')} →</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
103
frontend/src/components/phone-input.tsx
Normal file
103
frontend/src/components/phone-input.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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:');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
135
seeder-api/src/seeders/notifications.js
Normal file
135
seeder-api/src/seeders/notifications.js
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue