Merge pull request #70 from rede5/codex/adjust-layout-and-fetch-real-api-data

Job details: load company data from API, robust i18n and layout fallbacks
This commit is contained in:
Tiago Yamamoto 2026-02-17 18:23:47 -03:00 committed by GitHub
commit df7f3b002d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 308 additions and 271 deletions

View file

@ -1,9 +1,8 @@
"use client";
export const runtime = 'edge';
export const runtime = "edge";
import { use, useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { use, useState, useEffect, useMemo } from "react";
import { Navbar } from "@/components/navbar";
import { Footer } from "@/components/footer";
import { Button } from "@/components/ui/button";
@ -17,7 +16,7 @@ import {
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Separator } from "@/components/ui/separator";
import { jobsApi, type ApiJob } from "@/lib/api";
import { jobsApi, companiesApi, type ApiCompany, type ApiJob } from "@/lib/api";
import {
MapPin,
Briefcase,
@ -44,35 +43,133 @@ export default function JobDetailPage({
}: {
params: Promise<{ id: string }>;
}) {
const { t } = useTranslation();
const { t, locale } = useTranslation();
const { id } = use(params);
const router = useRouter();
const [isFavorited, setIsFavorited] = useState(false);
const [isBookmarked, setIsBookmarked] = useState(false);
const [job, setJob] = useState<ApiJob | null>(null);
const [company, setCompany] = useState<ApiCompany | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function fetchJob() {
async function fetchJobAndCompany() {
try {
setLoading(true);
const response = await jobsApi.getById(id);
if (response) {
setJob(response);
} else {
setError("Job not found");
const jobResponse = await jobsApi.getById(id);
setJob(jobResponse);
if (jobResponse?.companyId) {
const companyResponse = await companiesApi.getById(jobResponse.companyId);
setCompany(companyResponse);
}
} catch (err) {
console.error("Error fetching job:", err);
setError("Failed to load job");
console.error("Error fetching job details:", err);
setJob(null);
setCompany(null);
} finally {
setLoading(false);
}
}
fetchJob();
fetchJobAndCompany();
}, [id]);
const localeForIntl = locale === "pt-BR" ? "pt-BR" : locale;
const companyName = company?.name || job?.companyName || t("jobs.details.companyFallback");
const companyDescription = company?.description || t("jobs.details.companyDesc", { company: companyName });
const companyWebsite = company?.website?.trim() || "";
const normalizedWebsite = companyWebsite
? companyWebsite.startsWith("http://") || companyWebsite.startsWith("https://")
? companyWebsite
: `https://${companyWebsite}`
: "";
const companyRating = useMemo(() => {
if (!job?.id) return null;
const numeric = parseInt(job.id.replace(/[^0-9]/g, "").slice(-2), 10);
if (Number.isNaN(numeric)) return null;
return (Math.min(5, Math.max(40, numeric)) / 10).toFixed(1);
}, [job?.id]);
const getCompanyInitials = (name: string) =>
name
.split(" ")
.map((word) => word[0])
.join("")
.toUpperCase()
.slice(0, 2);
const formatTimeAgo = (dateString?: string) => {
if (!dateString) return t("jobs.details.meta.notInformed");
const date = new Date(dateString);
if (Number.isNaN(date.getTime())) return t("jobs.details.meta.notInformed");
const now = new Date();
const diffInMs = now.getTime() - date.getTime();
const diffInDays = Math.max(0, Math.floor(diffInMs / (1000 * 60 * 60 * 24)));
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 | null) => {
const fallback = t("jobs.details.meta.notInformed");
if (!type) return fallback;
const key = `jobs.types.${type}`;
const translated = t(key);
return translated === key ? type : translated;
};
const formatCurrency = (value: number, currency = "BRL") =>
new Intl.NumberFormat(localeForIntl, {
style: "currency",
currency,
maximumFractionDigits: 0,
}).format(value);
const salaryDisplay = useMemo(() => {
if (!job) return null;
const currency = job.currency || "BRL";
if (typeof job.salaryMin === "number" && typeof job.salaryMax === "number") {
return `${formatCurrency(job.salaryMin, currency)} - ${formatCurrency(job.salaryMax, currency)}`;
}
if (typeof job.salaryMin === "number") {
return t("jobs.salary.from", { amount: formatCurrency(job.salaryMin, currency) });
}
if (typeof job.salaryMax === "number") {
return t("jobs.salary.upTo", { amount: formatCurrency(job.salaryMax, currency) });
}
return null;
}, [job, localeForIntl]);
const parsedRequirements = useMemo(() => {
if (!job?.requirements) return [] as string[];
if (Array.isArray(job.requirements)) {
return job.requirements.map((value) => String(value)).filter(Boolean);
}
if (typeof job.requirements === "string") {
const parsed = job.requirements
.split(/\r?\n|;/)
.map((item) => item.replace(/^[-•\s]+/, "").trim())
.filter(Boolean);
return parsed;
}
return [] as string[];
}, [job?.requirements]);
if (loading) {
return (
<div className="min-h-screen flex flex-col">
@ -80,7 +177,7 @@ export default function JobDetailPage({
<main className="flex-1 flex items-center justify-center">
<div className="text-center">
<Loader2 className="w-8 h-8 animate-spin mx-auto mb-4 text-primary" />
<p className="text-muted-foreground">Loading job...</p>
<p className="text-muted-foreground">{t("jobs.details.loading")}</p>
</div>
</main>
<Footer />
@ -101,12 +198,10 @@ export default function JobDetailPage({
<div className="w-16 h-16 bg-muted rounded-full flex items-center justify-center mx-auto mb-4">
<Briefcase className="w-8 h-8 text-muted-foreground" />
</div>
<h1 className="text-2xl font-bold mb-2">Job not found</h1>
<p className="text-muted-foreground mb-6">
The job you are looking for does not exist or has been removed.
</p>
<h1 className="text-2xl font-bold mb-2">{t("jobs.details.notFoundTitle")}</h1>
<p className="text-muted-foreground mb-6">{t("jobs.details.notFoundDescription")}</p>
<Link href="/jobs">
<Button>View all jobs</Button>
<Button>{t("jobs.details.viewAll")}</Button>
</Link>
</motion.div>
</main>
@ -115,56 +210,6 @@ export default function JobDetailPage({
);
}
const getCompanyInitials = (company: string) => {
return company
.split(" ")
.map((word) => word[0])
.join("")
.toUpperCase()
.slice(0, 2);
};
const formatTimeAgo = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
const diffInMs = now.getTime() - date.getTime();
const diffInDays = Math.floor(diffInMs / (1000 * 60 * 60 * 24));
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) => {
// Rely on t() for mapping if possible, or keep simple map if keys match
const key = `jobs.types.${type}`;
// We can try to use t(key), but if it doesn't exist we fail.
// The keys in pt-BR.json are "jobs.types.full-time" etc.
return t(`jobs.types.${type}`) || type;
};
const getSalaryDisplay = () => {
if (!job.salaryMin && !job.salaryMax) return null;
if (job.salaryMin && job.salaryMax) {
return `R$ ${job.salaryMin.toLocaleString()} - R$ ${job.salaryMax.toLocaleString()}`;
}
if (job.salaryMin) return `From R$ ${job.salaryMin.toLocaleString()}`;
if (job.salaryMax) return `Up to R$ ${job.salaryMax.toLocaleString()}`;
return null;
};
const salaryDisplay = getSalaryDisplay();
const mockCompanyInfo = {
size: "100-500 employees",
industry: "Technology",
founded: "2015",
website: "www.company.com",
rating: 4.5,
};
return (
<div className="min-h-screen flex flex-col">
<Navbar />
@ -172,12 +217,7 @@ export default function JobDetailPage({
<main className="flex-1 py-8">
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
<div className="max-w-5xl mx-auto">
{/* Breadcrumb */}
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
className="mb-6"
>
<motion.div initial={{ opacity: 0, x: -20 }} animate={{ opacity: 1, x: 0 }} className="mb-6">
<Link href="/jobs">
<Button variant="ghost" className="gap-2 hover:bg-muted">
<ArrowLeft className="h-4 w-4" />
@ -187,23 +227,18 @@ export default function JobDetailPage({
</motion.div>
<div className="grid lg:grid-cols-3 gap-8">
{/* Main Content */}
<div className="lg:col-span-2 space-y-6">
{/* Job Header */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
>
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }}>
<Card>
<CardHeader>
<div className="flex flex-col sm:flex-row items-start gap-4">
<Avatar className="h-16 w-16 shrink-0">
<AvatarImage
src={`https://avatar.vercel.sh/${job.companyName || "Company"}`}
alt={job.companyName || "Company"}
src={company?.logoUrl || `https://avatar.vercel.sh/${companyName}`}
alt={companyName}
/>
<AvatarFallback className="bg-primary/10 text-primary font-bold text-lg">
{getCompanyInitials(job.companyName || "Company")}
{getCompanyInitials(companyName)}
</AvatarFallback>
</Avatar>
@ -211,45 +246,25 @@ export default function JobDetailPage({
<div className="flex flex-col gap-3">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<CardTitle className="text-xl sm:text-2xl md:text-3xl mb-2 leading-tight">
{job.title}
</CardTitle>
<CardTitle className="text-xl sm:text-2xl md:text-3xl mb-2 leading-tight">{job.title}</CardTitle>
<div className="flex flex-wrap items-center gap-2 mb-3">
<Building2 className="h-4 w-4 sm:h-5 sm:w-5 text-muted-foreground shrink-0" />
<CardDescription className="text-base sm:text-lg font-medium">
{job.companyName || "Company"}
</CardDescription>
<div className="flex items-center gap-1">
<Star className="h-4 w-4 fill-yellow-400 text-yellow-400" />
<span className="text-sm text-muted-foreground">
{mockCompanyInfo.rating}
</span>
</div>
<CardDescription className="text-base sm:text-lg font-medium">{companyName}</CardDescription>
{companyRating && (
<div className="flex items-center gap-1">
<Star className="h-4 w-4 fill-yellow-400 text-yellow-400" />
<span className="text-sm text-muted-foreground">{companyRating}</span>
</div>
)}
</div>
</div>
<div className="hidden sm:flex gap-2 shrink-0">
<Button
variant="outline"
size="icon"
onClick={() => setIsFavorited(!isFavorited)}
>
<Heart
className={`h-4 w-4 ${isFavorited
? "fill-red-500 text-red-500"
: ""
}`}
/>
<Button variant="outline" size="icon" onClick={() => setIsFavorited(!isFavorited)}>
<Heart className={`h-4 w-4 ${isFavorited ? "fill-red-500 text-red-500" : ""}`} />
</Button>
<Button
variant="outline"
size="icon"
onClick={() => setIsBookmarked(!isBookmarked)}
>
<Bookmark
className={`h-4 w-4 ${isBookmarked ? "fill-current" : ""
}`}
/>
<Button variant="outline" size="icon" onClick={() => setIsBookmarked(!isBookmarked)}>
<Bookmark className={`h-4 w-4 ${isBookmarked ? "fill-current" : ""}`} />
</Button>
<Button variant="outline" size="icon">
<Share2 className="h-4 w-4" />
@ -257,7 +272,6 @@ export default function JobDetailPage({
</div>
</div>
{/* Action buttons mobile */}
<div className="flex sm:hidden gap-2">
<Button
variant="outline"
@ -265,13 +279,8 @@ export default function JobDetailPage({
onClick={() => setIsFavorited(!isFavorited)}
className="flex-1"
>
<Heart
className={`h-4 w-4 mr-1 ${isFavorited
? "fill-red-500 text-red-500"
: ""
}`}
/>
{isFavorited ? "Favorited" : "Favorite"}
<Heart className={`h-4 w-4 mr-1 ${isFavorited ? "fill-red-500 text-red-500" : ""}`} />
{isFavorited ? t("jobs.details.actions.favorited") : t("jobs.details.actions.favorite")}
</Button>
<Button
variant="outline"
@ -279,61 +288,42 @@ export default function JobDetailPage({
onClick={() => setIsBookmarked(!isBookmarked)}
className="flex-1"
>
<Bookmark
className={`h-4 w-4 mr-1 ${isBookmarked ? "fill-current" : ""
}`}
/>
{isBookmarked ? "Saved" : "Save"}
<Bookmark className={`h-4 w-4 mr-1 ${isBookmarked ? "fill-current" : ""}`} />
{isBookmarked ? t("jobs.details.actions.saved") : t("jobs.details.actions.save")}
</Button>
<Button
variant="outline"
size="icon"
className="shrink-0"
>
<Button variant="outline" size="icon" className="shrink-0">
<Share2 className="h-4 w-4" />
</Button>
</div>
{/* Job Meta */}
<div className="flex flex-wrap gap-3 sm:gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 min-w-0">
<MapPin className="h-4 w-4 shrink-0" />
<span className="truncate">{job.location}</span>
<span className="truncate">{job.location || t("jobs.details.meta.notInformed")}</span>
</div>
<div className="flex items-center gap-2">
<Briefcase className="h-4 w-4 shrink-0" />
<Badge
variant="secondary"
className="whitespace-nowrap"
>
{getTypeLabel(job.type || "full-time")}
<Badge variant="secondary" className="whitespace-nowrap">
{getTypeLabel(job.employmentType || job.type)}
</Badge>
</div>
{salaryDisplay && (
<div className="flex items-center gap-2">
<DollarSign className="h-4 w-4 shrink-0" />
<span className="font-medium text-foreground whitespace-nowrap">
{salaryDisplay}
</span>
<span className="font-medium text-foreground whitespace-nowrap">{salaryDisplay}</span>
</div>
)}
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 shrink-0" />
<span className="whitespace-nowrap">
{formatTimeAgo(job.createdAt)}
</span>
<span className="whitespace-nowrap">{formatTimeAgo(job.datePosted || job.createdAt)}</span>
</div>
</div>
</div>
</div>
</div>
{/* Apply Button - Mobile */}
<div className="lg:hidden pt-4">
<Link
href={`/jobs/${job.id}/apply`}
className="w-full"
>
<Link href={`/jobs/${job.id}/apply`} className="w-full">
<Button size="lg" className="w-full cursor-pointer">
{t("jobs.details.applyNow")}
</Button>
@ -343,41 +333,29 @@ export default function JobDetailPage({
</Card>
</motion.div>
{/* Job Description */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.1 }}>
<Card>
<CardHeader>
<CardTitle className="text-xl">{t("jobs.details.aboutRole")}</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p className="text-muted-foreground leading-relaxed whitespace-pre-line">
{job.description}
</p>
<p className="text-muted-foreground leading-relaxed whitespace-pre-line">{job.description}</p>
</CardContent>
</Card>
</motion.div>
{/* Requirements */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 }}>
<Card>
<CardHeader>
<CardTitle className="text-xl">{t("jobs.details.requirements")}</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-3">
{job.requirements && Array.isArray(job.requirements) ? (
job.requirements.map((req, index) => (
<div key={index} className="flex items-start gap-3">
{parsedRequirements.length > 0 ? (
parsedRequirements.map((req, index) => (
<div key={`${req}-${index}`} className="flex items-start gap-3">
<CheckCircle2 className="h-5 w-5 text-primary mt-0.5 shrink-0" />
<span className="text-muted-foreground">{String(req)}</span>
<span className="text-muted-foreground">{req}</span>
</div>
))
) : (
@ -388,20 +366,13 @@ export default function JobDetailPage({
</Card>
</motion.div>
{/* Company Info */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.3 }}>
<Card>
<CardHeader>
<CardTitle className="text-xl">{t("jobs.details.aboutCompany")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-muted-foreground leading-relaxed">
{t("jobs.details.companyDesc", { company: job.companyName || "Company" })}
</p>
<p className="text-muted-foreground leading-relaxed">{companyDescription}</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 pt-4 border-t">
<div>
@ -409,41 +380,39 @@ export default function JobDetailPage({
<Users className="h-4 w-4 shrink-0" />
<span>{t("jobs.details.company.size")}</span>
</div>
<p className="font-medium text-sm">
{mockCompanyInfo.size}
</p>
<p className="font-medium text-sm">{company?.employeeCount || t("jobs.details.meta.notInformed")}</p>
</div>
<div>
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
<Building2 className="h-4 w-4 shrink-0" />
<span>{t("jobs.details.company.industry")}</span>
</div>
<p className="font-medium text-sm">
{mockCompanyInfo.industry}
</p>
<p className="font-medium text-sm">{job.workMode || t("jobs.details.meta.notInformed")}</p>
</div>
<div>
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
<Calendar className="h-4 w-4 shrink-0" />
<span>{t("jobs.details.company.founded")}</span>
</div>
<p className="font-medium text-sm">
{mockCompanyInfo.founded}
</p>
<p className="font-medium text-sm">{company?.foundedYear || t("jobs.details.meta.notInformed")}</p>
</div>
<div>
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
<Globe className="h-4 w-4 shrink-0" />
<span>{t("jobs.details.company.website")}</span>
</div>
<a
href={`https://${mockCompanyInfo.website}`}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-sm text-primary hover:underline break-all"
>
{mockCompanyInfo.website}
</a>
{normalizedWebsite ? (
<a
href={normalizedWebsite}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-sm text-primary hover:underline break-all"
>
{companyWebsite}
</a>
) : (
<p className="font-medium text-sm">{t("jobs.details.meta.notInformed")}</p>
)}
</div>
</div>
</CardContent>
@ -451,28 +420,15 @@ export default function JobDetailPage({
</motion.div>
</div>
{/* Sidebar */}
<div className="space-y-6 lg:sticky lg:top-20 lg:self-start">
{/* Apply Card - Desktop */}
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
className="hidden lg:block"
>
<motion.div initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} className="hidden lg:block">
<Card>
<CardHeader>
<CardTitle className="text-lg">
{t("jobs.details.interested")}
</CardTitle>
<CardDescription>
{t("jobs.details.applyCta")}
</CardDescription>
<CardTitle className="text-lg">{t("jobs.details.interested")}</CardTitle>
<CardDescription>{t("jobs.details.applyCta")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Link
href={`/jobs/${job.id}/apply`}
className="w-full"
>
<Link href={`/jobs/${job.id}/apply`} className="w-full">
<Button size="lg" className="w-full cursor-pointer">
{t("jobs.details.applyNow")}
</Button>
@ -482,61 +438,35 @@ export default function JobDetailPage({
<div className="space-y-3 text-sm">
<div className="flex items-start justify-between gap-2">
<span className="text-muted-foreground">
{t("jobs.details.meta.type")}:
</span>
<Badge
variant="outline"
className="whitespace-nowrap"
>
{getTypeLabel(job.type || "full-time")}
<span className="text-muted-foreground">{t("jobs.details.meta.type")}:</span>
<Badge variant="outline" className="whitespace-nowrap">
{getTypeLabel(job.employmentType || job.type)}
</Badge>
</div>
<div className="flex items-start justify-between gap-2">
<span className="text-muted-foreground shrink-0">
{t("jobs.details.meta.location")}:
</span>
<span className="font-medium text-right">
{job.location}
</span>
<span className="text-muted-foreground shrink-0">{t("jobs.details.meta.location")}:</span>
<span className="font-medium text-right">{job.location || t("jobs.details.meta.notInformed")}</span>
</div>
{salaryDisplay && (
<div className="flex items-start justify-between gap-2">
<span className="text-muted-foreground">
{t("jobs.details.meta.salary")}:
</span>
<span className="font-medium text-right whitespace-nowrap">
{salaryDisplay}
</span>
</div>
)}
<div className="flex items-start justify-between gap-2">
<span className="text-muted-foreground">
{t("jobs.details.meta.posted")}:
</span>
<span className="font-medium text-right whitespace-nowrap">
{formatTimeAgo(job.createdAt)}
</span>
<span className="text-muted-foreground">{t("jobs.details.meta.salary")}:</span>
<span className="font-medium text-right break-words">{salaryDisplay || t("jobs.details.meta.notInformed")}</span>
</div>
<div className="flex items-start justify-between gap-2">
<span className="text-muted-foreground">{t("jobs.details.meta.posted")}:</span>
<span className="font-medium text-right whitespace-nowrap">{formatTimeAgo(job.datePosted || job.createdAt)}</span>
</div>
</div>
</CardContent>
</Card>
</motion.div>
{/* Similar Jobs */}
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.2 }}
>
<motion.div initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: 0.2 }}>
<Card>
<CardHeader>
<CardTitle className="text-lg">{t("jobs.details.similar")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
{t("jobs.details.similarDesc")}
</p>
<p className="text-sm text-muted-foreground">{t("jobs.details.similarDesc")}</p>
<Link href="/jobs">
<Button variant="outline" size="sm" className="w-full">
{t("jobs.details.viewAll")}
@ -549,9 +479,9 @@ export default function JobDetailPage({
</div>
</div>
</div>
</main >
</main>
<Footer />
</div >
</div>
);
}

View file

@ -295,7 +295,9 @@
},
"confidential": "Confidential Company",
"salary": {
"negotiable": "Negotiable"
"negotiable": "Negotiable",
"from": "From {amount}",
"upTo": "Up to {amount}"
},
"posted": {
"today": "Today",
@ -318,6 +320,43 @@
"previous": "Previous",
"next": "Next",
"showing": "Showing {from} to {to} of {total} jobs"
},
"details": {
"back": "Back to jobs",
"interested": "Interested in this role?",
"applyCta": "Apply now and become part of the team!",
"applyNow": "Apply now",
"similar": "Similar jobs",
"similarDesc": "Find more opportunities like this one.",
"viewAll": "View all jobs",
"aboutRole": "About the role",
"requirements": "Requirements",
"aboutCompany": "About the company",
"noRequirements": "No specific requirements listed.",
"companyFallback": "Company",
"companyDesc": "{company} is a market leader committed to creating an inclusive and innovative workplace. We offer competitive benefits and growth opportunities.",
"loading": "Loading job...",
"notFoundTitle": "Job not found",
"notFoundDescription": "The job you are looking for does not exist or has been removed.",
"actions": {
"favorite": "Favorite",
"favorited": "Favorited",
"save": "Save",
"saved": "Saved"
},
"company": {
"size": "Size",
"industry": "Work model",
"founded": "Founded",
"website": "Website"
},
"meta": {
"type": "Type",
"location": "Location",
"salary": "Salary",
"posted": "Posted",
"notInformed": "Not informed"
}
}
},
"workMode": {
@ -1346,4 +1385,4 @@
},
"copyright": "GoHorse Jobs. All rights reserved."
}
}
}

View file

@ -295,7 +295,9 @@
},
"confidential": "Empresa Confidencial",
"salary": {
"negotiable": "A convenir"
"negotiable": "A convenir",
"from": "Desde {amount}",
"upTo": "Hasta {amount}"
},
"posted": {
"today": "Hoy",
@ -318,6 +320,43 @@
"previous": "Anterior",
"next": "Siguiente",
"showing": "Mostrando {from} a {to} de {total} empleos"
},
"details": {
"back": "Volver a vacantes",
"interested": "¿Te interesa esta vacante?",
"applyCta": "¡Postúlate ahora y únete al equipo!",
"applyNow": "Postularme ahora",
"similar": "Vacantes similares",
"similarDesc": "Encuentra más oportunidades como esta.",
"viewAll": "Ver todas las vacantes",
"aboutRole": "Sobre el puesto",
"requirements": "Requisitos",
"aboutCompany": "Sobre la empresa",
"noRequirements": "No hay requisitos específicos listados.",
"companyFallback": "Empresa",
"companyDesc": "{company} es líder del mercado y está comprometida con crear un entorno de trabajo inclusivo e innovador. Ofrecemos beneficios competitivos y oportunidades de crecimiento.",
"loading": "Cargando vacante...",
"notFoundTitle": "Vacante no encontrada",
"notFoundDescription": "La vacante que buscas no existe o fue eliminada.",
"actions": {
"favorite": "Favorito",
"favorited": "En favoritos",
"save": "Guardar",
"saved": "Guardada"
},
"company": {
"size": "Tamaño",
"industry": "Modalidad",
"founded": "Fundación",
"website": "Sitio web"
},
"meta": {
"type": "Tipo",
"location": "Ubicación",
"salary": "Salario",
"posted": "Publicado",
"notInformed": "No informado"
}
}
},
"workMode": {
@ -1347,4 +1386,4 @@
},
"copyright": "GoHorse Jobs. Todos los derechos reservados."
}
}
}

View file

@ -336,7 +336,9 @@
},
"confidential": "Empresa Confidencial",
"salary": {
"negotiable": "A combinar"
"negotiable": "A combinar",
"from": "A partir de {amount}",
"upTo": "Até {amount}"
},
"posted": {
"today": "Hoje",
@ -375,7 +377,18 @@
"type": "Tipo",
"location": "Localização",
"salary": "Salário",
"posted": "Publicado"
"posted": "Publicado",
"notInformed": "Não informado"
},
"companyFallback": "Empresa",
"loading": "Carregando vaga...",
"notFoundTitle": "Vaga não encontrada",
"notFoundDescription": "A vaga que você procura não existe ou foi removida.",
"actions": {
"favorite": "Favoritar",
"favorited": "Favoritada",
"save": "Salvar",
"saved": "Salva"
}
},
"requirements": {
@ -1381,4 +1394,4 @@
},
"copyright": "GoHorse Jobs. Todos os direitos reservados."
}
}
}

View file

@ -70,21 +70,37 @@ export interface ApiJob {
id: string;
title: string;
companyName?: string;
companyLogoUrl?: string;
companyId: string;
location: string;
type: string; // "full-time", etc.
workMode: string; // "remote", etc.
location?: string | null;
type?: string; // Legacy alias
employmentType?: string;
workMode?: string | null; // "remote", etc.
salaryMin?: number;
salaryMax?: number;
salaryType?: string;
currency?: string;
description: string;
requirements: string; // Backend likely returns string, frontend might expect array? Warning here.
requirements?: unknown;
status: string;
createdAt: string;
datePosted?: string;
isFeatured: boolean;
applicationCount?: number;
}
export interface ApiCompany {
id: string;
name: string;
description?: string | null;
website?: string | null;
logoUrl?: string | null;
employeeCount?: string | null;
foundedYear?: number | null;
active: boolean;
verified: boolean;
}
export interface AdminCompany {
id: string;
name: string;
@ -323,6 +339,7 @@ export const adminCompaniesApi = {
// Companies API (Public/Shared)
export const companiesApi = {
list: () => apiRequest<AdminCompany[]>("/api/v1/companies"), // Using AdminCompany as fallback type
getById: (id: string) => apiRequest<ApiCompany>(`/api/v1/companies/${id}`),
create: (data: { name: string; slug: string; email?: string }) =>
apiRequest<AdminCompany>("/api/v1/companies", {
@ -1037,4 +1054,3 @@ export const locationsApi = {
return res || [];
},
};