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:
Tiago Yamamoto 2026-02-16 05:20:46 -06:00
parent 9051430d7f
commit 6fbd1f5ffc
17 changed files with 225 additions and 197 deletions

View file

@ -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")

View file

@ -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")

View file

@ -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

View file

@ -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{

View file

@ -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 $$

View file

@ -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 '[]',

View file

@ -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,

View file

@ -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';

View file

@ -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';

View file

@ -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';

View file

@ -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">

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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);
}

View file

@ -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) => {

View file

@ -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(