feat: Build Education, Projects, and Contact sections (Task 9)

This commit is contained in:
2026-02-10 16:54:25 +00:00
parent 38e6d549a6
commit 13c2aa2121
4 changed files with 305 additions and 18 deletions
+6 -18
View File
@@ -6,6 +6,9 @@ import { FloatingNav } from './components/FloatingNav'
import { Hero } from './components/Hero' import { Hero } from './components/Hero'
import { Skills } from './components/Skills' import { Skills } from './components/Skills'
import { Experience } from './components/Experience' import { Experience } from './components/Experience'
import { Education } from './components/Education'
import { Projects } from './components/Projects'
import { Contact } from './components/Contact'
function App() { function App() {
const [phase, setPhase] = useState<Phase>('boot') const [phase, setPhase] = useState<Phase>('boot')
@@ -30,26 +33,11 @@ function App() {
<Experience /> <Experience />
<section id="education" className="py-20"> <Education />
<h2 className="font-primary text-2xl font-bold text-heading text-center mb-8">
Education
</h2>
<p className="text-muted text-center">Education section will be built in Task 9</p>
</section>
<section id="projects" className="py-20"> <Projects />
<h2 className="font-primary text-2xl font-bold text-heading text-center mb-8">
Projects
</h2>
<p className="text-muted text-center">Projects section will be built in Task 9</p>
</section>
<section id="contact" className="py-20"> <Contact />
<h2 className="font-primary text-2xl font-bold text-heading text-center mb-8">
Contact
</h2>
<p className="text-muted text-center">Contact section will be built in Task 9</p>
</section>
</main> </main>
</> </>
)} )}
+108
View File
@@ -0,0 +1,108 @@
import { motion } from 'framer-motion'
import { Phone, Mail, Linkedin, MapPin } from 'lucide-react'
import { useScrollReveal } from '@/hooks/useScrollReveal'
import type { ContactItem } from '@/types'
const contactData: ContactItem[] = [
{
icon: 'phone',
value: '07795553088',
label: 'Phone',
},
{
icon: 'mail',
value: 'andy@charlwood.xyz',
label: 'Email',
href: 'mailto:andy@charlwood.xyz',
},
{
icon: 'linkedin',
value: 'linkedin.com/in/andrewcharlwood',
label: 'LinkedIn',
href: 'https://linkedin.com/in/andrewcharlwood',
},
{
icon: 'mapPin',
value: 'Norwich, UK',
label: 'Location',
},
]
const iconMap = {
phone: Phone,
mail: Mail,
linkedin: Linkedin,
mapPin: MapPin,
}
const ContactItemCard = ({
item,
delay,
isVisible,
}: {
item: ContactItem
delay: number
isVisible: boolean
}) => {
const Icon = iconMap[item.icon]
return (
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={isVisible ? { opacity: 1, y: 0 } : { opacity: 0, y: 16 }}
transition={{ duration: 0.5, delay, ease: 'easeOut' }}
className="text-center"
>
<div className="w-10 h-10 rounded-full bg-[rgba(0,137,123,0.08)] flex items-center justify-center mx-auto mb-2 text-teal">
<Icon size={18} />
</div>
<div className="font-secondary text-[13px] text-heading break-words">
{item.href ? (
<a
href={item.href}
target={item.href.startsWith('http') ? '_blank' : undefined}
rel={item.href.startsWith('http') ? 'noopener noreferrer' : undefined}
className="text-teal hover:text-[#00796B] transition-colors"
>
{item.value}
</a>
) : (
item.value
)}
</div>
<div className="font-secondary text-[10px] uppercase tracking-wider text-muted mt-0.5">
{item.label}
</div>
</motion.div>
)
}
export function Contact() {
const [sectionRef, isVisible] = useScrollReveal<HTMLElement>({
threshold: 0.1,
})
return (
<section id="contact" ref={sectionRef} className="py-20">
<motion.h2
initial={{ opacity: 0, y: 12 }}
animate={isVisible ? { opacity: 1, y: 0 } : { opacity: 0, y: 12 }}
transition={{ duration: 0.5 }}
className="font-primary text-2xl font-bold text-heading text-center mb-8"
>
Contact
</motion.h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{contactData.map((item, index) => (
<ContactItemCard
key={item.label}
item={item}
delay={0.1 + index * 0.1}
isVisible={isVisible}
/>
))}
</div>
</section>
)
}
+86
View File
@@ -0,0 +1,86 @@
import { motion } from 'framer-motion'
import { useScrollReveal } from '@/hooks/useScrollReveal'
import type { Education as EducationType } from '@/types'
const educationData: EducationType[] = [
{
degree: 'MPharm (Hons) Pharmacy',
institution: 'University of East Anglia',
period: '2011 — 2015',
detail: 'Upper Second-Class Honours (2:1)',
},
{
degree: 'Mary Seacole Leadership Programme',
institution: 'NHS Leadership Academy',
period: '2018',
detail: 'National healthcare leadership development programme.',
},
]
const EducationCard = ({
education,
delay,
isVisible,
}: {
education: EducationType
delay: number
isVisible: boolean
}) => {
return (
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={isVisible ? { opacity: 1, y: 0 } : { opacity: 0, y: 16 }}
transition={{ duration: 0.5, delay, ease: 'easeOut' }}
className="relative bg-white rounded-2xl p-6 shadow-sm overflow-hidden transition-shadow hover:shadow-md hover:-translate-y-0.5"
>
<div className="absolute top-0 left-0 right-0 h-[3px] bg-gradient-to-r from-teal to-coral" />
<h3 className="font-primary text-[17px] font-semibold text-heading leading-tight">
{education.degree}
</h3>
<p className="text-sm text-teal mt-0.5">{education.institution}</p>
<p className="text-[13px] text-muted mt-0.5">{education.period}</p>
<p className="text-sm text-text mt-1.5 leading-relaxed">
{education.detail}
</p>
</motion.div>
)
}
export function Education() {
const [sectionRef, isVisible] = useScrollReveal<HTMLElement>({
threshold: 0.1,
})
return (
<section id="education" ref={sectionRef} className="py-20">
<motion.h2
initial={{ opacity: 0, y: 12 }}
animate={isVisible ? { opacity: 1, y: 0 } : { opacity: 0, y: 12 }}
transition={{ duration: 0.5 }}
className="font-primary text-2xl font-bold text-heading text-center mb-8"
>
Education
</motion.h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{educationData.map((education, index) => (
<EducationCard
key={education.degree}
education={education}
delay={0.1 + index * 0.1}
isVisible={isVisible}
/>
))}
</div>
<motion.p
initial={{ opacity: 0 }}
animate={isVisible ? { opacity: 1 } : { opacity: 0 }}
transition={{ duration: 0.5, delay: 0.4 }}
className="text-[13px] text-muted text-center mt-5"
>
A-Levels: Mathematics (A*), Chemistry (B), Politics (C)
</motion.p>
</section>
)
}
+105
View File
@@ -0,0 +1,105 @@
import { motion } from 'framer-motion'
import { ExternalLink } from 'lucide-react'
import { useScrollReveal } from '@/hooks/useScrollReveal'
import type { Project as ProjectType } from '@/types'
const projectsData: ProjectType[] = [
{
title: 'PharMetrics',
description:
'Real-time medicines expenditure dashboard providing actionable analytics for NHS decision-makers.',
link: 'https://medicines.charlwood.xyz/',
},
{
title: 'Patient Pathway Analysis',
description:
'Data-driven analysis of patient pathways to identify optimisation opportunities and improve clinical outcomes.',
},
{
title: 'Blueteq Generator',
description:
'Automation tool reducing high-cost drug approval processing time by 70%, saving 200+ hours annually.',
},
{
title: 'NMS Video',
description:
'Educational video resource supporting New Medicine Service consultations, improving patient engagement.',
},
]
const ProjectCard = ({
project,
delay,
isVisible,
}: {
project: ProjectType
delay: number
isVisible: boolean
}) => {
return (
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={isVisible ? { opacity: 1, y: 0 } : { opacity: 0, y: 16 }}
transition={{ duration: 0.5, delay, ease: 'easeOut' }}
className="group relative bg-white rounded-2xl p-6 shadow-sm overflow-hidden transition-all hover:shadow-md hover:-translate-y-0.5"
>
<div
className="absolute inset-0 rounded-2xl p-[2px] opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none"
style={{
background: 'linear-gradient(135deg, #00897B, #FF6B6B)',
WebkitMask:
'linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)',
WebkitMaskComposite: 'xor',
maskComposite: 'exclude',
}}
/>
<h3 className="font-primary text-base font-semibold text-heading leading-tight">
{project.title}
</h3>
<p className="text-sm text-text leading-relaxed mt-2">
{project.description}
</p>
{project.link && (
<a
href={project.link}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 mt-3 px-4 py-1.5 bg-teal text-white rounded-full text-xs font-medium font-secondary transition-all hover:bg-[#00796B] hover:-translate-y-px"
>
Visit Project
<ExternalLink size={12} />
</a>
)}
</motion.div>
)
}
export function Projects() {
const [sectionRef, isVisible] = useScrollReveal<HTMLElement>({
threshold: 0.1,
})
return (
<section id="projects" ref={sectionRef} className="py-20">
<motion.h2
initial={{ opacity: 0, y: 12 }}
animate={isVisible ? { opacity: 1, y: 0 } : { opacity: 0, y: 12 }}
transition={{ duration: 0.5 }}
className="font-primary text-2xl font-bold text-heading text-center mb-8"
>
Projects
</motion.h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{projectsData.map((project, index) => (
<ProjectCard
key={project.title}
project={project}
delay={0.1 + index * 0.1}
isVisible={isVisible}
/>
))}
</div>
</section>
)
}