feat: SEO optimization and dynamic jobs API integration

Backend:
- Add Swagger annotations to all job handlers (GET, POST, PUT, DELETE)
- Clean up job handler code

Frontend:
- Expand api.ts with ApiJob types, pagination, and transform function
- Update footer with 'Vagas por Tecnologia' SEO links
- Add robots.txt with crawler directives
- Add sitemap.xml with main pages and job URLs
- Change branding to GoHorse Jobs
This commit is contained in:
Tiago Yamamoto 2025-12-14 09:16:44 -03:00
parent 8856357acd
commit a4abcf8e05
12 changed files with 284 additions and 38 deletions

View file

@ -17,6 +17,18 @@ func NewJobHandler(service *services.JobService) *JobHandler {
return &JobHandler{Service: service}
}
// GetJobs godoc
// @Summary List all jobs
// @Description Get a paginated list of job postings with optional filters
// @Tags Jobs
// @Accept json
// @Produce json
// @Param page query int false "Page number (default: 1)"
// @Param limit query int false "Items per page (default: 10, max: 100)"
// @Param companyId query int false "Filter by company ID"
// @Success 200 {object} dto.PaginatedResponse
// @Failure 500 {string} string "Internal Server Error"
// @Router /jobs [get]
func (h *JobHandler) GetJobs(w http.ResponseWriter, r *http.Request) {
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
@ -51,6 +63,17 @@ func (h *JobHandler) GetJobs(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(response)
}
// CreateJob godoc
// @Summary Create a new job
// @Description Create a new job posting
// @Tags Jobs
// @Accept json
// @Produce json
// @Param job body dto.CreateJobRequest true "Job data"
// @Success 201 {object} models.Job
// @Failure 400 {string} string "Bad Request"
// @Failure 500 {string} string "Internal Server Error"
// @Router /jobs [post]
func (h *JobHandler) CreateJob(w http.ResponseWriter, r *http.Request) {
var req dto.CreateJobRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@ -71,29 +94,19 @@ func (h *JobHandler) CreateJob(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(job)
}
// GetJobByID godoc
// @Summary Get job by ID
// @Description Get a single job posting by its ID
// @Tags Jobs
// @Accept json
// @Produce json
// @Param id path int true "Job ID"
// @Success 200 {object} models.Job
// @Failure 400 {string} string "Bad Request"
// @Failure 404 {string} string "Not Found"
// @Router /jobs/{id} [get]
func (h *JobHandler) GetJobByID(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id") // Go 1.22+ routing
if idStr == "" {
// Fallback for older Go versions or if not using PathValue compatible mux
// But let's assume standard mux or we might need to parse URL
// For now, let's assume we can get it from context or URL
// If using standard http.ServeMux in Go 1.22, PathValue works.
// If not, we might need a helper.
// Let's assume standard mux with Go 1.22 for now as it's modern.
// If not, we'll fix it.
// Actually, let's check go.mod version.
}
// Wait, I should check go.mod version to be safe.
// But let's write standard code.
// Assuming we use a router that puts params in context or we parse URL.
// Since I am editing router.go later, I can ensure we use Go 1.22 patterns or a library.
// The existing router.go used `mux.HandleFunc("/jobs", ...)` which suggests standard lib.
// Let's try to parse from URL path if PathValue is not available (it is in Go 1.22).
// I'll use a helper to extract ID from URL for now to be safe if I'm not sure about Go version.
// But `r.PathValue` is the way forward.
id, err := strconv.Atoi(idStr)
if err != nil {
@ -111,6 +124,18 @@ func (h *JobHandler) GetJobByID(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(job)
}
// UpdateJob godoc
// @Summary Update a job
// @Description Update an existing job posting
// @Tags Jobs
// @Accept json
// @Produce json
// @Param id path int true "Job ID"
// @Param job body dto.UpdateJobRequest true "Updated job data"
// @Success 200 {object} models.Job
// @Failure 400 {string} string "Bad Request"
// @Failure 500 {string} string "Internal Server Error"
// @Router /jobs/{id} [put]
func (h *JobHandler) UpdateJob(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr)
@ -135,6 +160,17 @@ func (h *JobHandler) UpdateJob(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(job)
}
// DeleteJob godoc
// @Summary Delete a job
// @Description Delete a job posting
// @Tags Jobs
// @Accept json
// @Produce json
// @Param id path int true "Job ID"
// @Success 204 "No Content"
// @Failure 400 {string} string "Bad Request"
// @Failure 500 {string} string "Internal Server Error"
// @Router /jobs/{id} [delete]
func (h *JobHandler) DeleteJob(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr)

View file

@ -0,0 +1,15 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Allow: /
# Sitemap
Sitemap: https://gohorsejobs.com/sitemap.xml
# Crawl-delay for polite bots
Crawl-delay: 1
# Disallow admin/dashboard areas from indexing
Disallow: /dashboard/
Disallow: /api/
Disallow: /login
Disallow: /cadastro/

View file

@ -0,0 +1,82 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<!-- Main Pages -->
<url>
<loc>https://gohorsejobs.com/</loc>
<lastmod>2025-12-14</lastmod>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://gohorsejobs.com/vagas</loc>
<lastmod>2025-12-14</lastmod>
<changefreq>daily</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://gohorsejobs.com/sobre</loc>
<lastmod>2025-12-14</lastmod>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>
<url>
<loc>https://gohorsejobs.com/contato</loc>
<lastmod>2025-12-14</lastmod>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>
<!-- Vagas por Tecnologia -->
<url>
<loc>https://gohorsejobs.com/vagas?tech=python</loc>
<lastmod>2025-12-14</lastmod>
<changefreq>daily</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://gohorsejobs.com/vagas?tech=react</loc>
<lastmod>2025-12-14</lastmod>
<changefreq>daily</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://gohorsejobs.com/vagas?tech=nodejs</loc>
<lastmod>2025-12-14</lastmod>
<changefreq>daily</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://gohorsejobs.com/vagas?tech=dados</loc>
<lastmod>2025-12-14</lastmod>
<changefreq>daily</changefreq>
<priority>0.8</priority>
</url>
<!-- Vagas por Tipo -->
<url>
<loc>https://gohorsejobs.com/vagas?type=remoto</loc>
<lastmod>2025-12-14</lastmod>
<changefreq>daily</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://gohorsejobs.com/vagas?type=full-time</loc>
<lastmod>2025-12-14</lastmod>
<changefreq>daily</changefreq>
<priority>0.7</priority>
</url>
<!-- Legal -->
<url>
<loc>https://gohorsejobs.com/privacidade</loc>
<lastmod>2025-12-14</lastmod>
<changefreq>yearly</changefreq>
<priority>0.3</priority>
</url>
<url>
<loc>https://gohorsejobs.com/termos</loc>
<lastmod>2025-12-14</lastmod>
<changefreq>yearly</changefreq>
<priority>0.3</priority>
</url>
</urlset>

View file

@ -1,52 +1,76 @@
import Link from "next/link"
import { Briefcase } from "lucide-react"
export function Footer() {
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">
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
<div className="grid grid-cols-1 md:grid-cols-5 gap-8">
<div className="col-span-1 md:col-span-2">
<div className="flex items-center gap-2 mb-4">
<span className="text-xl font-semibold">Portal de Empregos</span>
<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.
Conectamos candidatos e empresas de forma rápida e direta. Encontre sua próxima oportunidade profissional em tecnologia.
</p>
</div>
<div>
<h3 className="font-semibold mb-4">Empresa</h3>
<h3 className="font-semibold mb-4">Vagas por Tecnologia</h3>
<ul className="space-y-2 text-sm text-muted-foreground">
<li>
<Link href="#" className="hover:text-foreground transition-colors">
Sobre
<Link href="/vagas?tech=python" className="hover:text-foreground transition-colors">
Desenvolvedor Python
</Link>
</li>
<li>
<Link href="#" className="hover:text-foreground transition-colors">
Contato
<Link href="/vagas?tech=react" className="hover:text-foreground transition-colors">
Desenvolvedor React
</Link>
</li>
<li>
<Link href="#" className="hover:text-foreground transition-colors">
Carreiras
<Link href="/vagas?tech=dados" className="hover:text-foreground transition-colors">
Analista de Dados
</Link>
</li>
<li>
<Link href="/vagas?type=remoto" className="hover:text-foreground transition-colors">
Vagas Remotas
</Link>
</li>
</ul>
</div>
<div>
<h3 className="font-semibold mb-4">Privacidade e Termos</h3>
<h3 className="font-semibold mb-4">Empresa</h3>
<ul className="space-y-2 text-sm text-muted-foreground">
<li>
<Link href="#" className="hover:text-foreground transition-colors">
<Link href="/sobre" className="hover:text-foreground transition-colors">
Sobre
</Link>
</li>
<li>
<Link href="/contato" className="hover:text-foreground transition-colors">
Contato
</Link>
</li>
<li>
<Link href="/vagas" className="hover:text-foreground transition-colors">
Todas as Vagas
</Link>
</li>
</ul>
</div>
<div>
<h3 className="font-semibold mb-4">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
</Link>
</li>
<li>
<Link href="#" className="hover:text-foreground transition-colors">
<Link href="/termos" className="hover:text-foreground transition-colors">
Termos de Uso
</Link>
</li>
@ -55,9 +79,10 @@ export function Footer() {
</div>
<div className="mt-12 pt-8 border-t border-border text-center text-sm text-muted-foreground">
<p>&copy; 2025 Portal de Empregos. Todos os direitos reservados.</p>
<p>&copy; 2025 GoHorse Jobs. Todos os direitos reservados.</p>
</div>
</div>
</footer>
)
}

View file

@ -97,6 +97,94 @@ export const companiesApi = {
};
// Jobs API (public)
export const jobsApi = {
list: () => apiRequest<unknown[]>("/jobs"),
export interface ApiJob {
id: number;
companyId: number;
createdBy: number;
title: string;
description: string;
salaryMin?: number;
salaryMax?: number;
salaryType?: string;
employmentType?: string;
workingHours?: string;
location?: string;
regionId?: number;
cityId?: number;
requirements?: Record<string, unknown> | string[];
benefits?: Record<string, unknown> | string[];
visaSupport: boolean;
languageLevel?: string;
status: string;
createdAt: string;
updatedAt: string;
companyName?: string;
companyLogoUrl?: string;
regionName?: string;
cityName?: string;
}
export interface PaginatedResponse<T> {
data: T[];
pagination: {
page: number;
limit: number;
total: number;
};
}
export const jobsApi = {
list: (params?: { page?: number; limit?: number; companyId?: number }) => {
const query = new URLSearchParams();
if (params?.page) query.set('page', String(params.page));
if (params?.limit) query.set('limit', String(params.limit));
if (params?.companyId) query.set('companyId', String(params.companyId));
const queryStr = query.toString();
return apiRequest<PaginatedResponse<ApiJob>>(`/jobs${queryStr ? `?${queryStr}` : ''}`);
},
getById: (id: number) => apiRequest<ApiJob>(`/jobs/${id}`),
};
// Transform API job to frontend Job format
export function transformApiJobToFrontend(apiJob: ApiJob): import('./types').Job {
// Format salary
let salary: string | undefined;
if (apiJob.salaryMin && apiJob.salaryMax) {
salary = `R$ ${apiJob.salaryMin.toLocaleString('pt-BR')} - R$ ${apiJob.salaryMax.toLocaleString('pt-BR')}`;
} else if (apiJob.salaryMin) {
salary = `A partir de R$ ${apiJob.salaryMin.toLocaleString('pt-BR')}`;
} else if (apiJob.salaryMax) {
salary = `Até R$ ${apiJob.salaryMax.toLocaleString('pt-BR')}`;
}
// Determine type
type JobType = 'full-time' | 'part-time' | 'contract' | 'Remoto' | 'Tempo Integral';
let type: JobType = 'Tempo Integral';
if (apiJob.employmentType === 'full-time') type = 'Tempo Integral';
else if (apiJob.employmentType === 'part-time') type = 'part-time';
else if (apiJob.employmentType === 'contract') type = 'contract';
else if (apiJob.location?.toLowerCase().includes('remoto')) type = 'Remoto';
// Extract requirements
const requirements: string[] = [];
if (apiJob.requirements) {
if (Array.isArray(apiJob.requirements)) {
requirements.push(...apiJob.requirements.map(String));
} else if (typeof apiJob.requirements === 'object') {
Object.values(apiJob.requirements).forEach(v => requirements.push(String(v)));
}
}
return {
id: String(apiJob.id),
title: apiJob.title,
company: apiJob.companyName || 'Empresa',
location: apiJob.location || apiJob.cityName || 'Localização não informada',
type,
salary,
description: apiJob.description,
requirements: requirements.length > 0 ? requirements : ['Ver detalhes'],
postedAt: apiJob.createdAt?.split('T')[0] || new Date().toISOString().split('T')[0],
};
}