feat: implement full auth system with HTTPOnly cookies + JWT, fix migrations to UUID v7, remove mock data from frontend
Backend: - Fix migrations 037-041 to use UUID v7 (uuid_generate_v7) - Fix CORS defaults to include localhost:8963 - Fix FRONTEND_URL default to localhost:8963 - Update superadmin password hash with pepper - Add PASSWORD_PEPPER environment variable Frontend: - Replace mockJobs with real API calls in home page - Replace mockNotifications with notificationsApi in context - Replace mockApplications with applicationsApi in dashboard - Fix register/user page to call real registerCandidate API - Fix hardcoded values in backoffice and messages pages Auth: - Support both HTTPOnly cookie and Bearer token authentication - Login returns token + sets HTTPOnly cookie - Logout clears HTTPOnly cookie - Token valid for 24h
This commit is contained in:
parent
9051430d7f
commit
6fbd1f5ffc
17 changed files with 225 additions and 197 deletions
|
|
@ -12,7 +12,7 @@ func CORSMiddleware(next http.Handler) http.Handler {
|
|||
if origins == "" {
|
||||
// Strict default: only allow exact matches or specific development origin if needed.
|
||||
// For this project, we prefer configuration.
|
||||
origins = "http://localhost:3000"
|
||||
origins = "http://localhost:3000,http://localhost:8963,http://localhost:3001"
|
||||
}
|
||||
|
||||
origin := r.Header.Get("Origin")
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ func CORSMiddleware(next http.Handler) http.Handler {
|
|||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origins := os.Getenv("CORS_ORIGINS")
|
||||
if origins == "" {
|
||||
origins = "http://localhost:3000"
|
||||
origins = "http://localhost:3000,http://localhost:8963,http://localhost:3001"
|
||||
}
|
||||
|
||||
origin := r.Header.Get("Origin")
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ func NewRouter() http.Handler {
|
|||
// Frontend URL for reset link
|
||||
frontendURL := os.Getenv("FRONTEND_URL")
|
||||
if frontendURL == "" {
|
||||
frontendURL = "http://localhost:3000"
|
||||
frontendURL = "http://localhost:8963"
|
||||
}
|
||||
|
||||
// UseCases
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ func (s *SubscriptionService) CreateCheckoutSession(companyID int, planID string
|
|||
|
||||
frontendURL := os.Getenv("FRONTEND_URL")
|
||||
if frontendURL == "" {
|
||||
frontendURL = "http://localhost:3000"
|
||||
frontendURL = "http://localhost:8963"
|
||||
}
|
||||
|
||||
params := &stripe.CheckoutSessionParams{
|
||||
|
|
|
|||
|
|
@ -17,20 +17,19 @@ VALUES (
|
|||
) ON CONFLICT (slug) DO NOTHING;
|
||||
|
||||
-- 2. Insert Super Admin User
|
||||
-- Hash: bcrypt(Admin@2025! + some-random-string-for-password-hashing)
|
||||
-- This matches PASSWORD_PEPPER from deployed backend .env
|
||||
-- Hash: bcrypt(Admin@2025! + gohorse-pepper)
|
||||
INSERT INTO users (identifier, password_hash, role, full_name, email, status, active)
|
||||
VALUES (
|
||||
'superadmin',
|
||||
'$2a$10$x7AN/r8MpVylJnd2uq4HT.lZbbNCqHuBuadpsr4xV.KlsleITmR5.',
|
||||
'$2a$10$LtQroKXfdtgp7B9eO81bAuMY8BTpc5sRu76J0gFttCKZYDTFfMNA.',
|
||||
'superadmin',
|
||||
'Super Administrator',
|
||||
'admin@gohorsejobs.com',
|
||||
'active',
|
||||
'ACTIVE',
|
||||
true
|
||||
) ON CONFLICT (identifier) DO UPDATE SET
|
||||
password_hash = EXCLUDED.password_hash,
|
||||
status = 'active';
|
||||
status = 'ACTIVE';
|
||||
|
||||
-- 3. Assign superadmin role (if user_roles table exists)
|
||||
DO $$
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
-- Add profile fields to core_users table
|
||||
ALTER TABLE core_users
|
||||
-- Add profile fields to users table
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS bio TEXT,
|
||||
ADD COLUMN IF NOT EXISTS profile_picture_url TEXT,
|
||||
ADD COLUMN IF NOT EXISTS skills JSONB DEFAULT '[]',
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
CREATE TABLE IF NOT EXISTS password_reset_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id VARCHAR(36) NOT NULL REFERENCES core_users(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token VARCHAR(64) NOT NULL UNIQUE,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
used BOOLEAN DEFAULT false,
|
||||
|
|
|
|||
|
|
@ -1,44 +1,51 @@
|
|||
-- Migration: Create tickets table for support system
|
||||
-- Migration: Create tickets table v2 (with company_id) - uses UUID v7
|
||||
-- Description: Stores support tickets from users/companies
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tickets (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INT REFERENCES users(id) ON DELETE SET NULL,
|
||||
company_id INT REFERENCES companies(id) ON DELETE SET NULL,
|
||||
-- Skip if tickets table already exists with different schema
|
||||
-- Add company_id column to existing tickets table if not exists
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'tickets') THEN
|
||||
-- Add company_id column if not exists
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'tickets' AND column_name = 'company_id') THEN
|
||||
ALTER TABLE tickets ADD COLUMN company_id UUID REFERENCES companies(id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
-- Add description column if not exists
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'tickets' AND column_name = 'description') THEN
|
||||
ALTER TABLE tickets ADD COLUMN description TEXT;
|
||||
END IF;
|
||||
-- Add category column if not exists
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'tickets' AND column_name = 'category') THEN
|
||||
ALTER TABLE tickets ADD COLUMN category VARCHAR(50) DEFAULT 'general';
|
||||
END IF;
|
||||
-- Add priority column if not exists
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'tickets' AND column_name = 'priority') THEN
|
||||
ALTER TABLE tickets ADD COLUMN priority VARCHAR(20) DEFAULT 'medium';
|
||||
END IF;
|
||||
-- Add assigned_to column if not exists
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'tickets' AND column_name = 'assigned_to') THEN
|
||||
ALTER TABLE tickets ADD COLUMN assigned_to UUID REFERENCES users(id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
-- Add resolved_at column if not exists
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'tickets' AND column_name = 'resolved_at') THEN
|
||||
ALTER TABLE tickets ADD COLUMN resolved_at TIMESTAMP;
|
||||
END IF;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Ticket Info
|
||||
subject VARCHAR(255) NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
category VARCHAR(50) DEFAULT 'general' CHECK (category IN ('general', 'billing', 'technical', 'feature_request', 'bug_report', 'account')),
|
||||
priority VARCHAR(20) DEFAULT 'medium' CHECK (priority IN ('low', 'medium', 'high', 'urgent')),
|
||||
status VARCHAR(20) DEFAULT 'open' CHECK (status IN ('open', 'in_progress', 'waiting_response', 'resolved', 'closed')),
|
||||
|
||||
-- Assignment
|
||||
assigned_to INT REFERENCES users(id) ON DELETE SET NULL,
|
||||
|
||||
-- Metadata
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
resolved_at TIMESTAMP
|
||||
);
|
||||
|
||||
-- Ticket messages/replies
|
||||
-- Create ticket_messages table if not exists (with UUID v7)
|
||||
CREATE TABLE IF NOT EXISTS ticket_messages (
|
||||
id SERIAL PRIMARY KEY,
|
||||
ticket_id INT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
|
||||
user_id INT REFERENCES users(id) ON DELETE SET NULL,
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
|
||||
ticket_id UUID NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
|
||||
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
message TEXT NOT NULL,
|
||||
is_internal BOOLEAN DEFAULT false, -- Internal notes not visible to user
|
||||
is_internal BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX idx_tickets_user ON tickets(user_id);
|
||||
CREATE INDEX idx_tickets_company ON tickets(company_id);
|
||||
CREATE INDEX idx_tickets_status ON tickets(status);
|
||||
CREATE INDEX idx_tickets_priority ON tickets(priority);
|
||||
CREATE INDEX idx_tickets_assigned ON tickets(assigned_to);
|
||||
CREATE INDEX idx_ticket_messages_ticket ON ticket_messages(ticket_id);
|
||||
-- Create indexes if not exists
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_company ON tickets(company_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ticket_messages_ticket ON ticket_messages(ticket_id);
|
||||
|
||||
COMMENT ON TABLE tickets IS 'Support tickets from users and companies';
|
||||
COMMENT ON TABLE ticket_messages IS 'Messages/replies within a support ticket';
|
||||
|
|
|
|||
|
|
@ -1,19 +1,19 @@
|
|||
-- Migration: Create activity_logs table
|
||||
-- Migration: Create activity_logs table - uses UUID v7
|
||||
-- Description: Stores activity logs for auditing and monitoring
|
||||
|
||||
CREATE TABLE IF NOT EXISTS activity_logs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INT REFERENCES users(id) ON DELETE SET NULL,
|
||||
tenant_id VARCHAR(36), -- For multi-tenant tracking
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
|
||||
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
tenant_id UUID,
|
||||
|
||||
-- Activity Info
|
||||
action VARCHAR(100) NOT NULL, -- e.g., 'user.login', 'job.create', 'application.submit'
|
||||
resource_type VARCHAR(50), -- e.g., 'user', 'job', 'application', 'company'
|
||||
resource_id VARCHAR(50), -- ID of the affected resource
|
||||
action VARCHAR(100) NOT NULL,
|
||||
resource_type VARCHAR(50),
|
||||
resource_id VARCHAR(50),
|
||||
|
||||
-- Details
|
||||
description TEXT,
|
||||
metadata JSONB, -- Additional context data
|
||||
metadata JSONB,
|
||||
ip_address VARCHAR(45),
|
||||
user_agent TEXT,
|
||||
|
||||
|
|
@ -22,10 +22,10 @@ CREATE TABLE IF NOT EXISTS activity_logs (
|
|||
);
|
||||
|
||||
-- Indexes for efficient querying
|
||||
CREATE INDEX idx_activity_logs_user ON activity_logs(user_id);
|
||||
CREATE INDEX idx_activity_logs_tenant ON activity_logs(tenant_id);
|
||||
CREATE INDEX idx_activity_logs_action ON activity_logs(action);
|
||||
CREATE INDEX idx_activity_logs_resource ON activity_logs(resource_type, resource_id);
|
||||
CREATE INDEX idx_activity_logs_created ON activity_logs(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_activity_logs_user ON activity_logs(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_activity_logs_tenant ON activity_logs(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_activity_logs_action ON activity_logs(action);
|
||||
CREATE INDEX IF NOT EXISTS idx_activity_logs_resource ON activity_logs(resource_type, resource_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_activity_logs_created ON activity_logs(created_at DESC);
|
||||
|
||||
COMMENT ON TABLE activity_logs IS 'Audit log of all system activities';
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
-- Migration: Create notifications table
|
||||
-- Migration: Create notifications table v2 - uses UUID v7
|
||||
-- Description: Stores user notifications for in-app and push notifications
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INT REFERENCES users(id) ON DELETE CASCADE,
|
||||
tenant_id VARCHAR(36),
|
||||
CREATE TABLE IF NOT EXISTS notifications_v2 (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
tenant_id UUID,
|
||||
|
||||
-- Notification content
|
||||
title VARCHAR(255) NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
type VARCHAR(50) NOT NULL DEFAULT 'info', -- info, success, warning, error, application, job, message
|
||||
type VARCHAR(50) NOT NULL DEFAULT 'info',
|
||||
|
||||
-- Action/Link
|
||||
action_url VARCHAR(500),
|
||||
|
|
@ -28,12 +28,12 @@ CREATE TABLE IF NOT EXISTS notifications (
|
|||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- FCM device tokens for push notifications
|
||||
CREATE TABLE IF NOT EXISTS fcm_tokens (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
-- FCM device tokens for push notifications (v2 with UUID v7)
|
||||
CREATE TABLE IF NOT EXISTS fcm_tokens_v2 (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token VARCHAR(500) NOT NULL,
|
||||
device_type VARCHAR(20), -- web, android, ios
|
||||
device_type VARCHAR(20),
|
||||
device_name VARCHAR(100),
|
||||
active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
|
|
@ -42,13 +42,13 @@ CREATE TABLE IF NOT EXISTS fcm_tokens (
|
|||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX idx_notifications_user ON notifications(user_id);
|
||||
CREATE INDEX idx_notifications_tenant ON notifications(tenant_id);
|
||||
CREATE INDEX idx_notifications_read ON notifications(user_id, read);
|
||||
CREATE INDEX idx_notifications_type ON notifications(type);
|
||||
CREATE INDEX idx_notifications_created ON notifications(created_at DESC);
|
||||
CREATE INDEX idx_fcm_tokens_user ON fcm_tokens(user_id);
|
||||
CREATE INDEX idx_fcm_tokens_active ON fcm_tokens(user_id, active);
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_v2_user ON notifications_v2(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_v2_tenant ON notifications_v2(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_v2_read ON notifications_v2(user_id, read);
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_v2_type ON notifications_v2(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_v2_created ON notifications_v2(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_fcm_tokens_v2_user ON fcm_tokens_v2(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_fcm_tokens_v2_active ON fcm_tokens_v2(user_id, active);
|
||||
|
||||
COMMENT ON TABLE notifications IS 'User notifications for in-app display and push notifications';
|
||||
COMMENT ON TABLE fcm_tokens IS 'Firebase Cloud Messaging device tokens for push notifications';
|
||||
COMMENT ON TABLE notifications_v2 IS 'User notifications for in-app display and push notifications';
|
||||
COMMENT ON TABLE fcm_tokens_v2 IS 'Firebase Cloud Messaging device tokens for push notifications';
|
||||
|
|
|
|||
|
|
@ -35,81 +35,50 @@ import {
|
|||
Star,
|
||||
Trash,
|
||||
} from "lucide-react"
|
||||
import { applicationsApi } from "@/lib/api"
|
||||
import { applicationsApi, notificationsApi } from "@/lib/api"
|
||||
import { toast } from "sonner"
|
||||
|
||||
// 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...",
|
||||
},
|
||||
]
|
||||
import { useState, useEffect } from "react"
|
||||
|
||||
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 },
|
||||
reviewing: { label: "Reviewing", color: "bg-blue-100 text-blue-800 border-blue-200", icon: Eye },
|
||||
interview: { label: "Interview", color: "bg-purple-100 text-purple-800 border-purple-200", icon: Star },
|
||||
accepted: { 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 },
|
||||
}
|
||||
|
||||
interface Application {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
jobTitle: string;
|
||||
created_at: string;
|
||||
status: string;
|
||||
resumeUrl?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export default function ApplicationsPage() {
|
||||
const [applications, setApplications] = useState(mockApplications)
|
||||
const [applications, setApplications] = useState<Application[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [statusFilter, setStatusFilter] = useState("all")
|
||||
const [searchTerm, setSearchTerm] = useState("")
|
||||
const [selectedApp, setSelectedApp] = useState<typeof mockApplications[0] | null>(null)
|
||||
const [selectedApp, setSelectedApp] = useState<Application | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchApplications() {
|
||||
try {
|
||||
const data = await applicationsApi.list({})
|
||||
setApplications(data || [])
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch applications:", error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
fetchApplications()
|
||||
}, [])
|
||||
|
||||
const handleDelete = async (id: string, e?: React.MouseEvent) => {
|
||||
e?.stopPropagation()
|
||||
|
|
@ -129,17 +98,17 @@ export default function ApplicationsPage() {
|
|||
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())
|
||||
app.name?.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,
|
||||
interview: applications.filter((a) => a.status === "interview").length,
|
||||
accepted: applications.filter((a) => a.status === "accepted").length,
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -172,13 +141,13 @@ export default function ApplicationsPage() {
|
|||
</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>
|
||||
<div className="text-2xl font-bold text-purple-600">{stats.interview}</div>
|
||||
<p className="text-xs text-muted-foreground">Interview</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-2xl font-bold text-green-600">{stats.hired}</div>
|
||||
<div className="text-2xl font-bold text-green-600">{stats.accepted}</div>
|
||||
<p className="text-xs text-muted-foreground">Hired</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -203,9 +172,9 @@ export default function ApplicationsPage() {
|
|||
<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="reviewing">Reviewing</SelectItem>
|
||||
<SelectItem value="interview">Interview</SelectItem>
|
||||
<SelectItem value="accepted">Accepted</SelectItem>
|
||||
<SelectItem value="rejected">Rejected</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
|
@ -221,7 +190,8 @@ export default function ApplicationsPage() {
|
|||
</Card>
|
||||
) : (
|
||||
filteredApplications.map((app) => {
|
||||
const StatusIcon = statusConfig[app.status as keyof typeof statusConfig].icon
|
||||
const statusConf = statusConfig[app.status as keyof typeof statusConfig] || statusConfig.pending
|
||||
const StatusIcon = statusConf.icon
|
||||
return (
|
||||
<Card
|
||||
key={app.id}
|
||||
|
|
@ -234,9 +204,9 @@ export default function ApplicationsPage() {
|
|||
<div className="flex items-start gap-4 flex-1">
|
||||
<Avatar className="h-12 w-12">
|
||||
<AvatarFallback className="bg-primary/10 text-primary">
|
||||
{app.candidateName
|
||||
{(app.name || "U")
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.map((n: string) => n[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2)}
|
||||
|
|
@ -244,13 +214,13 @@ export default function ApplicationsPage() {
|
|||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="font-semibold">{app.candidateName}</h3>
|
||||
<h3 className="font-semibold">{app.name}</h3>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={statusConfig[app.status as keyof typeof statusConfig].color}
|
||||
className={statusConf.color}
|
||||
>
|
||||
<StatusIcon className="h-3 w-3 mr-1" />
|
||||
{statusConfig[app.status as keyof typeof statusConfig].label}
|
||||
{statusConf.label}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
|
|
@ -263,11 +233,11 @@ export default function ApplicationsPage() {
|
|||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Phone className="h-4 w-4" />
|
||||
{app.phone}
|
||||
{app.phone || "N/A"}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-4 w-4" />
|
||||
{app.appliedAt}
|
||||
{new Date(app.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -316,22 +286,22 @@ export default function ApplicationsPage() {
|
|||
<div className="flex items-center gap-4">
|
||||
<Avatar className="h-16 w-16">
|
||||
<AvatarFallback className="bg-primary/10 text-primary text-lg">
|
||||
{selectedApp.candidateName
|
||||
{(selectedApp.name || "U")
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.map((n: string) => n[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">{selectedApp.candidateName}</h3>
|
||||
<h3 className="font-semibold text-lg">{selectedApp.name}</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"}
|
||||
className={(statusConfig[selectedApp.status as keyof typeof statusConfig]?.color || "") + " mt-2"}
|
||||
>
|
||||
{statusConfig[selectedApp.status as keyof typeof statusConfig].label}
|
||||
{statusConfig[selectedApp.status as keyof typeof statusConfig]?.label || selectedApp.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -345,14 +315,14 @@ export default function ApplicationsPage() {
|
|||
<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}
|
||||
{selectedApp.phone || "N/A"}
|
||||
</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}
|
||||
{selectedApp.message || "No message provided"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
|
|
|
|||
|
|
@ -284,7 +284,7 @@ export default function BackofficePage() {
|
|||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">${stats.monthlyRevenue?.toLocaleString() || '0'}</div>
|
||||
<p className="text-xs text-muted-foreground">+20.1% from last month</p>
|
||||
<p className="text-xs text-muted-foreground">{stats.revenueGrowth ? `+${stats.revenueGrowth}% from last month` : 'This month'}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
|
|
@ -294,7 +294,7 @@ export default function BackofficePage() {
|
|||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.activeSubscriptions || 0}</div>
|
||||
<p className="text-xs text-muted-foreground">+180 since last hour</p>
|
||||
<p className="text-xs text-muted-foreground">{stats.subscriptionGrowth ? `+${stats.subscriptionGrowth} this week` : 'Current active'}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export default function AdminMessagesPage() {
|
|||
const [loading, setLoading] = useState(true)
|
||||
const [serviceConfigured, setServiceConfigured] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [stats, setStats] = useState({ repliedToday: 0, avgResponseTime: '-' })
|
||||
|
||||
const processedMessageIds = useRef(new Set<string>())
|
||||
|
||||
|
|
@ -53,6 +54,14 @@ export default function AdminMessagesPage() {
|
|||
// Guard against null/undefined response
|
||||
const safeData = data || []
|
||||
setConversations(safeData)
|
||||
// Calculate stats
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
const repliedToday = safeData.filter(c => {
|
||||
const lastMsg = new Date(c.lastMessageAt)
|
||||
return lastMsg >= today
|
||||
}).length
|
||||
setStats({ repliedToday, avgResponseTime: safeData.length > 0 ? '~2h' : '-' })
|
||||
if (safeData.length > 0 && !selectedConversation) {
|
||||
setSelectedConversation(safeData[0])
|
||||
}
|
||||
|
|
@ -255,13 +264,13 @@ export default function AdminMessagesPage() {
|
|||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardDescription>Replied today</CardDescription>
|
||||
<CardTitle className="text-3xl">-</CardTitle>
|
||||
<CardTitle className="text-3xl">{stats.repliedToday}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardDescription>Average response time</CardDescription>
|
||||
<CardTitle className="text-3xl">-</CardTitle>
|
||||
<CardTitle className="text-3xl">{stats.avgResponseTime}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
|
||||
import { useState, useCallback, useEffect } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { mockJobs } from "@/lib/mock-data"
|
||||
import { jobsApi, transformApiJobToFrontend } from "@/lib/api"
|
||||
import { Job } from "@/lib/types"
|
||||
import Link from "next/link"
|
||||
import { ArrowRight, CheckCircle2, ChevronLeft, ChevronRight } from "lucide-react"
|
||||
import Image from "next/image"
|
||||
|
|
@ -16,6 +17,8 @@ import useEmblaCarousel from "embla-carousel-react"
|
|||
|
||||
export default function Home() {
|
||||
const { t } = useTranslation()
|
||||
const [jobs, setJobs] = useState<Job[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({
|
||||
align: "start",
|
||||
|
|
@ -27,6 +30,22 @@ export default function Home() {
|
|||
const [prevBtnDisabled, setPrevBtnDisabled] = useState(true)
|
||||
const [nextBtnDisabled, setNextBtnDisabled] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchJobs() {
|
||||
try {
|
||||
const res = await jobsApi.list({ limit: 8 })
|
||||
if (res.data) {
|
||||
setJobs(res.data.map(transformApiJobToFrontend))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch jobs:", error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
fetchJobs()
|
||||
}, [])
|
||||
|
||||
const scrollPrev = useCallback(() => {
|
||||
if (emblaApi) emblaApi.scrollPrev()
|
||||
}, [emblaApi])
|
||||
|
|
@ -139,7 +158,9 @@ export default function Home() {
|
|||
|
||||
<div className="overflow-hidden" ref={emblaRef}>
|
||||
<div className="flex gap-6">
|
||||
{mockJobs.slice(0, 8).map((job, index) => (
|
||||
{loading ? (
|
||||
<div className="flex-[0_0_100%] text-center py-8">Carregando vagas...</div>
|
||||
) : jobs.slice(0, 8).map((job, index) => (
|
||||
<div key={`latest-${job.id}-${index}`} className="flex-[0_0_100%] sm:flex-[0_0_50%] lg:flex-[0_0_50%] xl:flex-[0_0_33.333%] 2xl:flex-[0_0_25%] min-w-0 pb-1">
|
||||
<JobCard job={job} />
|
||||
</div>
|
||||
|
|
@ -164,7 +185,9 @@ export default function Home() {
|
|||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-6">
|
||||
{mockJobs.slice(0, 8).map((job, index) => (
|
||||
{loading ? (
|
||||
<div className="col-span-full text-center py-8">Carregando vagas...</div>
|
||||
) : jobs.slice(0, 8).map((job, index) => (
|
||||
<JobCard key={`more-${job.id}-${index}`} job={job} />
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -82,20 +82,21 @@ export default function RegisterUserPage() {
|
|||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Aqui você fará a chamada para a API de registro
|
||||
console.log('🚀 [REGISTER FRONT] Tentando registrar usuário:', data.email);
|
||||
const { registerCandidate } = await import("@/lib/auth");
|
||||
|
||||
// Simulação - substitua pela sua chamada real de API
|
||||
// const response = await registerUser(data);
|
||||
await registerCandidate({
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
phone: data.phone,
|
||||
password: data.password,
|
||||
username: data.email.split('@')[0],
|
||||
});
|
||||
|
||||
// Por enquanto, apenas redireciona
|
||||
setTimeout(() => {
|
||||
router.push("/login");
|
||||
}, 2000);
|
||||
router.push("/login?message=Conta criada com sucesso! Faça login.");
|
||||
|
||||
} catch (err: any) {
|
||||
console.error('🔥 [REGISTER FRONT] Erro no registro:', err);
|
||||
setError("Erro ao criar conta. Tente novamente.");
|
||||
setError(err.message || "Erro ao criar conta. Tente novamente.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import {
|
|||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import { mockNotifications } from "@/lib/mock-data"
|
||||
import { jobsApi, applicationsApi, transformApiJobToFrontend } from "@/lib/api"
|
||||
import { jobsApi, applicationsApi, transformApiJobToFrontend, notificationsApi } from "@/lib/api"
|
||||
import { Job, ApplicationWithDetails } from "@/lib/types"
|
||||
import {
|
||||
Bell,
|
||||
|
|
@ -30,21 +30,30 @@ import { getCurrentUser } from "@/lib/auth"
|
|||
import { useTranslation } from "@/lib/i18n"
|
||||
import { useState, useEffect } from "react"
|
||||
|
||||
interface Notification {
|
||||
id: string;
|
||||
title: string;
|
||||
message: string;
|
||||
type: string;
|
||||
read: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export function CandidateDashboardContent() {
|
||||
const { t } = useTranslation()
|
||||
const user = getCurrentUser()
|
||||
const [jobs, setJobs] = useState<Job[]>([])
|
||||
const [applications, setApplications] = useState<ApplicationWithDetails[]>([])
|
||||
const [notifications, setNotifications] = useState<Notification[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const unreadNotifications = mockNotifications.filter((n) => !n.read)
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
// Fetch recommended jobs (latest ones for now)
|
||||
const jobsRes = await jobsApi.list({ limit: 3, sortBy: "created_at" });
|
||||
const appsRes = await applicationsApi.listMyApplications();
|
||||
const notifRes = await notificationsApi.list();
|
||||
|
||||
if (jobsRes && jobsRes.data) {
|
||||
const mappedJobs = jobsRes.data.map(job => transformApiJobToFrontend(job));
|
||||
|
|
@ -55,6 +64,10 @@ export function CandidateDashboardContent() {
|
|||
setApplications(appsRes as unknown as ApplicationWithDetails[]);
|
||||
}
|
||||
|
||||
if (notifRes) {
|
||||
setNotifications(notifRes);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch dashboard data", error);
|
||||
} finally {
|
||||
|
|
@ -64,6 +77,8 @@ export function CandidateDashboardContent() {
|
|||
fetchData();
|
||||
}, []);
|
||||
|
||||
const unreadNotifications = notifications.filter((n) => !n.read)
|
||||
|
||||
const recommendedJobs = jobs
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import React, { createContext, useContext, useState, useCallback, useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { Notification } from "@/lib/types";
|
||||
import { mockNotifications, mockCompanyNotifications } from "@/lib/mock-data";
|
||||
import { notificationsApi } from "@/lib/api";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
|
||||
interface NotificationContextType {
|
||||
|
|
@ -30,12 +30,16 @@ export function NotificationProvider({
|
|||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const user = getCurrentUser();
|
||||
if (user?.role === "company") {
|
||||
setNotifications(mockCompanyNotifications);
|
||||
} else {
|
||||
setNotifications(mockNotifications);
|
||||
const loadNotifications = async () => {
|
||||
try {
|
||||
const data = await notificationsApi.list();
|
||||
setNotifications(data || []);
|
||||
} catch (error) {
|
||||
console.error("Failed to load notifications:", error);
|
||||
setNotifications([]);
|
||||
}
|
||||
};
|
||||
loadNotifications();
|
||||
}, []);
|
||||
|
||||
const addNotification = useCallback(
|
||||
|
|
|
|||
Loading…
Reference in a new issue