chore: database reset, frontend API configuration
This commit is contained in:
parent
65ac4233c2
commit
5291f3f15d
27 changed files with 11413 additions and 441 deletions
9810
frontend/pnpm-lock.yaml
Normal file
9810
frontend/pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load diff
BIN
frontend/public/Blog.jpg
Normal file
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
BIN
frontend/public/Vagas.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 MiB |
BIN
frontend/public/empresas.jpg
Normal file
BIN
frontend/public/empresas.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 MiB |
BIN
frontend/public/home-mobile.jpg
Normal file
BIN
frontend/public/home-mobile.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.7 MiB |
|
|
@ -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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
395
frontend/src/app/dashboard/candidato/perfil/page.tsx
Normal file
395
frontend/src/app/dashboard/candidato/perfil/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
170
frontend/src/app/reset-password/page.tsx
Normal file
170
frontend/src/app/reset-password/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
27
frontend/src/components/google-analytics.tsx
Normal file
27
frontend/src/components/google-analytics.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
frontend/src/components/home-search.tsx
Normal file
117
frontend/src/components/home-search.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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}`}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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 || [];
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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: '🇧🇷' },
|
||||||
|
];
|
||||||
|
|
|
||||||
201
frontend/src/lib/sanitize.ts
Normal file
201
frontend/src/lib/sanitize.ts
Normal 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> = {
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": ''',
|
||||||
|
'/': '/',
|
||||||
|
'`': '`',
|
||||||
|
'=': '='
|
||||||
|
};
|
||||||
|
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');
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue