feat(frontend): complete i18n implementation and set default to pt-BR

This commit is contained in:
Tiago Yamamoto 2025-12-15 15:10:36 -03:00
parent dd18c526e3
commit 4693bc5737
9 changed files with 99 additions and 70 deletions

View file

@ -10,8 +10,8 @@ import "./globals.css"
import { Suspense } from "react"
export const metadata: Metadata = {
title: "GoHorseJobs - Find your next opportunity",
description: "Connecting candidates and companies quickly and directly",
title: "GoHorse Jobs - Encontre sua próxima oportunidade",
description: "Conectamos candidatos e empresas de forma rápida e direta",
generator: "v0.app",
icons: {
icon: "/logohorse.png",
@ -26,7 +26,7 @@ export default function RootLayout({
children: React.ReactNode
}>) {
return (
<html lang="en">
<html lang="pt-BR">
<body className={`font-sans ${GeistSans.variable} ${GeistMono.variable} antialiased`}>
<I18nProvider>
<NotificationProvider>

View file

@ -34,10 +34,10 @@ export default function HomePage() {
const mappedJobs: Job[] = data.data.map((j: any) => ({
id: String(j.id),
title: j.title,
company: j.companyName || 'Empresa Confidencial',
location: j.location || 'Remoto',
company: j.companyName || t('jobs.confidential'),
location: j.location || t('workMode.remote'),
type: j.employmentType || 'full-time',
salary: j.salaryMin ? `R$ ${j.salaryMin}` : 'A combinar',
salary: j.salaryMin ? `R$ ${j.salaryMin}` : t('jobs.salary.negotiable'),
description: j.description,
requirements: j.requirements || [],
postedAt: j.createdAt,
@ -249,7 +249,7 @@ export default function HomePage() {
</section>
{/* CTA Section */}
<section className="py-[322p ] py-2.5">
<section className="py-20">
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
<Card className="overflow-hidden bg-primary">
<div className="grid lg:grid-cols-2 gap-0">

View file

@ -15,11 +15,13 @@ import { PageSkeleton } from "@/components/loading-skeletons"
import { mockJobs } from "@/lib/mock-data"
import { jobsApi, transformApiJobToFrontend } from "@/lib/api"
import { useDebounce } from "@/hooks/use-utils"
import { useTranslation } from "@/lib/i18n"
import { Search, MapPin, Briefcase, SlidersHorizontal, X, ArrowUpDown } from "lucide-react"
import { motion, AnimatePresence } from "framer-motion"
import type { Job } from "@/lib/types"
function JobsContent() {
const { t } = useTranslation()
const [jobs, setJobs] = useState<Job[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@ -69,7 +71,7 @@ function JobsContent() {
} catch (err) {
console.error("Erro ao buscar vagas", err)
if (isMounted) {
setError("Não foi possível carregar as vagas agora. Exibindo exemplos.")
setError(t('jobs.error'))
setJobs(mockJobs)
}
} finally {
@ -171,7 +173,7 @@ function JobsContent() {
animate={{ opacity: 1, y: 0 }}
className="text-4xl md:text-5xl font-bold text-foreground mb-4 text-balance"
>
Encontre sua próxima oportunidade
{t('jobs.title')}
</motion.h1>
<motion.p
initial={{ opacity: 0, y: 20 }}
@ -179,7 +181,7 @@ function JobsContent() {
transition={{ delay: 0.1 }}
className="text-lg text-muted-foreground text-pretty"
>
{loading ? "Carregando vagas..." : `${jobs.length} vagas disponíveis nas melhores empresas`}
{loading ? t('jobs.loading') : t('jobs.subtitle', { count: jobs.length })}
</motion.p>
</div>
</div>
@ -194,7 +196,7 @@ function JobsContent() {
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Buscar por cargo, empresa ou descrição..."
placeholder={t('jobs.search')}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 h-12"
@ -208,7 +210,7 @@ function JobsContent() {
className="h-12 gap-2"
>
<SlidersHorizontal className="h-4 w-4" />
Filtros
{t('jobs.filters.toggle')}
{hasActiveFilters && (
<Badge variant="secondary" className="ml-1 px-1 py-0 text-xs">
!
@ -233,10 +235,10 @@ function JobsContent() {
<Select value={locationFilter} onValueChange={setLocationFilter}>
<SelectTrigger>
<MapPin className="h-4 w-4 mr-2" />
<SelectValue placeholder="Localização" />
<SelectValue placeholder={t('jobs.filters.location')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todas as localizações</SelectItem>
<SelectItem value="all">{t('jobs.filters.all')}</SelectItem>
{uniqueLocations.map((location) => (
<SelectItem key={location} value={location}>
{location}
@ -248,10 +250,10 @@ function JobsContent() {
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger>
<Briefcase className="h-4 w-4 mr-2" />
<SelectValue placeholder="Tipo" />
<SelectValue placeholder={t('jobs.filters.type')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos os tipos</SelectItem>
<SelectItem value="all">{t('jobs.filters.all')}</SelectItem>
{uniqueTypes.map((type) => (
<SelectItem key={type} value={type}>
{type === "full-time" ? "Tempo integral" :
@ -265,15 +267,15 @@ function JobsContent() {
<Select value={workModeFilter} onValueChange={setWorkModeFilter}>
<SelectTrigger>
<MapPin className="h-4 w-4 mr-2" />
<SelectValue placeholder="Modalidade" />
<SelectValue placeholder={t('jobs.filters.workMode')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todas as modalidades</SelectItem>
<SelectItem value="all">{t('jobs.filters.all')}</SelectItem>
{uniqueWorkModes.map((mode) => (
<SelectItem key={mode} value={mode}>
{mode === "remote" ? "Remoto" :
mode === "hybrid" ? "Híbrido" :
mode === "onsite" ? "Presencial" : mode}
{mode === "remote" ? t('workMode.remote') :
mode === "hybrid" ? t('workMode.hybrid') :
mode === "onsite" ? t('workMode.onsite') : mode}
</SelectItem>
))}
</SelectContent>
@ -282,13 +284,13 @@ function JobsContent() {
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger>
<ArrowUpDown className="h-4 w-4 mr-2" />
<SelectValue placeholder="Ordenar por" />
<SelectValue placeholder={t('jobs.filters.order')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="recent">Mais recentes</SelectItem>
<SelectItem value="title">Título</SelectItem>
<SelectItem value="company">Empresa</SelectItem>
<SelectItem value="location">Localização</SelectItem>
<SelectItem value="recent">{t('jobs.sort.recent')}</SelectItem>
<SelectItem value="title">{t('jobs.sort.title')}</SelectItem>
<SelectItem value="company">{t('jobs.sort.company')}</SelectItem>
<SelectItem value="location">{t('jobs.sort.location')}</SelectItem>
</SelectContent>
</Select>
@ -299,7 +301,7 @@ function JobsContent() {
className="gap-2"
>
<X className="h-4 w-4" />
Limpar
{t('jobs.reset')}
</Button>
)}
</div>
@ -312,8 +314,11 @@ function JobsContent() {
{/* Results Summary */}
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>
{filteredAndSortedJobs.length} vaga{filteredAndSortedJobs.length !== 1 ? 's' : ''} encontrada{filteredAndSortedJobs.length !== 1 ? 's' : ''}
{totalPages > 1 && ` (Página ${currentPage} de ${totalPages})`}
{t('jobs.pagination.showing', {
from: (currentPage - 1) * ITEMS_PER_PAGE + 1,
to: Math.min(currentPage * ITEMS_PER_PAGE, filteredAndSortedJobs.length),
total: filteredAndSortedJobs.length
})}
</span>
{hasActiveFilters && (
<div className="flex items-center gap-2">
@ -370,7 +375,7 @@ function JobsContent() {
)}
{loading ? (
<div className="text-center text-muted-foreground">Carregando vagas...</div>
<div className="text-center text-muted-foreground">{t('jobs.loading')}</div>
) : paginatedJobs.length > 0 ? (
<div className="space-y-8">
<motion.div layout className="grid gap-6">
@ -398,17 +403,17 @@ function JobsContent() {
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
>
Anterior
{t('jobs.pagination.previous')}
</Button>
<div className="text-sm text-muted-foreground px-4">
Página {currentPage} de {totalPages}
{currentPage} / {totalPages}
</div>
<Button
variant="outline"
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
>
Próxima
{t('jobs.pagination.next')}
</Button>
</div>
)}
@ -421,9 +426,9 @@ function JobsContent() {
>
<div className="max-w-md mx-auto">
<Search className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-semibold mb-2">Nenhuma vaga encontrada</h3>
<h3 className="text-lg font-semibold mb-2">{t('jobs.noResults.title')}</h3>
<p className="text-muted-foreground mb-4">
Não encontramos vagas que correspondam aos seus critérios de busca.
{t('jobs.noResults.desc')}
</p>
<Button
variant="outline"
@ -431,7 +436,7 @@ function JobsContent() {
className="gap-2"
>
<X className="h-4 w-4" />
Limpar filtros
{t('jobs.resetFilters')}
</Button>
</div>
</motion.div>

View file

@ -1,6 +1,10 @@
import Link from "next/link"
import { useTranslation } from "@/lib/i18n"
export function Footer() {
const { t } = useTranslation()
const currentYear = new Date().getFullYear()
return (
<footer className="border-t border-border bg-muted/30 mt-24">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 py-12">
@ -10,12 +14,12 @@ export function Footer() {
<span className="text-xl font-semibold">GoHorse Jobs</span>
</div>
<p className="text-muted-foreground text-sm leading-relaxed max-w-md">
Conectamos candidatos e empresas de forma rápida e direta. Encontre sua próxima oportunidade profissional em tecnologia.
{t('home.hero.subtitle')}
</p>
</div>
<div>
<h3 className="font-semibold mb-4">Vagas por Tecnologia</h3>
<h3 className="font-semibold mb-4">{t('footer.jobsByTech')}</h3>
<ul className="space-y-2 text-sm text-muted-foreground">
<li>
<Link href="/vagas?tech=python" className="hover:text-foreground transition-colors">
@ -34,44 +38,44 @@ export function Footer() {
</li>
<li>
<Link href="/vagas?type=remoto" className="hover:text-foreground transition-colors">
Vagas Remotas
{t('workMode.remote')}
</Link>
</li>
</ul>
</div>
<div>
<h3 className="font-semibold mb-4">Empresa</h3>
<h3 className="font-semibold mb-4">{t('footer.company')}</h3>
<ul className="space-y-2 text-sm text-muted-foreground">
<li>
<Link href="/sobre" className="hover:text-foreground transition-colors">
Sobre
{t('footer.about')}
</Link>
</li>
<li>
<Link href="/contato" className="hover:text-foreground transition-colors">
Contato
{t('nav.contact')}
</Link>
</li>
<li>
<Link href="/vagas" className="hover:text-foreground transition-colors">
Todas as Vagas
{t('nav.jobs')}
</Link>
</li>
</ul>
</div>
<div>
<h3 className="font-semibold mb-4">Legal</h3>
<h3 className="font-semibold mb-4">{t('footer.legal')}</h3>
<ul className="space-y-2 text-sm text-muted-foreground">
<li>
<Link href="/privacidade" className="hover:text-foreground transition-colors">
Política de Privacidade
{t('footer.privacy')}
</Link>
</li>
<li>
<Link href="/termos" className="hover:text-foreground transition-colors">
Termos de Uso
{t('footer.terms')}
</Link>
</li>
</ul>
@ -79,7 +83,7 @@ export function Footer() {
</div>
<div className="mt-12 pt-8 border-t border-border text-center text-sm text-muted-foreground">
<p>&copy; 2025 GoHorse Jobs. Todos os direitos reservados.</p>
<p>{t('footer.copyright', { year: currentYear })}</p>
</div>
</div>
</footer>

View file

@ -20,12 +20,14 @@ import Link from "next/link";
import { motion } from "framer-motion";
import { useState } from "react";
import { useNotify } from "@/contexts/notification-context";
import { useTranslation } from "@/lib/i18n";
interface JobCardProps {
job: Job;
}
export function JobCard({ job }: JobCardProps) {
const { t } = useTranslation();
const [isFavorited, setIsFavorited] = useState(false);
const notify = useNotify();
@ -35,21 +37,15 @@ export function JobCard({ job }: JobCardProps) {
const diffInMs = now.getTime() - date.getTime();
const diffInDays = Math.floor(diffInMs / (1000 * 60 * 60 * 24));
if (diffInDays === 0) return "Hoje";
if (diffInDays === 1) return "Ontem";
if (diffInDays < 7) return `${diffInDays} dias atrás`;
if (diffInDays < 30) return `${Math.floor(diffInDays / 7)} semanas atrás`;
return `${Math.floor(diffInDays / 30)} meses atrás`;
if (diffInDays === 0) return t('jobs.posted.today');
if (diffInDays === 1) return t('jobs.posted.yesterday');
if (diffInDays < 7) return t('jobs.posted.daysAgo', { count: diffInDays });
if (diffInDays < 30) return t('jobs.posted.weeksAgo', { count: Math.floor(diffInDays / 7) });
return t('jobs.posted.monthsAgo', { count: Math.floor(diffInDays / 30) });
};
const getTypeLabel = (type: string) => {
const typeLabels: { [key: string]: string } = {
"full-time": "Tempo integral",
"part-time": "Meio período",
contract: "Contrato",
Remoto: "Remoto",
};
return typeLabels[type] || type;
return t(`jobs.types.${type}`) !== `jobs.types.${type}` ? t(`jobs.types.${type}`) : type;
};
const getTypeBadgeVariant = (type: string) => {
@ -80,11 +76,11 @@ export function JobCard({ job }: JobCardProps) {
setIsFavorited(!isFavorited);
if (!isFavorited) {
notify.info(
"Vaga favoritada!",
`${job.title} foi adicionada aos seus favoritos.`,
t('jobs.favorites.added.title'),
t('jobs.favorites.added.desc', { title: job.title }),
{
actionUrl: "/dashboard/candidato/favoritos",
actionLabel: "Ver favoritos",
actionLabel: t('jobs.favorites.action'),
}
);
}
@ -183,7 +179,7 @@ export function JobCard({ job }: JobCardProps) {
variant="outline"
className="text-xs text-muted-foreground"
>
+{job.requirements.length - 3} mais
{t('jobs.requirements.more', { count: job.requirements.length - 3 })}
</Badge>
)}
</div>
@ -200,11 +196,11 @@ export function JobCard({ job }: JobCardProps) {
<div className="flex gap-2 w-full">
<Link href={`/vagas/${job.id}`} className="flex-1">
<Button variant="outline" className="w-full cursor-pointer">
Ver detalhes
{t('jobs.card.viewDetails')}
</Button>
</Link>
<Link href={`/vagas/${job.id}/candidatura`} className="flex-1">
<Button className="w-full cursor-pointer">Candidatar-se</Button>
<Button className="w-full cursor-pointer">{t('jobs.card.apply')}</Button>
</Link>
</div>
</CardFooter>

View file

@ -114,13 +114,13 @@ export function Navbar() {
<Link href="/login" onClick={() => setIsOpen(false)}>
<Button variant="ghost" className="w-full justify-start gap-2">
<LogIn className="w-4 h-4" />
Entrar
{t('nav.login')}
</Button>
</Link>
<Link href="/cadastro/candidato" onClick={() => setIsOpen(false)}>
<Button className="w-full justify-start gap-2">
<User className="w-4 h-4" />
Cadastrar
{t('nav.register')}
</Button>
</Link>
</>

View file

@ -39,8 +39,20 @@
"title": "Find your next opportunity",
"subtitle": "{count} jobs available at the best companies",
"search": "Search jobs by title, company...",
"filters": { "all": "All", "onsite": "On-site", "hybrid": "Hybrid", "remote": "Remote", "workMode": "Work Mode" },
"filters": { "all": "All", "toggle": "Filters", "location": "Location", "type": "Type", "workMode": "Work Mode", "order": "Sort by" },
"sort": { "recent": "Most recent", "title": "Title", "company": "Company", "location": "Location" },
"reset": "Clear",
"resetFilters": "Clear filters",
"noResults": { "title": "No jobs found", "desc": "We couldn't find any jobs matching your criteria." },
"loading": "Loading jobs...",
"error": "Could not load jobs right now. Showing examples.",
"card": { "viewDetails": "View details", "apply": "Apply now", "perMonth": "/month", "postedAgo": "Posted {time} ago" },
"types": { "full-time": "Full Time", "part-time": "Part Time", "contract": "Contract", "freelance": "Freelance" },
"confidential": "Confidential Company",
"salary": { "negotiable": "Negotiable" },
"posted": { "today": "Today", "yesterday": "Yesterday", "daysAgo": "{count} days ago", "weeksAgo": "{count} weeks ago", "monthsAgo": "{count} months ago" },
"favorites": { "added": { "title": "Job favorited!", "desc": "{title} added to your favorites." }, "action": "View favorites" },
"requirements": { "more": "+{count} more" },
"pagination": { "previous": "Previous", "next": "Next", "showing": "Showing {from} to {to} of {total} jobs" }
},
"workMode": { "onsite": "On-site", "hybrid": "Hybrid", "remote": "Remote" },

View file

@ -39,8 +39,20 @@
"title": "Encontre sua próxima oportunidade",
"subtitle": "{count} vagas disponíveis nas melhores empresas",
"search": "Buscar vagas por título, empresa...",
"filters": { "all": "Todas", "onsite": "Presencial", "hybrid": "Híbrido", "remote": "Remoto", "workMode": "Modalidade" },
"filters": { "all": "Todas", "toggle": "Filtros", "location": "Localização", "type": "Tipo", "workMode": "Modalidade", "order": "Ordenar por" },
"sort": { "recent": "Mais recentes", "title": "Título", "company": "Empresa", "location": "Localização" },
"reset": "Limpar",
"resetFilters": "Limpar filtros",
"noResults": { "title": "Nenhuma vaga encontrada", "desc": "Não encontramos vagas que correspondam aos seus critérios de busca." },
"loading": "Carregando vagas...",
"error": "Não foi possível carregar as vagas agora. Exibindo exemplos.",
"card": { "viewDetails": "Ver detalhes", "apply": "Candidatar-se", "perMonth": "/mês", "postedAgo": "Publicada há {time}" },
"types": { "full-time": "Tempo Integral", "part-time": "Meio Período", "contract": "Contrato", "freelance": "Freelance" },
"confidential": "Empresa Confidencial",
"salary": { "negotiable": "A combinar" },
"posted": { "today": "Hoje", "yesterday": "Ontem", "daysAgo": "{count} dias atrás", "weeksAgo": "{count} semanas atrás", "monthsAgo": "{count} meses atrás" },
"favorites": { "added": { "title": "Vaga favoritada!", "desc": "{title} foi adicionada aos seus favoritos." }, "action": "Ver favoritos" },
"requirements": { "more": "+{count} mais" },
"pagination": { "previous": "Anterior", "next": "Próximo", "showing": "Mostrando {from} a {to} de {total} vagas" }
},
"workMode": { "onsite": "Presencial", "hybrid": "Híbrido", "remote": "Remoto" },

View file

@ -22,7 +22,7 @@ const dictionaries: Record<Locale, typeof en> = {
const I18nContext = createContext<I18nContextType | null>(null);
export function I18nProvider({ children }: { children: ReactNode }) {
const [locale, setLocale] = useState<Locale>('en');
const [locale, setLocale] = useState<Locale>('pt-BR');
const t = useCallback((key: string, params?: Record<string, string | number>): string => {
const keys = key.split('.');