From 8070492e485facf85ecc4ccc32a2da4457469b0c Mon Sep 17 00:00:00 2001 From: GoHorse Deploy Date: Tue, 27 Jan 2026 01:26:10 +0000 Subject: [PATCH] feat: New Home layout, Navbar and I18n improvements --- backend/start_dev.sh | 5 + frontend/src/app/page.tsx | 658 +++++------------- frontend/src/components/home-search.tsx | 117 ++++ frontend/src/components/language-switcher.tsx | 100 +-- frontend/src/components/navbar.tsx | 313 ++++----- frontend/src/lib/i18n.tsx | 231 +++--- 6 files changed, 605 insertions(+), 819 deletions(-) create mode 100755 backend/start_dev.sh create mode 100644 frontend/src/components/home-search.tsx diff --git a/backend/start_dev.sh b/backend/start_dev.sh new file mode 100755 index 0000000..e75c455 --- /dev/null +++ b/backend/start_dev.sh @@ -0,0 +1,5 @@ +#!/bin/sh +export GOPATH=/go +export PATH=/go/bin:/usr/local/go/bin:$PATH +go install github.com/air-verse/air@v1.60.0 +air diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index e767942..7bbbbb9 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,499 +1,159 @@ -"use client" - -import { Navbar } from "@/components/navbar" -import { Footer } from "@/components/footer" -import { JobCard } from "@/components/job-card" -import { Button } from "@/components/ui/button" -import { Card, CardContent } from "@/components/ui/card" -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" -import { mockJobs, mockTestimonials } from "@/lib/mock-data" -import { FileText, CheckCircle, ArrowRight, Building2, Users, ChevronLeft, ChevronRight, Eye } from "lucide-react" -import Link from "next/link" -import { motion } from "framer-motion" -import Image from "next/image" -import { useTranslation } from "@/lib/i18n" -import { useConfig } from "@/contexts/ConfigContext" - -import { useState, useEffect } from "react" -import type { Job } from "@/lib/types" - -export default function HomePage() { - const { t } = useTranslation() - const config = useConfig() - const [featuredJobs, setFeaturedJobs] = useState(mockJobs.slice(0, 31)) - const [loading, setLoading] = useState(true) - const [featuredIndex, setFeaturedIndex] = useState(0) - const [moreJobsIndex, setMoreJobsIndex] = useState(0) - const [openFilters, setOpenFilters] = useState({ - contractType: false, - workMode: false, - location: false, - salary: false - }) - - const toggleFilter = (filterName: keyof typeof openFilters) => { - setOpenFilters(prev => ({ - contractType: false, - workMode: false, - location: false, - salary: false, - [filterName]: !prev[filterName] - })) - } - - useEffect(() => { - async function fetchFeaturedJobs() { - try { - const apiBase = config.apiUrl - console.log("[DEBUG] API Base URL:", apiBase) - - const mapJobs = (jobs: any[]): Job[] => - jobs.map((j: any) => ({ - id: String(j.id), - title: j.title, - company: j.companyName || t("jobs.confidential"), - location: j.location || t("workMode.remote"), - type: j.employmentType || "full-time", - salary: j.salaryMin ? `R$ ${j.salaryMin}` : t("jobs.salary.negotiable"), - description: j.description, - requirements: j.requirements || [], - postedAt: j.createdAt, - isFeatured: j.isFeatured - })) - - console.log("[DEBUG] Fetching featured jobs from:", `${apiBase}/api/v1/jobs?featured=true&limit=31`) - const featuredRes = await fetch(`${apiBase}/api/v1/jobs?featured=true&limit=31`) - console.log("[DEBUG] Featured response status:", featuredRes.status) - - if (!featuredRes.ok) throw new Error("Failed to fetch featured jobs") - const featuredData = await featuredRes.json() - console.log("[DEBUG] Featured data from API:", featuredData) - - const featuredList = featuredData.data ? mapJobs(featuredData.data) : [] - console.log("[DEBUG] Mapped featured jobs:", featuredList.length, "jobs") - - if (featuredList.length >= 24) { - console.log("[DEBUG] Using featured/API jobs") - setFeaturedJobs(featuredList.slice(0, 31)) - return - } - - console.log("[DEBUG] Fetching fallback jobs from:", `${apiBase}/api/v1/jobs?limit=31`) - const fallbackRes = await fetch(`${apiBase}/api/v1/jobs?limit=31`) - console.log("[DEBUG] Fallback response status:", fallbackRes.status) - - if (!fallbackRes.ok) throw new Error("Failed to fetch fallback jobs") - const fallbackData = await fallbackRes.json() - console.log("[DEBUG] Fallback data from API:", fallbackData) - - const fallbackList = fallbackData.data ? mapJobs(fallbackData.data) : [] - console.log("[DEBUG] Mapped fallback jobs:", fallbackList.length, "jobs") - - const combined = [...featuredList, ...fallbackList].slice(0, 31) - console.log("[DEBUG] Combined jobs:", combined.length, "jobs") - - if (combined.length >= 24) { - console.log("[DEBUG] Using combined jobs") - setFeaturedJobs(combined) - } - } catch (error) { - console.error("[DEBUG] ❌ Error fetching featured jobs:", error) - } finally { - setLoading(false) - } - } - fetchFeaturedJobs() - }, []) - - return ( -
- - -
- {/* Hero Section */} -
- {/* Mobile Background */} -
- Background - {/* Black overlay with 20% opacity */} -
-
- {/* Desktop Background */} -
- Background -
-
-
-
- - {t('home.hero.title')}
{t('home.hero.titleLine2')} -
- - {t('home.hero.subtitle')} - - - - - - -
-
-
-
- - {/* Search Bar Section */} -
-
-
-
- - - - -
- -
- {/* Contract Type - Static Expanded */} -
-
- {t('home.search.contractType')} -
-
- - - -
-
- - {/* Work Mode - Static Expanded */} -
-
- {t('home.search.workMode')} -
-
- - - -
-
- - {/* Location - Static Expanded */} -
-
- {t('home.search.location')} -
-
- -
-
- - {/* Salary - Static Expanded */} -
-
- {t('home.search.salary')} -
-
- -
-
- - {/* Filter Button - Unified */} - -
-
-
-
- - {/* Featured Jobs */} -
-
-
-

{t('home.featuredJobs.title')}

-
- -
- {(featuredJobs.length >= 4 ? featuredJobs.slice(0, 4) : mockJobs.slice(0, 4)) - .map((job, index) => { - const dates = ['02/06', '05/06', '08/06', '11/06']; - const randomDate = dates[index % dates.length]; - const levels = [t('home.levels.mid'), t('home.levels.junior'), t('home.levels.senior'), t('home.levels.mid')]; - const level = levels[index % levels.length]; - const statusLabels = [t('workMode.remote'), t('workMode.hybrid'), t('workMode.onsite'), t('workMode.remote')]; - const statusLabel = statusLabels[index % statusLabels.length]; - return ( - - -
- {/* Header */} -
-
-
- -
- {job.company} -
- - {statusLabel} - -
- - {/* Content */} -
-

{job.title}

-
- Job Illustration -
-
-
- - {/* Footer Section with Separator */} -
-
-

- {level} - - {job.location} -

-
- -
- - - - -
-
-
-
- ) - })} -
-
-
- - {/* More Jobs Section */} -
-
-
-

{t('home.moreJobs.title')}

- - - -
- -
- {mockJobs.slice(0, 8) - .map((job, index) => { - const colors = [ - 'bg-cyan-500', 'bg-blue-500', 'bg-indigo-500', 'bg-gray-500', - 'bg-teal-500', 'bg-sky-500', 'bg-orange-500', 'bg-purple-500' - ]; - const bgColor = colors[index % colors.length]; - const icons = ['💻', '🎨', '📊', '🚀', '⚙️', '🔧', '📱', '🎯']; - const icon = icons[index % icons.length]; - - const descriptions = [ - 'Buscamos um Senior Full Stack para liderar soluções robustas e escaláveis de ponta a ponta.', - 'O UX/UI Designer ideal para transformar ideias em experiências visuais incríveis.', - 'Faça parte do time como Data Engineer e construa pipelines de dados inteligentes e eficientes.', - 'Procuramos um Product Manager para liderar produtos inovadores do conceito ao lançamento.', - 'Oportunidade para Mobile Developer criar aplicativos modernos e de alto desempenho.', - 'Junte-se a nós como DevOps Engineer e automatize infraestruturas em ambientes de nuvem.', - 'Vaga para Backend Developer focado em performance, segurança e APIs escaláveis.', - 'Buscamos um QA Analyst atento aos detalhes para garantir a máxima qualidade dos produtos.' - ]; - const description = descriptions[index % descriptions.length]; - - return ( - - - - {/* Cabeçalho com logo e seta */} -
-
-
- {icon} -
-
-

{job.title}

-

{job.company}

-

{description}

-
-
- -
- - {/* Rodapé com botões */} -
-
- - - - -
-
-
-
-
- ) - })} -
-
-
- - {/* CTA Section */} -
-
-
- {/* Image Layer: Single Image with Seamless Gradient Overlay */} -
- Woman with Notebook - {/* - Seamless Blend Gradient: - Starts solid gray-900 (matching, container) on left. - Fades gradually to transparent on right. - This "dyes" the dark background of the photo to match the container. - */} -
-
- -
- {/* Text Content */} -
-

- {t('home.cta.title')} -

-

- {t('home.cta.subtitle')} -

- -
-
-
-
-
-
- -
-
- ) -} - -function FilterIcon() { - return ( - - - - - - ); -} +"use client" + +import { Button } from "@/components/ui/button" +import { mockJobs } from "@/lib/mock-data" +import Link from "next/link" +import { ArrowRight, CheckCircle2 } from 'lucide-react' +import Image from "next/image" +import { motion } from "framer-motion" +import { useTranslation } from "@/lib/i18n" +import { Navbar } from "@/components/navbar" +import { Footer } from "@/components/footer" +import { HomeSearch } from "@/components/home-search" +import { JobCard } from "@/components/job-card" + +export default function Home() { + const { t } = useTranslation() + + return ( +
+ + +
+ {/* Hero Section */} +
+ {/* Background Image with Overlay */} +
+ Background +
+
+ +
+
+ + Encontre a Vaga de TI
+ dos Seus Sonhos. +
+ + + Conectamos você com as melhores empresas e techs. + + + + + + + +
+
+
+ + {/* Search Section */} +
+
+ +
+
+ + {/* Latest Jobs Section */} +
+
+

+ Últimas Vagas Cadastradas +

+ +
+ {mockJobs.slice(0, 4).map((job, index) => ( + + ))} +
+
+
+ + {/* More Jobs Section */} +
+
+
+

+ Mais Vagas +

+ + + +
+ +
+ {mockJobs.slice(0, 8).map((job, index) => ( + + ))} +
+
+
+ + + {/* Bottom CTA Section */} +
+
+
+ + {/* Content */} +
+

+ Milhares de oportunidades
esperam você. +

+

+ Conecte cargos, talentos, tomada de ações de vagas. +

+ + + +
+ + {/* Image Background for CTA */} +
+
+ Professional + {/* Gradient Overlay to blend with dark background */} +
+
+
+
+
+
+
+ +
+
+ ) +} diff --git a/frontend/src/components/home-search.tsx b/frontend/src/components/home-search.tsx new file mode 100644 index 0000000..2f8abd7 --- /dev/null +++ b/frontend/src/components/home-search.tsx @@ -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 ( +
+
+
+ + setSearchTerm(e.target.value)} + /> +
+
+ +
+ {/* Contract Type */} +
+ + +
+ + {/* Work Mode */} +
+ + +
+ + {/* Location */} +
+ +
+ setLocation(e.target.value)} + /> +
+
+ + {/* Salary */} +
+ +
+ setSalary(e.target.value)} + /> +
+
+ + {/* Search Button */} +
+ +
+
+
+ ) +} diff --git a/frontend/src/components/language-switcher.tsx b/frontend/src/components/language-switcher.tsx index 58234ff..41e55f0 100644 --- a/frontend/src/components/language-switcher.tsx +++ b/frontend/src/components/language-switcher.tsx @@ -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 ( - - - - - - {locales.map((l) => ( - { - 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" - > - {l.flag} - {l.name} - {locale === l.code && } - - ))} - - - ); -} +"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 ( + + + + + + {locales.map((l) => ( + { + 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" + > + {l.flag} + {l.name} + {locale === l.code && } + + ))} + + + ); +} diff --git a/frontend/src/components/navbar.tsx b/frontend/src/components/navbar.tsx index 1e66607..b9f3b73 100644 --- a/frontend/src/components/navbar.tsx +++ b/frontend/src/components/navbar.tsx @@ -1,155 +1,158 @@ -"use client" - -import { useState } from "react" -import Link from "next/link" -import Image from "next/image" -import { Button } from "@/components/ui/button" -import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" -import { Menu, User, LogIn, Building2, UserPlus } from "lucide-react" -import { getCurrentUser } from "@/lib/auth" -import { useTranslation } from "@/lib/i18n" -import { LanguageSwitcher } from "@/components/language-switcher" - -export function Navbar() { - const [isOpen, setIsOpen] = useState(false) - const user = getCurrentUser() - const { t } = useTranslation() - - const navigationItems = [ - { href: "/jobs", label: t('nav.jobs') }, - { href: "/companies", label: t('footer.empresas') }, - { href: "/blog", label: t('footer.blog') }, - ] - - return ( - - ) -} +"use client" + +import { useState } from "react" +import Link from "next/link" +import Image from "next/image" +import { Button } from "@/components/ui/button" +import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Menu, User, LogIn, Building2, UserPlus } from "lucide-react" +import { getCurrentUser } from "@/lib/auth" +import { useTranslation } from "@/lib/i18n" +import { LanguageSwitcher } from "@/components/language-switcher" + +export function Navbar() { + const [isOpen, setIsOpen] = useState(false) + const user = getCurrentUser() + const { t } = useTranslation() + + const navigationItems = [ + { href: "/jobs", label: t('nav.jobs') }, + { href: "/companies", label: t('footer.empresas') }, + { href: "/blog", label: t('footer.blog') }, + ] + + return ( + + ) +} diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 993a3c1..2ce191f 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -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; -} - -const dictionaries: Record = { - en, - es, - 'pt-BR': ptBR, -}; - -const I18nContext = createContext(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(getInitialLocale); - - const setLocale = (newLocale: Locale) => { - console.log(`[I18n] Setting locale to: ${newLocale}`); - setLocaleState(newLocale); - }; - - const t = useCallback((key: string, params?: Record): 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)[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 ( - - {children} - - ); -} - -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; +} + +const dictionaries: Record = { + en, + es, + 'pt-BR': ptBR, +}; + +const I18nContext = createContext(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('en'); + + const setLocale = (newLocale: Locale) => { + console.log(`[I18n] Setting locale to: ${newLocale}`); + setLocaleState(newLocale); + }; + + const t = useCallback((key: string, params?: Record) => { + 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)[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 ( + + {children} + + ); +} + +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: '🇧🇷' }, +];