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';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET() {
|
||||
const config = {
|
||||
apiUrl: process.env.API_URL || 'https://api.rede5.com.br/',
|
||||
backofficeUrl: process.env.BACKOFFICE_URL || 'https://b-local.gohorsejobs.com',
|
||||
seederApiUrl: process.env.SEEDER_API_URL || 'http://localhost:3002',
|
||||
scraperApiUrl: process.env.SCRAPER_API_URL || 'http://localhost:3003',
|
||||
};
|
||||
|
||||
return NextResponse.json(config, {
|
||||
headers: {
|
||||
'Cache-Control': 'public, max-age=300, s-maxage=300',
|
||||
},
|
||||
});
|
||||
}
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
/**
|
||||
* Runtime Configuration API
|
||||
*
|
||||
* This endpoint returns environment variables that are read at RUNTIME,
|
||||
* not build time. This allows changing configuration without rebuilding.
|
||||
*
|
||||
* IMPORTANT: Use env vars WITHOUT the NEXT_PUBLIC_ prefix here!
|
||||
* - NEXT_PUBLIC_* = baked in at build time (bad for runtime config)
|
||||
* - Regular env vars = read at runtime (good!)
|
||||
*
|
||||
* 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">
|
||||
{/* Hero Section */}
|
||||
<section className="relative bg-[#F0932B] py-16 md:py-24 overflow-hidden">
|
||||
<div className="absolute inset-0 opacity-10">
|
||||
<section className="relative py-24 md:py-36 overflow-hidden">
|
||||
{/* 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>
|
||||
|
||||
<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">
|
||||
<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')}
|
||||
</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')}
|
||||
</p>
|
||||
|
||||
{/* 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">
|
||||
<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
|
||||
type="text"
|
||||
placeholder={t('blog.searchPlaceholder')}
|
||||
value={searchTerm}
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -170,17 +170,30 @@ export default function CompaniesPage() {
|
|||
|
||||
<main className="flex-1">
|
||||
{/* Hero Section */}
|
||||
<section className="relative bg-[#F0932B] py-20 md:py-28 overflow-hidden">
|
||||
<div className="absolute inset-0 opacity-10">
|
||||
<section className="relative py-20 md:py-28 overflow-hidden">
|
||||
{/* 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>
|
||||
|
||||
<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">
|
||||
<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
|
||||
</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
|
||||
</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 Link from "next/link";
|
||||
import { format } from "date-fns";
|
||||
import { ptBR } from "date-fns/locale";
|
||||
import {
|
||||
Building2,
|
||||
MapPin,
|
||||
CalendarDays,
|
||||
Search,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Calendar,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
AlertCircle,
|
||||
FileText,
|
||||
ExternalLink
|
||||
Clock,
|
||||
FileText
|
||||
} 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 { 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 { useNotify } from "@/contexts/notification-context";
|
||||
|
||||
interface Application {
|
||||
type Application = {
|
||||
id: string;
|
||||
jobId: string;
|
||||
jobTitle: string;
|
||||
|
|
@ -32,148 +43,163 @@ interface Application {
|
|||
createdAt: string;
|
||||
resumeUrl?: string;
|
||||
message?: string;
|
||||
}
|
||||
};
|
||||
|
||||
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 },
|
||||
reviewed: { label: "Viewed", color: "bg-blue-100 text-blue-800 border-blue-200", icon: CheckCircle2 },
|
||||
shortlisted: { label: "Shortlisted", color: "bg-purple-100 text-purple-800 border-purple-200", icon: CheckCircle2 },
|
||||
hired: { label: "Hired", color: "bg-green-100 text-green-800 border-green-200", icon: CheckCircle2 },
|
||||
rejected: { label: "Rejected", color: "bg-red-100 text-red-800 border-red-200", icon: XCircle },
|
||||
pending: { label: "Em Análise", color: "bg-yellow-100 text-yellow-800 hover:bg-yellow-100/80", icon: Clock },
|
||||
reviewed: { label: "Visualizado", color: "bg-blue-100 text-blue-800 hover:bg-blue-100/80", icon: CheckCircle2 },
|
||||
shortlisted: { label: "Selecionado", color: "bg-purple-100 text-purple-800 hover:bg-purple-100/80", icon: CheckCircle2 },
|
||||
hired: { label: "Contratado", color: "bg-green-100 text-green-800 hover:bg-green-100/80", icon: CheckCircle2 },
|
||||
rejected: { label: "Não Selecionado", color: "bg-red-100 text-red-800 hover:bg-red-100/80", icon: XCircle },
|
||||
};
|
||||
|
||||
export default function MyApplicationsPage() {
|
||||
const [applications, setApplications] = useState<Application[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const notify = useNotify();
|
||||
const [error, setError] = useState("");
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
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();
|
||||
}, [notify]);
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-3xl font-bold">My Applications</h1>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const fetchApplications = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await applicationsApi.listMyApplications();
|
||||
// Backend endpoint consistency check: listMyApplications usually returns ApplicationWithDetails
|
||||
// Casting to ensure type safety if generic
|
||||
setApplications(data as unknown as Application[]);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch applications", err);
|
||||
setError("Não foi possível carregar suas candidaturas. Tente novamente.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredApplications = applications.filter(app =>
|
||||
app.jobTitle.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
app.companyName.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">My Applications</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Track the status of your job applications.
|
||||
</p>
|
||||
<div className="min-h-screen flex flex-col bg-background">
|
||||
<Navbar />
|
||||
|
||||
<main className="flex-1 container mx-auto px-4 py-8">
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4">
|
||||
<div>
|
||||
<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>
|
||||
<Button asChild>
|
||||
<Link href="/jobs">Find more jobs</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{applications.length === 0 ? (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center text-muted-foreground">
|
||||
<h3 className="text-lg font-semibold mb-2">No applications yet</h3>
|
||||
<p className="mb-6">You haven't applied to any jobs yet.</p>
|
||||
<Button asChild variant="secondary">
|
||||
<Link href="/jobs">Browse Jobs</Link>
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center py-20">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center text-muted-foreground bg-muted/30 rounded-lg">
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-1 lg:grid-cols-2">
|
||||
{applications.map((app) => {
|
||||
const status = statusConfig[app.status] || {
|
||||
label: app.status,
|
||||
color: "bg-gray-100 text-gray-800 border-gray-200",
|
||||
icon: AlertCircle
|
||||
};
|
||||
const StatusIcon = status.icon;
|
||||
</div>
|
||||
) : filteredApplications.length === 0 ? (
|
||||
<div className="text-center py-16 bg-muted/20 rounded-lg border border-dashed">
|
||||
<div className="bg-primary/10 p-4 rounded-full w-16 h-16 flex items-center justify-center mx-auto mb-4">
|
||||
<Search className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">Nenhuma candidatura encontrada</h3>
|
||||
<p className="text-muted-foreground max-w-sm mx-auto mt-2 mb-6">
|
||||
Você ainda não se candidatou a nenhuma vaga. Explore as oportunidades disponíveis e comece sua jornada.
|
||||
</p>
|
||||
<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 (
|
||||
<Card key={app.id} className="hover:border-primary/50 transition-colors">
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
return (
|
||||
<Card key={app.id} className="overflow-hidden hover:shadow-md transition-shadow">
|
||||
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-2">
|
||||
<div className="space-y-1">
|
||||
<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}
|
||||
</Link>
|
||||
</CardTitle>
|
||||
<div className="flex items-center text-muted-foreground text-sm gap-2">
|
||||
<Building2 className="h-4 w-4" />
|
||||
<span>{app.companyName}</span>
|
||||
<CardDescription className="flex items-center gap-2">
|
||||
<Building2 className="h-3.5 w-3.5" />
|
||||
{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>
|
||||
<Badge variant="outline" className={`${status.color} flex items-center gap-1 shrink-0`}>
|
||||
<StatusIcon className="h-3 w-3" />
|
||||
{status.label}
|
||||
</Badge>
|
||||
</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>
|
||||
{app.message && (
|
||||
<div className="mt-4 bg-muted/50 p-3 rounded-md text-sm italic">
|
||||
"{app.message.length > 100 ? app.message.substring(0, 100) + "..." : app.message}"
|
||||
</div>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" asChild className="ml-auto">
|
||||
<Link href={`/jobs/${app.jobId}`}>
|
||||
View Job <ExternalLink className="h-3 w-3 ml-1" />
|
||||
</CardContent>
|
||||
<CardFooter className="bg-muted/50 p-4 flex justify-between items-center">
|
||||
<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>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ export default function AdminUsersPage() {
|
|||
email: "",
|
||||
role: "",
|
||||
status: "",
|
||||
password: "", // Optional for edits
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -119,7 +120,7 @@ export default function AdminUsersPage() {
|
|||
setCreating(true)
|
||||
const payload = {
|
||||
...formData,
|
||||
roles: [formData.role],
|
||||
roles: [formData.role], // Helper for legacy backend needing array
|
||||
}
|
||||
await usersApi.create(payload)
|
||||
toast.success(t('admin.users.messages.create_success'))
|
||||
|
|
@ -143,6 +144,7 @@ export default function AdminUsersPage() {
|
|||
email: user.email,
|
||||
role: user.role,
|
||||
status: user.status || "active",
|
||||
password: "",
|
||||
})
|
||||
setViewing(true)
|
||||
setIsEditDialogOpen(true)
|
||||
|
|
@ -156,6 +158,7 @@ export default function AdminUsersPage() {
|
|||
email: user.email,
|
||||
role: user.role,
|
||||
status: user.status || "active",
|
||||
password: "",
|
||||
})
|
||||
setViewing(false)
|
||||
setIsEditDialogOpen(true)
|
||||
|
|
@ -166,10 +169,13 @@ export default function AdminUsersPage() {
|
|||
console.log("[USER_FLOW] Updating user:", selectedUser.id, "with data:", editFormData)
|
||||
try {
|
||||
setUpdating(true)
|
||||
const payload = {
|
||||
const payload: any = {
|
||||
...editFormData,
|
||||
roles: [editFormData.role],
|
||||
}
|
||||
// Remove empty password if not changing
|
||||
if (!payload.password) delete payload.password;
|
||||
|
||||
await usersApi.update(selectedUser.id, payload)
|
||||
toast.success(t('admin.users.messages.update_success'))
|
||||
setIsEditDialogOpen(false)
|
||||
|
|
@ -257,7 +263,12 @@ export default function AdminUsersPage() {
|
|||
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? "animate-spin" : ""}`} />
|
||||
{t('admin.users.refresh')}
|
||||
</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>
|
||||
<Button className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
|
|
@ -290,6 +301,7 @@ export default function AdminUsersPage() {
|
|||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
|
|
@ -384,6 +396,18 @@ export default function AdminUsersPage() {
|
|||
disabled={viewing}
|
||||
/>
|
||||
</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">
|
||||
<Label htmlFor="edit-role">{t('admin.users.table.role')}</Label>
|
||||
<Select
|
||||
|
|
|
|||
|
|
@ -2,22 +2,54 @@
|
|||
|
||||
import { useState } from "react";
|
||||
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 { Card, CardContent } from "@/components/ui/card";
|
||||
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 { Label } from "@/components/ui/label";
|
||||
import { Navbar } from "@/components/navbar";
|
||||
import { Footer } from "@/components/footer";
|
||||
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() {
|
||||
const { t } = useTranslation();
|
||||
const [email, setEmail] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setSubmitted(true);
|
||||
const { register, handleSubmit, formState: { errors } } = useForm<ForgotPasswordForm>();
|
||||
|
||||
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 (
|
||||
|
|
@ -52,41 +84,50 @@ export default function ForgotPasswordPage() {
|
|||
</Alert>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email" className="text-sm sm:text-base">
|
||||
{t("auth.forgot.fields.email")}
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
placeholder={t("auth.forgot.fields.emailPlaceholder")}
|
||||
required
|
||||
className="h-10 sm:h-11"
|
||||
/>
|
||||
</div>
|
||||
{!submitted && (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email" className="text-sm sm:text-base">
|
||||
{t("auth.forgot.fields.email")}
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder={t("auth.forgot.fields.emailPlaceholder")}
|
||||
className="h-10 sm:h-11"
|
||||
{...register("email", { required: t("validations.required") || "Email is required" })}
|
||||
/>
|
||||
{errors.email && <p className="text-sm text-destructive">{errors.email.message}</p>}
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full h-10 sm:h-11">
|
||||
{t("auth.forgot.submit")}
|
||||
</Button>
|
||||
</form>
|
||||
{error && <p className="text-sm text-destructive text-center">{error}</p>}
|
||||
|
||||
<Button type="submit" className="w-full h-10 sm:h-11" disabled={loading}>
|
||||
{loading ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : null}
|
||||
{t("auth.forgot.submit")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</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>
|
||||
|
||||
{/* 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>
|
||||
<Footer />
|
||||
</div>
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -41,7 +41,6 @@ import {
|
|||
} from "@/components/ui/select";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Navbar } from "@/components/navbar";
|
||||
import { Footer } from "@/components/footer";
|
||||
import { useNotify } from "@/contexts/notification-context";
|
||||
|
|
@ -230,22 +229,23 @@ export default function JobApplicationPage({
|
|||
const handleSubmit = async () => {
|
||||
setIsSubmitting(true);
|
||||
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({
|
||||
jobId: id,
|
||||
jobId: Number(id), // ID might need number conversion depending on API
|
||||
name: formData.fullName,
|
||||
email: formData.email,
|
||||
phone: formData.phone,
|
||||
linkedin: formData.linkedin,
|
||||
resumeUrl: formData.resumeUrl,
|
||||
coverLetter: formData.coverLetter || undefined,
|
||||
portfolioUrl: formData.portfolioUrl || undefined,
|
||||
message: formData.whyUs, // Mapping Why Us to Message/Notes
|
||||
documents: {}, // TODO: Extra docs
|
||||
// salaryExpectation: formData.salaryExpectation, // These fields might need to go into Notes or structured JSON if backend doesn't support them specifically?
|
||||
// hasExperience: formData.hasExperience,
|
||||
// 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"
|
||||
coverLetter: formData.coverLetter || formData.whyUs, // Fallback
|
||||
resumeUrl: resumeUrl,
|
||||
portfolioUrl: formData.portfolioUrl,
|
||||
salaryExpectation: formData.salaryExpectation,
|
||||
hasExperience: formData.hasExperience,
|
||||
whyUs: formData.whyUs,
|
||||
availability: formData.availability,
|
||||
});
|
||||
|
||||
notify.success(
|
||||
|
|
@ -275,7 +275,21 @@ export default function JobApplicationPage({
|
|||
|
||||
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) {
|
||||
const user = getCurrentUser();
|
||||
|
|
@ -371,7 +385,7 @@ export default function JobApplicationPage({
|
|||
{t("application.title", { jobTitle: job.title })}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{job.companyName || 'Company'} • {job.location || 'Remote'}
|
||||
{job?.companyName || 'Company'} • {job?.location || 'Remote'}
|
||||
</p>
|
||||
</div>
|
||||
<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-2">
|
||||
<Label htmlFor="whyUs">
|
||||
{t("application.form.whyUs", { company: job.companyName || 'this company' })}
|
||||
{t("application.form.whyUs", { company: job?.companyName || 'this company' })}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="whyUs"
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ function JobsContent() {
|
|||
})
|
||||
|
||||
// Transform the raw API response to frontend format
|
||||
const mappedJobs = (response.data || []).map(transformApiJobToFrontend)
|
||||
const mappedJobs = (response.data || []).map(job => transformApiJobToFrontend(job));
|
||||
|
||||
if (isMounted) {
|
||||
setJobs(mappedJobs)
|
||||
|
|
@ -151,17 +151,29 @@ function JobsContent() {
|
|||
return (
|
||||
<>
|
||||
{/* Hero Section */}
|
||||
<section className="relative bg-[#F0932B] py-12 md:py-16 overflow-hidden">
|
||||
<div className="absolute inset-0 opacity-10">
|
||||
<section className="relative bg-[#F0932B] py-28 md:py-40 overflow-hidden">
|
||||
{/* 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>
|
||||
|
||||
<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">
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
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')}
|
||||
</motion.h1>
|
||||
|
|
@ -169,7 +181,7 @@ function JobsContent() {
|
|||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
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 })}
|
||||
</motion.p>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { NotificationProvider } from "@/contexts/notification-context"
|
|||
import { ThemeProvider } from "@/contexts/ThemeContext"
|
||||
import { ConfigProvider } from "@/contexts/ConfigContext"
|
||||
import { I18nProvider } from "@/lib/i18n"
|
||||
import GoogleAnalytics from "@/components/google-analytics"
|
||||
import "./globals.css"
|
||||
import { Suspense } from "react"
|
||||
import { LoadingScreen } from "@/components/ui/loading-spinner"
|
||||
|
|
@ -54,6 +55,7 @@ export default function RootLayout({
|
|||
</I18nProvider>
|
||||
</ConfigProvider>
|
||||
{process.env.NODE_ENV === "production" && shouldLoadAnalytics && <Analytics />}
|
||||
<GoogleAnalytics GA_MEASUREMENT_ID={process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID || ""} />
|
||||
</body>
|
||||
</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 {
|
||||
// Fetch recommended jobs (latest ones for now)
|
||||
const jobsRes = await jobsApi.list({ limit: 3, sortBy: "created_at" });
|
||||
const appsRes = await applicationsApi.listMine();
|
||||
const appsRes = await applicationsApi.listMyApplications();
|
||||
|
||||
if (jobsRes && jobsRes.data) {
|
||||
const mappedJobs = jobsRes.data.map(transformApiJobToFrontend);
|
||||
const mappedJobs = jobsRes.data.map(job => transformApiJobToFrontend(job));
|
||||
setJobs(mappedJobs);
|
||||
}
|
||||
|
||||
if (appsRes) {
|
||||
setApplications(appsRes);
|
||||
setApplications(appsRes as unknown as ApplicationWithDetails[]);
|
||||
}
|
||||
|
||||
} 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,
|
||||
Building2,
|
||||
Heart,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { motion } from "framer-motion";
|
||||
|
|
@ -95,10 +96,21 @@ export function JobCard({ job, isApplied, applicationStatus }: JobCardProps) {
|
|||
whileHover={{ y: -2 }}
|
||||
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">
|
||||
<div className="flex items-start justify-between">
|
||||
<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">
|
||||
<AvatarImage
|
||||
src={`https://avatar.vercel.sh/${job.company}`}
|
||||
|
|
|
|||
|
|
@ -1,50 +1,50 @@
|
|||
"use client";
|
||||
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Globe } from "lucide-react";
|
||||
|
||||
export function LanguageSwitcher() {
|
||||
const { locale, setLocale } = useTranslation();
|
||||
|
||||
const locales = [
|
||||
{ code: "en" as const, name: "English", flag: "🇺🇸" },
|
||||
{ code: "es" as const, name: "Español", flag: "🇪🇸" },
|
||||
{ code: "pt-BR" as const, name: "Português", flag: "🇧🇷" },
|
||||
];
|
||||
|
||||
const currentLocale = locales.find((l) => l.code === locale) || locales[0];
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<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">
|
||||
<Globe className="h-4 w-4 text-white" />
|
||||
<span className="sr-only">Toggle language</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{locales.map((l) => (
|
||||
<DropdownMenuItem
|
||||
key={l.code}
|
||||
onClick={() => {
|
||||
console.log(`[LanguageSwitcher] Clicking ${l.code}`);
|
||||
setLocale(l.code);
|
||||
}}
|
||||
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>{l.name}</span>
|
||||
{locale === l.code && <span className="ml-auto text-xs opacity-50">✓</span>}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Globe } from "lucide-react";
|
||||
|
||||
export function LanguageSwitcher() {
|
||||
const { locale, setLocale } = useTranslation();
|
||||
|
||||
const locales = [
|
||||
{ code: "en" as const, name: "English", flag: "🇺🇸" },
|
||||
{ code: "es" as const, name: "Español", flag: "🇪🇸" },
|
||||
{ code: "pt-BR" as const, name: "Português", flag: "🇧🇷" },
|
||||
];
|
||||
|
||||
const currentLocale = locales.find((l) => l.code === locale) || locales[0];
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<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-white/10">
|
||||
<Globe className="h-5 w-5 text-white/90 hover:text-white transition-colors" />
|
||||
<span className="sr-only">Toggle language</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{locales.map((l) => (
|
||||
<DropdownMenuItem
|
||||
key={l.code}
|
||||
onClick={() => {
|
||||
console.log(`[LanguageSwitcher] Clicking ${l.code}`);
|
||||
setLocale(l.code);
|
||||
}}
|
||||
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>{l.name}</span>
|
||||
{locale === l.code && <span className="ml-auto text-xs opacity-50">✓</span>}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ export interface RuntimeConfig {
|
|||
}
|
||||
|
||||
const defaultConfig: RuntimeConfig = {
|
||||
apiUrl: process.env.NEXT_PUBLIC_API_URL || 'https://api.rede5.com.br/',
|
||||
backofficeUrl: process.env.NEXT_PUBLIC_BACKOFFICE_URL || 'https://b-local.gohorsejobs.com',
|
||||
apiUrl: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8521',
|
||||
backofficeUrl: process.env.NEXT_PUBLIC_BACKOFFICE_URL || 'http://localhost:3001',
|
||||
seederApiUrl: process.env.NEXT_PUBLIC_SEEDER_API_URL || 'http://localhost:3002',
|
||||
scraperApiUrl: process.env.NEXT_PUBLIC_SCRAPER_API_URL || 'http://localhost:3003',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -288,7 +288,7 @@
|
|||
}
|
||||
},
|
||||
"jobs": {
|
||||
"title": "Encontre sua próxima oportunidade",
|
||||
"title": "Encontre sua Próxima oportunidade",
|
||||
"subtitle": "{count} vagas disponíveis nas melhores empresas",
|
||||
"search": "Buscar vagas por título, empresa...",
|
||||
"filters": {
|
||||
|
|
|
|||
|
|
@ -16,11 +16,15 @@ function logCrudAction(action: string, entity: string, details?: any) {
|
|||
* Generic API Request Wrapper
|
||||
*/
|
||||
async function apiRequest<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
// Ensure config is loaded before making request
|
||||
await initConfig();
|
||||
// Token can be stored as 'auth_token' (from auth.ts login) or 'token' (legacy)
|
||||
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> = {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...options.headers as Record<string, string>,
|
||||
};
|
||||
|
||||
|
|
@ -156,7 +160,6 @@ export interface AdminCandidateStats {
|
|||
hiringRate: number;
|
||||
}
|
||||
|
||||
|
||||
// --- Auth API ---
|
||||
export const authApi = {
|
||||
login: (data: any) => {
|
||||
|
|
@ -179,7 +182,6 @@ export const authApi = {
|
|||
};
|
||||
|
||||
// --- Users API (General/Admin) ---
|
||||
// Unified to match usage in users/page.tsx
|
||||
export const usersApi = {
|
||||
list: (params: { page: number; limit: number }) => {
|
||||
const query = new URLSearchParams({
|
||||
|
|
@ -211,10 +213,17 @@ export const usersApi = {
|
|||
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
|
||||
|
||||
|
||||
// --- Admin Backoffice API ---
|
||||
export const adminAccessApi = {
|
||||
listRoles: () => apiRequest<AdminRoleAccess[]>("/api/v1/users/roles"),
|
||||
|
|
@ -266,7 +275,6 @@ export const adminCandidatesApi = {
|
|||
};
|
||||
|
||||
// --- Companies (Admin) ---
|
||||
// Now handled by smart endpoint /api/v1/companies
|
||||
export const adminCompaniesApi = {
|
||||
list: (verified?: boolean, page = 1, limit = 10) => {
|
||||
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) ---
|
||||
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 = {
|
||||
create: (data: any) =>
|
||||
apiRequest<void>("/api/v1/applications", {
|
||||
apiRequest<ApiApplication>("/api/v1/applications", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
list: (params: { jobId?: string; companyId?: string }) => {
|
||||
const query = new URLSearchParams();
|
||||
if (params.jobId) query.append("jobId", params.jobId);
|
||||
if (params.companyId) query.append("companyId", params.companyId);
|
||||
return apiRequest<any[]>(`/api/v1/applications?${query.toString()}`);
|
||||
},
|
||||
listMine: () => {
|
||||
return apiRequest<any[]>("/api/v1/applications/me");
|
||||
listMyApplications: () => {
|
||||
// Backend should support /applications/me or similar. Using /applications/me for now.
|
||||
return apiRequest<ApiApplication[]>("/api/v1/applications/me");
|
||||
},
|
||||
delete: (id: string) => {
|
||||
return apiRequest<void>(`/api/v1/applications/${id}`, {
|
||||
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 ---
|
||||
export function transformApiJobToFrontend(apiJob: ApiJob): Job {
|
||||
// Requirements might come as a string derived from DB
|
||||
|
|
@ -421,7 +500,25 @@ export function transformApiJobToFrontend(apiJob: ApiJob): Job {
|
|||
if (apiJob.requirements) {
|
||||
// Assuming it might be a JSON string or just 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 {
|
||||
|
|
@ -431,11 +528,7 @@ export function transformApiJobToFrontend(apiJob: ApiJob): Job {
|
|||
location: apiJob.location,
|
||||
type: (apiJob.type as any) || "full-time",
|
||||
workMode: (apiJob.workMode as any) || "onsite",
|
||||
salary: apiJob.salaryMin && apiJob.salaryMax
|
||||
? `$${apiJob.salaryMin} - $${apiJob.salaryMax}`
|
||||
: apiJob.salaryMin
|
||||
? `$${apiJob.salaryMin}+`
|
||||
: undefined,
|
||||
salary: salaryLabel,
|
||||
description: apiJob.description,
|
||||
requirements: reqs,
|
||||
postedAt: apiJob.createdAt,
|
||||
|
|
@ -587,11 +680,12 @@ export const profileApi = {
|
|||
// Backoffice URL - now uses runtime config
|
||||
|
||||
async function backofficeRequest<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
// Ensure config is loaded before making request
|
||||
await initConfig();
|
||||
// Token can be stored as 'auth_token' (from auth.ts login) or 'token' (legacy)
|
||||
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> = {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...options.headers as Record<string, string>,
|
||||
};
|
||||
|
||||
|
|
@ -727,9 +821,10 @@ export interface Conversation {
|
|||
lastMessageAt: string;
|
||||
participantName: string;
|
||||
participantAvatar?: string;
|
||||
unreadCount: number;
|
||||
unreadCount?: number;
|
||||
}
|
||||
|
||||
|
||||
export const chatApi = {
|
||||
listConversations: () => apiRequest<Conversation[]>("/api/v1/conversations"),
|
||||
listMessages: (conversationId: string) => apiRequest<Message[]>(`/api/v1/conversations/${conversationId}/messages`),
|
||||
|
|
@ -771,37 +866,8 @@ export const credentialsApi = {
|
|||
}),
|
||||
};
|
||||
|
||||
export const storageApi = {
|
||||
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);
|
||||
// Duplicate storageApi removed
|
||||
|
||||
// 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 ---
|
||||
export interface EmailTemplate {
|
||||
|
|
@ -903,3 +969,4 @@ export const locationsApi = {
|
|||
return res || [];
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ export interface RuntimeConfig {
|
|||
// Default values (fallback to build-time env or hardcoded defaults)
|
||||
const defaultConfig: RuntimeConfig = {
|
||||
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',
|
||||
scraperApiUrl: process.env.NEXT_PUBLIC_SCRAPER_API_URL || 'http://localhost:3003',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,115 +1,116 @@
|
|||
'use client';
|
||||
|
||||
import React, { createContext, useContext, useState, useCallback, ReactNode, useEffect } from 'react';
|
||||
import en from '@/i18n/en.json';
|
||||
import es from '@/i18n/es.json';
|
||||
import ptBR from '@/i18n/pt-BR.json';
|
||||
|
||||
type Locale = 'en' | 'es' | 'pt-BR';
|
||||
|
||||
interface I18nContextType {
|
||||
locale: Locale;
|
||||
setLocale: (locale: Locale) => void;
|
||||
t: (key: string, params?: Record<string, string | number>) => string;
|
||||
}
|
||||
|
||||
const dictionaries: Record<Locale, typeof en> = {
|
||||
en,
|
||||
es,
|
||||
'pt-BR': ptBR,
|
||||
};
|
||||
|
||||
const I18nContext = createContext<I18nContextType | null>(null);
|
||||
|
||||
const localeStorageKey = 'locale';
|
||||
|
||||
const normalizeLocale = (language: string): Locale => {
|
||||
if (language.startsWith('pt')) return 'pt-BR';
|
||||
if (language.startsWith('es')) return 'es';
|
||||
return 'en';
|
||||
};
|
||||
|
||||
const getInitialLocale = (): Locale => {
|
||||
if (typeof window === 'undefined') return 'en';
|
||||
const storedLocale = localStorage.getItem(localeStorageKey);
|
||||
if (storedLocale && storedLocale in dictionaries) {
|
||||
return storedLocale as Locale;
|
||||
}
|
||||
return normalizeLocale(navigator.language);
|
||||
};
|
||||
|
||||
export function I18nProvider({ children }: { children: ReactNode }) {
|
||||
const [locale, setLocaleState] = useState<Locale>(getInitialLocale);
|
||||
|
||||
const setLocale = (newLocale: Locale) => {
|
||||
console.log(`[I18n] Setting locale to: ${newLocale}`);
|
||||
setLocaleState(newLocale);
|
||||
};
|
||||
|
||||
const t = useCallback((key: string, params?: Record<string, string | number>): string => {
|
||||
const keys = key.split('.');
|
||||
let value: unknown = dictionaries[locale];
|
||||
|
||||
for (const k of keys) {
|
||||
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) {
|
||||
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
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const stored = localStorage.getItem(localeStorageKey);
|
||||
if (stored && stored in dictionaries && stored !== locale) {
|
||||
console.log('[I18n] Restoring locale from storage:', stored);
|
||||
setLocale(stored as Locale);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
localStorage.setItem(localeStorageKey, locale);
|
||||
document.documentElement.lang = locale;
|
||||
}, [locale]);
|
||||
|
||||
return (
|
||||
<I18nContext.Provider value={{ locale, setLocale, t }}>
|
||||
{children}
|
||||
</I18nContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useI18n() {
|
||||
const context = useContext(I18nContext);
|
||||
if (!context) {
|
||||
throw new Error('useI18n must be used within an I18nProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useTranslation() {
|
||||
const { t, locale, setLocale } = useI18n();
|
||||
return { t, locale, setLocale };
|
||||
}
|
||||
|
||||
export const locales: { code: Locale; name: string; flag: string }[] = [
|
||||
{ code: 'en', name: 'English', flag: '🇺🇸' },
|
||||
{ code: 'es', name: 'Español', flag: '🇪🇸' },
|
||||
{ code: 'pt-BR', name: 'Português', flag: '🇧🇷' },
|
||||
];
|
||||
'use client';
|
||||
|
||||
import React, { createContext, useContext, useState, useCallback, ReactNode, useEffect } from 'react';
|
||||
import en from '@/i18n/en.json';
|
||||
import es from '@/i18n/es.json';
|
||||
import ptBR from '@/i18n/pt-BR.json';
|
||||
|
||||
type Locale = 'en' | 'es' | 'pt-BR';
|
||||
|
||||
interface I18nContextType {
|
||||
locale: Locale;
|
||||
setLocale: (locale: Locale) => void;
|
||||
t: (key: string, params?: Record<string, string | number>) => string;
|
||||
}
|
||||
|
||||
const dictionaries: Record<Locale, typeof en> = {
|
||||
en,
|
||||
es,
|
||||
'pt-BR': ptBR,
|
||||
};
|
||||
|
||||
const I18nContext = createContext<I18nContextType | null>(null);
|
||||
|
||||
const localeStorageKey = 'locale';
|
||||
|
||||
const normalizeLocale = (language: string): Locale => {
|
||||
if (language.startsWith('pt')) return 'pt-BR';
|
||||
if (language.startsWith('es')) return 'es';
|
||||
return 'en';
|
||||
};
|
||||
|
||||
const getInitialLocale = (): Locale => {
|
||||
if (typeof window === 'undefined') return 'en';
|
||||
const storedLocale = localStorage.getItem(localeStorageKey);
|
||||
if (storedLocale && storedLocale in dictionaries) {
|
||||
return storedLocale as Locale;
|
||||
}
|
||||
// Default to English instead of browser language for consistency
|
||||
return 'en';
|
||||
};
|
||||
|
||||
export function I18nProvider({ children }: { children: ReactNode }) {
|
||||
// FIX: Initialize with 'en' to match Server-Side Rendering (SSR)
|
||||
// This prevents hydration mismatch errors.
|
||||
const [locale, setLocaleState] = useState<Locale>('en');
|
||||
|
||||
const setLocale = (newLocale: Locale) => {
|
||||
console.log(`[I18n] Setting locale to: ${newLocale}`);
|
||||
setLocaleState(newLocale);
|
||||
};
|
||||
|
||||
const t = useCallback((key: string, params?: Record<string, string | number>) => {
|
||||
const keys = key.split('.');
|
||||
let value: unknown = dictionaries[locale];
|
||||
|
||||
for (const k of keys) {
|
||||
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) {
|
||||
return Object.entries(params).reduce(
|
||||
(str, [paramKey, paramValue]) => str.replace(`{${paramKey}}`, String(paramValue)),
|
||||
value
|
||||
);
|
||||
}
|
||||
|
||||
return value;
|
||||
}, [locale]);
|
||||
|
||||
// Sync from localStorage on mount (Client-side only)
|
||||
useEffect(() => {
|
||||
const stored = getInitialLocale();
|
||||
if (stored !== 'en') {
|
||||
console.log('[I18n] Restoring locale from storage on client:', stored);
|
||||
setLocaleState(stored);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
localStorage.setItem(localeStorageKey, locale);
|
||||
document.documentElement.lang = locale;
|
||||
}, [locale]);
|
||||
|
||||
return (
|
||||
<I18nContext.Provider value={{ locale, setLocale, t }}>
|
||||
{children}
|
||||
</I18nContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useI18n() {
|
||||
const context = useContext(I18nContext);
|
||||
if (!context) {
|
||||
throw new Error('useI18n must be used within an I18nProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useTranslation() {
|
||||
const { t, locale, setLocale } = useI18n();
|
||||
return { t, locale, setLocale };
|
||||
}
|
||||
|
||||
export const locales: { code: Locale; name: string; flag: string }[] = [
|
||||
{ code: 'en', name: 'English', 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