Task 4: Add PatientBanner component with full/condensed modes
- Create useScrollCondensation hook with IntersectionObserver - PatientBanner with 80px full mode and 48px condensed mode - Smooth height transition (200ms) on scroll past 100px - Status dot (green) and badge for 'Open to opportunities' - Action buttons: Download CV, Email, LinkedIn - NHS blue outlined buttons with hover fill effect - Proper GPhC number formatting with tooltip - Sticky positioning for persistent visibility
This commit is contained in:
@@ -0,0 +1,189 @@
|
|||||||
|
import { Download, Mail, Linkedin } from 'lucide-react'
|
||||||
|
import { patient } from '@/data/patient'
|
||||||
|
import { useScrollCondensation } from '@/hooks/useScrollCondensation'
|
||||||
|
|
||||||
|
export function PatientBanner() {
|
||||||
|
const { isCondensed, sentinelRef } = useScrollCondensation({ threshold: 100 })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
ref={sentinelRef}
|
||||||
|
className="h-0 w-full absolute top-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<header
|
||||||
|
className={`
|
||||||
|
sticky top-0 z-40 w-full
|
||||||
|
bg-pmr-banner border-b border-slate-600
|
||||||
|
transition-all duration-200 ease-out
|
||||||
|
${isCondensed ? 'h-12' : 'h-20'}
|
||||||
|
`}
|
||||||
|
role="banner"
|
||||||
|
>
|
||||||
|
{isCondensed ? (
|
||||||
|
<CondensedBanner />
|
||||||
|
) : (
|
||||||
|
<FullBanner />
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FullBanner() {
|
||||||
|
return (
|
||||||
|
<div className="h-full px-4 lg:px-6 flex flex-col justify-center">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<h1 className="font-inter font-semibold text-white text-lg tracking-tight">
|
||||||
|
{patient.name}
|
||||||
|
</h1>
|
||||||
|
<StatusDot status={patient.status} />
|
||||||
|
<span className="text-slate-400 text-sm">{patient.status}</span>
|
||||||
|
<StatusBadge badge={patient.badge} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 mt-1 flex-wrap text-sm text-slate-300">
|
||||||
|
<span>
|
||||||
|
<span className="text-slate-500">DOB:</span> {patient.dob}
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-500">|</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="text-slate-500">NHS No:</span>{' '}
|
||||||
|
<span className="font-geist" title={patient.nhsNumberTooltip}>
|
||||||
|
{patient.nhsNumber}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-500">|</span>
|
||||||
|
<span>{patient.address}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 mt-1 flex-wrap text-sm text-slate-300">
|
||||||
|
<a
|
||||||
|
href={`tel:${patient.phone}`}
|
||||||
|
className="hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
{patient.phone}
|
||||||
|
</a>
|
||||||
|
<span className="text-slate-500">|</span>
|
||||||
|
<a
|
||||||
|
href={`mailto:${patient.email}`}
|
||||||
|
className="hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
{patient.email}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<ActionButton
|
||||||
|
icon={<Download size={14} />}
|
||||||
|
label="Download CV"
|
||||||
|
href="/cv.pdf"
|
||||||
|
/>
|
||||||
|
<ActionButton
|
||||||
|
icon={<Mail size={14} />}
|
||||||
|
label="Email"
|
||||||
|
href={`mailto:${patient.email}`}
|
||||||
|
/>
|
||||||
|
<ActionButton
|
||||||
|
icon={<Linkedin size={14} />}
|
||||||
|
label="LinkedIn"
|
||||||
|
href={`https://${patient.linkedin}`}
|
||||||
|
external
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CondensedBanner() {
|
||||||
|
return (
|
||||||
|
<div className="h-full px-4 lg:px-6 flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-4 min-w-0">
|
||||||
|
<h1 className="font-inter font-semibold text-white text-sm tracking-tight truncate">
|
||||||
|
{patient.name}
|
||||||
|
</h1>
|
||||||
|
<span className="text-slate-500">|</span>
|
||||||
|
<span className="flex items-center gap-1 text-sm text-slate-300">
|
||||||
|
<span className="text-slate-500">NHS No:</span>{' '}
|
||||||
|
<span className="font-geist" title={patient.nhsNumberTooltip}>
|
||||||
|
{patient.nhsNumber}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-500">|</span>
|
||||||
|
<StatusDot status={patient.status} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<ActionButton
|
||||||
|
icon={<Download size={14} />}
|
||||||
|
label="Download CV"
|
||||||
|
href="/cv.pdf"
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
<ActionButton
|
||||||
|
icon={<Mail size={14} />}
|
||||||
|
label="Email"
|
||||||
|
href={`mailto:${patient.email}`}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatusDotProps {
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusDot({ status }: StatusDotProps) {
|
||||||
|
const colorClass = status === 'Active' ? 'bg-pmr-green' : 'bg-slate-400'
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`w-2 h-2 rounded-full ${colorClass} flex-shrink-0`}
|
||||||
|
aria-label={`Status: ${status}`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatusBadgeProps {
|
||||||
|
badge: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ badge }: StatusBadgeProps) {
|
||||||
|
return (
|
||||||
|
<span className="px-2 py-0.5 bg-pmr-nhsblue text-white text-xs font-medium rounded-sm">
|
||||||
|
{badge}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActionButtonProps {
|
||||||
|
icon: React.ReactNode
|
||||||
|
label: string
|
||||||
|
href: string
|
||||||
|
external?: boolean
|
||||||
|
compact?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionButton({ icon, label, href, external, compact }: ActionButtonProps) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
target={external ? '_blank' : undefined}
|
||||||
|
rel={external ? 'noopener noreferrer' : undefined}
|
||||||
|
className={`
|
||||||
|
inline-flex items-center gap-1.5
|
||||||
|
border border-pmr-nhsblue text-pmr-nhsblue
|
||||||
|
hover:bg-pmr-nhsblue hover:text-white
|
||||||
|
transition-colors duration-100
|
||||||
|
rounded
|
||||||
|
${compact ? 'px-2 py-1 text-xs' : 'px-3 py-1.5 text-sm'}
|
||||||
|
font-inter font-medium
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
<span>{label}</span>
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
interface UseScrollCondensationOptions {
|
||||||
|
threshold?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useScrollCondensation(options: UseScrollCondensationOptions = {}) {
|
||||||
|
const { threshold = 100 } = options
|
||||||
|
const [isCondensed, setIsCondensed] = useState(false)
|
||||||
|
const sentinelRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sentinel = sentinelRef.current
|
||||||
|
if (!sentinel) return
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
const [entry] = entries
|
||||||
|
setIsCondensed(!entry.isIntersecting)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rootMargin: `-${threshold}px 0px 0px 0px`,
|
||||||
|
threshold: 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
observer.observe(sentinel)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.disconnect()
|
||||||
|
}
|
||||||
|
}, [threshold])
|
||||||
|
|
||||||
|
return { isCondensed, sentinelRef }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user