mobile banner v1
This commit is contained in:
@@ -50,7 +50,11 @@
|
|||||||
"mcp__plugin_playwright_playwright__browser_snapshot",
|
"mcp__plugin_playwright_playwright__browser_snapshot",
|
||||||
"mcp__plugin_playwright_playwright__browser_resize",
|
"mcp__plugin_playwright_playwright__browser_resize",
|
||||||
"mcp__plugin_playwright_playwright__browser_evaluate",
|
"mcp__plugin_playwright_playwright__browser_evaluate",
|
||||||
"mcp__plugin_playwright_playwright__browser_press_key"
|
"mcp__plugin_playwright_playwright__browser_press_key",
|
||||||
|
"Bash(npx eslint:*)",
|
||||||
|
"Bash(git checkout:*)",
|
||||||
|
"mcp__plugin_playwright_playwright__browser_hover",
|
||||||
|
"mcp__plugin_playwright_playwright__browser_run_code"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-16
@@ -1,11 +1,11 @@
|
|||||||
# Session Handoff
|
# Session Handoff
|
||||||
|
|
||||||
_Generated: 2026-02-17 21:19:40 UTC_
|
_Generated: 2026-02-18 00:42:07 UTC_
|
||||||
|
|
||||||
## Git Context
|
## Git Context
|
||||||
|
|
||||||
- **Branch:** `master`
|
- **Branch:** `master`
|
||||||
- **HEAD:** d51efb5: chore: auto-commit before merge (loop primary)
|
- **HEAD:** 134e41f: chore: auto-commit before merge (loop primary)
|
||||||
|
|
||||||
## Tasks
|
## Tasks
|
||||||
|
|
||||||
@@ -15,16 +15,16 @@ _No tasks tracked in this session._
|
|||||||
|
|
||||||
Recently modified:
|
Recently modified:
|
||||||
|
|
||||||
|
- `.claude/settings.json`
|
||||||
|
- `.claude/settings.local.json`
|
||||||
|
- `.ralph/agent/handoff.md`
|
||||||
|
- `.ralph/agent/memories.md`
|
||||||
|
- `.ralph/agent/memories.md.lock`
|
||||||
- `.ralph/agent/scratchpad.md`
|
- `.ralph/agent/scratchpad.md`
|
||||||
- `.ralph/agent/summary.md`
|
- `.ralph/agent/summary.md`
|
||||||
- `.ralph/agent/tasks.jsonl.lock`
|
- `.ralph/agent/tasks.jsonl`
|
||||||
- `.ralph/current-events`
|
- `.ralph/current-events`
|
||||||
- `.ralph/current-loop-id`
|
- `.ralph/current-loop-id`
|
||||||
- `.ralph/events-20260217-140400.jsonl`
|
|
||||||
- `.ralph/events-20260217-205901.jsonl`
|
|
||||||
- `.ralph/history.jsonl`
|
|
||||||
- `.ralph/loop.lock`
|
|
||||||
- `.ralph/plan.md`
|
|
||||||
|
|
||||||
## Next Session
|
## Next Session
|
||||||
|
|
||||||
@@ -33,14 +33,12 @@ Session completed successfully. No pending work.
|
|||||||
**Original objective:**
|
**Original objective:**
|
||||||
|
|
||||||
```
|
```
|
||||||
# Task: Fix Mobile Responsiveness for Small Viewport Widths (≤430px)
|
# Task: Portfolio UX Improvements — GP Clinical System Theme Polish
|
||||||
|
|
||||||
The portfolio website is broken on phones with viewport widths around 400px. Text overflows off-screen, elements are hidden behind `overflow: hidden`, and layout components are sized inappropriately for small screens.
|
Implement 11 prioritised UX improvements to the portfolio site. This is an interactive CV/portfolio themed as a GP primary care clinical system (like EMIS Web / SystmOne). The site should feel like a real GP system but function as a portfolio.
|
||||||
|
|
||||||
## Context
|
**Important constraints:**
|
||||||
|
- Do NOT change the overall structure or architecture
|
||||||
- **Tech stack:** React + TypeScript + Tailwind CSS + Framer Motion + D3
|
- Preserve the GP clinical system theme — improvements should reinforce it, not break it
|
||||||
- **Dev server:** `npm run dev` (localhost:5173)
|
- Respect ex...
|
||||||
- **Quality gates:** `npm run lint && npm run typecheck && npm run build`
|
|
||||||
-...
|
|
||||||
```
|
```
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh'
|
|||||||
import tseslint from 'typescript-eslint'
|
import tseslint from 'typescript-eslint'
|
||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{ ignores: ['dist'] },
|
{ ignores: ['dist', 'dist-server', 'server.ts'] },
|
||||||
{
|
{
|
||||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
files: ['**/*.{ts,tsx}'],
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
|||||||
Generated
+921
-3
File diff suppressed because it is too large
Load Diff
+6
-1
@@ -10,7 +10,8 @@
|
|||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"generate-embeddings": "npx tsx scripts/generate-embeddings.ts",
|
"generate-embeddings": "npx tsx scripts/generate-embeddings.ts",
|
||||||
"benchmark": "npx tsx scripts/benchmark.ts"
|
"benchmark": "npx tsx scripts/benchmark.ts",
|
||||||
|
"start": "node dist-server/server.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/d3": "^7.4.3",
|
"@types/d3": "^7.4.3",
|
||||||
@@ -22,11 +23,15 @@
|
|||||||
"framer-motion": "^11.15.0",
|
"framer-motion": "^11.15.0",
|
||||||
"fuse.js": "^7.1.0",
|
"fuse.js": "^7.1.0",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
|
"express": "^4.21.0",
|
||||||
|
"nodemailer": "^6.9.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.17.0",
|
"@eslint/js": "^9.17.0",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/nodemailer": "^6.4.0",
|
||||||
"@types/react": "^18.3.18",
|
"@types/react": "^18.3.18",
|
||||||
"@types/react-dom": "^18.3.5",
|
"@types/react-dom": "^18.3.5",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import express from 'express'
|
||||||
|
import nodemailer from 'nodemailer'
|
||||||
|
import path from 'path'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
const __dirname = path.dirname(__filename)
|
||||||
|
|
||||||
|
const app = express()
|
||||||
|
app.use(express.json())
|
||||||
|
|
||||||
|
// Serve static files from Vite build
|
||||||
|
app.use(express.static(path.join(__dirname, 'dist')))
|
||||||
|
|
||||||
|
// Contact API endpoint
|
||||||
|
app.post('/api/contact', async (req, res) => {
|
||||||
|
const { name, organisation, email, subject, message } = req.body
|
||||||
|
|
||||||
|
if (!name || !email || !subject || !message) {
|
||||||
|
return res.status(400).json({ success: false, message: 'All fields are required' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
if (!emailRegex.test(email)) {
|
||||||
|
return res.status(400).json({ success: false, message: 'Invalid email address' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
host: process.env.SMTP_HOST,
|
||||||
|
port: Number(process.env.SMTP_PORT),
|
||||||
|
secure: true,
|
||||||
|
auth: {
|
||||||
|
user: process.env.SMTP_USER,
|
||||||
|
pass: process.env.SMTP_PASS,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const contactEmail = process.env.CONTACT_EMAIL || 'andy@charlwood.xyz'
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Admin notification
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: `"${name}" <${process.env.SMTP_USER}>`,
|
||||||
|
replyTo: email,
|
||||||
|
to: contactEmail,
|
||||||
|
subject: `Portfolio Referral: ${subject}`,
|
||||||
|
html: `
|
||||||
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<h2 style="color: #00897B; border-bottom: 2px solid #00897B; padding-bottom: 10px;">
|
||||||
|
New Patient Referral
|
||||||
|
</h2>
|
||||||
|
<div style="background: #f5f5f5; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||||
|
<p><strong>Referring Clinician:</strong> ${name}</p>
|
||||||
|
<p><strong>Organisation:</strong> ${organisation || 'Not specified'}</p>
|
||||||
|
<p><strong>Email:</strong> ${email}</p>
|
||||||
|
<p><strong>Subject:</strong> ${subject}</p>
|
||||||
|
</div>
|
||||||
|
<div style="padding: 20px 0;">
|
||||||
|
<h3 style="color: #333;">Clinical Details:</h3>
|
||||||
|
<p style="white-space: pre-wrap; line-height: 1.6;">${message}</p>
|
||||||
|
</div>
|
||||||
|
<hr style="border: none; border-top: 1px solid #ddd; margin: 20px 0;" />
|
||||||
|
<p style="color: #666; font-size: 12px;">
|
||||||
|
This message was sent from your portfolio contact form.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Auto-reply
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: `"Andy Charlwood" <${process.env.SMTP_USER}>`,
|
||||||
|
to: email,
|
||||||
|
subject: 'Thanks for getting in touch!',
|
||||||
|
html: `
|
||||||
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<h2 style="color: #00897B; border-bottom: 2px solid #00897B; padding-bottom: 10px;">
|
||||||
|
Thanks for your message, ${name}!
|
||||||
|
</h2>
|
||||||
|
<p style="line-height: 1.6;">
|
||||||
|
I've received your referral and will get back to you as soon as possible.
|
||||||
|
</p>
|
||||||
|
<div style="background: #f5f5f5; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||||
|
<p><strong>Your message:</strong></p>
|
||||||
|
<p style="white-space: pre-wrap; color: #555;">${message}</p>
|
||||||
|
</div>
|
||||||
|
<p style="line-height: 1.6;">
|
||||||
|
Best regards,<br/>
|
||||||
|
<strong>Andy Charlwood</strong><br/>
|
||||||
|
Informatics Pharmacist · NHS Norfolk & Waveney ICB
|
||||||
|
</p>
|
||||||
|
<hr style="border: none; border-top: 1px solid #ddd; margin: 20px 0;" />
|
||||||
|
<p style="color: #666; font-size: 12px;">
|
||||||
|
This is an automated confirmation. Please do not reply to this email.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
|
||||||
|
return res.status(200).json({ success: true, message: 'Referral sent successfully!' })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Email error:', error)
|
||||||
|
return res.status(500).json({ success: false, message: 'Failed to send referral. Please try again.' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// SPA fallback
|
||||||
|
app.get('*', (_req, res) => {
|
||||||
|
res.sendFile(path.join(__dirname, 'dist', 'index.html'))
|
||||||
|
})
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 3000
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`Server running on port ${PORT}`)
|
||||||
|
})
|
||||||
@@ -11,6 +11,7 @@ import { TimelineInterventionsSubsection } from './TimelineInterventionsSubsecti
|
|||||||
import { RepeatMedicationsSubsection } from './RepeatMedicationsSubsection'
|
import { RepeatMedicationsSubsection } from './RepeatMedicationsSubsection'
|
||||||
import { LastConsultationCard } from './LastConsultationCard'
|
import { LastConsultationCard } from './LastConsultationCard'
|
||||||
import { ChatWidget } from './ChatWidget'
|
import { ChatWidget } from './ChatWidget'
|
||||||
|
import { MobilePatientBanner } from './MobilePatientBanner'
|
||||||
import { useActiveSection } from '@/hooks/useActiveSection'
|
import { useActiveSection } from '@/hooks/useActiveSection'
|
||||||
import { useIsMobileNav } from '@/hooks/useIsMobileNav'
|
import { useIsMobileNav } from '@/hooks/useIsMobileNav'
|
||||||
import { useDetailPanel } from '@/contexts/DetailPanelContext'
|
import { useDetailPanel } from '@/contexts/DetailPanelContext'
|
||||||
@@ -299,43 +300,7 @@ export function DashboardLayout() {
|
|||||||
paddingBottom: isMobileNav ? 'calc(56px + env(safe-area-inset-bottom) + 16px)' : undefined,
|
paddingBottom: isMobileNav ? 'calc(56px + env(safe-area-inset-bottom) + 16px)' : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isMobileNav && (
|
{isMobileNav && <MobilePatientBanner />}
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
padding: '10px 16px',
|
|
||||||
background: 'var(--accent)',
|
|
||||||
borderRadius: 'var(--radius-sm)',
|
|
||||||
marginBottom: '12px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '14px',
|
|
||||||
fontWeight: 700,
|
|
||||||
color: '#FFFFFF',
|
|
||||||
letterSpacing: '0.04em',
|
|
||||||
fontFamily: 'var(--font-ui)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
CHARLWOOD, Andrew
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '11px',
|
|
||||||
color: 'rgba(255,255,255,0.75)',
|
|
||||||
fontFamily: 'var(--font-geist-mono)',
|
|
||||||
letterSpacing: '0.02em',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Informatics Pharmacist · NHS Norfolk & Waveney ICB
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="dashboard-grid">
|
<div className="dashboard-grid">
|
||||||
{/* PatientSummaryTile — full width (includes Latest Results subsection) */}
|
{/* PatientSummaryTile — full width (includes Latest Results subsection) */}
|
||||||
<div ref={patientSummaryRef}>
|
<div ref={patientSummaryRef}>
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export function LastConsultationCard({ highlightedRoleId, focusRelatedIds }: Las
|
|||||||
opacity: isDimmed ? 0.25 : 1,
|
opacity: isDimmed ? 0.25 : 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CardHeader dotColor="green" title="LAST CONSULTATION" rightText="Current role" />
|
<CardHeader dotColor="green" title="LATEST CONSULTATION" rightText="Current role" />
|
||||||
|
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
@@ -126,12 +126,52 @@ export function LastConsultationCard({ highlightedRoleId, focusRelatedIds }: Las
|
|||||||
fontSize: '15px',
|
fontSize: '15px',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: consultation.orgColor ?? 'var(--accent)',
|
color: consultation.orgColor ?? 'var(--accent)',
|
||||||
marginBottom: '4px',
|
marginBottom: '12px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{consultation.role}
|
{consultation.role}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ul
|
||||||
|
style={{
|
||||||
|
listStyle: 'none',
|
||||||
|
padding: 0,
|
||||||
|
margin: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '7px',
|
||||||
|
marginBottom: '0px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{consultation.examination.map((bullet, index) => (
|
||||||
|
<li
|
||||||
|
key={index}
|
||||||
|
style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
paddingLeft: '16px',
|
||||||
|
lineHeight: '1.5',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: '0',
|
||||||
|
top: '8px',
|
||||||
|
width: '5px',
|
||||||
|
height: '5px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: consultation.orgColor ?? 'var(--accent)',
|
||||||
|
opacity: 0.5,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{bullet}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleOpenPanel}
|
onClick={handleOpenPanel}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { ChevronDown } from 'lucide-react'
|
||||||
|
import { patient } from '@/data/patient'
|
||||||
|
import { getSidebarCopy } from '@/lib/profile-content'
|
||||||
|
import { PhoneCaptcha } from './PhoneCaptcha'
|
||||||
|
|
||||||
|
function DataRow({ label, children }: { label: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
fontSize: '13px',
|
||||||
|
padding: '2px 0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: 'var(--text-tertiary)', fontWeight: 400 }}>{label}</span>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobilePatientBanner() {
|
||||||
|
const sidebarCopy = getSidebarCopy()
|
||||||
|
const [expanded, setExpanded] = useState(true)
|
||||||
|
const expandedByClickRef = useRef(false)
|
||||||
|
const clickExpandScrollRef = useRef(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const scrollContainer = document.querySelector('.dashboard-main')
|
||||||
|
if (!scrollContainer) return
|
||||||
|
|
||||||
|
let prevScrollTop = scrollContainer.scrollTop
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
const currentScroll = scrollContainer.scrollTop
|
||||||
|
const delta = currentScroll - prevScrollTop
|
||||||
|
prevScrollTop = currentScroll
|
||||||
|
|
||||||
|
if (delta <= 0) return
|
||||||
|
|
||||||
|
if (expandedByClickRef.current) {
|
||||||
|
// After click-expand, collapse once user scrolls 20px from where they expanded
|
||||||
|
const scrollSinceExpand = currentScroll - clickExpandScrollRef.current
|
||||||
|
if (scrollSinceExpand > 20) {
|
||||||
|
setExpanded(false)
|
||||||
|
expandedByClickRef.current = false
|
||||||
|
}
|
||||||
|
} else if (currentScroll > 40) {
|
||||||
|
// Initial collapse after scrolling 40px from top
|
||||||
|
setExpanded(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollContainer.addEventListener('scroll', handleScroll, { passive: true })
|
||||||
|
return () => scrollContainer.removeEventListener('scroll', handleScroll)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleToggle = useCallback(() => {
|
||||||
|
setExpanded((prev) => {
|
||||||
|
if (!prev) {
|
||||||
|
expandedByClickRef.current = true
|
||||||
|
const container = document.querySelector('.dashboard-main')
|
||||||
|
if (container) clickExpandScrollRef.current = container.scrollTop
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return prev
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="-mx-3 xs:-mx-5 -mt-3 xs:-mt-5"
|
||||||
|
style={{
|
||||||
|
position: 'sticky',
|
||||||
|
top: 0,
|
||||||
|
zIndex: 20,
|
||||||
|
marginBottom: '12px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
boxShadow: expanded ? 'none' : '0 2px 8px rgba(0,0,0,0.1)',
|
||||||
|
transition: 'box-shadow 0.25s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Green header — always visible */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleToggle}
|
||||||
|
aria-expanded={expanded}
|
||||||
|
aria-label={expanded ? 'Patient summary expanded' : 'Tap to view patient details'}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '10px 16px',
|
||||||
|
background: 'var(--accent)',
|
||||||
|
border: 'none',
|
||||||
|
cursor: expanded ? 'default' : 'pointer',
|
||||||
|
textAlign: 'left',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: '0.04em',
|
||||||
|
fontFamily: 'var(--font-ui)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
CHARLWOOD, Andrew
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '11px',
|
||||||
|
opacity: 0.75,
|
||||||
|
fontFamily: 'var(--font-geist-mono)',
|
||||||
|
letterSpacing: '0.02em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Informatics Pharmacist · NHS Norfolk & Waveney ICB
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
animate={
|
||||||
|
expanded
|
||||||
|
? { rotate: 180, opacity: 0.3 }
|
||||||
|
: { rotate: 0, opacity: 0.65, y: [0, 2, 0] }
|
||||||
|
}
|
||||||
|
transition={
|
||||||
|
expanded
|
||||||
|
? { duration: 0.2 }
|
||||||
|
: {
|
||||||
|
rotate: { duration: 0.2 },
|
||||||
|
opacity: { duration: 0.2 },
|
||||||
|
y: { duration: 1.2, repeat: 2, ease: 'easeInOut', delay: 0.3 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
style={{ flexShrink: 0, marginLeft: '8px', display: 'flex' }}
|
||||||
|
>
|
||||||
|
<ChevronDown size={16} />
|
||||||
|
</motion.div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Expandable patient data panel */}
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{expanded && (
|
||||||
|
<motion.div
|
||||||
|
key="patient-data-panel"
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.25, ease: [0.4, 0, 0.2, 1] }}
|
||||||
|
style={{ overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--surface)',
|
||||||
|
borderTop: '1px solid var(--border-light)',
|
||||||
|
padding: '10px 16px 12px',
|
||||||
|
display: 'grid',
|
||||||
|
gap: '4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DataRow label={sidebarCopy.gphcLabel}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
fontWeight: 500,
|
||||||
|
fontFamily: 'Geist Mono, monospace',
|
||||||
|
fontSize: '12px',
|
||||||
|
letterSpacing: '0.12em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{patient.nhsNumber.replace(/\s/g, '')}
|
||||||
|
</span>
|
||||||
|
</DataRow>
|
||||||
|
|
||||||
|
<DataRow label={sidebarCopy.educationLabel}>
|
||||||
|
<span style={{ color: 'var(--text-primary)', fontWeight: 500 }}>
|
||||||
|
{patient.qualification}
|
||||||
|
</span>
|
||||||
|
</DataRow>
|
||||||
|
|
||||||
|
<DataRow label={sidebarCopy.locationLabel}>
|
||||||
|
<span style={{ color: 'var(--text-primary)', fontWeight: 500 }}>
|
||||||
|
{patient.address}
|
||||||
|
</span>
|
||||||
|
</DataRow>
|
||||||
|
|
||||||
|
<DataRow label={sidebarCopy.phoneLabel}>
|
||||||
|
<PhoneCaptcha phone={patient.phone} />
|
||||||
|
</DataRow>
|
||||||
|
|
||||||
|
<DataRow label={sidebarCopy.emailLabel}>
|
||||||
|
<a
|
||||||
|
href={`mailto:${patient.email}`}
|
||||||
|
style={{
|
||||||
|
color: 'var(--accent)',
|
||||||
|
fontWeight: 500,
|
||||||
|
textDecoration: 'none',
|
||||||
|
textAlign: 'right',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{patient.email}
|
||||||
|
</a>
|
||||||
|
</DataRow>
|
||||||
|
|
||||||
|
<DataRow label={sidebarCopy.registeredLabel}>
|
||||||
|
<span style={{ color: 'var(--text-primary)', fontWeight: 500, textAlign: 'right' }}>
|
||||||
|
{patient.registrationYear}
|
||||||
|
</span>
|
||||||
|
</DataRow>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { X, Send } from 'lucide-react'
|
||||||
|
|
||||||
|
interface ReferralFormModalProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormData {
|
||||||
|
referringClinician: string
|
||||||
|
organisationFrom: string
|
||||||
|
presentingComplaint: string
|
||||||
|
clinicalDetails: string
|
||||||
|
contactEmail: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIAL_FORM: FormData = {
|
||||||
|
referringClinician: '',
|
||||||
|
organisationFrom: '',
|
||||||
|
presentingComplaint: '',
|
||||||
|
clinicalDetails: '',
|
||||||
|
contactEmail: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
type SubmitStatus = 'idle' | 'submitting' | 'success' | 'error'
|
||||||
|
|
||||||
|
export function ReferralFormModal({ isOpen, onClose }: ReferralFormModalProps) {
|
||||||
|
const [form, setForm] = useState<FormData>(INITIAL_FORM)
|
||||||
|
const [status, setStatus] = useState<SubmitStatus>('idle')
|
||||||
|
const [errorMessage, setErrorMessage] = useState('')
|
||||||
|
|
||||||
|
const updateField = (field: keyof FormData, value: string) => {
|
||||||
|
setForm(prev => ({ ...prev, [field]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setStatus('submitting')
|
||||||
|
setErrorMessage('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/contact', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: form.referringClinician,
|
||||||
|
organisation: form.organisationFrom,
|
||||||
|
subject: form.presentingComplaint,
|
||||||
|
message: form.clinicalDetails,
|
||||||
|
email: form.contactEmail,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await res.json()
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.message || 'Failed to send referral')
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus('success')
|
||||||
|
setTimeout(() => {
|
||||||
|
setForm(INITIAL_FORM)
|
||||||
|
setStatus('idle')
|
||||||
|
onClose()
|
||||||
|
}, 2000)
|
||||||
|
} catch (err) {
|
||||||
|
setStatus('error')
|
||||||
|
setErrorMessage(err instanceof Error ? err.message : 'Failed to send referral. Please try again.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const labelStyle: React.CSSProperties = {
|
||||||
|
display: 'block',
|
||||||
|
fontFamily: 'var(--font-geist-mono)',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: 'var(--text-tertiary, #8DA8A5)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.06em',
|
||||||
|
marginBottom: '6px',
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
width: '100%',
|
||||||
|
padding: '10px 12px',
|
||||||
|
fontFamily: 'var(--font-ui)',
|
||||||
|
fontSize: '14px',
|
||||||
|
color: 'var(--text-primary, #1A2B2A)',
|
||||||
|
backgroundColor: 'var(--surface, #FFFFFF)',
|
||||||
|
border: '1px solid var(--border, #D1DDD9)',
|
||||||
|
borderRadius: 'var(--radius-sm, 6px)',
|
||||||
|
outline: 'none',
|
||||||
|
transition: 'border-color 150ms ease',
|
||||||
|
}
|
||||||
|
|
||||||
|
const readOnlyStyle: React.CSSProperties = {
|
||||||
|
...inputStyle,
|
||||||
|
backgroundColor: 'var(--bg-dashboard, #F0F5F4)',
|
||||||
|
fontStyle: 'italic',
|
||||||
|
color: 'var(--text-secondary, #5B7A78)',
|
||||||
|
cursor: 'default',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<motion.div
|
||||||
|
key="referral-overlay"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
backgroundColor: 'rgba(26, 43, 42, 0.5)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 1100,
|
||||||
|
padding: '16px',
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) onClose()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
key="referral-modal"
|
||||||
|
initial={{ opacity: 0, y: 24 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: 24 }}
|
||||||
|
transition={{ duration: 0.25, ease: 'easeOut' }}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: '540px',
|
||||||
|
maxHeight: 'calc(100vh - 32px)',
|
||||||
|
overflowY: 'auto',
|
||||||
|
backgroundColor: 'var(--surface, #FFFFFF)',
|
||||||
|
borderRadius: 'var(--radius-card, 8px)',
|
||||||
|
boxShadow: 'var(--shadow-lg, 0 8px 32px rgba(26,43,42,0.12))',
|
||||||
|
border: '1px solid var(--border-light, #E4EDEB)',
|
||||||
|
}}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="referral-form-title"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '16px 24px',
|
||||||
|
borderBottom: '2px solid var(--accent, #0D6E6E)',
|
||||||
|
backgroundColor: 'var(--bg-dashboard, #F0F5F4)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '8px',
|
||||||
|
height: '8px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: 'var(--accent, #0D6E6E)',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<h2
|
||||||
|
id="referral-form-title"
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--font-geist-mono)',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--accent, #0D6E6E)',
|
||||||
|
letterSpacing: '0.08em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Patient Referral Form
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close referral form"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '32px',
|
||||||
|
height: '32px',
|
||||||
|
border: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
borderRadius: 'var(--radius-sm, 6px)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'var(--text-secondary, #5B7A78)',
|
||||||
|
transition: 'background-color 150ms, color 150ms',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'var(--accent-light, #E0F2F1)'
|
||||||
|
e.currentTarget.style.color = 'var(--accent, #0D6E6E)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'transparent'
|
||||||
|
e.currentTarget.style.color = 'var(--text-secondary, #5B7A78)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form body */}
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
style={{ padding: '24px', display: 'flex', flexDirection: 'column', gap: '18px' }}
|
||||||
|
>
|
||||||
|
{/* Referring Clinician */}
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle} htmlFor="referringClinician">
|
||||||
|
Referring Clinician
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="referringClinician"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={form.referringClinician}
|
||||||
|
onChange={(e) => updateField('referringClinician', e.target.value)}
|
||||||
|
placeholder="Your name"
|
||||||
|
style={inputStyle}
|
||||||
|
onFocus={(e) => { e.currentTarget.style.borderColor = 'var(--accent, #0D6E6E)' }}
|
||||||
|
onBlur={(e) => { e.currentTarget.style.borderColor = 'var(--border, #D1DDD9)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Organisation Referred From */}
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle} htmlFor="organisationFrom">
|
||||||
|
Organisation Referred From
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="organisationFrom"
|
||||||
|
type="text"
|
||||||
|
value={form.organisationFrom}
|
||||||
|
onChange={(e) => updateField('organisationFrom', e.target.value)}
|
||||||
|
placeholder="Your organisation"
|
||||||
|
style={inputStyle}
|
||||||
|
onFocus={(e) => { e.currentTarget.style.borderColor = 'var(--accent, #0D6E6E)' }}
|
||||||
|
onBlur={(e) => { e.currentTarget.style.borderColor = 'var(--border, #D1DDD9)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Organisation Referred To (read-only) */}
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle} htmlFor="organisationTo">
|
||||||
|
Organisation Referred To
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="organisationTo"
|
||||||
|
type="text"
|
||||||
|
readOnly
|
||||||
|
value="A. Charlwood"
|
||||||
|
style={readOnlyStyle}
|
||||||
|
tabIndex={-1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Receiving Clinician (read-only) */}
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle} htmlFor="receivingClinician">
|
||||||
|
Receiving Clinician
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="receivingClinician"
|
||||||
|
type="text"
|
||||||
|
readOnly
|
||||||
|
value="Mr A. Charlwood"
|
||||||
|
style={readOnlyStyle}
|
||||||
|
tabIndex={-1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Presenting Complaint */}
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle} htmlFor="presentingComplaint">
|
||||||
|
Presenting Complaint
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="presentingComplaint"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={form.presentingComplaint}
|
||||||
|
onChange={(e) => updateField('presentingComplaint', e.target.value)}
|
||||||
|
placeholder="Subject / reason for referral"
|
||||||
|
style={inputStyle}
|
||||||
|
onFocus={(e) => { e.currentTarget.style.borderColor = 'var(--accent, #0D6E6E)' }}
|
||||||
|
onBlur={(e) => { e.currentTarget.style.borderColor = 'var(--border, #D1DDD9)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Clinical Details */}
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle} htmlFor="clinicalDetails">
|
||||||
|
Clinical Details
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="clinicalDetails"
|
||||||
|
required
|
||||||
|
value={form.clinicalDetails}
|
||||||
|
onChange={(e) => updateField('clinicalDetails', e.target.value)}
|
||||||
|
placeholder="Your message..."
|
||||||
|
rows={5}
|
||||||
|
style={{
|
||||||
|
...inputStyle,
|
||||||
|
resize: 'vertical',
|
||||||
|
minHeight: '100px',
|
||||||
|
}}
|
||||||
|
onFocus={(e) => { e.currentTarget.style.borderColor = 'var(--accent, #0D6E6E)' }}
|
||||||
|
onBlur={(e) => { e.currentTarget.style.borderColor = 'var(--border, #D1DDD9)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contact Email */}
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle} htmlFor="contactEmail">
|
||||||
|
Contact Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="contactEmail"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={form.contactEmail}
|
||||||
|
onChange={(e) => updateField('contactEmail', e.target.value)}
|
||||||
|
placeholder="your.email@example.com"
|
||||||
|
style={inputStyle}
|
||||||
|
onFocus={(e) => { e.currentTarget.style.borderColor = 'var(--accent, #0D6E6E)' }}
|
||||||
|
onBlur={(e) => { e.currentTarget.style.borderColor = 'var(--border, #D1DDD9)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Success message */}
|
||||||
|
{status === 'success' && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '12px 16px',
|
||||||
|
backgroundColor: 'rgba(5, 150, 105, 0.08)',
|
||||||
|
border: '1px solid rgba(5, 150, 105, 0.2)',
|
||||||
|
borderRadius: 'var(--radius-sm, 6px)',
|
||||||
|
fontFamily: 'var(--font-ui)',
|
||||||
|
fontSize: '13px',
|
||||||
|
color: 'var(--success, #059669)',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Referral sent successfully!
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
{status === 'error' && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '12px 16px',
|
||||||
|
backgroundColor: 'rgba(220, 38, 38, 0.08)',
|
||||||
|
border: '1px solid rgba(220, 38, 38, 0.2)',
|
||||||
|
borderRadius: 'var(--radius-sm, 6px)',
|
||||||
|
fontFamily: 'var(--font-ui)',
|
||||||
|
fontSize: '13px',
|
||||||
|
color: 'var(--alert, #DC2626)',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{errorMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Submit button */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={status === 'submitting' || status === 'success'}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px 16px',
|
||||||
|
fontFamily: 'var(--font-ui)',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#FFFFFF',
|
||||||
|
backgroundColor: status === 'submitting' || status === 'success'
|
||||||
|
? 'var(--accent-hover, #0A8080)'
|
||||||
|
: 'var(--accent, #0D6E6E)',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 'var(--radius-sm, 6px)',
|
||||||
|
cursor: status === 'submitting' || status === 'success' ? 'default' : 'pointer',
|
||||||
|
opacity: status === 'submitting' ? 0.8 : 1,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
transition: 'background-color 150ms, opacity 150ms',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (status === 'idle' || status === 'error') {
|
||||||
|
e.currentTarget.style.backgroundColor = 'var(--accent-hover, #0A8080)'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (status === 'idle' || status === 'error') {
|
||||||
|
e.currentTarget.style.backgroundColor = 'var(--accent, #0D6E6E)'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{status === 'submitting' ? (
|
||||||
|
'Sending referral...'
|
||||||
|
) : status === 'success' ? (
|
||||||
|
'Referral sent!'
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Send size={16} />
|
||||||
|
Submit Referral
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
)
|
||||||
|
}
|
||||||
+122
-64
@@ -1,11 +1,13 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import type { CSSProperties, ReactNode } from 'react'
|
import type { CSSProperties, ReactNode } from 'react'
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
Download,
|
||||||
AlertTriangle,
|
Github,
|
||||||
|
Linkedin,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
Menu,
|
Menu,
|
||||||
Search,
|
Search,
|
||||||
|
Send,
|
||||||
UserRound,
|
UserRound,
|
||||||
Workflow,
|
Workflow,
|
||||||
Wrench,
|
Wrench,
|
||||||
@@ -14,11 +16,11 @@ import {
|
|||||||
import { useIsMobileNav } from '@/hooks/useIsMobileNav'
|
import { useIsMobileNav } from '@/hooks/useIsMobileNav'
|
||||||
import { CvmisLogo } from './CvmisLogo'
|
import { CvmisLogo } from './CvmisLogo'
|
||||||
import { PhoneCaptcha } from './PhoneCaptcha'
|
import { PhoneCaptcha } from './PhoneCaptcha'
|
||||||
|
import { ReferralFormModal } from './ReferralFormModal'
|
||||||
import { patient } from '@/data/patient'
|
import { patient } from '@/data/patient'
|
||||||
import { tags } from '@/data/tags'
|
import { tags } from '@/data/tags'
|
||||||
import { alerts } from '@/data/alerts'
|
|
||||||
import { getSidebarCopy } from '@/lib/profile-content'
|
import { getSidebarCopy } from '@/lib/profile-content'
|
||||||
import type { Tag, Alert } from '@/types/pmr'
|
import type { Tag } from '@/types/pmr'
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
activeSection: string
|
activeSection: string
|
||||||
@@ -110,62 +112,12 @@ function TagPill({ tag }: TagPillProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AlertFlagProps {
|
|
||||||
alert: Alert
|
|
||||||
}
|
|
||||||
|
|
||||||
function AlertFlag({ alert }: AlertFlagProps) {
|
|
||||||
const Icon = alert.icon === 'AlertTriangle' ? AlertTriangle : AlertCircle
|
|
||||||
|
|
||||||
const styles: Record<Alert['severity'], CSSProperties> = {
|
|
||||||
alert: {
|
|
||||||
background: 'var(--alert-light)',
|
|
||||||
color: 'var(--alert)',
|
|
||||||
border: '1px solid var(--alert-border)',
|
|
||||||
},
|
|
||||||
amber: {
|
|
||||||
background: 'var(--amber-light)',
|
|
||||||
color: 'var(--amber)',
|
|
||||||
border: '1px solid var(--amber-border)',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '8px',
|
|
||||||
fontSize: '13px',
|
|
||||||
fontWeight: 700,
|
|
||||||
padding: '8px 12px',
|
|
||||||
borderRadius: 'var(--radius-sm)',
|
|
||||||
letterSpacing: '0.02em',
|
|
||||||
...styles[alert.severity],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: '18px',
|
|
||||||
height: '18px',
|
|
||||||
flexShrink: 0,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon size={16} strokeWidth={2.5} />
|
|
||||||
</div>
|
|
||||||
<span>{alert.message}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Sidebar({ activeSection, onNavigate, onSearchClick }: SidebarProps) {
|
export default function Sidebar({ activeSection, onNavigate, onSearchClick }: SidebarProps) {
|
||||||
const sidebarCopy = getSidebarCopy()
|
const sidebarCopy = getSidebarCopy()
|
||||||
const [isDesktop, setIsDesktop] = useState(() => window.matchMedia('(min-width: 1024px)').matches)
|
const [isDesktop, setIsDesktop] = useState(() => window.matchMedia('(min-width: 1024px)').matches)
|
||||||
const isMobileNav = useIsMobileNav()
|
const isMobileNav = useIsMobileNav()
|
||||||
const [isMobileExpanded, setIsMobileExpanded] = useState(false)
|
const [isMobileExpanded, setIsMobileExpanded] = useState(false)
|
||||||
|
const [showReferralForm, setShowReferralForm] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const mediaQuery = window.matchMedia('(min-width: 1024px)')
|
const mediaQuery = window.matchMedia('(min-width: 1024px)')
|
||||||
@@ -481,6 +433,35 @@ export default function Sidebar({ activeSection, onNavigate, onSearchClick }: Si
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<a
|
||||||
|
href="/References/CV_v4.md"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
minHeight: '40px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
border: '1px solid var(--accent-border)',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
color: 'var(--accent)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 600,
|
||||||
|
//fontFamily: 'var(--font-geist-mono)',
|
||||||
|
letterSpacing: '0.03em',
|
||||||
|
transition: 'border-color 150ms, color 150ms',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.textDecoration = 'underline')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.textDecoration = 'none')}
|
||||||
|
>
|
||||||
|
|
||||||
|
<Download size={14} />
|
||||||
|
Download CV
|
||||||
|
</a>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -530,6 +511,91 @@ export default function Sidebar({ activeSection, onNavigate, onSearchClick }: Si
|
|||||||
|
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<>
|
<>
|
||||||
|
<section style={{ paddingTop: '4px' }}>
|
||||||
|
<SectionTitle>Contact</SectionTitle>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowReferralForm(true)}
|
||||||
|
className="sidebar-control"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
minHeight: '40px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
border: '1px solid var(--accent-border)',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
color: 'var(--accent)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 600,
|
||||||
|
//fontFamily: 'var(--font-geist-mono)',
|
||||||
|
letterSpacing: '0.03em',
|
||||||
|
transition: 'border-color 150ms, color 150ms',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Send size={14} />
|
||||||
|
Refer Patient
|
||||||
|
</button>
|
||||||
|
<div style={{ display: 'flex', gap: '6px' }}>
|
||||||
|
<a
|
||||||
|
href="https://linkedin.com/in/andycharlwood"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="sidebar-control"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minHeight: '36px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
border: '1px solid var(--border-light)',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 500,
|
||||||
|
textDecoration: 'none',
|
||||||
|
transition: 'border-color 150ms, color 150ms',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Linkedin size={14} />
|
||||||
|
LinkedIn
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://github.com/andycharlwood"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="sidebar-control"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minHeight: '36px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
border: '1px solid var(--border-light)',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 500,
|
||||||
|
textDecoration: 'none',
|
||||||
|
transition: 'border-color 150ms, color 150ms',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Github size={14} />
|
||||||
|
GitHub
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section style={{ paddingTop: '8px' }}>
|
<section style={{ paddingTop: '8px' }}>
|
||||||
<SectionTitle>{sidebarCopy.tagsTitle}</SectionTitle>
|
<SectionTitle>{sidebarCopy.tagsTitle}</SectionTitle>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '5px' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '5px' }}>
|
||||||
@@ -538,18 +604,10 @@ export default function Sidebar({ activeSection, onNavigate, onSearchClick }: Si
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section style={{ padding: '8px 0 4px' }}>
|
|
||||||
<SectionTitle>{sidebarCopy.alertsTitle}</SectionTitle>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
|
||||||
{alerts.map((alert, index) => (
|
|
||||||
<AlertFlag key={index} alert={alert} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
|
<ReferralFormModal isOpen={showReferralForm} onClose={() => setShowReferralForm(false)} />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { FileText, ChevronRight, Mail, Linkedin, Github, Download } from 'lucide-react'
|
import { FileText, ChevronRight } from 'lucide-react'
|
||||||
import { CardHeader } from '../Card'
|
import { CardHeader } from '../Card'
|
||||||
import { ParentSection } from '../ParentSection'
|
import { ParentSection } from '../ParentSection'
|
||||||
import { kpis } from '@/data/kpis'
|
import { kpis } from '@/data/kpis'
|
||||||
import type { KPI } from '@/types/pmr'
|
import type { KPI } from '@/types/pmr'
|
||||||
import { useDetailPanel } from '@/contexts/DetailPanelContext'
|
import { useDetailPanel } from '@/contexts/DetailPanelContext'
|
||||||
import { getLatestResultsCopy, getProfileSectionTitle, getStructuredProfile } from '@/lib/profile-content'
|
import { getLatestResultsCopy, getProfileSectionTitle, getPatientSummaryNarrative } from '@/lib/profile-content'
|
||||||
import { KPI_COLORS } from '@/lib/theme-colors'
|
import { KPI_COLORS } from '@/lib/theme-colors'
|
||||||
import { useIsMobileNav } from '@/hooks/useIsMobileNav'
|
|
||||||
import { ProjectsCarousel } from './ProjectsTile'
|
import { ProjectsCarousel } from './ProjectsTile'
|
||||||
|
|
||||||
interface MetricCardProps {
|
interface MetricCardProps {
|
||||||
@@ -108,37 +107,9 @@ function MetricCard({ kpi }: MetricCardProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACTION_LINKS = [
|
|
||||||
{ label: 'Email', href: 'mailto:andy@charlwood.xyz', icon: Mail, external: false },
|
|
||||||
{ label: 'LinkedIn', href: 'https://linkedin.com/in/andycharlwood', icon: Linkedin, external: true },
|
|
||||||
{ label: 'GitHub', href: 'https://github.com/andycharlwood', icon: Github, external: true },
|
|
||||||
{ label: 'Download CV', href: '/References/CV_v4.md', icon: Download, external: true },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const actionButtonStyles: React.CSSProperties = {
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '6px',
|
|
||||||
padding: '6px 12px',
|
|
||||||
fontSize: '12px',
|
|
||||||
fontWeight: 600,
|
|
||||||
fontFamily: 'var(--font-geist-mono)',
|
|
||||||
letterSpacing: '0.03em',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
borderRadius: 'var(--radius-sm)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
background: 'var(--surface)',
|
|
||||||
color: 'var(--accent)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'background-color 150ms, border-color 150ms',
|
|
||||||
textDecoration: 'none',
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PatientSummaryTile() {
|
export function PatientSummaryTile() {
|
||||||
const structuredProfile = getStructuredProfile()
|
|
||||||
const latestResultsCopy = getLatestResultsCopy()
|
const latestResultsCopy = getLatestResultsCopy()
|
||||||
const sectionTitle = getProfileSectionTitle()
|
const sectionTitle = getProfileSectionTitle()
|
||||||
const isMobile = useIsMobileNav()
|
|
||||||
|
|
||||||
const profileTextStyles: React.CSSProperties = {
|
const profileTextStyles: React.CSSProperties = {
|
||||||
fontSize: '15px',
|
fontSize: '15px',
|
||||||
@@ -146,30 +117,6 @@ export function PatientSummaryTile() {
|
|||||||
color: 'var(--text-primary)',
|
color: 'var(--text-primary)',
|
||||||
}
|
}
|
||||||
|
|
||||||
const fieldsGridStyles: React.CSSProperties = {
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: isMobile ? '1fr' : 'auto 1fr',
|
|
||||||
gap: isMobile ? '2px 0' : '6px 16px',
|
|
||||||
borderTop: '1px solid var(--border-light)',
|
|
||||||
paddingTop: '14px',
|
|
||||||
marginTop: '14px',
|
|
||||||
}
|
|
||||||
|
|
||||||
const fieldLabelStyles: React.CSSProperties = {
|
|
||||||
fontSize: '12px',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
letterSpacing: '0.06em',
|
|
||||||
color: 'var(--text-tertiary)',
|
|
||||||
fontFamily: 'var(--font-geist-mono)',
|
|
||||||
}
|
|
||||||
|
|
||||||
const fieldValueStyles: React.CSSProperties = {
|
|
||||||
fontSize: '13px',
|
|
||||||
fontWeight: 600,
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
marginBottom: isMobile ? '8px' : undefined,
|
|
||||||
}
|
|
||||||
|
|
||||||
const kpiGridStyles: React.CSSProperties = {
|
const kpiGridStyles: React.CSSProperties = {
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gap: '10px',
|
gap: '10px',
|
||||||
@@ -177,33 +124,7 @@ export function PatientSummaryTile() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ParentSection title={sectionTitle} tileId="patient-summary">
|
<ParentSection title={sectionTitle} tileId="patient-summary">
|
||||||
{/* Presenting complaint */}
|
<div style={profileTextStyles}>{getPatientSummaryNarrative()}</div>
|
||||||
<div style={profileTextStyles}>{structuredProfile.presentingComplaint}</div>
|
|
||||||
|
|
||||||
{/* Structured profile fields */}
|
|
||||||
<div style={fieldsGridStyles}>
|
|
||||||
{structuredProfile.fields.map((field) => (
|
|
||||||
<React.Fragment key={field.label}>
|
|
||||||
<span style={fieldLabelStyles}>{field.label}</span>
|
|
||||||
<span style={fieldValueStyles}>{field.value}</span>
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Contact / CTA action bar */}
|
|
||||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', marginTop: '16px' }}>
|
|
||||||
{ACTION_LINKS.map((action) => (
|
|
||||||
<a
|
|
||||||
key={action.label}
|
|
||||||
href={action.href}
|
|
||||||
{...(action.external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
|
|
||||||
style={actionButtonStyles}
|
|
||||||
>
|
|
||||||
<action.icon size={13} aria-hidden="true" />
|
|
||||||
{action.label}
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Latest Results subsection */}
|
{/* Latest Results subsection */}
|
||||||
<div style={{ marginTop: '28px' }}>
|
<div style={{ marginTop: '28px' }}>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ function ProjectItem({
|
|||||||
}: ProjectItemProps) {
|
}: ProjectItemProps) {
|
||||||
const dotColor = PROJECT_STATUS_COLORS[project.status]
|
const dotColor = PROJECT_STATUS_COLORS[project.status]
|
||||||
const livePillLabel = project.demoUrl ? 'Live Demo' : project.externalUrl ? 'Live' : null
|
const livePillLabel = project.demoUrl ? 'Live Demo' : project.externalUrl ? 'Live' : null
|
||||||
|
const [isHovered, setIsHovered] = useState(false)
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(e: React.KeyboardEvent) => {
|
(e: React.KeyboardEvent) => {
|
||||||
@@ -34,11 +35,14 @@ function ProjectItem({
|
|||||||
[onClick],
|
[onClick],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const maxVisibleResults = 4
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
flex: `0 0 ${slideWidth}`,
|
flex: `0 0 ${slideWidth}`,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
|
containerType: 'inline-size',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -47,6 +51,7 @@ function ProjectItem({
|
|||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
style={{
|
style={{
|
||||||
|
position: 'relative',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: '10px',
|
gap: '10px',
|
||||||
@@ -59,12 +64,15 @@ function ProjectItem({
|
|||||||
color: 'var(--text-primary)',
|
color: 'var(--text-primary)',
|
||||||
transition: 'border-color 0.15s, box-shadow 0.15s',
|
transition: 'border-color 0.15s, box-shadow 0.15s',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
|
setIsHovered(true)
|
||||||
e.currentTarget.style.borderColor = 'var(--accent-border)'
|
e.currentTarget.style.borderColor = 'var(--accent-border)'
|
||||||
e.currentTarget.style.boxShadow = '0 2px 8px rgba(26,43,42,0.08)'
|
e.currentTarget.style.boxShadow = '0 2px 8px rgba(26,43,42,0.08)'
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
|
setIsHovered(false)
|
||||||
e.currentTarget.style.borderColor = 'var(--border-light)'
|
e.currentTarget.style.borderColor = 'var(--border-light)'
|
||||||
e.currentTarget.style.boxShadow = 'none'
|
e.currentTarget.style.boxShadow = 'none'
|
||||||
}}
|
}}
|
||||||
@@ -77,6 +85,92 @@ function ProjectItem({
|
|||||||
e.currentTarget.style.boxShadow = 'none'
|
e.currentTarget.style.boxShadow = 'none'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* Results hover overlay */}
|
||||||
|
{project.results && project.results.length > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 1,
|
||||||
|
background: 'rgba(20, 40, 38, 0.96)',
|
||||||
|
borderRadius: 'inherit',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
padding: 'clamp(10px, 4cqi, 18px) clamp(12px, 5cqi, 20px)',
|
||||||
|
opacity: isHovered ? 1 : 0,
|
||||||
|
transition: 'opacity 0.25s ease',
|
||||||
|
pointerEvents: isHovered ? 'auto' : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 'clamp(9px, 3.5cqi, 13px)',
|
||||||
|
fontFamily: 'var(--font-geist-mono)',
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '0.1em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: 'rgba(255, 255, 255, 0.45)',
|
||||||
|
marginBottom: 'clamp(6px, 3cqi, 12px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Intervention Outcomes
|
||||||
|
</div>
|
||||||
|
<ul
|
||||||
|
style={{
|
||||||
|
listStyle: 'none',
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 'clamp(5px, 2.5cqi, 12px)',
|
||||||
|
flex: 1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{project.results.slice(0, maxVisibleResults).map((result, i) => (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 'clamp(6px, 2.5cqi, 10px)',
|
||||||
|
fontSize: 'clamp(11px, 4.5cqi, 16px)',
|
||||||
|
lineHeight: 1.4,
|
||||||
|
color: 'rgba(255, 255, 255, 0.85)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
width: 'clamp(4px, 1.5cqi, 7px)',
|
||||||
|
height: 'clamp(4px, 1.5cqi, 7px)',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'var(--accent-primary, #00897B)',
|
||||||
|
marginTop: 'clamp(4px, 2cqi, 7px)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>{result}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 'auto',
|
||||||
|
paddingTop: 'clamp(6px, 3cqi, 14px)',
|
||||||
|
fontSize: 'clamp(10px, 4cqi, 14px)',
|
||||||
|
fontFamily: 'var(--font-geist-mono)',
|
||||||
|
fontWeight: 500,
|
||||||
|
letterSpacing: '0.02em',
|
||||||
|
color: 'var(--accent-primary, #00897B)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'clamp(3px, 1.5cqi, 6px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Click to view more
|
||||||
|
<span style={{ fontSize: 'clamp(12px, 4.5cqi, 16px)', lineHeight: 1 }}>→</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
aspectRatio: '16 / 9',
|
aspectRatio: '16 / 9',
|
||||||
|
|||||||
@@ -4,18 +4,8 @@ export const profileContent: DeepReadonly<ProfileContent> = {
|
|||||||
profile: {
|
profile: {
|
||||||
sectionTitle: 'Patient Summary',
|
sectionTitle: 'Patient Summary',
|
||||||
patientSummaryNarrative: 'Healthcare leader combining clinical pharmacy expertise with proficiency in Python, SQL, and data analytics, self-taught over the past decade through a drive to find root causes in data and build the most efficient solutions to complex problems. Currently leading population health analytics for NHS Norfolk & Waveney ICB, serving a population of 1.2 million. Experienced in working with messy, real-world prescribing data at scale to deliver actionable insights — from financial scenario modelling and pharmaceutical rebate negotiation to algorithm design and population-level pathway development. Proven track record of identifying and prioritising efficiency programmes worth £14.6M+ through automated, data-driven analysis. Skilled at translating complex clinical, financial, and analytical requirements into clear recommendations for executive stakeholders.',
|
patientSummaryNarrative: 'Healthcare leader combining clinical pharmacy expertise with proficiency in Python, SQL, and data analytics, self-taught over the past decade through a drive to find root causes in data and build the most efficient solutions to complex problems. Currently leading population health analytics for NHS Norfolk & Waveney ICB, serving a population of 1.2 million. Experienced in working with messy, real-world prescribing data at scale to deliver actionable insights — from financial scenario modelling and pharmaceutical rebate negotiation to algorithm design and population-level pathway development. Proven track record of identifying and prioritising efficiency programmes worth £14.6M+ through automated, data-driven analysis. Skilled at translating complex clinical, financial, and analytical requirements into clear recommendations for executive stakeholders.',
|
||||||
structuredProfile: {
|
|
||||||
presentingComplaint: 'Healthcare leader combining clinical pharmacy expertise with proficiency in Python, SQL, and data analytics. Currently leading population health analytics for NHS Norfolk & Waveney ICB, serving 1.2 million.',
|
|
||||||
fields: [
|
|
||||||
{ label: 'Specialisation', value: 'Population Health Analytics & Medicines Optimisation' },
|
|
||||||
{ label: 'Current System', value: 'NHS Norfolk & Waveney ICB' },
|
|
||||||
{ label: 'Population', value: '1.2 million' },
|
|
||||||
{ label: 'Focus Areas', value: 'Prescribing analytics, financial modelling, algorithm design, data pipelines' },
|
|
||||||
{ label: 'Key Achievement', value: '£14.6M+ efficiency programmes identified' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
latestResults: {
|
latestResults: {
|
||||||
title: 'KEY METRICS',
|
title: 'LATEST RESULTS',
|
||||||
rightText: 'Updated February 2026',
|
rightText: 'Updated February 2026',
|
||||||
helperText: 'Select a metric to inspect methodology, impact, and outcomes.',
|
helperText: 'Select a metric to inspect methodology, impact, and outcomes.',
|
||||||
evidenceCta: 'Click to view evidence',
|
evidenceCta: 'Click to view evidence',
|
||||||
|
|||||||
@@ -8,13 +8,16 @@ import type {
|
|||||||
QuickActionCopyEntry,
|
QuickActionCopyEntry,
|
||||||
SidebarCopy,
|
SidebarCopy,
|
||||||
SkillsUICopy,
|
SkillsUICopy,
|
||||||
StructuredProfile,
|
|
||||||
} from '@/types/profile-content'
|
} from '@/types/profile-content'
|
||||||
|
|
||||||
export function getProfileSectionTitle(): string {
|
export function getProfileSectionTitle(): string {
|
||||||
return profileContent.profile.sectionTitle
|
return profileContent.profile.sectionTitle
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getPatientSummaryNarrative(): string {
|
||||||
|
return profileContent.profile.patientSummaryNarrative
|
||||||
|
}
|
||||||
|
|
||||||
export function getLatestResultsCopy(): DeepReadonly<LatestResultsCopy> {
|
export function getLatestResultsCopy(): DeepReadonly<LatestResultsCopy> {
|
||||||
return profileContent.profile.latestResults
|
return profileContent.profile.latestResults
|
||||||
}
|
}
|
||||||
@@ -43,6 +46,3 @@ export function getEducationEntries(): ReadonlyArray<EducationCopyEntry> {
|
|||||||
return profileContent.experienceEducation.educationEntries
|
return profileContent.experienceEducation.educationEntries
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getStructuredProfile(): DeepReadonly<StructuredProfile> {
|
|
||||||
return profileContent.profile.structuredProfile
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -80,21 +80,10 @@ export interface SkillsUICopy {
|
|||||||
readonly categories: ReadonlyArray<SkillsCategoryCopyEntry>
|
readonly categories: ReadonlyArray<SkillsCategoryCopyEntry>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StructuredProfileField {
|
|
||||||
readonly label: string
|
|
||||||
readonly value: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StructuredProfile {
|
|
||||||
readonly presentingComplaint: string
|
|
||||||
readonly fields: ReadonlyArray<StructuredProfileField>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProfileContent {
|
export interface ProfileContent {
|
||||||
readonly profile: {
|
readonly profile: {
|
||||||
readonly sectionTitle: string
|
readonly sectionTitle: string
|
||||||
readonly patientSummaryNarrative: string
|
readonly patientSummaryNarrative: string
|
||||||
readonly structuredProfile: StructuredProfile
|
|
||||||
readonly latestResults: LatestResultsCopy
|
readonly latestResults: LatestResultsCopy
|
||||||
readonly sidebar: SidebarCopy
|
readonly sidebar: SidebarCopy
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"outDir": "dist-server",
|
||||||
|
"rootDir": ".",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"include": ["server.ts"]
|
||||||
|
},
|
||||||
|
"include": ["server.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user