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:
commit
df7f3b002d
5 changed files with 308 additions and 271 deletions
|
|
@ -1,9 +1,8 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
export const runtime = 'edge';
|
export const runtime = "edge";
|
||||||
|
|
||||||
import { use, useState, useEffect } from "react";
|
import { use, useState, useEffect, useMemo } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { Navbar } from "@/components/navbar";
|
import { Navbar } from "@/components/navbar";
|
||||||
import { Footer } from "@/components/footer";
|
import { Footer } from "@/components/footer";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -17,7 +16,7 @@ import {
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { jobsApi, type ApiJob } from "@/lib/api";
|
import { jobsApi, companiesApi, type ApiCompany, type ApiJob } from "@/lib/api";
|
||||||
import {
|
import {
|
||||||
MapPin,
|
MapPin,
|
||||||
Briefcase,
|
Briefcase,
|
||||||
|
|
@ -44,35 +43,133 @@ export default function JobDetailPage({
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t, locale } = useTranslation();
|
||||||
const { id } = use(params);
|
const { id } = use(params);
|
||||||
const router = useRouter();
|
|
||||||
const [isFavorited, setIsFavorited] = useState(false);
|
const [isFavorited, setIsFavorited] = useState(false);
|
||||||
const [isBookmarked, setIsBookmarked] = useState(false);
|
const [isBookmarked, setIsBookmarked] = useState(false);
|
||||||
const [job, setJob] = useState<ApiJob | null>(null);
|
const [job, setJob] = useState<ApiJob | null>(null);
|
||||||
|
const [company, setCompany] = useState<ApiCompany | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchJob() {
|
async function fetchJobAndCompany() {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const response = await jobsApi.getById(id);
|
const jobResponse = await jobsApi.getById(id);
|
||||||
if (response) {
|
setJob(jobResponse);
|
||||||
setJob(response);
|
|
||||||
} else {
|
if (jobResponse?.companyId) {
|
||||||
setError("Job not found");
|
const companyResponse = await companiesApi.getById(jobResponse.companyId);
|
||||||
|
setCompany(companyResponse);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error fetching job:", err);
|
console.error("Error fetching job details:", err);
|
||||||
setError("Failed to load job");
|
setJob(null);
|
||||||
|
setCompany(null);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fetchJob();
|
|
||||||
|
fetchJobAndCompany();
|
||||||
}, [id]);
|
}, [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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col">
|
<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">
|
<main className="flex-1 flex items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<Loader2 className="w-8 h-8 animate-spin mx-auto mb-4 text-primary" />
|
<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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<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">
|
<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" />
|
<Briefcase className="w-8 h-8 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold mb-2">Job not found</h1>
|
<h1 className="text-2xl font-bold mb-2">{t("jobs.details.notFoundTitle")}</h1>
|
||||||
<p className="text-muted-foreground mb-6">
|
<p className="text-muted-foreground mb-6">{t("jobs.details.notFoundDescription")}</p>
|
||||||
The job you are looking for does not exist or has been removed.
|
|
||||||
</p>
|
|
||||||
<Link href="/jobs">
|
<Link href="/jobs">
|
||||||
<Button>View all jobs</Button>
|
<Button>{t("jobs.details.viewAll")}</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</main>
|
</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 (
|
return (
|
||||||
<div className="min-h-screen flex flex-col">
|
<div className="min-h-screen flex flex-col">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
@ -172,12 +217,7 @@ export default function JobDetailPage({
|
||||||
<main className="flex-1 py-8">
|
<main className="flex-1 py-8">
|
||||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="max-w-5xl mx-auto">
|
<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">
|
<Link href="/jobs">
|
||||||
<Button variant="ghost" className="gap-2 hover:bg-muted">
|
<Button variant="ghost" className="gap-2 hover:bg-muted">
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
|
@ -187,23 +227,18 @@ export default function JobDetailPage({
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<div className="grid lg:grid-cols-3 gap-8">
|
<div className="grid lg:grid-cols-3 gap-8">
|
||||||
{/* Main Content */}
|
|
||||||
<div className="lg:col-span-2 space-y-6">
|
<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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex flex-col sm:flex-row items-start gap-4">
|
<div className="flex flex-col sm:flex-row items-start gap-4">
|
||||||
<Avatar className="h-16 w-16 shrink-0">
|
<Avatar className="h-16 w-16 shrink-0">
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
src={`https://avatar.vercel.sh/${job.companyName || "Company"}`}
|
src={company?.logoUrl || `https://avatar.vercel.sh/${companyName}`}
|
||||||
alt={job.companyName || "Company"}
|
alt={companyName}
|
||||||
/>
|
/>
|
||||||
<AvatarFallback className="bg-primary/10 text-primary font-bold text-lg">
|
<AvatarFallback className="bg-primary/10 text-primary font-bold text-lg">
|
||||||
{getCompanyInitials(job.companyName || "Company")}
|
{getCompanyInitials(companyName)}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
|
||||||
|
|
@ -211,45 +246,25 @@ export default function JobDetailPage({
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<CardTitle className="text-xl sm:text-2xl md:text-3xl mb-2 leading-tight">
|
<CardTitle className="text-xl sm:text-2xl md:text-3xl mb-2 leading-tight">{job.title}</CardTitle>
|
||||||
{job.title}
|
|
||||||
</CardTitle>
|
|
||||||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
<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" />
|
<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">
|
<CardDescription className="text-base sm:text-lg font-medium">{companyName}</CardDescription>
|
||||||
{job.companyName || "Company"}
|
{companyRating && (
|
||||||
</CardDescription>
|
<div className="flex items-center gap-1">
|
||||||
<div className="flex items-center gap-1">
|
<Star className="h-4 w-4 fill-yellow-400 text-yellow-400" />
|
||||||
<Star className="h-4 w-4 fill-yellow-400 text-yellow-400" />
|
<span className="text-sm text-muted-foreground">{companyRating}</span>
|
||||||
<span className="text-sm text-muted-foreground">
|
</div>
|
||||||
{mockCompanyInfo.rating}
|
)}
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hidden sm:flex gap-2 shrink-0">
|
<div className="hidden sm:flex gap-2 shrink-0">
|
||||||
<Button
|
<Button variant="outline" size="icon" onClick={() => setIsFavorited(!isFavorited)}>
|
||||||
variant="outline"
|
<Heart className={`h-4 w-4 ${isFavorited ? "fill-red-500 text-red-500" : ""}`} />
|
||||||
size="icon"
|
|
||||||
onClick={() => setIsFavorited(!isFavorited)}
|
|
||||||
>
|
|
||||||
<Heart
|
|
||||||
className={`h-4 w-4 ${isFavorited
|
|
||||||
? "fill-red-500 text-red-500"
|
|
||||||
: ""
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="outline" size="icon" onClick={() => setIsBookmarked(!isBookmarked)}>
|
||||||
variant="outline"
|
<Bookmark className={`h-4 w-4 ${isBookmarked ? "fill-current" : ""}`} />
|
||||||
size="icon"
|
|
||||||
onClick={() => setIsBookmarked(!isBookmarked)}
|
|
||||||
>
|
|
||||||
<Bookmark
|
|
||||||
className={`h-4 w-4 ${isBookmarked ? "fill-current" : ""
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="icon">
|
<Button variant="outline" size="icon">
|
||||||
<Share2 className="h-4 w-4" />
|
<Share2 className="h-4 w-4" />
|
||||||
|
|
@ -257,7 +272,6 @@ export default function JobDetailPage({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action buttons mobile */}
|
|
||||||
<div className="flex sm:hidden gap-2">
|
<div className="flex sm:hidden gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
@ -265,13 +279,8 @@ export default function JobDetailPage({
|
||||||
onClick={() => setIsFavorited(!isFavorited)}
|
onClick={() => setIsFavorited(!isFavorited)}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
>
|
>
|
||||||
<Heart
|
<Heart className={`h-4 w-4 mr-1 ${isFavorited ? "fill-red-500 text-red-500" : ""}`} />
|
||||||
className={`h-4 w-4 mr-1 ${isFavorited
|
{isFavorited ? t("jobs.details.actions.favorited") : t("jobs.details.actions.favorite")}
|
||||||
? "fill-red-500 text-red-500"
|
|
||||||
: ""
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
{isFavorited ? "Favorited" : "Favorite"}
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
@ -279,61 +288,42 @@ export default function JobDetailPage({
|
||||||
onClick={() => setIsBookmarked(!isBookmarked)}
|
onClick={() => setIsBookmarked(!isBookmarked)}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
>
|
>
|
||||||
<Bookmark
|
<Bookmark className={`h-4 w-4 mr-1 ${isBookmarked ? "fill-current" : ""}`} />
|
||||||
className={`h-4 w-4 mr-1 ${isBookmarked ? "fill-current" : ""
|
{isBookmarked ? t("jobs.details.actions.saved") : t("jobs.details.actions.save")}
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
{isBookmarked ? "Saved" : "Save"}
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="outline" size="icon" className="shrink-0">
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
className="shrink-0"
|
|
||||||
>
|
|
||||||
<Share2 className="h-4 w-4" />
|
<Share2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Job Meta */}
|
|
||||||
<div className="flex flex-wrap gap-3 sm:gap-4 text-sm text-muted-foreground">
|
<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" />
|
<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>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Briefcase className="h-4 w-4 shrink-0" />
|
<Briefcase className="h-4 w-4 shrink-0" />
|
||||||
<Badge
|
<Badge variant="secondary" className="whitespace-nowrap">
|
||||||
variant="secondary"
|
{getTypeLabel(job.employmentType || job.type)}
|
||||||
className="whitespace-nowrap"
|
|
||||||
>
|
|
||||||
{getTypeLabel(job.type || "full-time")}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
{salaryDisplay && (
|
{salaryDisplay && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<DollarSign className="h-4 w-4 shrink-0" />
|
<DollarSign className="h-4 w-4 shrink-0" />
|
||||||
<span className="font-medium text-foreground whitespace-nowrap">
|
<span className="font-medium text-foreground whitespace-nowrap">{salaryDisplay}</span>
|
||||||
{salaryDisplay}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Clock className="h-4 w-4 shrink-0" />
|
<Clock className="h-4 w-4 shrink-0" />
|
||||||
<span className="whitespace-nowrap">
|
<span className="whitespace-nowrap">{formatTimeAgo(job.datePosted || job.createdAt)}</span>
|
||||||
{formatTimeAgo(job.createdAt)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Apply Button - Mobile */}
|
|
||||||
<div className="lg:hidden pt-4">
|
<div className="lg:hidden pt-4">
|
||||||
<Link
|
<Link href={`/jobs/${job.id}/apply`} className="w-full">
|
||||||
href={`/jobs/${job.id}/apply`}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
<Button size="lg" className="w-full cursor-pointer">
|
<Button size="lg" className="w-full cursor-pointer">
|
||||||
{t("jobs.details.applyNow")}
|
{t("jobs.details.applyNow")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -343,41 +333,29 @@ export default function JobDetailPage({
|
||||||
</Card>
|
</Card>
|
||||||
</motion.div>
|
</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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-xl">{t("jobs.details.aboutRole")}</CardTitle>
|
<CardTitle className="text-xl">{t("jobs.details.aboutRole")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="prose prose-sm max-w-none">
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
<p className="text-muted-foreground leading-relaxed whitespace-pre-line">
|
<p className="text-muted-foreground leading-relaxed whitespace-pre-line">{job.description}</p>
|
||||||
{job.description}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</motion.div>
|
</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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-xl">{t("jobs.details.requirements")}</CardTitle>
|
<CardTitle className="text-xl">{t("jobs.details.requirements")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
{job.requirements && Array.isArray(job.requirements) ? (
|
{parsedRequirements.length > 0 ? (
|
||||||
job.requirements.map((req, index) => (
|
parsedRequirements.map((req, index) => (
|
||||||
<div key={index} className="flex items-start gap-3">
|
<div key={`${req}-${index}`} className="flex items-start gap-3">
|
||||||
<CheckCircle2 className="h-5 w-5 text-primary mt-0.5 shrink-0" />
|
<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>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -388,20 +366,13 @@ export default function JobDetailPage({
|
||||||
</Card>
|
</Card>
|
||||||
</motion.div>
|
</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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-xl">{t("jobs.details.aboutCompany")}</CardTitle>
|
<CardTitle className="text-xl">{t("jobs.details.aboutCompany")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<p className="text-muted-foreground leading-relaxed">
|
<p className="text-muted-foreground leading-relaxed">{companyDescription}</p>
|
||||||
{t("jobs.details.companyDesc", { company: job.companyName || "Company" })}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 pt-4 border-t">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 pt-4 border-t">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -409,41 +380,39 @@ export default function JobDetailPage({
|
||||||
<Users className="h-4 w-4 shrink-0" />
|
<Users className="h-4 w-4 shrink-0" />
|
||||||
<span>{t("jobs.details.company.size")}</span>
|
<span>{t("jobs.details.company.size")}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="font-medium text-sm">
|
<p className="font-medium text-sm">{company?.employeeCount || t("jobs.details.meta.notInformed")}</p>
|
||||||
{mockCompanyInfo.size}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
|
||||||
<Building2 className="h-4 w-4 shrink-0" />
|
<Building2 className="h-4 w-4 shrink-0" />
|
||||||
<span>{t("jobs.details.company.industry")}</span>
|
<span>{t("jobs.details.company.industry")}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="font-medium text-sm">
|
<p className="font-medium text-sm">{job.workMode || t("jobs.details.meta.notInformed")}</p>
|
||||||
{mockCompanyInfo.industry}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
|
||||||
<Calendar className="h-4 w-4 shrink-0" />
|
<Calendar className="h-4 w-4 shrink-0" />
|
||||||
<span>{t("jobs.details.company.founded")}</span>
|
<span>{t("jobs.details.company.founded")}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="font-medium text-sm">
|
<p className="font-medium text-sm">{company?.foundedYear || t("jobs.details.meta.notInformed")}</p>
|
||||||
{mockCompanyInfo.founded}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
|
||||||
<Globe className="h-4 w-4 shrink-0" />
|
<Globe className="h-4 w-4 shrink-0" />
|
||||||
<span>{t("jobs.details.company.website")}</span>
|
<span>{t("jobs.details.company.website")}</span>
|
||||||
</div>
|
</div>
|
||||||
<a
|
{normalizedWebsite ? (
|
||||||
href={`https://${mockCompanyInfo.website}`}
|
<a
|
||||||
target="_blank"
|
href={normalizedWebsite}
|
||||||
rel="noopener noreferrer"
|
target="_blank"
|
||||||
className="font-medium text-sm text-primary hover:underline break-all"
|
rel="noopener noreferrer"
|
||||||
>
|
className="font-medium text-sm text-primary hover:underline break-all"
|
||||||
{mockCompanyInfo.website}
|
>
|
||||||
</a>
|
{companyWebsite}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<p className="font-medium text-sm">{t("jobs.details.meta.notInformed")}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -451,28 +420,15 @@ export default function JobDetailPage({
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sidebar */}
|
|
||||||
<div className="space-y-6 lg:sticky lg:top-20 lg:self-start">
|
<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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg">
|
<CardTitle className="text-lg">{t("jobs.details.interested")}</CardTitle>
|
||||||
{t("jobs.details.interested")}
|
<CardDescription>{t("jobs.details.applyCta")}</CardDescription>
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
{t("jobs.details.applyCta")}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<Link
|
<Link href={`/jobs/${job.id}/apply`} className="w-full">
|
||||||
href={`/jobs/${job.id}/apply`}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
<Button size="lg" className="w-full cursor-pointer">
|
<Button size="lg" className="w-full cursor-pointer">
|
||||||
{t("jobs.details.applyNow")}
|
{t("jobs.details.applyNow")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -482,61 +438,35 @@ export default function JobDetailPage({
|
||||||
|
|
||||||
<div className="space-y-3 text-sm">
|
<div className="space-y-3 text-sm">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">{t("jobs.details.meta.type")}:</span>
|
||||||
{t("jobs.details.meta.type")}:
|
<Badge variant="outline" className="whitespace-nowrap">
|
||||||
</span>
|
{getTypeLabel(job.employmentType || job.type)}
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="whitespace-nowrap"
|
|
||||||
>
|
|
||||||
{getTypeLabel(job.type || "full-time")}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<span className="text-muted-foreground shrink-0">
|
<span className="text-muted-foreground shrink-0">{t("jobs.details.meta.location")}:</span>
|
||||||
{t("jobs.details.meta.location")}:
|
<span className="font-medium text-right">{job.location || t("jobs.details.meta.notInformed")}</span>
|
||||||
</span>
|
|
||||||
<span className="font-medium text-right">
|
|
||||||
{job.location}
|
|
||||||
</span>
|
|
||||||
</div>
|
</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">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">{t("jobs.details.meta.salary")}:</span>
|
||||||
{t("jobs.details.meta.posted")}:
|
<span className="font-medium text-right break-words">{salaryDisplay || t("jobs.details.meta.notInformed")}</span>
|
||||||
</span>
|
</div>
|
||||||
<span className="font-medium text-right whitespace-nowrap">
|
<div className="flex items-start justify-between gap-2">
|
||||||
{formatTimeAgo(job.createdAt)}
|
<span className="text-muted-foreground">{t("jobs.details.meta.posted")}:</span>
|
||||||
</span>
|
<span className="font-medium text-right whitespace-nowrap">{formatTimeAgo(job.datePosted || job.createdAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</motion.div>
|
</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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg">{t("jobs.details.similar")}</CardTitle>
|
<CardTitle className="text-lg">{t("jobs.details.similar")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">{t("jobs.details.similarDesc")}</p>
|
||||||
{t("jobs.details.similarDesc")}
|
|
||||||
</p>
|
|
||||||
<Link href="/jobs">
|
<Link href="/jobs">
|
||||||
<Button variant="outline" size="sm" className="w-full">
|
<Button variant="outline" size="sm" className="w-full">
|
||||||
{t("jobs.details.viewAll")}
|
{t("jobs.details.viewAll")}
|
||||||
|
|
@ -549,9 +479,9 @@ export default function JobDetailPage({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main >
|
</main>
|
||||||
|
|
||||||
<Footer />
|
<Footer />
|
||||||
</div >
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -295,7 +295,9 @@
|
||||||
},
|
},
|
||||||
"confidential": "Confidential Company",
|
"confidential": "Confidential Company",
|
||||||
"salary": {
|
"salary": {
|
||||||
"negotiable": "Negotiable"
|
"negotiable": "Negotiable",
|
||||||
|
"from": "From {amount}",
|
||||||
|
"upTo": "Up to {amount}"
|
||||||
},
|
},
|
||||||
"posted": {
|
"posted": {
|
||||||
"today": "Today",
|
"today": "Today",
|
||||||
|
|
@ -318,6 +320,43 @@
|
||||||
"previous": "Previous",
|
"previous": "Previous",
|
||||||
"next": "Next",
|
"next": "Next",
|
||||||
"showing": "Showing {from} to {to} of {total} jobs"
|
"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": {
|
"workMode": {
|
||||||
|
|
|
||||||
|
|
@ -295,7 +295,9 @@
|
||||||
},
|
},
|
||||||
"confidential": "Empresa Confidencial",
|
"confidential": "Empresa Confidencial",
|
||||||
"salary": {
|
"salary": {
|
||||||
"negotiable": "A convenir"
|
"negotiable": "A convenir",
|
||||||
|
"from": "Desde {amount}",
|
||||||
|
"upTo": "Hasta {amount}"
|
||||||
},
|
},
|
||||||
"posted": {
|
"posted": {
|
||||||
"today": "Hoy",
|
"today": "Hoy",
|
||||||
|
|
@ -318,6 +320,43 @@
|
||||||
"previous": "Anterior",
|
"previous": "Anterior",
|
||||||
"next": "Siguiente",
|
"next": "Siguiente",
|
||||||
"showing": "Mostrando {from} a {to} de {total} empleos"
|
"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": {
|
"workMode": {
|
||||||
|
|
|
||||||
|
|
@ -336,7 +336,9 @@
|
||||||
},
|
},
|
||||||
"confidential": "Empresa Confidencial",
|
"confidential": "Empresa Confidencial",
|
||||||
"salary": {
|
"salary": {
|
||||||
"negotiable": "A combinar"
|
"negotiable": "A combinar",
|
||||||
|
"from": "A partir de {amount}",
|
||||||
|
"upTo": "Até {amount}"
|
||||||
},
|
},
|
||||||
"posted": {
|
"posted": {
|
||||||
"today": "Hoje",
|
"today": "Hoje",
|
||||||
|
|
@ -375,7 +377,18 @@
|
||||||
"type": "Tipo",
|
"type": "Tipo",
|
||||||
"location": "Localização",
|
"location": "Localização",
|
||||||
"salary": "Salário",
|
"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": {
|
"requirements": {
|
||||||
|
|
|
||||||
|
|
@ -70,21 +70,37 @@ export interface ApiJob {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
companyName?: string;
|
companyName?: string;
|
||||||
|
companyLogoUrl?: string;
|
||||||
companyId: string;
|
companyId: string;
|
||||||
location: string;
|
location?: string | null;
|
||||||
type: string; // "full-time", etc.
|
type?: string; // Legacy alias
|
||||||
workMode: string; // "remote", etc.
|
employmentType?: string;
|
||||||
|
workMode?: string | null; // "remote", etc.
|
||||||
salaryMin?: number;
|
salaryMin?: number;
|
||||||
salaryMax?: number;
|
salaryMax?: number;
|
||||||
salaryType?: string;
|
salaryType?: string;
|
||||||
|
currency?: string;
|
||||||
description: string;
|
description: string;
|
||||||
requirements: string; // Backend likely returns string, frontend might expect array? Warning here.
|
requirements?: unknown;
|
||||||
status: string;
|
status: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
datePosted?: string;
|
||||||
isFeatured: boolean;
|
isFeatured: boolean;
|
||||||
applicationCount?: number;
|
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 {
|
export interface AdminCompany {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -323,6 +339,7 @@ export const adminCompaniesApi = {
|
||||||
// Companies API (Public/Shared)
|
// Companies API (Public/Shared)
|
||||||
export const companiesApi = {
|
export const companiesApi = {
|
||||||
list: () => apiRequest<AdminCompany[]>("/api/v1/companies"), // Using AdminCompany as fallback type
|
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 }) =>
|
create: (data: { name: string; slug: string; email?: string }) =>
|
||||||
apiRequest<AdminCompany>("/api/v1/companies", {
|
apiRequest<AdminCompany>("/api/v1/companies", {
|
||||||
|
|
@ -1037,4 +1054,3 @@ export const locationsApi = {
|
||||||
return res || [];
|
return res || [];
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue