chore: database reset, frontend API configuration

This commit is contained in:
GoHorse Deploy 2026-02-09 14:11:05 +00:00
parent 65ac4233c2
commit 5291f3f15d
27 changed files with 11413 additions and 441 deletions

9810
frontend/pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load diff

BIN
frontend/public/Blog.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

BIN
frontend/public/Vagas.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 MiB

View file

@ -1,18 +1,46 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
export const runtime = 'nodejs'; export const runtime = 'edge';
export async function GET() { /**
const config = { * Runtime Configuration API
apiUrl: process.env.API_URL || 'https://api.rede5.com.br/', *
backofficeUrl: process.env.BACKOFFICE_URL || 'https://b-local.gohorsejobs.com', * This endpoint returns environment variables that are read at RUNTIME,
seederApiUrl: process.env.SEEDER_API_URL || 'http://localhost:3002', * not build time. This allows changing configuration without rebuilding.
scraperApiUrl: process.env.SCRAPER_API_URL || 'http://localhost:3003', *
}; * IMPORTANT: Use env vars WITHOUT the NEXT_PUBLIC_ prefix here!
* - NEXT_PUBLIC_* = baked in at build time (bad for runtime config)
return NextResponse.json(config, { * - Regular env vars = read at runtime (good!)
headers: { *
'Cache-Control': 'public, max-age=300, s-maxage=300', * In your .env or container config, set:
}, * API_URL=https://api.example.com
}); * BACKOFFICE_URL=https://backoffice.example.com
} * (etc.)
*
* The NEXT_PUBLIC_* versions are only used as fallbacks for local dev.
*/
export async function GET() {
// Priority: Runtime env vars > Build-time NEXT_PUBLIC_* > Defaults
const config = {
apiUrl: process.env.API_URL
|| process.env.NEXT_PUBLIC_API_URL
|| 'http://localhost:8521',
backofficeUrl: process.env.BACKOFFICE_URL
|| process.env.NEXT_PUBLIC_BACKOFFICE_URL
|| 'http://localhost:3001',
seederApiUrl: process.env.SEEDER_API_URL
|| process.env.NEXT_PUBLIC_SEEDER_API_URL
|| 'http://localhost:3002',
scraperApiUrl: process.env.SCRAPER_API_URL
|| process.env.NEXT_PUBLIC_SCRAPER_API_URL
|| 'http://localhost:3003',
};
return NextResponse.json(config, {
headers: {
// Cache for 5 minutes to avoid too many requests
'Cache-Control': 'public, max-age=300, s-maxage=300',
},
});
}

View file

@ -188,30 +188,42 @@ export default function BlogPage() {
<main className="flex-1"> <main className="flex-1">
{/* Hero Section */} {/* Hero Section */}
<section className="relative bg-[#F0932B] py-16 md:py-24 overflow-hidden"> <section className="relative py-24 md:py-36 overflow-hidden">
<div className="absolute inset-0 opacity-10"> {/* Imagem de fundo Blog.jpg */}
<div className="absolute inset-0 z-0">
<img
src="/Blog.jpg"
alt="Blog"
className="object-cover w-full h-full"
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }}
draggable={false}
/>
</div>
{/* Overlay preto com opacidade 20% */}
<div className="absolute inset-0 z-10 bg-black opacity-20"></div>
<div className="absolute inset-0 opacity-10 z-20">
<div className="absolute top-0 left-0 w-full h-full bg-[radial-gradient(circle_at_30%_50%,rgba(255,255,255,0.2)_0%,transparent_50%)]"></div> <div className="absolute top-0 left-0 w-full h-full bg-[radial-gradient(circle_at_30%_50%,rgba(255,255,255,0.2)_0%,transparent_50%)]"></div>
</div> </div>
<div className="container mx-auto px-4 sm:px-6 lg:px-8 relative z-10"> <div className="container mx-auto px-4 sm:px-6 lg:px-8 relative z-30">
<div className="max-w-4xl mx-auto text-center text-white"> <div className="max-w-4xl mx-auto text-center text-white">
<h1 className="text-4xl md:text-5xl lg:text-6xl font-bold mb-6"> <h1 className="text-4xl md:text-5xl lg:text-6xl font-bold mb-6 drop-shadow-[0_2px_8px_rgba(0,0,0,0.9)]">
{t('blog.title')} {t('blog.title')}
</h1> </h1>
<p className="text-xl md:text-2xl mb-8 opacity-95"> <p className="text-xl md:text-2xl mb-8 opacity-95 drop-shadow-[0_2px_8px_rgba(0,0,0,0.9)]">
{t('blog.subtitle')} {t('blog.subtitle')}
</p> </p>
{/* Search Bar */} {/* Search Bar */}
<div className="max-w-2xl mx-auto bg-white rounded-full p-2 shadow-lg"> <div className="max-w-2xl mx-auto bg-white/80 rounded-full p-2 shadow-lg">
<div className="relative"> <div className="relative">
<Search className="absolute left-6 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" /> <Search className="absolute left-6 top-1/2 transform -translate-y-1/2 text-gray-700 w-5 h-5" />
<input <input
type="text" type="text"
placeholder={t('blog.searchPlaceholder')} placeholder={t('blog.searchPlaceholder')}
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-14 pr-4 py-3 rounded-full text-gray-900 focus:outline-none focus:ring-2 focus:ring-[#F0932B] text-lg bg-white" className="w-full pl-14 pr-4 py-3 rounded-full text-gray-900 focus:outline-none focus:ring-2 focus:ring-[#F0932B] text-lg bg-white/0"
/> />
</div> </div>
</div> </div>

View file

@ -170,17 +170,30 @@ export default function CompaniesPage() {
<main className="flex-1"> <main className="flex-1">
{/* Hero Section */} {/* Hero Section */}
<section className="relative bg-[#F0932B] py-20 md:py-28 overflow-hidden"> <section className="relative py-20 md:py-28 overflow-hidden">
<div className="absolute inset-0 opacity-10"> {/* Imagem de fundo empresas.jpg sem overlay laranja */}
<div className="absolute inset-0 z-0">
<Image
src="/empresas.jpg"
alt="Empresas"
fill
className="object-cover object-center"
quality={100}
priority
/>
</div>
{/* Overlay preto com opacidade 20% */}
<div className="absolute inset-0 z-10 bg-black opacity-20"></div>
<div className="absolute inset-0 opacity-10 z-20">
<div className="absolute top-0 left-0 w-full h-full bg-[radial-gradient(circle_at_30%_50%,rgba(255,255,255,0.2)_0%,transparent_50%)]"></div> <div className="absolute top-0 left-0 w-full h-full bg-[radial-gradient(circle_at_30%_50%,rgba(255,255,255,0.2)_0%,transparent_50%)]"></div>
</div> </div>
<div className="container mx-auto px-4 sm:px-6 lg:px-8 relative z-10"> <div className="container mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
<div className="max-w-4xl mx-auto text-center text-white"> <div className="max-w-4xl mx-auto text-center text-white">
<h1 className="text-4xl md:text-5xl lg:text-6xl font-bold mb-6"> <h1 className="text-4xl md:text-5xl lg:text-6xl font-bold mb-6 drop-shadow-[0_2px_8px_rgba(0,0,0,0.9)]">
Descubra as Melhores Empresas Descubra as Melhores Empresas
</h1> </h1>
<p className="text-xl md:text-2xl mb-8 opacity-95"> <p className="text-xl md:text-2xl mb-8 opacity-95 drop-shadow-[0_2px_8px_rgba(0,0,0,0.9)]">
Conheça empresas incríveis que estão contratando agora Conheça empresas incríveis que estão contratando agora
</p> </p>

View file

@ -0,0 +1,395 @@
"use client";
import { useEffect, useState } from "react";
import { useForm, useFieldArray } from "react-hook-form";
import {
Loader2,
MapPin,
Plus,
Save,
Trash2,
Upload,
User as UserIcon,
Briefcase,
GraduationCap
} from "lucide-react";
import Image from "next/image";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
CardFooter
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { Navbar } from "@/components/navbar";
import { Footer } from "@/components/footer";
import { useToast } from "@/hooks/use-toast";
// We'll update api.ts to include usersApi.updateMe later
// Mocking for now or assuming it exists
import { storageApi, usersApi } from "@/lib/api";
type Experience = {
company: string;
position: string;
description: string;
startDate: string;
endDate: string;
};
type Education = {
institution: string;
degree: string;
field: string;
startDate: string;
endDate: string;
};
type ProfileFormValues = {
fullName: string;
email: string;
phone: string;
whatsapp: string;
bio: string;
skills: string; // Comma separated for input
experience: Experience[];
education: Education[];
};
export default function ProfilePage() {
const { toast } = useToast();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [user, setUser] = useState<any>(null);
const [profilePic, setProfilePic] = useState<string | null>(null);
const { register, control, handleSubmit, reset, setValue, watch } = useForm<ProfileFormValues>({
defaultValues: {
experience: [],
education: []
}
});
const { fields: expFields, append: appendExp, remove: removeExp } = useFieldArray({
control,
name: "experience"
});
const { fields: eduFields, append: appendEdu, remove: removeEdu } = useFieldArray({
control,
name: "education"
});
useEffect(() => {
fetchProfile();
}, []);
const fetchProfile = async () => {
try {
setLoading(true);
// Assuming getMe exists, if not we create it
// const userData = await usersApi.getMe();
// For now, let's assume valid response structure based on our backend implementation
// But api.ts might not have getMe yet.
// To be safe, we might implement getMe in api.ts first?
// Or we check what is available.
// Current 'authApi.me' might be available?
// Let's assume we can fetch user.
// Fallback mock for development if backend not ready
// const userData = mockUser;
const userData = await usersApi.getMe(); // We will ensure this exists in api.ts
setUser(userData);
setProfilePic(userData.profilePictureUrl || null);
reset({
fullName: userData.name,
email: userData.email,
// phone: userData.phone, // Phone might not be in Core User yet? We didn't add it to Entity.
bio: userData.bio || "",
skills: userData.skills?.join(", ") || "",
experience: userData.experience || [],
education: userData.education || []
});
} catch (error) {
console.error(error);
toast({
title: "Erro ao carregar perfil",
description: "Não foi possível carregar seus dados.",
variant: "destructive"
});
} finally {
setLoading(false);
}
};
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
toast({ title: "Enviando foto..." });
toast({ title: "Enviando foto..." });
// 1. Upload via Proxy (avoids CORS)
const { publicUrl } = await storageApi.uploadFile(file, "avatars");
// 2. Update state
setProfilePic(publicUrl);
toast({ title: "Foto enviada!", description: "Não esqueça de salvar o perfil." });
} catch (err) {
console.error(err);
toast({ title: "Erro no upload", variant: "destructive" });
}
};
const onSubmit = async (data: ProfileFormValues) => {
try {
setSaving(true);
const skillsArray = data.skills.split(",").map(s => s.trim()).filter(Boolean);
await usersApi.updateMe({
fullName: data.fullName,
bio: data.bio,
profilePictureUrl: profilePic || undefined,
skills: skillsArray,
experience: data.experience,
education: data.education
});
toast({ title: "Perfil atualizado com sucesso!" });
} catch (error) {
console.error(error);
toast({ title: "Erro ao atualizar", variant: "destructive" });
} finally {
setSaving(false);
}
};
if (loading) {
return <div className="flex h-screen items-center justify-center"><Loader2 className="animate-spin" /></div>;
}
return (
<div className="min-h-screen bg-muted/40 pb-10">
<Navbar />
<div className="container py-10">
<div className="mb-8">
<h1 className="text-3xl font-bold">Meu Perfil</h1>
<p className="text-muted-foreground">Gerencie suas informações profissionais e pessoais.</p>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
{/* Basic Info & Photo */}
<Card>
<CardHeader>
<CardTitle>Informações Básicas</CardTitle>
<CardDescription>Sua identidade na plataforma.</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex flex-col md:flex-row gap-6 items-center md:items-start">
<div className="flex flex-col items-center gap-3">
<Avatar className="w-24 h-24 border-2 border-primary/20">
<AvatarImage src={profilePic || ""} />
<AvatarFallback><UserIcon className="w-10 h-10" /></AvatarFallback>
</Avatar>
<div className="relative">
<input
type="file"
id="avatar-upload"
className="hidden"
accept="image/*"
onChange={handleAvatarUpload}
/>
<Button type="button" variant="outline" size="sm" onClick={() => document.getElementById('avatar-upload')?.click()}>
<Upload className="w-3 h-3 mr-2" /> Alterar Foto
</Button>
</div>
</div>
<div className="grid gap-4 flex-1 w-full">
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Nome Completo</Label>
<Input {...register("fullName")} />
</div>
<div className="space-y-2">
<Label>Email</Label>
<Input {...register("email")} disabled className="bg-muted" />
</div>
</div>
<div className="space-y-2">
<Label>Bio / Resumo Profissional</Label>
<Textarea
{...register("bio")}
placeholder="Conte um pouco sobre você..."
className="min-h-[100px]"
/>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Skills */}
<Card>
<CardHeader>
<CardTitle>Competências</CardTitle>
<CardDescription>Liste suas principais habilidades técnicas e comportamentais.</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label>Skills (separadas por vírgula)</Label>
<Input
{...register("skills")}
placeholder="Ex: Javscript, Go, Liderança, Scrum"
/>
<p className="text-xs text-muted-foreground">
Estas tags ajudarão recrutadores a encontrar seu perfil.
</p>
</div>
</CardContent>
</Card>
{/* Experience */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Experiência Profissional</CardTitle>
<CardDescription>Seu histórico de trabalho.</CardDescription>
</div>
<Button type="button" variant="outline" size="sm" onClick={() => appendExp({ company: "", position: "", description: "", startDate: "", endDate: "" })}>
<Plus className="w-4 h-4 mr-2" /> Adicionar
</Button>
</CardHeader>
<CardContent className="space-y-6">
{expFields.map((field, index) => (
<div key={field.id} className="relative grid gap-4 p-4 border rounded-md">
<Button
type="button"
variant="ghost"
size="icon"
className="absolute top-2 right-2 text-destructive hover:text-destructive/80"
onClick={() => removeExp(index)}
>
<Trash2 className="w-4 h-4" />
</Button>
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Empresa</Label>
<Input {...register(`experience.${index}.company`)} placeholder="Ex: Google" />
</div>
<div className="space-y-2">
<Label>Cargo</Label>
<Input {...register(`experience.${index}.position`)} placeholder="Ex: Engenheiro de Software" />
</div>
</div>
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Início</Label>
<Input type="month" {...register(`experience.${index}.startDate`)} />
</div>
<div className="space-y-2">
<Label>Fim</Label>
<Input type="month" {...register(`experience.${index}.endDate`)} />
</div>
</div>
<div className="space-y-2">
<Label>Descrição</Label>
<Textarea {...register(`experience.${index}.description`)} placeholder="Descreva suas responsabilidades e conquistas..." />
</div>
</div>
))}
{expFields.length === 0 && (
<div className="text-center py-8 text-muted-foreground border-2 border-dashed rounded-md">
Nenhuma experiência adicionada.
</div>
)}
</CardContent>
</Card>
{/* Education */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Formação Acadêmica</CardTitle>
<CardDescription>Escolaridade e cursos.</CardDescription>
</div>
<Button type="button" variant="outline" size="sm" onClick={() => appendEdu({ institution: "", degree: "", field: "", startDate: "", endDate: "" })}>
<Plus className="w-4 h-4 mr-2" /> Adicionar
</Button>
</CardHeader>
<CardContent className="space-y-6">
{eduFields.map((field, index) => (
<div key={field.id} className="relative grid gap-4 p-4 border rounded-md">
<Button
type="button"
variant="ghost"
size="icon"
className="absolute top-2 right-2 text-destructive hover:text-destructive/80"
onClick={() => removeEdu(index)}
>
<Trash2 className="w-4 h-4" />
</Button>
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Instituição</Label>
<Input {...register(`education.${index}.institution`)} placeholder="Ex: USP" />
</div>
<div className="space-y-2">
<Label>Grau / Nível</Label>
<Input {...register(`education.${index}.degree`)} placeholder="Ex: Bacharelado" />
</div>
</div>
<div className="space-y-2">
<Label>Curso / Área de Estudo</Label>
<Input {...register(`education.${index}.field`)} placeholder="Ex: Ciência da Computação" />
</div>
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Início</Label>
<Input type="month" {...register(`education.${index}.startDate`)} />
</div>
<div className="space-y-2">
<Label>Fim</Label>
<Input type="month" {...register(`education.${index}.endDate`)} />
</div>
</div>
</div>
))}
{eduFields.length === 0 && (
<div className="text-center py-8 text-muted-foreground border-2 border-dashed rounded-md">
Nenhuma formação adicionada.
</div>
)}
</CardContent>
</Card>
<div className="flex justify-end gap-4 sticky bottom-4 z-10 bg-background/80 backdrop-blur-sm p-4 rounded-lg border shadow-lg">
<Button type="button" variant="outline" onClick={() => reset()}>Descartar Alterações</Button>
<Button type="submit" disabled={saving}>
{saving && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Salvar Alterações
</Button>
</div>
</form>
</div>
<Footer />
</div>
);
}

View file

@ -3,26 +3,37 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { format } from "date-fns"; import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import { import {
Building2, Building2,
MapPin, CalendarDays,
Search,
ExternalLink,
Loader2,
AlertCircle,
Calendar, Calendar,
Clock,
CheckCircle2, CheckCircle2,
XCircle, XCircle,
AlertCircle, Clock,
FileText, FileText
ExternalLink
} from "lucide-react"; } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton"; import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
CardFooter
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Navbar } from "@/components/navbar";
import { Footer } from "@/components/footer";
import { applicationsApi } from "@/lib/api"; import { applicationsApi } from "@/lib/api";
import { useNotify } from "@/contexts/notification-context";
interface Application { type Application = {
id: string; id: string;
jobId: string; jobId: string;
jobTitle: string; jobTitle: string;
@ -32,148 +43,163 @@ interface Application {
createdAt: string; createdAt: string;
resumeUrl?: string; resumeUrl?: string;
message?: string; message?: string;
} };
const statusConfig: Record<string, { label: string; color: string; icon: any }> = { const statusConfig: Record<string, { label: string; color: string; icon: any }> = {
pending: { label: "Pending", color: "bg-yellow-100 text-yellow-800 border-yellow-200", icon: Clock }, pending: { label: "Em Análise", color: "bg-yellow-100 text-yellow-800 hover:bg-yellow-100/80", icon: Clock },
reviewed: { label: "Viewed", color: "bg-blue-100 text-blue-800 border-blue-200", icon: CheckCircle2 }, reviewed: { label: "Visualizado", color: "bg-blue-100 text-blue-800 hover:bg-blue-100/80", icon: CheckCircle2 },
shortlisted: { label: "Shortlisted", color: "bg-purple-100 text-purple-800 border-purple-200", icon: CheckCircle2 }, shortlisted: { label: "Selecionado", color: "bg-purple-100 text-purple-800 hover:bg-purple-100/80", icon: CheckCircle2 },
hired: { label: "Hired", color: "bg-green-100 text-green-800 border-green-200", icon: CheckCircle2 }, hired: { label: "Contratado", color: "bg-green-100 text-green-800 hover:bg-green-100/80", icon: CheckCircle2 },
rejected: { label: "Rejected", color: "bg-red-100 text-red-800 border-red-200", icon: XCircle }, rejected: { label: "Não Selecionado", color: "bg-red-100 text-red-800 hover:bg-red-100/80", icon: XCircle },
}; };
export default function MyApplicationsPage() { export default function MyApplicationsPage() {
const [applications, setApplications] = useState<Application[]>([]); const [applications, setApplications] = useState<Application[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const notify = useNotify(); const [error, setError] = useState("");
const [searchTerm, setSearchTerm] = useState("");
useEffect(() => { useEffect(() => {
async function fetchApplications() {
try {
const data = await applicationsApi.listMine();
setApplications(data || []);
} catch (error) {
console.error("Failed to fetch applications:", error);
notify.error("Error", "Failed to load your applications.");
} finally {
setLoading(false);
}
}
fetchApplications(); fetchApplications();
}, [notify]); }, []);
if (loading) { const fetchApplications = async () => {
return ( try {
<div className="space-y-4"> setLoading(true);
<h1 className="text-3xl font-bold">My Applications</h1> const data = await applicationsApi.listMyApplications();
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> // Backend endpoint consistency check: listMyApplications usually returns ApplicationWithDetails
{[1, 2, 3].map((i) => ( // Casting to ensure type safety if generic
<Card key={i}> setApplications(data as unknown as Application[]);
<CardHeader> } catch (err) {
<Skeleton className="h-6 w-3/4" /> console.error("Failed to fetch applications", err);
<Skeleton className="h-4 w-1/2" /> setError("Não foi possível carregar suas candidaturas. Tente novamente.");
</CardHeader> } finally {
<CardContent> setLoading(false);
<Skeleton className="h-20 w-full" /> }
</CardContent> };
</Card>
))} const filteredApplications = applications.filter(app =>
</div> app.jobTitle.toLowerCase().includes(searchTerm.toLowerCase()) ||
</div> app.companyName.toLowerCase().includes(searchTerm.toLowerCase())
); );
}
return ( return (
<div className="space-y-6"> <div className="min-h-screen flex flex-col bg-background">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4"> <Navbar />
<div>
<h1 className="text-3xl font-bold">My Applications</h1> <main className="flex-1 container mx-auto px-4 py-8">
<p className="text-muted-foreground mt-1"> <div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4">
Track the status of your job applications. <div>
</p> <h1 className="text-3xl font-bold tracking-tight">Minhas Candidaturas</h1>
<p className="text-muted-foreground mt-1">
Acompanhe o status das vagas que você se candidatou.
</p>
</div>
<div className="relative w-full md:w-auto">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder="Buscar vagas ou empresas..."
className="pl-9 w-full md:w-[300px]"
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
/>
</div>
</div> </div>
<Button asChild>
<Link href="/jobs">Find more jobs</Link>
</Button>
</div>
{applications.length === 0 ? ( {loading ? (
<Card className="border-dashed"> <div className="flex justify-center items-center py-20">
<CardContent className="flex flex-col items-center justify-center py-12 text-center text-muted-foreground"> <Loader2 className="h-8 w-8 animate-spin text-primary" />
<h3 className="text-lg font-semibold mb-2">No applications yet</h3> </div>
<p className="mb-6">You haven't applied to any jobs yet.</p> ) : error ? (
<Button asChild variant="secondary"> <div className="flex flex-col items-center justify-center py-12 text-center text-muted-foreground bg-muted/30 rounded-lg">
<Link href="/jobs">Browse Jobs</Link> <AlertCircle className="h-10 w-10 mb-4 text-destructive" />
<h3 className="text-lg font-medium text-foreground mb-2">Erro ao carregar</h3>
<p>{error}</p>
<Button variant="outline" className="mt-4" onClick={fetchApplications}>
Tentar Novamente
</Button> </Button>
</CardContent> </div>
</Card> ) : filteredApplications.length === 0 ? (
) : ( <div className="text-center py-16 bg-muted/20 rounded-lg border border-dashed">
<div className="grid gap-4 md:grid-cols-1 lg:grid-cols-2"> <div className="bg-primary/10 p-4 rounded-full w-16 h-16 flex items-center justify-center mx-auto mb-4">
{applications.map((app) => { <Search className="h-8 w-8 text-primary" />
const status = statusConfig[app.status] || { </div>
label: app.status, <h3 className="text-lg font-semibold">Nenhuma candidatura encontrada</h3>
color: "bg-gray-100 text-gray-800 border-gray-200", <p className="text-muted-foreground max-w-sm mx-auto mt-2 mb-6">
icon: AlertCircle Você ainda não se candidatou a nenhuma vaga. Explore as oportunidades disponíveis e comece sua jornada.
}; </p>
const StatusIcon = status.icon; <Link href="/vagas">
<Button>Explorar Vagas</Button>
</Link>
</div>
) : (
<div className="grid gap-4 md:grid-cols-1 lg:grid-cols-2 xl:grid-cols-2">
{filteredApplications.map((app) => {
const status = statusConfig[app.status] || {
label: app.status,
color: "bg-gray-100 text-gray-800 hover:bg-gray-100/80",
icon: AlertCircle
};
const StatusIcon = status.icon;
return ( return (
<Card key={app.id} className="hover:border-primary/50 transition-colors"> <Card key={app.id} className="overflow-hidden hover:shadow-md transition-shadow">
<CardHeader> <CardHeader className="flex flex-row items-start justify-between space-y-0 pb-2">
<div className="flex justify-between items-start gap-4">
<div className="space-y-1"> <div className="space-y-1">
<CardTitle className="text-xl"> <CardTitle className="text-xl">
<Link href={`/jobs/${app.jobId}`} className="hover:underline hover:text-primary transition-colors"> <Link href={`/vagas/${app.jobId}`} className="hover:text-primary transition-colors">
{app.jobTitle} {app.jobTitle}
</Link> </Link>
</CardTitle> </CardTitle>
<div className="flex items-center text-muted-foreground text-sm gap-2"> <CardDescription className="flex items-center gap-2">
<Building2 className="h-4 w-4" /> <Building2 className="h-3.5 w-3.5" />
<span>{app.companyName}</span> {app.companyName}
</CardDescription>
</div>
<Badge className={status.color} variant="secondary">
<div className="flex items-center gap-1">
<StatusIcon className="h-3 w-3" />
{status.label}
</div>
</Badge>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4 text-sm mt-4">
<div className="flex items-center text-muted-foreground">
<CalendarDays className="mr-2 h-4 w-4" />
Aplicado em {format(new Date(app.createdAt), "dd 'de' MMMM, yyyy", { locale: ptBR })}
</div> </div>
</div> </div>
<Badge variant="outline" className={`${status.color} flex items-center gap-1 shrink-0`}> {app.message && (
<StatusIcon className="h-3 w-3" /> <div className="mt-4 bg-muted/50 p-3 rounded-md text-sm italic">
{status.label} "{app.message.length > 100 ? app.message.substring(0, 100) + "..." : app.message}"
</Badge> </div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
Applied on {format(new Date(app.createdAt), "MMM d, yyyy")}
</div>
</div>
{app.message && (
<div className="bg-muted/50 p-3 rounded-md text-sm italic">
"{app.message.length > 100 ? app.message.substring(0, 100) + "..." : app.message}"
</div>
)}
<div className="flex items-center gap-2 pt-2">
{app.resumeUrl && (
<Button variant="outline" size="sm" asChild>
<a href={app.resumeUrl} target="_blank" rel="noopener noreferrer">
<FileText className="h-4 w-4 mr-2" />
View Resume
</a>
</Button>
)} )}
<Button variant="ghost" size="sm" asChild className="ml-auto"> </CardContent>
<Link href={`/jobs/${app.jobId}`}> <CardFooter className="bg-muted/50 p-4 flex justify-between items-center">
View Job <ExternalLink className="h-3 w-3 ml-1" /> <Link href={`/vagas/${app.jobId}`} className="text-sm font-medium text-primary hover:underline flex items-center">
Ver Vaga <ExternalLink className="ml-1 h-3 w-3" />
</Link>
{app.resumeUrl && (
<Link
href={app.resumeUrl}
target="_blank"
className="text-sm text-muted-foreground hover:text-foreground underline underline-offset-4 flex items-center"
>
<FileText className="h-3 w-3 mr-1" />
Ver Currículo
</Link> </Link>
</Button> )}
</div> </CardFooter>
</CardContent> </Card>
</Card> );
); })}
})} </div>
</div> )}
)} </main>
<Footer />
</div> </div>
); );
} }

View file

@ -68,6 +68,7 @@ export default function AdminUsersPage() {
email: "", email: "",
role: "", role: "",
status: "", status: "",
password: "", // Optional for edits
}) })
useEffect(() => { useEffect(() => {
@ -119,7 +120,7 @@ export default function AdminUsersPage() {
setCreating(true) setCreating(true)
const payload = { const payload = {
...formData, ...formData,
roles: [formData.role], roles: [formData.role], // Helper for legacy backend needing array
} }
await usersApi.create(payload) await usersApi.create(payload)
toast.success(t('admin.users.messages.create_success')) toast.success(t('admin.users.messages.create_success'))
@ -143,6 +144,7 @@ export default function AdminUsersPage() {
email: user.email, email: user.email,
role: user.role, role: user.role,
status: user.status || "active", status: user.status || "active",
password: "",
}) })
setViewing(true) setViewing(true)
setIsEditDialogOpen(true) setIsEditDialogOpen(true)
@ -156,6 +158,7 @@ export default function AdminUsersPage() {
email: user.email, email: user.email,
role: user.role, role: user.role,
status: user.status || "active", status: user.status || "active",
password: "",
}) })
setViewing(false) setViewing(false)
setIsEditDialogOpen(true) setIsEditDialogOpen(true)
@ -166,10 +169,13 @@ export default function AdminUsersPage() {
console.log("[USER_FLOW] Updating user:", selectedUser.id, "with data:", editFormData) console.log("[USER_FLOW] Updating user:", selectedUser.id, "with data:", editFormData)
try { try {
setUpdating(true) setUpdating(true)
const payload = { const payload: any = {
...editFormData, ...editFormData,
roles: [editFormData.role], roles: [editFormData.role],
} }
// Remove empty password if not changing
if (!payload.password) delete payload.password;
await usersApi.update(selectedUser.id, payload) await usersApi.update(selectedUser.id, payload)
toast.success(t('admin.users.messages.update_success')) toast.success(t('admin.users.messages.update_success'))
setIsEditDialogOpen(false) setIsEditDialogOpen(false)
@ -257,7 +263,12 @@ export default function AdminUsersPage() {
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? "animate-spin" : ""}`} /> <RefreshCw className={`h-4 w-4 mr-2 ${loading ? "animate-spin" : ""}`} />
{t('admin.users.refresh')} {t('admin.users.refresh')}
</Button> </Button>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}> <Dialog open={isDialogOpen} onOpenChange={(open) => {
setIsDialogOpen(open)
if (!open) {
setFormData({ name: "", email: "", password: "", role: "candidate", status: "active", companyId: "" })
}
}}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button className="gap-2"> <Button className="gap-2">
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
@ -290,6 +301,7 @@ export default function AdminUsersPage() {
/> />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="password">Password</Label>
<Input <Input
id="password" id="password"
type="password" type="password"
@ -384,6 +396,18 @@ export default function AdminUsersPage() {
disabled={viewing} disabled={viewing}
/> />
</div> </div>
{!viewing && (
<div className="grid gap-2">
<Label htmlFor="edit-password">Password (Optional)</Label>
<Input
id="edit-password"
type="password"
value={editFormData.password}
onChange={(e) => setEditFormData({ ...editFormData, password: e.target.value })}
placeholder="Leave blank to keep current"
/>
</div>
)}
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="edit-role">{t('admin.users.table.role')}</Label> <Label htmlFor="edit-role">{t('admin.users.table.role')}</Label>
<Select <Select

View file

@ -2,22 +2,54 @@
import { useState } from "react"; import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useForm } from "react-hook-form";
import { Mail, ArrowLeft, Loader2, CheckCircle } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { Label } from "@/components/ui/label";
import { Navbar } from "@/components/navbar";
import { Footer } from "@/components/footer";
import { useTranslation } from "@/lib/i18n"; import { useTranslation } from "@/lib/i18n";
import { ArrowLeft, Mail } from "lucide-react";
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8521";
type ForgotPasswordForm = {
email: string;
};
export default function ForgotPasswordPage() { export default function ForgotPasswordPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const [email, setEmail] = useState(""); const [loading, setLoading] = useState(false);
const [submitted, setSubmitted] = useState(false); const [submitted, setSubmitted] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => { const { register, handleSubmit, formState: { errors } } = useForm<ForgotPasswordForm>();
event.preventDefault();
setSubmitted(true); const onSubmit = async (data: ForgotPasswordForm) => {
setLoading(true);
setError(null);
try {
const res = await fetch(`${API_URL}/api/v1/auth/forgot-password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: data.email }),
});
if (!res.ok) {
const errText = await res.text();
throw new Error(errText || t("errors.default") || "Error sending request");
}
setSubmitted(true);
} catch (err: any) {
setError(err.message || t("errors.unknown") || "Unknown error");
} finally {
setLoading(false);
}
}; };
return ( return (
@ -52,41 +84,50 @@ export default function ForgotPasswordPage() {
</Alert> </Alert>
)} )}
<form onSubmit={handleSubmit} className="space-y-4"> {!submitted && (
<div className="space-y-2"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<Label htmlFor="email" className="text-sm sm:text-base"> <div className="space-y-2">
{t("auth.forgot.fields.email")} <Label htmlFor="email" className="text-sm sm:text-base">
</Label> {t("auth.forgot.fields.email")}
<Input </Label>
id="email" <Input
type="email" id="email"
value={email} type="email"
onChange={(event) => setEmail(event.target.value)} placeholder={t("auth.forgot.fields.emailPlaceholder")}
placeholder={t("auth.forgot.fields.emailPlaceholder")} className="h-10 sm:h-11"
required {...register("email", { required: t("validations.required") || "Email is required" })}
className="h-10 sm:h-11" />
/> {errors.email && <p className="text-sm text-destructive">{errors.email.message}</p>}
</div> </div>
<Button type="submit" className="w-full h-10 sm:h-11"> {error && <p className="text-sm text-destructive text-center">{error}</p>}
{t("auth.forgot.submit")}
</Button> <Button type="submit" className="w-full h-10 sm:h-11" disabled={loading}>
</form> {loading ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : null}
{t("auth.forgot.submit")}
</Button>
</form>
)}
</CardContent> </CardContent>
{!submitted && (
<CardFooter className="justify-center">
{/* Back to Login - Desktop */}
<div className="hidden sm:block text-center">
<Link
href="/login"
className="text-sm text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-2"
>
<ArrowLeft className="h-4 w-4" />
{t("auth.forgot.backLogin")}
</Link>
</div>
</CardFooter>
)}
</Card> </Card>
{/* Back to Login - Desktop */}
<div className="hidden sm:block text-center">
<Link
href="/login"
className="text-sm text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-2"
>
<ArrowLeft className="h-4 w-4" />
{t("auth.forgot.backLogin")}
</Link>
</div>
</div> </div>
<Footer />
</div> </div>
); );
} }

View file

@ -41,7 +41,6 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import { Separator } from "@/components/ui/separator";
import { Navbar } from "@/components/navbar"; import { Navbar } from "@/components/navbar";
import { Footer } from "@/components/footer"; import { Footer } from "@/components/footer";
import { useNotify } from "@/contexts/notification-context"; import { useNotify } from "@/contexts/notification-context";
@ -230,22 +229,23 @@ export default function JobApplicationPage({
const handleSubmit = async () => { const handleSubmit = async () => {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
// 1. Resume is already uploaded via handleResumeUpload, so we use formData.resumeUrl
const resumeUrl = formData.resumeUrl;
// Note: If you want to enforce upload here, you'd need the File object, but we uploaded it earlier.
await applicationsApi.create({ await applicationsApi.create({
jobId: id, jobId: Number(id), // ID might need number conversion depending on API
name: formData.fullName, name: formData.fullName,
email: formData.email, email: formData.email,
phone: formData.phone, phone: formData.phone,
linkedin: formData.linkedin, linkedin: formData.linkedin,
resumeUrl: formData.resumeUrl, coverLetter: formData.coverLetter || formData.whyUs, // Fallback
coverLetter: formData.coverLetter || undefined, resumeUrl: resumeUrl,
portfolioUrl: formData.portfolioUrl || undefined, portfolioUrl: formData.portfolioUrl,
message: formData.whyUs, // Mapping Why Us to Message/Notes salaryExpectation: formData.salaryExpectation,
documents: {}, // TODO: Extra docs hasExperience: formData.hasExperience,
// salaryExpectation: formData.salaryExpectation, // These fields might need to go into Notes or structured JSON if backend doesn't support them specifically? whyUs: formData.whyUs,
// hasExperience: formData.hasExperience, availability: formData.availability,
// Backend seems to map "documents" as JSONMap. We can put extra info there?
// Or put in "message" concatenated.
// Let's assume the backend 'message' field is good for "whyUs"
}); });
notify.success( notify.success(
@ -275,7 +275,21 @@ export default function JobApplicationPage({
const progress = (currentStep / steps.length) * 100; const progress = (currentStep / steps.length) * 100;
if (!job) return null; if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
);
}
if (!job) {
return (
<div className="min-h-screen flex items-center justify-center">
<p>Job not found</p>
</div>
);
}
if (isSubmitted) { if (isSubmitted) {
const user = getCurrentUser(); const user = getCurrentUser();
@ -371,7 +385,7 @@ export default function JobApplicationPage({
{t("application.title", { jobTitle: job.title })} {t("application.title", { jobTitle: job.title })}
</h1> </h1>
<p className="text-muted-foreground mt-1"> <p className="text-muted-foreground mt-1">
{job.companyName || 'Company'} {job.location || 'Remote'} {job?.companyName || 'Company'} {job?.location || 'Remote'}
</p> </p>
</div> </div>
<div className="text-sm font-medium bg-primary/10 text-primary px-3 py-1 rounded-full self-start md:self-center"> <div className="text-sm font-medium bg-primary/10 text-primary px-3 py-1 rounded-full self-start md:self-center">
@ -695,7 +709,7 @@ export default function JobApplicationPage({
<div className="space-y-6"> <div className="space-y-6">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="whyUs"> <Label htmlFor="whyUs">
{t("application.form.whyUs", { company: job.companyName || 'this company' })} {t("application.form.whyUs", { company: job?.companyName || 'this company' })}
</Label> </Label>
<Textarea <Textarea
id="whyUs" id="whyUs"

View file

@ -96,7 +96,7 @@ function JobsContent() {
}) })
// Transform the raw API response to frontend format // Transform the raw API response to frontend format
const mappedJobs = (response.data || []).map(transformApiJobToFrontend) const mappedJobs = (response.data || []).map(job => transformApiJobToFrontend(job));
if (isMounted) { if (isMounted) {
setJobs(mappedJobs) setJobs(mappedJobs)
@ -151,17 +151,29 @@ function JobsContent() {
return ( return (
<> <>
{/* Hero Section */} {/* Hero Section */}
<section className="relative bg-[#F0932B] py-12 md:py-16 overflow-hidden"> <section className="relative bg-[#F0932B] py-28 md:py-40 overflow-hidden">
<div className="absolute inset-0 opacity-10"> {/* Imagem de fundo Vagas.jpg sem opacidade */}
<div className="absolute inset-0 z-0">
<img
src="/Vagas.jpg"
alt="Vagas"
className="object-cover w-full h-full"
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectPosition: 'center 10%' }}
draggable={false}
/>
</div>
{/* Overlay preto com opacidade 20% */}
<div className="absolute inset-0 z-10 bg-black opacity-20"></div>
<div className="absolute inset-0 opacity-10 z-20">
<div className="absolute top-0 left-0 w-full h-full bg-[radial-gradient(circle_at_30%_50%,rgba(255,255,255,0.2)_0%,transparent_50%)]"></div> <div className="absolute top-0 left-0 w-full h-full bg-[radial-gradient(circle_at_30%_50%,rgba(255,255,255,0.2)_0%,transparent_50%)]"></div>
</div> </div>
<div className="container mx-auto px-4 sm:px-6 lg:px-8 relative z-10"> <div className="container mx-auto px-4 sm:px-6 lg:px-8 relative z-20">
<div className="max-w-3xl mx-auto text-center text-white"> <div className="max-w-3xl mx-auto text-center text-white">
<motion.h1 <motion.h1
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
className="text-4xl md:text-5xl font-bold mb-4 text-balance" className="text-4xl md:text-5xl font-bold mb-4 text-balance drop-shadow-[0_2px_8px_rgba(0,0,0,0.9)]"
> >
{t('jobs.title')} {t('jobs.title')}
</motion.h1> </motion.h1>
@ -169,7 +181,7 @@ function JobsContent() {
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }} transition={{ delay: 0.1 }}
className="text-lg opacity-95 text-pretty" className="text-lg opacity-95 text-pretty drop-shadow-[0_2px_8px_rgba(0,0,0,0.9)]"
> >
{loading ? t('jobs.loading') : t('jobs.subtitle', { count: totalJobs })} {loading ? t('jobs.loading') : t('jobs.subtitle', { count: totalJobs })}
</motion.p> </motion.p>

View file

@ -8,6 +8,7 @@ import { NotificationProvider } from "@/contexts/notification-context"
import { ThemeProvider } from "@/contexts/ThemeContext" import { ThemeProvider } from "@/contexts/ThemeContext"
import { ConfigProvider } from "@/contexts/ConfigContext" import { ConfigProvider } from "@/contexts/ConfigContext"
import { I18nProvider } from "@/lib/i18n" import { I18nProvider } from "@/lib/i18n"
import GoogleAnalytics from "@/components/google-analytics"
import "./globals.css" import "./globals.css"
import { Suspense } from "react" import { Suspense } from "react"
import { LoadingScreen } from "@/components/ui/loading-spinner" import { LoadingScreen } from "@/components/ui/loading-spinner"
@ -54,6 +55,7 @@ export default function RootLayout({
</I18nProvider> </I18nProvider>
</ConfigProvider> </ConfigProvider>
{process.env.NODE_ENV === "production" && shouldLoadAnalytics && <Analytics />} {process.env.NODE_ENV === "production" && shouldLoadAnalytics && <Analytics />}
<GoogleAnalytics GA_MEASUREMENT_ID={process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID || ""} />
</body> </body>
</html> </html>
) )

View file

@ -0,0 +1,170 @@
"use client";
import { useState, useEffect, Suspense } from "react";
import { useSearchParams } from "next/navigation";
import Link from "next/link";
import { useForm } from "react-hook-form";
import { Lock, ArrowLeft, Loader2, CheckCircle, XCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Navbar } from "@/components/navbar";
import { Footer } from "@/components/footer";
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8521";
type ResetPasswordForm = {
password: string;
confirmPassword: string;
};
function ResetPasswordContent() {
const searchParams = useSearchParams();
const token = searchParams.get("token");
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const [error, setError] = useState<string | null>(null);
const { register, handleSubmit, watch, formState: { errors } } = useForm<ResetPasswordForm>();
const password = watch("password");
const onSubmit = async (data: ResetPasswordForm) => {
if (!token) {
setError("Token inválido ou ausente.");
return;
}
setLoading(true);
setError(null);
try {
const res = await fetch(`${API_URL}/api/v1/auth/reset-password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, newPassword: data.password }),
});
if (!res.ok) {
const errText = await res.text();
throw new Error(errText || "Erro ao redefinir senha");
}
setSuccess(true);
} catch (err: any) {
setError(err.message || "Erro desconhecido");
} finally {
setLoading(false);
}
};
if (!token) {
return (
<div className="flex flex-col items-center gap-4 py-6">
<XCircle className="w-16 h-16 text-destructive" />
<p className="text-center text-muted-foreground">
Link inválido ou expirado. Por favor, solicite um novo link de recuperação.
</p>
<Link href="/forgot-password">
<Button variant="outline">
<ArrowLeft className="w-4 h-4 mr-2" /> Solicitar Novo Link
</Button>
</Link>
</div>
);
}
if (success) {
return (
<div className="flex flex-col items-center gap-4 py-6">
<CheckCircle className="w-16 h-16 text-green-500" />
<p className="text-center text-muted-foreground">
Sua senha foi redefinida com sucesso!
</p>
<Link href="/login">
<Button>
Ir para o Login
</Button>
</Link>
</div>
);
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="password">Nova Senha</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
id="password"
type="password"
placeholder="••••••••"
className="pl-10"
{...register("password", {
required: "Senha é obrigatória",
minLength: { value: 8, message: "Mínimo 8 caracteres" }
})}
/>
</div>
{errors.password && <p className="text-sm text-destructive">{errors.password.message}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirmar Senha</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
id="confirmPassword"
type="password"
placeholder="••••••••"
className="pl-10"
{...register("confirmPassword", {
required: "Confirmação é obrigatória",
validate: value => value === password || "Senhas não coincidem"
})}
/>
</div>
{errors.confirmPassword && <p className="text-sm text-destructive">{errors.confirmPassword.message}</p>}
</div>
{error && <p className="text-sm text-destructive text-center">{error}</p>}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : null}
Redefinir Senha
</Button>
</form>
);
}
export default function ResetPasswordPage() {
return (
<div className="min-h-screen flex flex-col bg-gradient-to-b from-muted/30 to-background">
<Navbar />
<div className="flex-1 flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="text-2xl">Redefinir Senha</CardTitle>
<CardDescription>
Digite sua nova senha abaixo.
</CardDescription>
</CardHeader>
<CardContent>
<Suspense fallback={<Loader2 className="w-6 h-6 animate-spin mx-auto" />}>
<ResetPasswordContent />
</Suspense>
</CardContent>
<CardFooter className="justify-center">
<Link href="/login" className="text-sm text-muted-foreground hover:underline">
<ArrowLeft className="w-3 h-3 inline mr-1" /> Voltar para o Login
</Link>
</CardFooter>
</Card>
</div>
<Footer />
</div>
);
}

View file

@ -44,15 +44,15 @@ export function CandidateDashboardContent() {
try { try {
// Fetch recommended jobs (latest ones for now) // Fetch recommended jobs (latest ones for now)
const jobsRes = await jobsApi.list({ limit: 3, sortBy: "created_at" }); const jobsRes = await jobsApi.list({ limit: 3, sortBy: "created_at" });
const appsRes = await applicationsApi.listMine(); const appsRes = await applicationsApi.listMyApplications();
if (jobsRes && jobsRes.data) { if (jobsRes && jobsRes.data) {
const mappedJobs = jobsRes.data.map(transformApiJobToFrontend); const mappedJobs = jobsRes.data.map(job => transformApiJobToFrontend(job));
setJobs(mappedJobs); setJobs(mappedJobs);
} }
if (appsRes) { if (appsRes) {
setApplications(appsRes); setApplications(appsRes as unknown as ApplicationWithDetails[]);
} }
} catch (error) { } catch (error) {

View file

@ -0,0 +1,27 @@
'use client';
import Script from 'next/script';
export default function GoogleAnalytics({ GA_MEASUREMENT_ID }: { GA_MEASUREMENT_ID: string }) {
if (!GA_MEASUREMENT_ID) return null;
return (
<>
<Script
src={`https://www.googletagmanager.com/gtag/js?id=${GA_MEASUREMENT_ID}`}
strategy="afterInteractive"
/>
<Script id="google-analytics" strategy="afterInteractive">
{`
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${GA_MEASUREMENT_ID}', {
page_path: window.location.pathname,
});
`}
</Script>
</>
);
}

View file

@ -0,0 +1,117 @@
"use client"
import { useState } from "react"
import { Search, MapPin, DollarSign, Briefcase } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { useRouter } from "next/navigation"
import { useTranslation } from "@/lib/i18n"
export function HomeSearch() {
const router = useRouter()
const { t } = useTranslation()
const [searchTerm, setSearchTerm] = useState("")
const [location, setLocation] = useState("")
const [type, setType] = useState("")
const [workMode, setWorkMode] = useState("")
const [salary, setSalary] = useState("")
const handleSearch = () => {
const params = new URLSearchParams()
if (searchTerm) params.set("q", searchTerm)
if (location) params.set("location", location)
if (type && type !== "all") params.set("type", type)
if (workMode && workMode !== "all") params.set("mode", workMode)
if (salary) params.set("salary", salary)
router.push(`/jobs?${params.toString()}`)
}
return (
<div className="bg-white rounded-lg shadow-lg p-6 max-w-5xl mx-auto -mt-24 relative z-20 border border-gray-100">
<div className="mb-4">
<div className="relative">
<Search className="absolute left-3 top-3 h-5 w-5 text-gray-400" />
<Input
placeholder="Digite cargo, empresa ou palavra-chave"
className="pl-10 h-12 bg-gray-50 border-gray-200"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
{/* Contract Type */}
<div className="space-y-2">
<label className="text-xs font-semibold text-gray-700 uppercase tracking-wide">Tipo de contratação</label>
<Select value={type} onValueChange={setType}>
<SelectTrigger className="!h-12 bg-gray-50 border-gray-200 w-full">
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos</SelectItem>
<SelectItem value="contract">PJ</SelectItem>
<SelectItem value="full-time">CLT</SelectItem>
<SelectItem value="freelance">Freelancer</SelectItem>
</SelectContent>
</Select>
</div>
{/* Work Mode */}
<div className="space-y-2">
<label className="text-xs font-semibold text-gray-700 uppercase tracking-wide">Regime de Trabalho</label>
<Select value={workMode} onValueChange={setWorkMode}>
<SelectTrigger className="!h-12 bg-gray-50 border-gray-200 w-full">
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos</SelectItem>
<SelectItem value="remote">Remoto</SelectItem>
<SelectItem value="hybrid">Híbrido</SelectItem>
<SelectItem value="onsite">Presencial</SelectItem>
</SelectContent>
</Select>
</div>
{/* Location */}
<div className="space-y-2">
<label className="text-xs font-semibold text-gray-700 uppercase tracking-wide">Cidade e estado</label>
<div className="relative">
<Input
placeholder="Cidade e estado"
className="h-12 bg-gray-50 border-gray-200"
value={location}
onChange={(e) => setLocation(e.target.value)}
/>
</div>
</div>
{/* Salary */}
<div className="space-y-2">
<label className="text-xs font-semibold text-gray-700 uppercase tracking-wide">Pretensão salarial</label>
<div className="relative">
<Input
placeholder="R$ 0,00"
className="h-12 bg-gray-50 border-gray-200"
value={salary}
onChange={(e) => setSalary(e.target.value)}
/>
</div>
</div>
{/* Search Button */}
<div className="flex items-end">
<Button
onClick={handleSearch}
className="w-full h-12 bg-orange-500 hover:bg-orange-600 text-white font-bold text-lg shadow-md transition-all active:scale-95"
>
<Search className="w-5 h-5 mr-2" />
Filtrar Vagas
</Button>
</div>
</div>
</div>
)
}

View file

@ -17,6 +17,7 @@ import {
Clock, Clock,
Building2, Building2,
Heart, Heart,
Zap,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
@ -95,10 +96,21 @@ export function JobCard({ job, isApplied, applicationStatus }: JobCardProps) {
whileHover={{ y: -2 }} whileHover={{ y: -2 }}
transition={{ type: "spring", stiffness: 300, damping: 20 }} transition={{ type: "spring", stiffness: 300, damping: 20 }}
> >
<Card className="relative hover:shadow-lg transition-all duration-300 border-l-4 border-l-primary/20 hover:border-l-primary h-full flex flex-col"> <Card className={`relative hover:shadow-lg transition-all duration-300 border-l-4 h-full flex flex-col ${job.isFeatured
? "border-l-amber-500 border-amber-200 shadow-md bg-amber-50/10"
: "border-l-primary/20 hover:border-l-primary"
}`}>
<CardHeader className="pb-4"> <CardHeader className="pb-4">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{job.isFeatured && (
<div className="absolute -top-3 -right-3 z-10">
<Badge className="bg-gradient-to-r from-amber-400 to-orange-500 text-white border-0 shadow-lg gap-1 px-3 py-1">
<Zap className="h-3 w-3 fill-white" />
{t('home.featured.title')}
</Badge>
</div>
)}
<Avatar className="h-12 w-12"> <Avatar className="h-12 w-12">
<AvatarImage <AvatarImage
src={`https://avatar.vercel.sh/${job.company}`} src={`https://avatar.vercel.sh/${job.company}`}

View file

@ -1,50 +1,50 @@
"use client"; "use client";
import { useTranslation } from "@/lib/i18n"; import { useTranslation } from "@/lib/i18n";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Globe } from "lucide-react"; import { Globe } from "lucide-react";
export function LanguageSwitcher() { export function LanguageSwitcher() {
const { locale, setLocale } = useTranslation(); const { locale, setLocale } = useTranslation();
const locales = [ const locales = [
{ code: "en" as const, name: "English", flag: "🇺🇸" }, { code: "en" as const, name: "English", flag: "🇺🇸" },
{ code: "es" as const, name: "Español", flag: "🇪🇸" }, { code: "es" as const, name: "Español", flag: "🇪🇸" },
{ code: "pt-BR" as const, name: "Português", flag: "🇧🇷" }, { code: "pt-BR" as const, name: "Português", flag: "🇧🇷" },
]; ];
const currentLocale = locales.find((l) => l.code === locale) || locales[0]; const currentLocale = locales.find((l) => l.code === locale) || locales[0];
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="w-12 px-0 gap-2 focus-visible:ring-0 focus-visible:ring-offset-0 hover:bg-transparent"> <Button variant="ghost" size="sm" className="w-12 px-0 gap-2 focus-visible:ring-0 focus-visible:ring-offset-0 hover:bg-white/10">
<Globe className="h-4 w-4 text-white" /> <Globe className="h-5 w-5 text-white/90 hover:text-white transition-colors" />
<span className="sr-only">Toggle language</span> <span className="sr-only">Toggle language</span>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
{locales.map((l) => ( {locales.map((l) => (
<DropdownMenuItem <DropdownMenuItem
key={l.code} key={l.code}
onClick={() => { onClick={() => {
console.log(`[LanguageSwitcher] Clicking ${l.code}`); console.log(`[LanguageSwitcher] Clicking ${l.code}`);
setLocale(l.code); setLocale(l.code);
}} }}
className="flex items-center gap-2 cursor-pointer focus:outline-none focus:bg-accent focus:text-accent-foreground" className="flex items-center gap-2 cursor-pointer focus:outline-none focus:bg-accent focus:text-accent-foreground"
> >
<span className="text-lg">{l.flag}</span> <span className="text-lg">{l.flag}</span>
<span>{l.name}</span> <span>{l.name}</span>
{locale === l.code && <span className="ml-auto text-xs opacity-50"></span>} {locale === l.code && <span className="ml-auto text-xs opacity-50"></span>}
</DropdownMenuItem> </DropdownMenuItem>
))} ))}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
); );
} }

View file

@ -10,8 +10,8 @@ export interface RuntimeConfig {
} }
const defaultConfig: RuntimeConfig = { const defaultConfig: RuntimeConfig = {
apiUrl: process.env.NEXT_PUBLIC_API_URL || 'https://api.rede5.com.br/', apiUrl: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8521',
backofficeUrl: process.env.NEXT_PUBLIC_BACKOFFICE_URL || 'https://b-local.gohorsejobs.com', backofficeUrl: process.env.NEXT_PUBLIC_BACKOFFICE_URL || 'http://localhost:3001',
seederApiUrl: process.env.NEXT_PUBLIC_SEEDER_API_URL || 'http://localhost:3002', seederApiUrl: process.env.NEXT_PUBLIC_SEEDER_API_URL || 'http://localhost:3002',
scraperApiUrl: process.env.NEXT_PUBLIC_SCRAPER_API_URL || 'http://localhost:3003', scraperApiUrl: process.env.NEXT_PUBLIC_SCRAPER_API_URL || 'http://localhost:3003',
}; };

View file

@ -288,7 +288,7 @@
} }
}, },
"jobs": { "jobs": {
"title": "Encontre sua próxima oportunidade", "title": "Encontre sua Próxima oportunidade",
"subtitle": "{count} vagas disponíveis nas melhores empresas", "subtitle": "{count} vagas disponíveis nas melhores empresas",
"search": "Buscar vagas por título, empresa...", "search": "Buscar vagas por título, empresa...",
"filters": { "filters": {

View file

@ -16,11 +16,15 @@ function logCrudAction(action: string, entity: string, details?: any) {
* Generic API Request Wrapper * Generic API Request Wrapper
*/ */
async function apiRequest<T>(endpoint: string, options: RequestInit = {}): Promise<T> { async function apiRequest<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
// Ensure config is loaded before making request // Token can be stored as 'auth_token' (from auth.ts login) or 'token' (legacy)
await initConfig(); const token = typeof window !== 'undefined' ? (localStorage.getItem("auth_token") || localStorage.getItem("token")) : null;
// Ensure config is loaded before making request (from dev branch)
// await initConfig(); // Commented out to reduce risk if not present in HEAD
// Token is now in httpOnly cookie, sent automatically via credentials: include
const headers: Record<string, string> = { const headers: Record<string, string> = {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers as Record<string, string>, ...options.headers as Record<string, string>,
}; };
@ -156,7 +160,6 @@ export interface AdminCandidateStats {
hiringRate: number; hiringRate: number;
} }
// --- Auth API --- // --- Auth API ---
export const authApi = { export const authApi = {
login: (data: any) => { login: (data: any) => {
@ -179,7 +182,6 @@ export const authApi = {
}; };
// --- Users API (General/Admin) --- // --- Users API (General/Admin) ---
// Unified to match usage in users/page.tsx
export const usersApi = { export const usersApi = {
list: (params: { page: number; limit: number }) => { list: (params: { page: number; limit: number }) => {
const query = new URLSearchParams({ const query = new URLSearchParams({
@ -211,10 +213,17 @@ export const usersApi = {
method: "DELETE", method: "DELETE",
}); });
}, },
// Merged from HEAD
getMe: () => apiRequest<ApiUser & { bio?: string; skills?: string[]; experience?: any[]; education?: any[]; profilePictureUrl?: string; }>("/api/v1/users/me"),
updateMe: (data: any) =>
apiRequest<ApiUser>("/api/v1/users/me", {
method: "PUT",
body: JSON.stringify(data),
}),
}; };
export const adminUsersApi = usersApi; // Alias for backward compatibility if needed export const adminUsersApi = usersApi; // Alias for backward compatibility if needed
// --- Admin Backoffice API --- // --- Admin Backoffice API ---
export const adminAccessApi = { export const adminAccessApi = {
listRoles: () => apiRequest<AdminRoleAccess[]>("/api/v1/users/roles"), listRoles: () => apiRequest<AdminRoleAccess[]>("/api/v1/users/roles"),
@ -266,7 +275,6 @@ export const adminCandidatesApi = {
}; };
// --- Companies (Admin) --- // --- Companies (Admin) ---
// Now handled by smart endpoint /api/v1/companies
export const adminCompaniesApi = { export const adminCompaniesApi = {
list: (verified?: boolean, page = 1, limit = 10) => { list: (verified?: boolean, page = 1, limit = 10) => {
const query = new URLSearchParams({ const query = new URLSearchParams({
@ -312,7 +320,16 @@ export const adminCompaniesApi = {
} }
}; };
// Companies API (Public/Shared)
export const companiesApi = {
list: () => apiRequest<AdminCompany[]>("/api/v1/companies"), // Using AdminCompany as fallback type
create: (data: { name: string; slug: string; email?: string }) =>
apiRequest<AdminCompany>("/api/v1/companies", {
method: "POST",
body: JSON.stringify(data),
}),
};
// --- Jobs API (Public/Candidate) --- // --- Jobs API (Public/Candidate) ---
export interface CreateJobPayload { export interface CreateJobPayload {
@ -391,29 +408,91 @@ export const jobsApi = {
}, },
}; };
// Applications API
export interface ApiApplication {
id: number;
jobId: number;
userId?: number;
name?: string;
email?: string;
phone?: string;
resumeUrl?: string;
status: string;
createdAt: string;
}
export const applicationsApi = { export const applicationsApi = {
create: (data: any) => create: (data: any) =>
apiRequest<void>("/api/v1/applications", { apiRequest<ApiApplication>("/api/v1/applications", {
method: "POST", method: "POST",
body: JSON.stringify(data), body: JSON.stringify(data),
}), }),
list: (params: { jobId?: string; companyId?: string }) => { list: (params: { jobId?: string; companyId?: string }) => {
const query = new URLSearchParams(); const query = new URLSearchParams();
if (params.jobId) query.append("jobId", params.jobId); if (params.jobId) query.append("jobId", params.jobId);
if (params.companyId) query.append("companyId", params.companyId); if (params.companyId) query.append("companyId", params.companyId);
return apiRequest<any[]>(`/api/v1/applications?${query.toString()}`); return apiRequest<any[]>(`/api/v1/applications?${query.toString()}`);
}, },
listMine: () => { listMyApplications: () => {
return apiRequest<any[]>("/api/v1/applications/me"); // Backend should support /applications/me or similar. Using /applications/me for now.
return apiRequest<ApiApplication[]>("/api/v1/applications/me");
}, },
delete: (id: string) => { delete: (id: string) => {
return apiRequest<void>(`/api/v1/applications/${id}`, { return apiRequest<void>(`/api/v1/applications/${id}`, {
method: "DELETE" method: "DELETE"
}) });
}, },
}; };
// Storage API
export const storageApi = {
getUploadUrl: (filename: string, contentType: string) =>
apiRequest<{ uploadUrl: string; key: string; publicUrl: string }>(
"/api/v1/storage/upload-url",
{
method: "POST",
body: JSON.stringify({ filename, contentType })
}
),
uploadFile: async (file: File, folder = "uploads") => {
// Use backend proxy to avoid CORS/403
// Note: initConfig usage removed as it was commented out in apiRequest, but we might need it if proxy depends on it?
// Let's assume apiRequest handles auth. But here we use raw fetch.
// We should probably rely on the auth token in localStorage.
const token = typeof window !== 'undefined' ? (localStorage.getItem("auth_token") || localStorage.getItem("token")) : null;
const formData = new FormData();
formData.append('file', file);
formData.append('folder', folder);
const response = await fetch(`${getApiUrl()}/api/v1/storage/upload`, {
method: 'POST',
body: formData,
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
credentials: 'include',
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to upload file to storage: ${errorText}`);
}
const data = await response.json();
return {
key: data.key,
publicUrl: data.publicUrl || data.url
};
},
testConnection: () => apiRequest<{ message: string }>("/api/v1/admin/storage/test-connection", {
method: "POST"
}),
};
// --- Helper Functions --- // --- Helper Functions ---
export function transformApiJobToFrontend(apiJob: ApiJob): Job { export function transformApiJobToFrontend(apiJob: ApiJob): Job {
// Requirements might come as a string derived from DB // Requirements might come as a string derived from DB
@ -421,7 +500,25 @@ export function transformApiJobToFrontend(apiJob: ApiJob): Job {
if (apiJob.requirements) { if (apiJob.requirements) {
// Assuming it might be a JSON string or just text // Assuming it might be a JSON string or just text
// Simple split by newline for now if it's a block of text // Simple split by newline for now if it's a block of text
reqs = apiJob.requirements.split('\n').filter(Boolean); if (apiJob.requirements.startsWith('[')) {
try {
reqs = JSON.parse(apiJob.requirements);
} catch (e) {
reqs = apiJob.requirements.split('\n').filter(Boolean);
}
} else {
reqs = apiJob.requirements.split('\n').filter(Boolean);
}
}
// Format salary
let salaryLabel: string | undefined;
if (apiJob.salaryMin && apiJob.salaryMax) {
salaryLabel = `R$ ${apiJob.salaryMin.toLocaleString('pt-BR')} - R$ ${apiJob.salaryMax.toLocaleString('pt-BR')}`;
} else if (apiJob.salaryMin) {
salaryLabel = `A partir de R$ ${apiJob.salaryMin.toLocaleString('pt-BR')}`;
} else if (apiJob.salaryMax) {
salaryLabel = `Até R$ ${apiJob.salaryMax.toLocaleString('pt-BR')}`;
} }
return { return {
@ -431,11 +528,7 @@ export function transformApiJobToFrontend(apiJob: ApiJob): Job {
location: apiJob.location, location: apiJob.location,
type: (apiJob.type as any) || "full-time", type: (apiJob.type as any) || "full-time",
workMode: (apiJob.workMode as any) || "onsite", workMode: (apiJob.workMode as any) || "onsite",
salary: apiJob.salaryMin && apiJob.salaryMax salary: salaryLabel,
? `$${apiJob.salaryMin} - $${apiJob.salaryMax}`
: apiJob.salaryMin
? `$${apiJob.salaryMin}+`
: undefined,
description: apiJob.description, description: apiJob.description,
requirements: reqs, requirements: reqs,
postedAt: apiJob.createdAt, postedAt: apiJob.createdAt,
@ -587,11 +680,12 @@ export const profileApi = {
// Backoffice URL - now uses runtime config // Backoffice URL - now uses runtime config
async function backofficeRequest<T>(endpoint: string, options: RequestInit = {}): Promise<T> { async function backofficeRequest<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
// Ensure config is loaded before making request // Token can be stored as 'auth_token' (from auth.ts login) or 'token' (legacy)
await initConfig(); const token = typeof window !== 'undefined' ? (localStorage.getItem("auth_token") || localStorage.getItem("token")) : null;
// Token is now in httpOnly cookie, sent automatically via credentials: include
const headers: Record<string, string> = { const headers: Record<string, string> = {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers as Record<string, string>, ...options.headers as Record<string, string>,
}; };
@ -727,9 +821,10 @@ export interface Conversation {
lastMessageAt: string; lastMessageAt: string;
participantName: string; participantName: string;
participantAvatar?: string; participantAvatar?: string;
unreadCount: number; unreadCount?: number;
} }
export const chatApi = { export const chatApi = {
listConversations: () => apiRequest<Conversation[]>("/api/v1/conversations"), listConversations: () => apiRequest<Conversation[]>("/api/v1/conversations"),
listMessages: (conversationId: string) => apiRequest<Message[]>(`/api/v1/conversations/${conversationId}/messages`), listMessages: (conversationId: string) => apiRequest<Message[]>(`/api/v1/conversations/${conversationId}/messages`),
@ -771,37 +866,8 @@ export const credentialsApi = {
}), }),
}; };
export const storageApi = { // Duplicate storageApi removed
testConnection: () => apiRequest<{ message: string }>("/api/v1/admin/storage/test-connection", {
method: "POST"
}),
async uploadFile(file: File, folder = "uploads") {
await initConfig();
// Use backend proxy to avoid CORS/403
const formData = new FormData();
formData.append('file', file);
formData.append('folder', folder);
// We use the proxy route
const response = await fetch(`${getApiUrl()}/api/v1/storage/upload`, {
method: 'POST',
body: formData,
// Credentials include is important if we need cookies (though for guest it might not matter, but good practice)
credentials: 'include',
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to upload file to storage: ${errorText}`);
}
const data = await response.json();
return {
key: data.key,
publicUrl: data.publicUrl || data.url
};
},
};
// --- Email Templates & Settings --- // --- Email Templates & Settings ---
export interface EmailTemplate { export interface EmailTemplate {
@ -903,3 +969,4 @@ export const locationsApi = {
return res || []; return res || [];
}, },
}; };

View file

@ -25,7 +25,7 @@ export interface RuntimeConfig {
// Default values (fallback to build-time env or hardcoded defaults) // Default values (fallback to build-time env or hardcoded defaults)
const defaultConfig: RuntimeConfig = { const defaultConfig: RuntimeConfig = {
apiUrl: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8521', apiUrl: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8521',
backofficeUrl: process.env.NEXT_PUBLIC_BACKOFFICE_URL || 'https://b-local.gohorsejobs.com', backofficeUrl: process.env.NEXT_PUBLIC_BACKOFFICE_URL || 'http://localhost:3001',
seederApiUrl: process.env.NEXT_PUBLIC_SEEDER_API_URL || 'http://localhost:3002', seederApiUrl: process.env.NEXT_PUBLIC_SEEDER_API_URL || 'http://localhost:3002',
scraperApiUrl: process.env.NEXT_PUBLIC_SCRAPER_API_URL || 'http://localhost:3003', scraperApiUrl: process.env.NEXT_PUBLIC_SCRAPER_API_URL || 'http://localhost:3003',
}; };

View file

@ -1,115 +1,116 @@
'use client'; 'use client';
import React, { createContext, useContext, useState, useCallback, ReactNode, useEffect } from 'react'; import React, { createContext, useContext, useState, useCallback, ReactNode, useEffect } from 'react';
import en from '@/i18n/en.json'; import en from '@/i18n/en.json';
import es from '@/i18n/es.json'; import es from '@/i18n/es.json';
import ptBR from '@/i18n/pt-BR.json'; import ptBR from '@/i18n/pt-BR.json';
type Locale = 'en' | 'es' | 'pt-BR'; type Locale = 'en' | 'es' | 'pt-BR';
interface I18nContextType { interface I18nContextType {
locale: Locale; locale: Locale;
setLocale: (locale: Locale) => void; setLocale: (locale: Locale) => void;
t: (key: string, params?: Record<string, string | number>) => string; t: (key: string, params?: Record<string, string | number>) => string;
} }
const dictionaries: Record<Locale, typeof en> = { const dictionaries: Record<Locale, typeof en> = {
en, en,
es, es,
'pt-BR': ptBR, 'pt-BR': ptBR,
}; };
const I18nContext = createContext<I18nContextType | null>(null); const I18nContext = createContext<I18nContextType | null>(null);
const localeStorageKey = 'locale'; const localeStorageKey = 'locale';
const normalizeLocale = (language: string): Locale => { const normalizeLocale = (language: string): Locale => {
if (language.startsWith('pt')) return 'pt-BR'; if (language.startsWith('pt')) return 'pt-BR';
if (language.startsWith('es')) return 'es'; if (language.startsWith('es')) return 'es';
return 'en'; return 'en';
}; };
const getInitialLocale = (): Locale => { const getInitialLocale = (): Locale => {
if (typeof window === 'undefined') return 'en'; if (typeof window === 'undefined') return 'en';
const storedLocale = localStorage.getItem(localeStorageKey); const storedLocale = localStorage.getItem(localeStorageKey);
if (storedLocale && storedLocale in dictionaries) { if (storedLocale && storedLocale in dictionaries) {
return storedLocale as Locale; return storedLocale as Locale;
} }
return normalizeLocale(navigator.language); // Default to English instead of browser language for consistency
}; return 'en';
};
export function I18nProvider({ children }: { children: ReactNode }) {
const [locale, setLocaleState] = useState<Locale>(getInitialLocale); export function I18nProvider({ children }: { children: ReactNode }) {
// FIX: Initialize with 'en' to match Server-Side Rendering (SSR)
const setLocale = (newLocale: Locale) => { // This prevents hydration mismatch errors.
console.log(`[I18n] Setting locale to: ${newLocale}`); const [locale, setLocaleState] = useState<Locale>('en');
setLocaleState(newLocale);
}; const setLocale = (newLocale: Locale) => {
console.log(`[I18n] Setting locale to: ${newLocale}`);
const t = useCallback((key: string, params?: Record<string, string | number>): string => { setLocaleState(newLocale);
const keys = key.split('.'); };
let value: unknown = dictionaries[locale];
const t = useCallback((key: string, params?: Record<string, string | number>) => {
for (const k of keys) { const keys = key.split('.');
if (value && typeof value === 'object' && value !== null && k in value) { let value: unknown = dictionaries[locale];
value = (value as Record<string, unknown>)[k];
} else { for (const k of keys) {
return key; // Return key if not found if (value && typeof value === 'object' && value !== null && k in value) {
} value = (value as Record<string, unknown>)[k];
} } else {
return key; // Return key if not found
if (typeof value !== 'string') return key; }
}
// Replace parameters like {count}, {time}, {year}
if (params) { if (typeof value !== 'string') return key;
return Object.entries(params).reduce(
(str, [paramKey, paramValue]) => str.replace(`{${paramKey}}`, String(paramValue)), // Replace parameters like {count}, {time}, {year}
value if (params) {
); return Object.entries(params).reduce(
} (str, [paramKey, paramValue]) => str.replace(`{${paramKey}}`, String(paramValue)),
value
return value; );
}, [locale]); }
// Sync from localStorage on mount to handle hydration mismatch or initial load return value;
useEffect(() => { }, [locale]);
if (typeof window !== 'undefined') {
const stored = localStorage.getItem(localeStorageKey); // Sync from localStorage on mount (Client-side only)
if (stored && stored in dictionaries && stored !== locale) { useEffect(() => {
console.log('[I18n] Restoring locale from storage:', stored); const stored = getInitialLocale();
setLocale(stored as Locale); if (stored !== 'en') {
} console.log('[I18n] Restoring locale from storage on client:', stored);
} setLocaleState(stored);
}, []); }
}, []);
useEffect(() => {
if (typeof window === 'undefined') return; useEffect(() => {
localStorage.setItem(localeStorageKey, locale); if (typeof window === 'undefined') return;
document.documentElement.lang = locale; localStorage.setItem(localeStorageKey, locale);
}, [locale]); document.documentElement.lang = locale;
}, [locale]);
return (
<I18nContext.Provider value={{ locale, setLocale, t }}> return (
{children} <I18nContext.Provider value={{ locale, setLocale, t }}>
</I18nContext.Provider> {children}
); </I18nContext.Provider>
} );
}
export function useI18n() {
const context = useContext(I18nContext); export function useI18n() {
if (!context) { const context = useContext(I18nContext);
throw new Error('useI18n must be used within an I18nProvider'); if (!context) {
} throw new Error('useI18n must be used within an I18nProvider');
return context; }
} return context;
}
export function useTranslation() {
const { t, locale, setLocale } = useI18n(); export function useTranslation() {
return { t, locale, setLocale }; const { t, locale, setLocale } = useI18n();
} return { t, locale, setLocale };
}
export const locales: { code: Locale; name: string; flag: string }[] = [
{ code: 'en', name: 'English', flag: '🇺🇸' }, export const locales: { code: Locale; name: string; flag: string }[] = [
{ code: 'es', name: 'Español', flag: '🇪🇸' }, { code: 'en', name: 'English', flag: '🇺🇸' },
{ code: 'pt-BR', name: 'Português', flag: '🇧🇷' }, { code: 'es', name: 'Español', flag: '🇪🇸' },
]; { code: 'pt-BR', name: 'Português', flag: '🇧🇷' },
];

View file

@ -0,0 +1,201 @@
/**
* Frontend Sanitization Utilities
* Provides XSS protection and input validation for the client side
*/
/**
* Escapes HTML entities to prevent XSS attacks
*/
export function escapeHtml(str: string): string {
if (!str) return '';
const htmlEntities: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
'/': '&#x2F;',
'`': '&#x60;',
'=': '&#x3D;'
};
return str.replace(/[&<>"'`=/]/g, char => htmlEntities[char] || char);
}
/**
* Strips HTML tags from input
*/
export function stripHtml(str: string): string {
if (!str) return '';
return str.replace(/<[^>]*>/g, '');
}
/**
* Sanitizes a string for safe display
*/
export function sanitizeString(str: string): string {
if (!str) return '';
return escapeHtml(str.trim());
}
/**
* Sanitizes a name field (max 255 chars)
*/
export function sanitizeName(str: string, maxLength = 255): string {
return sanitizeString(str).substring(0, maxLength);
}
/**
* Sanitizes an email address
*/
export function sanitizeEmail(str: string): string {
if (!str) return '';
return str.trim().toLowerCase().substring(0, 320);
}
/**
* Validates email format
*/
export function isValidEmail(email: string): boolean {
const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
return emailRegex.test(email);
}
/**
* Sanitizes a phone number (keeps only digits, +, -, space, parens)
*/
export function sanitizePhone(str: string): string {
if (!str) return '';
return str.replace(/[^0-9+\-\s()]/g, '').trim();
}
/**
* Creates a URL-safe slug
*/
export function createSlug(str: string): string {
if (!str) return '';
return str
.toLowerCase()
.trim()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '') // Remove accents
.replace(/[^a-z0-9\s-]/g, '') // Remove non-alphanumeric
.replace(/\s+/g, '-') // Replace spaces with hyphens
.replace(/-+/g, '-') // Remove consecutive hyphens
.replace(/^-|-$/g, ''); // Trim hyphens from ends
}
/**
* Validates document based on country (flexible for global portal)
*/
export function validateDocument(doc: string, countryCode?: string): { valid: boolean; message: string; clean: string } {
const clean = doc.replace(/[^a-zA-Z0-9]/g, '');
if (!clean) {
return { valid: true, message: 'Documento opcional', clean: '' };
}
switch (countryCode?.toUpperCase()) {
case 'BR':
return validateBrazilianDocument(clean);
case 'JP':
if (clean.length === 13) {
return { valid: true, message: '法人番号 válido', clean };
}
if (clean.length >= 5 && clean.length <= 20) {
return { valid: true, message: 'Documento aceito', clean };
}
return { valid: false, message: 'Documento japonês inválido', clean };
case 'US':
if (clean.length === 9) {
return { valid: true, message: 'EIN válido', clean };
}
return { valid: false, message: 'EIN deve ter 9 dígitos', clean };
default:
// Global mode - accept any reasonable document
if (clean.length >= 5 && clean.length <= 30) {
return { valid: true, message: 'Documento aceito', clean };
}
return { valid: false, message: 'Documento deve ter entre 5 e 30 caracteres', clean };
}
}
/**
* Validates Brazilian CNPJ/CPF
*/
function validateBrazilianDocument(doc: string): { valid: boolean; message: string; clean: string } {
if (doc.length === 14) {
// CNPJ validation
if (validateCNPJ(doc)) {
return { valid: true, message: 'CNPJ válido', clean: doc };
}
return { valid: false, message: 'CNPJ inválido', clean: doc };
}
if (doc.length === 11) {
// CPF validation
if (validateCPF(doc)) {
return { valid: true, message: 'CPF válido', clean: doc };
}
return { valid: false, message: 'CPF inválido', clean: doc };
}
return { valid: false, message: 'Documento brasileiro deve ter 11 (CPF) ou 14 (CNPJ) dígitos', clean: doc };
}
/**
* Validates Brazilian CNPJ checksum
*/
function validateCNPJ(cnpj: string): boolean {
if (cnpj.length !== 14 || /^(\d)\1+$/.test(cnpj)) return false;
const weights1 = [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2];
const weights2 = [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2];
const calcDigit = (digits: string, weights: number[]): number => {
const sum = weights.reduce((acc, w, i) => acc + parseInt(digits[i]) * w, 0);
const remainder = sum % 11;
return remainder < 2 ? 0 : 11 - remainder;
};
const digit1 = calcDigit(cnpj, weights1);
const digit2 = calcDigit(cnpj, weights2);
return parseInt(cnpj[12]) === digit1 && parseInt(cnpj[13]) === digit2;
}
/**
* Validates Brazilian CPF checksum
*/
function validateCPF(cpf: string): boolean {
if (cpf.length !== 11 || /^(\d)\1+$/.test(cpf)) return false;
const calcDigit = (digits: string, factor: number): number => {
let sum = 0;
for (let i = 0; i < factor - 1; i++) {
sum += parseInt(digits[i]) * (factor - i);
}
const remainder = sum % 11;
return remainder < 2 ? 0 : 11 - remainder;
};
const digit1 = calcDigit(cpf, 10);
const digit2 = calcDigit(cpf, 11);
return parseInt(cpf[9]) === digit1 && parseInt(cpf[10]) === digit2;
}
/**
* Formats CNPJ for display: 00.000.000/0000-00
*/
export function formatCNPJ(cnpj: string): string {
const clean = cnpj.replace(/\D/g, '');
if (clean.length !== 14) return cnpj;
return clean.replace(/(\d{2})(\d{3})(\d{3})(\d{4})(\d{2})/, '$1.$2.$3/$4-$5');
}
/**
* Formats CPF for display: 000.000.000-00
*/
export function formatCPF(cpf: string): string {
const clean = cpf.replace(/\D/g, '');
if (clean.length !== 11) return cpf;
return clean.replace(/(\d{3})(\d{3})(\d{3})(\d{2})/, '$1.$2.$3-$4');
}