Image error resolve
This commit is contained in:
@ -1,254 +1,417 @@
|
|||||||
// services/educationService.ts
|
// components/EducationTraining.tsx
|
||||||
export interface ApiCourse {
|
'use client';
|
||||||
id: number;
|
import { useState, useEffect } from 'react';
|
||||||
title: string;
|
import Image from 'next/image';
|
||||||
description: string;
|
import Link from 'next/link';
|
||||||
duration: string;
|
import { ChevronRight, Clock, Users, Award, Calendar } from 'lucide-react';
|
||||||
seats: number;
|
import { educationService, Course } from '../../services/educationService';
|
||||||
category: string;
|
import { upcomingEventsService, UpcomingEvent } from '../../services/upcomingEventsService';
|
||||||
level: string;
|
|
||||||
instructor: string;
|
|
||||||
price?: string;
|
|
||||||
startDate?: string;
|
|
||||||
imageUrl?: string;
|
|
||||||
eligibility: string[];
|
|
||||||
objectives: string[];
|
|
||||||
isActive: boolean;
|
|
||||||
createdDate?: string;
|
|
||||||
updatedDate?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Course {
|
const EducationTraining: React.FC = () => {
|
||||||
id: string;
|
const [mounted, setMounted] = useState(false);
|
||||||
title: string;
|
const [courses, setCourses] = useState<Course[]>([]);
|
||||||
description: string;
|
const [upcomingEvents, setUpcomingEvents] = useState<UpcomingEvent[]>([]);
|
||||||
duration: string;
|
const [loading, setLoading] = useState(true);
|
||||||
seats: number;
|
const [error, setError] = useState<string | null>(null);
|
||||||
category: string;
|
const [selectedCategory, setSelectedCategory] = useState<string>('All');
|
||||||
level: string;
|
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||||
instructor: string;
|
|
||||||
price: string;
|
|
||||||
startDate: string;
|
|
||||||
image: string;
|
|
||||||
eligibility: string[];
|
|
||||||
objectives: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CourseApplicationData {
|
const categories = ['All', 'Certification', 'Training', 'Workshop', 'Fellowship'];
|
||||||
courseId: number;
|
|
||||||
fullName: string;
|
|
||||||
email: string;
|
|
||||||
phone: string;
|
|
||||||
qualification: string;
|
|
||||||
experience?: string;
|
|
||||||
coverLetter?: string;
|
|
||||||
resumeUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
class EducationService {
|
useEffect(() => {
|
||||||
private apiBaseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';
|
setMounted(true);
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Helper method to convert relative URLs to absolute URLs
|
const loadData = async () => {
|
||||||
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 {
|
try {
|
||||||
const response = await fetch(`${this.apiBaseUrl}/api/courses/active`, {
|
setLoading(true);
|
||||||
cache: 'no-store'
|
setError(null);
|
||||||
});
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async getCourseById(id: number): Promise<Course | null> {
|
// Load both courses and upcoming events concurrently
|
||||||
try {
|
const [fetchedCourses, fetchedEvents] = await Promise.all([
|
||||||
const token = typeof window !== 'undefined' ? localStorage.getItem('authToken') : null;
|
educationService.getActiveCourses(),
|
||||||
const response = await fetch(`${this.apiBaseUrl}/api/courses/${id}`, {
|
upcomingEventsService.getActiveUpcomingEvents()
|
||||||
headers: {
|
]);
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
||||||
},
|
|
||||||
cache: 'no-store'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
setCourses(fetchedCourses);
|
||||||
if (response.status === 404) return null;
|
setUpcomingEvents(fetchedEvents);
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
} 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();
|
if (!mounted) {
|
||||||
return this.transformApiCourseToCourse(apiCourse);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error fetching course ${id}:`, error);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
async submitApplication(applicationData: CourseApplicationData): Promise<boolean> {
|
// Filter courses based on category and search
|
||||||
try {
|
const filteredCourses = courses.filter(course => {
|
||||||
const response = await fetch(`${this.apiBaseUrl}/api/course-applications`, {
|
const matchesCategory = selectedCategory === 'All' || course.category === selectedCategory;
|
||||||
method: 'POST',
|
const matchesSearch = !searchQuery.trim() ||
|
||||||
headers: {
|
course.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
'Content-Type': 'application/json',
|
course.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
},
|
course.instructor.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
body: JSON.stringify(applicationData),
|
|
||||||
|
return matchesCategory && matchesSearch;
|
||||||
});
|
});
|
||||||
|
|
||||||
return response.ok;
|
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
} catch (error) {
|
setSearchQuery(e.target.value);
|
||||||
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 || []
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 {
|
return (
|
||||||
const defaultImages = [
|
<div className="min-h-screen">
|
||||||
"https://images.unsplash.com/photo-1576091160550-2173dba999ef?w=400&h=200&fit=crop&crop=center",
|
{/* Header Section */}
|
||||||
"https://images.unsplash.com/photo-1559757175-0eb30cd8c063?w=400&h=300&fit=crop&crop=center",
|
<section
|
||||||
"https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?w=400&h=300&fit=crop&crop=center",
|
className="py-4 relative overflow-hidden"
|
||||||
"https://images.unsplash.com/photo-1582750433449-648ed127bb54?w=400&h=300&fit=crop&crop=center",
|
style={{ backgroundColor: '#f4f4f4' }}
|
||||||
"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"
|
<div className="max-w-7xl mx-auto px-4 relative z-10">
|
||||||
];
|
{/* Breadcrumb */}
|
||||||
return defaultImages[Math.floor(Math.random() * defaultImages.length)];
|
<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[] {
|
{/* Page Header */}
|
||||||
return [
|
<div className="mb-2">
|
||||||
{
|
<h1 className="text-4xl font-bold mb-4" style={{ color: '#012068' }}>
|
||||||
id: '1',
|
Education & Training
|
||||||
title: "ATLS® (Advanced Trauma Life Support)",
|
</h1>
|
||||||
description: "Eligibility: MBBS + internship complete. Last Course: Aug 31 – Sep 2, 2023 (60 doctors certified). Next Schedule: [#Incomplete – Date TBD]",
|
<p className="text-lg max-w-3xl leading-relaxed" style={{ color: '#333' }}>
|
||||||
duration: "3 Days",
|
Advance your trauma care expertise with structured training programs for doctors, nurses, students, and community partners.
|
||||||
seats: 60,
|
</p>
|
||||||
category: "Certification",
|
</div>
|
||||||
level: "Professional",
|
</div>
|
||||||
image: "https://images.unsplash.com/photo-1576091160550-2173dba999ef?w=400&h=200&fit=crop&crop=center",
|
</section>
|
||||||
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"]
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
class EducationService {
|
||||||
private apiBaseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';
|
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[]> {
|
async getActiveCourses(): Promise<Course[]> {
|
||||||
try {
|
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) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
}
|
}
|
||||||
@ -58,18 +76,19 @@ class EducationService {
|
|||||||
return this.transformApiCoursesToCourses(apiCourses);
|
return this.transformApiCoursesToCourses(apiCourses);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching courses:', 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> {
|
async getCourseById(id: number): Promise<Course | null> {
|
||||||
try {
|
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}`, {
|
const response = await fetch(`${this.apiBaseUrl}/api/courses/${id}`, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
},
|
},
|
||||||
|
cache: 'no-store'
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@ -85,7 +104,6 @@ class EducationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async submitApplication(applicationData: CourseApplicationData): Promise<boolean> {
|
async submitApplication(applicationData: CourseApplicationData): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${this.apiBaseUrl}/api/course-applications`, {
|
const response = await fetch(`${this.apiBaseUrl}/api/course-applications`, {
|
||||||
@ -119,29 +137,12 @@ class EducationService {
|
|||||||
instructor: apiCourse.instructor,
|
instructor: apiCourse.instructor,
|
||||||
price: apiCourse.price || 'N/A',
|
price: apiCourse.price || 'N/A',
|
||||||
startDate: apiCourse.startDate || '',
|
startDate: apiCourse.startDate || '',
|
||||||
image: this.getImageUrl(apiCourse.imageUrl),
|
image: this.getFullImageUrl(apiCourse.imageUrl),
|
||||||
eligibility: apiCourse.eligibility || [],
|
eligibility: apiCourse.eligibility || [],
|
||||||
objectives: apiCourse.objectives || []
|
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 {
|
private getRandomDefaultImage(): string {
|
||||||
const defaultImages = [
|
const defaultImages = [
|
||||||
"https://images.unsplash.com/photo-1576091160550-2173dba999ef?w=400&h=200&fit=crop&crop=center",
|
"https://images.unsplash.com/photo-1576091160550-2173dba999ef?w=400&h=200&fit=crop&crop=center",
|
||||||
@ -155,7 +156,6 @@ class EducationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getFallbackCourses(): Course[] {
|
private getFallbackCourses(): Course[] {
|
||||||
// Return the original hardcoded courses as fallback
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: '1',
|
id: '1',
|
||||||
|
|||||||
Reference in New Issue
Block a user