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";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 || [];
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue