This commit is contained in:
Iliyan Angelov
2025-11-25 09:21:00 +02:00
parent 82024016cd
commit 4c8b71fe0d
3 changed files with 162 additions and 122 deletions

View File

@@ -1,110 +1,89 @@
"use client"; import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { useParams } from "next/navigation";
import { useEffect } from "react";
import Link from "next/link";
import Header from "@/components/shared/layout/header/Header"; import Header from "@/components/shared/layout/header/Header";
import JobSingle from "@/components/pages/career/JobSingle"; import JobSingle from "@/components/pages/career/JobSingle";
import Footer from "@/components/shared/layout/footer/Footer"; import Footer from "@/components/shared/layout/footer/Footer";
import CareerScrollProgressButton from "@/components/pages/career/CareerScrollProgressButton"; import CareerScrollProgressButton from "@/components/pages/career/CareerScrollProgressButton";
import CareerInitAnimations from "@/components/pages/career/CareerInitAnimations"; import CareerInitAnimations from "@/components/pages/career/CareerInitAnimations";
import { useJob } from "@/lib/hooks/useCareer"; import { JobPosition } from "@/lib/api/careerService";
import { generateCareerMetadata } from "@/lib/seo/metadata"; import { generateCareerMetadata } from "@/lib/seo/metadata";
import { API_CONFIG, getApiHeaders } from "@/lib/config/api";
const JobPage = () => { interface JobPageProps {
const params = useParams(); params: Promise<{
const slug = params?.slug as string; slug: string;
const { job, loading, error } = useJob(slug); }>;
}
// Update metadata dynamically for client component // Generate metadata for each job page
useEffect(() => { export async function generateMetadata({ params }: JobPageProps): Promise<Metadata> {
if (job) { const { slug } = await params;
const metadata = generateCareerMetadata(job);
const title = typeof metadata.title === 'string' ? metadata.title : `Career - ${job.title} | GNX Soft`; try {
document.title = title; const response = await fetch(
`${API_CONFIG.BASE_URL}/api/career/jobs/${slug}/`,
// Update meta description {
let metaDescription = document.querySelector('meta[name="description"]'); method: 'GET',
if (!metaDescription) { headers: getApiHeaders(),
metaDescription = document.createElement('meta'); next: { revalidate: 3600 }, // Revalidate every hour
metaDescription.setAttribute('name', 'description');
document.head.appendChild(metaDescription);
} }
const description = typeof metadata.description === 'string' ? metadata.description : `Apply for ${job.title} at GNX Soft. ${job.location || 'Remote'} position.`; );
metaDescription.setAttribute('content', description);
// Update canonical URL if (!response.ok) {
let canonical = document.querySelector('link[rel="canonical"]'); throw new Error(`HTTP error! status: ${response.status}`);
if (!canonical) {
canonical = document.createElement('link');
canonical.setAttribute('rel', 'canonical');
document.head.appendChild(canonical);
}
canonical.setAttribute('href', `${window.location.origin}/career/${job.slug}`);
} }
}, [job]);
if (loading) { const job = await response.json();
return generateCareerMetadata({
title: job.title,
description: job.short_description || job.about_role,
slug: job.slug,
location: job.location,
department: job.department,
employment_type: job.employment_type,
});
} catch (error) {
return {
title: 'Job Not Found | GNX Soft',
description: 'The requested job position could not be found.',
};
}
}
const JobPage = async ({ params }: JobPageProps) => {
const { slug } = await params;
try {
const response = await fetch(
`${API_CONFIG.BASE_URL}/api/career/jobs/${slug}/`,
{
method: 'GET',
headers: getApiHeaders(),
next: { revalidate: 3600 }, // Revalidate every hour
}
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const job: JobPosition = await response.json();
return ( return (
<div className="tp-app"> <div className="tp-app">
<Header /> <Header />
<main> <main>
<section className="pt-120 pb-120"> <JobSingle job={job} />
<div className="container">
<div className="row">
<div className="col-12 text-center">
<h2>Loading job details...</h2>
</div>
</div>
</div>
</section>
</main> </main>
<Footer /> <Footer />
<CareerScrollProgressButton /> <CareerScrollProgressButton />
<CareerInitAnimations /> <CareerInitAnimations />
</div> </div>
); );
} catch (error) {
notFound();
} }
if (error || !job) {
return (
<div className="tp-app">
<Header />
<main>
<section className="pt-120 pb-120">
<div className="container">
<div className="row">
<div className="col-12 text-center">
<h2 className="text-danger">Job Not Found</h2>
<p className="mt-24">
The job position you are looking for does not exist or is no longer available.
</p>
<Link href="/career" className="btn mt-40">
View All Positions
</Link>
</div>
</div>
</div>
</section>
</main>
<Footer />
<CareerScrollProgressButton />
<CareerInitAnimations />
</div>
);
}
return (
<div className="tp-app">
<Header />
<main>
<JobSingle job={job} />
</main>
<Footer />
<CareerScrollProgressButton />
<CareerInitAnimations />
</div>
);
}; };
export default JobPage; export default JobPage;

View File

@@ -9,9 +9,10 @@ import Transform from "@/components/pages/services/Transform";
import Footer from "@/components/shared/layout/footer/Footer"; import Footer from "@/components/shared/layout/footer/Footer";
import ServicesScrollProgressButton from "@/components/pages/services/ServicesScrollProgressButton"; import ServicesScrollProgressButton from "@/components/pages/services/ServicesScrollProgressButton";
import ServicesInitAnimations from "@/components/pages/services/ServicesInitAnimations"; import ServicesInitAnimations from "@/components/pages/services/ServicesInitAnimations";
import { serviceService, Service } from "@/lib/api/serviceService"; import { Service } from "@/lib/api/serviceService";
import { generateServiceMetadata } from "@/lib/seo/metadata"; import { generateServiceMetadata } from "@/lib/seo/metadata";
import { ServiceSchema, BreadcrumbSchema } from "@/components/shared/seo/StructuredData"; import { ServiceSchema, BreadcrumbSchema } from "@/components/shared/seo/StructuredData";
import { API_CONFIG } from "@/lib/config/api";
interface ServicePageProps { interface ServicePageProps {
params: Promise<{ params: Promise<{
@@ -19,16 +20,30 @@ interface ServicePageProps {
}>; }>;
} }
// Force static generation - pages are pre-rendered at build time // Generate static params for all services at build time (optional - for better performance)
export const dynamic = 'force-static'; // This pre-generates known pages, but new pages can still be generated on-demand
export const dynamicParams = false; // Return 404 for unknown slugs
export const revalidate = false; // Never revalidate - fully static
// Generate static params for all services (optional - for better performance)
export async function generateStaticParams() { export async function generateStaticParams() {
try { try {
const services = await serviceService.getServices(); const response = await fetch(
return services.results.map((service: Service) => ({ `${API_CONFIG.BASE_URL}/api/services/`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
next: { revalidate: 60 }, // Revalidate every minute for faster image updates
}
);
if (!response.ok) {
console.error('Error fetching services for static params:', response.status);
return [];
}
const data = await response.json();
const services = data.results || data;
return services.map((service: Service) => ({
slug: service.slug, slug: service.slug,
})); }));
} catch (error) { } catch (error) {
@@ -39,9 +54,25 @@ export async function generateStaticParams() {
// Generate enhanced metadata for each service page // Generate enhanced metadata for each service page
export async function generateMetadata({ params }: ServicePageProps) { export async function generateMetadata({ params }: ServicePageProps) {
const { slug } = await params;
try { try {
const { slug } = await params; const response = await fetch(
const service = await serviceService.getServiceBySlug(slug); `${API_CONFIG.BASE_URL}/api/services/${slug}/`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
next: { revalidate: 60 }, // Revalidate every minute for faster image updates
}
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const service = await response.json();
return generateServiceMetadata(service); return generateServiceMetadata(service);
} catch (error) { } catch (error) {
@@ -53,23 +84,34 @@ export async function generateMetadata({ params }: ServicePageProps) {
} }
const ServicePage = async ({ params }: ServicePageProps) => { const ServicePage = async ({ params }: ServicePageProps) => {
let service: Service; const { slug } = await params;
try { try {
const { slug } = await params; const response = await fetch(
service = await serviceService.getServiceBySlug(slug); `${API_CONFIG.BASE_URL}/api/services/${slug}/`,
} catch (error) { {
notFound(); method: 'GET',
} headers: {
'Content-Type': 'application/json',
},
next: { revalidate: 60 }, // Revalidate every minute for faster image updates
}
);
// Breadcrumb data for structured data if (!response.ok) {
const breadcrumbItems = [ throw new Error(`HTTP error! status: ${response.status}`);
{ name: 'Home', url: '/' }, }
{ name: 'Services', url: '/services' },
{ name: service.title, url: `/services/${service.slug}` },
];
return ( const service: Service = await response.json();
// Breadcrumb data for structured data
const breadcrumbItems = [
{ name: 'Home', url: '/' },
{ name: 'Services', url: '/services' },
{ name: service.title, url: `/services/${service.slug}` },
];
return (
<div className="enterprise-app"> <div className="enterprise-app">
{/* SEO Structured Data */} {/* SEO Structured Data */}
<ServiceSchema service={service} /> <ServiceSchema service={service} />
@@ -88,7 +130,10 @@ const ServicePage = async ({ params }: ServicePageProps) => {
<ServicesScrollProgressButton /> <ServicesScrollProgressButton />
<ServicesInitAnimations /> <ServicesInitAnimations />
</div> </div>
); );
} catch (error) {
notFound();
}
}; };
export default ServicePage; export default ServicePage;

View File

@@ -427,32 +427,48 @@ export const serviceUtils = {
}).format(numPrice); }).format(numPrice);
}, },
// Get service image URL // Get service image URL with cache-busting
// Use relative URLs for same-domain images (Next.js can optimize via rewrites) // Use relative URLs for same-domain images (Next.js can optimize via rewrites)
// Use absolute URLs only for external images // Use absolute URLs only for external images
// Adds updated_at timestamp as query parameter for cache-busting when images change
getServiceImageUrl: (service: Service): string => { getServiceImageUrl: (service: Service): string => {
let imageUrl: string = '';
// If service has an uploaded image // If service has an uploaded image
if (service.image && typeof service.image === 'string' && service.image.startsWith('/media/')) { if (service.image && typeof service.image === 'string' && service.image.startsWith('/media/')) {
// Use relative URL - Next.js rewrite will handle fetching from backend during optimization imageUrl = service.image;
return service.image;
} }
// If service has an image_url // If service has an image_url
if (service.image_url) { else if (service.image_url) {
if (service.image_url.startsWith('http')) { if (service.image_url.startsWith('http')) {
// External URL - keep as absolute // External URL - keep as absolute
return service.image_url; imageUrl = service.image_url;
} } else if (service.image_url.startsWith('/media/')) {
if (service.image_url.startsWith('/media/')) {
// Same domain media - use relative URL // Same domain media - use relative URL
return service.image_url; imageUrl = service.image_url;
} else {
// Other relative URLs
imageUrl = service.image_url;
} }
// Other relative URLs } else {
return service.image_url; // Fallback to default image (relative is fine for public images)
imageUrl = '/images/service/default.png';
} }
// Fallback to default image (relative is fine for public images) // Add cache-busting query parameter using updated_at timestamp
return '/images/service/default.png'; // This ensures images refresh when service is updated
if (service.updated_at && imageUrl && !imageUrl.includes('?')) {
try {
const timestamp = new Date(service.updated_at).getTime();
const separator = imageUrl.includes('?') ? '&' : '?';
imageUrl = `${imageUrl}${separator}v=${timestamp}`;
} catch (error) {
// If date parsing fails, just return the URL without cache-busting
console.warn('Failed to parse updated_at for cache-busting:', error);
}
}
return imageUrl;
}, },
// Generate service slug from title // Generate service slug from title