Image error resolve
This commit is contained in:
@ -1,254 +1,417 @@
|
||||
// services/educationService.ts
|
||||
export interface ApiCourse {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
duration: string;
|
||||
seats: number;
|
||||
category: string;
|
||||
level: string;
|
||||
instructor: string;
|
||||
price?: string;
|
||||
startDate?: string;
|
||||
imageUrl?: string;
|
||||
eligibility: string[];
|
||||
objectives: string[];
|
||||
isActive: boolean;
|
||||
createdDate?: string;
|
||||
updatedDate?: string;
|
||||
}
|
||||
// components/EducationTraining.tsx
|
||||
'use client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { ChevronRight, Clock, Users, Award, Calendar } from 'lucide-react';
|
||||
import { educationService, Course } from '../../services/educationService';
|
||||
import { upcomingEventsService, UpcomingEvent } from '../../services/upcomingEventsService';
|
||||
|
||||
export interface Course {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
duration: string;
|
||||
seats: number;
|
||||
category: string;
|
||||
level: string;
|
||||
instructor: string;
|
||||
price: string;
|
||||
startDate: string;
|
||||
image: string;
|
||||
eligibility: string[];
|
||||
objectives: string[];
|
||||
}
|
||||
const EducationTraining: React.FC = () => {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [courses, setCourses] = useState<Course[]>([]);
|
||||
const [upcomingEvents, setUpcomingEvents] = useState<UpcomingEvent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('All');
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
|
||||
export interface CourseApplicationData {
|
||||
courseId: number;
|
||||
fullName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
qualification: string;
|
||||
experience?: string;
|
||||
coverLetter?: string;
|
||||
resumeUrl?: string;
|
||||
}
|
||||
const categories = ['All', 'Certification', 'Training', 'Workshop', 'Fellowship'];
|
||||
|
||||
class EducationService {
|
||||
private apiBaseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
// Helper method to convert relative URLs to absolute URLs
|
||||
private getFullImageUrl(imageUrl: string | undefined): string {
|
||||
if (!imageUrl || imageUrl.trim() === '') {
|
||||
return this.getRandomDefaultImage();
|
||||
}
|
||||
|
||||
// If it's already a full URL, return as-is
|
||||
if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) {
|
||||
return imageUrl;
|
||||
}
|
||||
|
||||
// If it's a relative URL, prepend the backend API URL
|
||||
const cleanUrl = imageUrl.startsWith('/') ? imageUrl.substring(1) : imageUrl;
|
||||
return `${this.apiBaseUrl}/${cleanUrl}`;
|
||||
}
|
||||
|
||||
async getActiveCourses(): Promise<Course[]> {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const response = await fetch(`${this.apiBaseUrl}/api/courses/active`, {
|
||||
cache: 'no-store'
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const apiCourses: ApiCourse[] = await response.json();
|
||||
return this.transformApiCoursesToCourses(apiCourses);
|
||||
} catch (error) {
|
||||
console.error('Error fetching courses:', error);
|
||||
return this.getFallbackCourses();
|
||||
}
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
async getCourseById(id: number): Promise<Course | null> {
|
||||
try {
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('authToken') : null;
|
||||
const response = await fetch(`${this.apiBaseUrl}/api/courses/${id}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
cache: 'no-store'
|
||||
});
|
||||
// Load both courses and upcoming events concurrently
|
||||
const [fetchedCourses, fetchedEvents] = await Promise.all([
|
||||
educationService.getActiveCourses(),
|
||||
upcomingEventsService.getActiveUpcomingEvents()
|
||||
]);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) return null;
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
setCourses(fetchedCourses);
|
||||
setUpcomingEvents(fetchedEvents);
|
||||
} catch (err) {
|
||||
setError('Failed to load courses and events. Please try again later.');
|
||||
console.error('Error loading data:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const apiCourse: ApiCourse = await response.json();
|
||||
return this.transformApiCourseToCourse(apiCourse);
|
||||
} catch (error) {
|
||||
console.error(`Error fetching course ${id}:`, error);
|
||||
if (!mounted) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async submitApplication(applicationData: CourseApplicationData): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${this.apiBaseUrl}/api/course-applications`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(applicationData),
|
||||
// Filter courses based on category and search
|
||||
const filteredCourses = courses.filter(course => {
|
||||
const matchesCategory = selectedCategory === 'All' || course.category === selectedCategory;
|
||||
const matchesSearch = !searchQuery.trim() ||
|
||||
course.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
course.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
course.instructor.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
|
||||
return matchesCategory && matchesSearch;
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error('Error submitting application:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private transformApiCoursesToCourses(apiCourses: ApiCourse[]): Course[] {
|
||||
return apiCourses.map(apiCourse => this.transformApiCourseToCourse(apiCourse));
|
||||
}
|
||||
|
||||
private transformApiCourseToCourse(apiCourse: ApiCourse): Course {
|
||||
return {
|
||||
id: apiCourse.id.toString(),
|
||||
title: apiCourse.title,
|
||||
description: apiCourse.description,
|
||||
duration: apiCourse.duration,
|
||||
seats: apiCourse.seats,
|
||||
category: apiCourse.category,
|
||||
level: apiCourse.level,
|
||||
instructor: apiCourse.instructor,
|
||||
price: apiCourse.price || 'N/A',
|
||||
startDate: apiCourse.startDate || '',
|
||||
image: this.getFullImageUrl(apiCourse.imageUrl),
|
||||
eligibility: apiCourse.eligibility || [],
|
||||
objectives: apiCourse.objectives || []
|
||||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchQuery(e.target.value);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-900 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">Loading courses...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private getRandomDefaultImage(): string {
|
||||
const defaultImages = [
|
||||
"https://images.unsplash.com/photo-1576091160550-2173dba999ef?w=400&h=200&fit=crop&crop=center",
|
||||
"https://images.unsplash.com/photo-1559757175-0eb30cd8c063?w=400&h=300&fit=crop&crop=center",
|
||||
"https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?w=400&h=300&fit=crop&crop=center",
|
||||
"https://images.unsplash.com/photo-1582750433449-648ed127bb54?w=400&h=300&fit=crop&crop=center",
|
||||
"https://images.unsplash.com/photo-1551601651-2a8555f1a136?w=400&h=300&fit=crop&crop=center",
|
||||
"https://images.unsplash.com/photo-1559757148-5c350d0d3c56?w=400&h=300&fit=crop&crop=center"
|
||||
];
|
||||
return defaultImages[Math.floor(Math.random() * defaultImages.length)];
|
||||
}
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{/* Header Section */}
|
||||
<section
|
||||
className="py-4 relative overflow-hidden"
|
||||
style={{ backgroundColor: '#f4f4f4' }}
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-4 relative z-10">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center space-x-2 text-sm mb-6">
|
||||
<Link
|
||||
href="/"
|
||||
className="hover:opacity-70 transition-opacity duration-200"
|
||||
style={{ color: '#012068' }}
|
||||
>
|
||||
Home
|
||||
</Link>
|
||||
<ChevronRight className="w-4 h-4" style={{ color: '#012068' }} />
|
||||
<span className="font-medium" style={{ color: '#e64838' }}>
|
||||
Education & Training
|
||||
</span>
|
||||
</nav>
|
||||
|
||||
private getFallbackCourses(): Course[] {
|
||||
return [
|
||||
{
|
||||
id: '1',
|
||||
title: "ATLS® (Advanced Trauma Life Support)",
|
||||
description: "Eligibility: MBBS + internship complete. Last Course: Aug 31 – Sep 2, 2023 (60 doctors certified). Next Schedule: [#Incomplete – Date TBD]",
|
||||
duration: "3 Days",
|
||||
seats: 60,
|
||||
category: "Certification",
|
||||
level: "Professional",
|
||||
image: "https://images.unsplash.com/photo-1576091160550-2173dba999ef?w=400&h=200&fit=crop&crop=center",
|
||||
instructor: "Trauma Faculty Team",
|
||||
price: "N/A",
|
||||
startDate: "2023-08-31",
|
||||
eligibility: ["MBBS + internship complete"],
|
||||
objectives: ["Advanced trauma life support skills", "Emergency trauma management"]
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: "ATCN® (Advanced Trauma Care for Nurses)",
|
||||
description: "First Course: Apr 11-13, 2024 (manikin-based training). Participants: 40 critical care nurses from CMC and partner hospitals. Next Batch: [#Incomplete – Date TBD]",
|
||||
duration: "3 Days",
|
||||
seats: 40,
|
||||
category: "Training",
|
||||
level: "Professional",
|
||||
image: "https://images.unsplash.com/photo-1559757175-0eb30cd8c063?w=400&h=300&fit=crop&crop=center",
|
||||
instructor: "Nursing Faculty Team",
|
||||
price: "N/A",
|
||||
startDate: "2024-04-11",
|
||||
eligibility: ["Registered Nurse", "Critical care experience preferred"],
|
||||
objectives: ["Advanced trauma nursing skills", "Manikin-based training proficiency"]
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: "Trauma First Responder Program",
|
||||
description: "Partners: RCPSG Hope Foundation, local colleges. Locations: Walajapet, Auxilium College—250 students trained. Curriculum: CPR, airway support, bleeding control, scene assessment.",
|
||||
duration: "Varies",
|
||||
seats: 250,
|
||||
category: "Workshop",
|
||||
level: "Beginner",
|
||||
image: "https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?w=400&h=300&fit=crop&crop=center",
|
||||
instructor: "Community Trainers",
|
||||
price: "N/A",
|
||||
startDate: "2023-01-01",
|
||||
eligibility: ["Students", "Community members"],
|
||||
objectives: ["CPR proficiency", "Basic trauma response", "Scene safety assessment"]
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: "FNB in Trauma Surgery",
|
||||
description: "3-year structured training program in acute surgery, ICU management, and research. Open to MS-qualified surgeons seeking specialized trauma surgery expertise.",
|
||||
duration: "3 Years",
|
||||
seats: 8,
|
||||
category: "Certification",
|
||||
level: "Advanced",
|
||||
image: "https://images.unsplash.com/photo-1582750433449-648ed127bb54?w=400&h=300&fit=crop&crop=center",
|
||||
instructor: "Senior Trauma Surgeons",
|
||||
price: "N/A",
|
||||
startDate: "2025-07-01",
|
||||
eligibility: ["MS qualification in Surgery", "Valid medical license"],
|
||||
objectives: ["Advanced trauma surgery skills", "ICU management", "Research methodology"]
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
title: "Observerships & Electives",
|
||||
description: "4-8 week clinical blocks for national and international residents. Includes ATLS® course access and hands-on trauma experience. Application by email required.",
|
||||
duration: "4-8 Weeks",
|
||||
seats: 20,
|
||||
category: "Training",
|
||||
level: "Intermediate",
|
||||
image: "https://images.unsplash.com/photo-1551601651-2a8555f1a136?w=400&h=300&fit=crop&crop=center",
|
||||
instructor: "Clinical Faculty",
|
||||
price: "N/A",
|
||||
startDate: "2025-01-15",
|
||||
eligibility: ["Medical residency status", "Valid medical credentials"],
|
||||
objectives: ["Clinical observation skills", "Hands-on trauma experience", "ATLS certification"]
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
title: "Nursing Skills Lab",
|
||||
description: "Trauma-focused skills laboratory sessions open quarterly (Q2, Q4). Includes chest tube insertion, airway management drills, and EFAST simulation training.",
|
||||
duration: "2 Days",
|
||||
seats: 30,
|
||||
category: "Workshop",
|
||||
level: "Intermediate",
|
||||
image: "https://images.unsplash.com/photo-1559757148-5c350d0d3c56?w=400&h=300&fit=crop&crop=center",
|
||||
instructor: "Nursing Skills Faculty",
|
||||
price: "N/A",
|
||||
startDate: "2025-04-01",
|
||||
eligibility: ["Licensed nurse", "Basic trauma knowledge"],
|
||||
objectives: ["Chest tube insertion", "Airway management", "EFAST simulation"]
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
{/* Page Header */}
|
||||
<div className="mb-2">
|
||||
<h1 className="text-4xl font-bold mb-4" style={{ color: '#012068' }}>
|
||||
Education & Training
|
||||
</h1>
|
||||
<p className="text-lg max-w-3xl leading-relaxed" style={{ color: '#333' }}>
|
||||
Advance your trauma care expertise with structured training programs for doctors, nurses, students, and community partners.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
export const educationService = new EducationService();
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="max-w-7xl mx-auto px-4 py-4">
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<p className="text-red-800 text-sm">{error}</p>
|
||||
<button
|
||||
onClick={loadData}
|
||||
className="text-red-600 underline text-sm mt-2"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upcoming Training Section with Dynamic Cards */}
|
||||
<section className="py-8" style={{ backgroundColor: '#fff' }}>
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<h2 className="text-3xl font-bold mb-8 text-center" style={{ color: '#012068' }}>
|
||||
Upcoming Training Programs
|
||||
</h2>
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{upcomingEvents.length > 0 ? (
|
||||
upcomingEvents.map((event) => (
|
||||
<div key={event.id} className="bg-white rounded-xl p-6 shadow-lg hover:shadow-xl transition-shadow duration-300 border-l-4" style={{ borderLeftColor: '#012068' }}>
|
||||
<div className="flex items-center mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold" style={{ color: '#012068' }}>
|
||||
{event.title}
|
||||
</h3>
|
||||
<div className="flex items-center text-sm">
|
||||
<Calendar className="w-4 h-4 mr-1" style={{ color: '#e64838' }} />
|
||||
<span style={{ color: '#666' }}>{event.schedule}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed" style={{ color: '#666' }}>
|
||||
{event.description}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
// Fallback to static cards if no events are loaded
|
||||
<>
|
||||
<div className="bg-white rounded-xl p-6 shadow-lg hover:shadow-xl transition-shadow duration-300 border-l-4" style={{ borderLeftColor: '#012068' }}>
|
||||
<div className="flex items-center mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold" style={{ color: '#012068' }}>
|
||||
Simulation-based Team Drills
|
||||
</h3>
|
||||
<div className="flex items-center text-sm">
|
||||
<Calendar className="w-4 h-4 mr-1" style={{ color: '#e64838' }} />
|
||||
<span>Q3 2025</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed" style={{ color: '#666' }}>
|
||||
Hands-on simulation training designed to improve team coordination and emergency response in high-pressure trauma situations.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl p-6 shadow-lg hover:shadow-xl transition-shadow duration-300 border-l-4" style={{ borderLeftColor: '#012068' }}>
|
||||
<div className="flex items-center mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold" style={{ color: '#012068' }}>
|
||||
Online Webinar Series
|
||||
</h3>
|
||||
<div className="flex items-center text-sm">
|
||||
<Calendar className="w-4 h-4 mr-1" style={{ color: '#e64838' }} />
|
||||
<span>Monthly Sessions</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed" style={{ color: '#666' }}>
|
||||
Monthly online sessions covering trauma ethics, young doctor support, and professional development in emergency medicine.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl p-6 shadow-lg hover:shadow-xl transition-shadow duration-300 border-l-4" style={{ borderLeftColor: '#012068' }}>
|
||||
<div className="flex items-center mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold" style={{ color: '#012068' }}>
|
||||
Community Education
|
||||
</h3>
|
||||
<div className="flex items-center text-sm">
|
||||
<Calendar className="w-4 h-4 mr-1" style={{ color: '#e64838' }} />
|
||||
<span>Ongoing</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed" style={{ color: '#666' }}>
|
||||
Road safety fairs and school education sessions to promote trauma prevention and basic first aid awareness in the community.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Filter & Search */}
|
||||
<section className="py-8" style={{ backgroundColor: '#fff' }}>
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-8">
|
||||
{/* Category Filter */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={category}
|
||||
onClick={() => setSelectedCategory(category)}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors duration-200 ${selectedCategory === category
|
||||
? 'text-white'
|
||||
: 'border-2'
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: selectedCategory === category ? '#012068' : 'transparent',
|
||||
borderColor: '#012068',
|
||||
color: selectedCategory === category ? 'white' : '#012068'
|
||||
}}
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search programs..."
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
className="border-2 rounded-lg px-4 py-2 pl-4 pr-10 text-sm focus:outline-none w-64"
|
||||
style={{ borderColor: '#012068', color:'#333' }}
|
||||
/>
|
||||
<button className="absolute inset-y-0 right-0 flex items-center px-3 rounded-lg"
|
||||
style={{ backgroundColor: '#012068' }}>
|
||||
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results Info */}
|
||||
{(selectedCategory !== 'All' || searchQuery.trim()) && (
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
{filteredCourses.length === 0
|
||||
? 'No courses found matching your criteria.'
|
||||
: `Showing ${filteredCourses.length} of ${courses.length} course${courses.length !== 1 ? 's' : ''}`
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Courses Grid */}
|
||||
<section className="pb-12" style={{ backgroundColor: '#fff' }}>
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
{filteredCourses.length === 0 && !error ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 text-lg mb-4">
|
||||
{searchQuery.trim() || selectedCategory !== 'All'
|
||||
? 'No courses match your search criteria.'
|
||||
: 'No courses available at the moment.'
|
||||
}
|
||||
</p>
|
||||
{(searchQuery.trim() || selectedCategory !== 'All') && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
setSelectedCategory('All');
|
||||
}}
|
||||
className="px-4 py-2 text-sm border border-blue-900 text-blue-900 rounded hover:bg-blue-50"
|
||||
>
|
||||
Clear Filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3 auto-rows-fr">
|
||||
{filteredCourses.map((course) => (
|
||||
<Link
|
||||
key={course.id}
|
||||
href={`/education-training/course-detail?id=${course.id}`}
|
||||
className="group bg-white rounded-lg overflow-hidden border border-gray-300 hover:shadow-xl transition-all duration-300 flex flex-col h-full cursor-pointer"
|
||||
>
|
||||
{/* Image */}
|
||||
<div className="relative h-48 overflow-hidden flex-shrink-0">
|
||||
<Image
|
||||
src={course.image}
|
||||
alt={course.title}
|
||||
fill
|
||||
className="object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex flex-col flex-grow">
|
||||
<div className="p-6 flex-grow flex flex-col">
|
||||
<h3
|
||||
className="text-lg font-semibold mb-3 group-hover:opacity-70 transition-opacity duration-300 h-14 overflow-hidden"
|
||||
style={{
|
||||
color: '#012068',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical'
|
||||
}}
|
||||
>
|
||||
{course.title}
|
||||
</h3>
|
||||
<p
|
||||
className="text-sm leading-relaxed mb-4 flex-grow overflow-hidden"
|
||||
style={{
|
||||
color: '#666',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 4,
|
||||
WebkitBoxOrient: 'vertical'
|
||||
}}
|
||||
>
|
||||
{course.description}
|
||||
</p>
|
||||
<div className="mt-auto">
|
||||
<span
|
||||
className="inline-block px-3 py-1 text-xs font-medium rounded-full border-2"
|
||||
style={{
|
||||
color: '#e64838',
|
||||
backgroundColor: 'transparent'
|
||||
}}
|
||||
>
|
||||
{course.category}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 pb-6 pt-2 border-t border-gray-100 flex-shrink-0">
|
||||
<div className="flex items-end mb-3">
|
||||
<span className="text-sm font-medium truncate" style={{ color: '#012068' }}>
|
||||
{course.instructor}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center mb-3 text-sm" style={{ color: '#666' }}>
|
||||
<div className="flex items-center">
|
||||
<Clock className="w-4 h-4 mr-1 flex-shrink-0" />
|
||||
<span className="truncate">{course.duration}</span>
|
||||
</div>
|
||||
<div className="flex items-center ml-2">
|
||||
<Users className="w-4 h-4 mr-1 flex-shrink-0" />
|
||||
<span>{course.seats}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="text-sm" style={{ color: '#666' }}>
|
||||
{course.startDate ? `Starts: ${new Date(course.startDate).toLocaleDateString()}` : 'Contact for dates'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<section className="py-16" style={{ backgroundColor: '#f4f4f4' }}>
|
||||
<div className="max-w-4xl mx-auto px-4 text-center">
|
||||
<Award className="w-12 h-12 mx-auto mb-6" style={{ color: '#e64838' }} />
|
||||
<h2 className="text-3xl font-bold mb-4" style={{ color: '#012068' }}>
|
||||
Ready to Advance Your Trauma Care Expertise?
|
||||
</h2>
|
||||
<p className="text-lg mb-8 max-w-2xl mx-auto" style={{ color: '#666' }}>
|
||||
Join our structured training programs designed to empower healthcare professionals, nurses, and community responders with critical trauma care skills.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Link
|
||||
href="/contact"
|
||||
className="inline-block px-6 py-3 text-sm font-medium rounded hover:opacity-90 transition-opacity duration-300"
|
||||
style={{
|
||||
backgroundColor: '#012068',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
Contact Admissions
|
||||
</Link>
|
||||
<Link
|
||||
href="/#"
|
||||
className="inline-block px-6 py-3 text-sm font-medium rounded border-2 hover:opacity-70 transition-opacity duration-300"
|
||||
style={{
|
||||
borderColor: '#012068',
|
||||
color: '#012068'
|
||||
}}
|
||||
>
|
||||
Download Brochure
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EducationTraining;
|
||||
@ -48,9 +48,27 @@ export interface CourseApplicationData {
|
||||
class EducationService {
|
||||
private apiBaseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';
|
||||
|
||||
// Helper method to convert relative URLs to absolute URLs
|
||||
private getFullImageUrl(imageUrl: string | undefined): string {
|
||||
if (!imageUrl || imageUrl.trim() === '') {
|
||||
return this.getRandomDefaultImage();
|
||||
}
|
||||
|
||||
// If it's already a full URL, return as-is
|
||||
if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) {
|
||||
return imageUrl;
|
||||
}
|
||||
|
||||
// If it's a relative URL, prepend the backend API URL
|
||||
const cleanUrl = imageUrl.startsWith('/') ? imageUrl.substring(1) : imageUrl;
|
||||
return `${this.apiBaseUrl}/${cleanUrl}`;
|
||||
}
|
||||
|
||||
async getActiveCourses(): Promise<Course[]> {
|
||||
try {
|
||||
const response = await fetch(`${this.apiBaseUrl}/api/courses/active`);
|
||||
const response = await fetch(`${this.apiBaseUrl}/api/courses/active`, {
|
||||
cache: 'no-store'
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
@ -58,18 +76,19 @@ class EducationService {
|
||||
return this.transformApiCoursesToCourses(apiCourses);
|
||||
} catch (error) {
|
||||
console.error('Error fetching courses:', error);
|
||||
return this.getFallbackCourses(); // Return fallback data if API fails
|
||||
return this.getFallbackCourses();
|
||||
}
|
||||
}
|
||||
|
||||
async getCourseById(id: number): Promise<Course | null> {
|
||||
try {
|
||||
const token = localStorage.getItem('authToken'); // Or from cookies/session
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('authToken') : null;
|
||||
const response = await fetch(`${this.apiBaseUrl}/api/courses/${id}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
cache: 'no-store'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@ -83,8 +102,7 @@ class EducationService {
|
||||
console.error(`Error fetching course ${id}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async submitApplication(applicationData: CourseApplicationData): Promise<boolean> {
|
||||
try {
|
||||
@ -119,29 +137,12 @@ class EducationService {
|
||||
instructor: apiCourse.instructor,
|
||||
price: apiCourse.price || 'N/A',
|
||||
startDate: apiCourse.startDate || '',
|
||||
image: this.getImageUrl(apiCourse.imageUrl),
|
||||
image: this.getFullImageUrl(apiCourse.imageUrl),
|
||||
eligibility: apiCourse.eligibility || [],
|
||||
objectives: apiCourse.objectives || []
|
||||
};
|
||||
}
|
||||
|
||||
private getImageUrl(imageUrl?: string): string {
|
||||
if (imageUrl) {
|
||||
// If imageUrl starts with /uploads/, prepend the full API path
|
||||
if (imageUrl.startsWith('/uploads/')) {
|
||||
return `${this.apiBaseUrl}/api/files${imageUrl}`; // This adds /api/files before /uploads/
|
||||
}
|
||||
// If it's already a full URL, return as is
|
||||
if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) {
|
||||
return imageUrl;
|
||||
}
|
||||
// Otherwise, assume it's a relative path and prepend base URL with API path
|
||||
return `${this.apiBaseUrl}/api/files/${imageUrl}`;
|
||||
}
|
||||
// Return random default image if no imageUrl provided
|
||||
return this.getRandomDefaultImage();
|
||||
}
|
||||
|
||||
private getRandomDefaultImage(): string {
|
||||
const defaultImages = [
|
||||
"https://images.unsplash.com/photo-1576091160550-2173dba999ef?w=400&h=200&fit=crop&crop=center",
|
||||
@ -155,7 +156,6 @@ class EducationService {
|
||||
}
|
||||
|
||||
private getFallbackCourses(): Course[] {
|
||||
// Return the original hardcoded courses as fallback
|
||||
return [
|
||||
{
|
||||
id: '1',
|
||||
|
||||
Reference in New Issue
Block a user