Mobile overview changes
This commit is contained in:
+7
-3
@@ -45,8 +45,12 @@ function SkipButton({ onSkip }: { onSkip: () => void }) {
|
||||
|
||||
function App() {
|
||||
const [phase, setPhase] = useState<Phase>(() => {
|
||||
if (typeof window !== 'undefined' && sessionStorage.getItem('portfolio-visited')) {
|
||||
return 'pmr'
|
||||
if (typeof window !== 'undefined') {
|
||||
const visitedAt = sessionStorage.getItem('portfolio-visited')
|
||||
if (visitedAt && Date.now() - Number(visitedAt) < 60 * 60 * 1000) {
|
||||
return 'pmr'
|
||||
}
|
||||
sessionStorage.removeItem('portfolio-visited')
|
||||
}
|
||||
return 'boot'
|
||||
})
|
||||
@@ -57,7 +61,7 @@ function App() {
|
||||
|
||||
useEffect(() => {
|
||||
if (phase === 'pmr') {
|
||||
sessionStorage.setItem('portfolio-visited', '1')
|
||||
sessionStorage.setItem('portfolio-visited', String(Date.now()))
|
||||
}
|
||||
}, [phase])
|
||||
|
||||
|
||||
@@ -87,8 +87,8 @@ const BOOT_CONFIG: BootConfig = {
|
||||
timing: {
|
||||
lineDelay: 220,
|
||||
cursorBlinkInterval: 300,
|
||||
holdAfterComplete: 200,
|
||||
loadingDuration: 600,
|
||||
holdAfterComplete: 1000,
|
||||
loadingDuration: 2000,
|
||||
fadeOutDuration: 500,
|
||||
cursorShrinkDuration: 400,
|
||||
},
|
||||
@@ -190,10 +190,10 @@ const TYPED_LINES = buildTypedLines()
|
||||
const TOTAL_CHARS = TYPED_LINES.reduce((sum, l) => sum + l.totalChars, 0)
|
||||
|
||||
// =============================================================================
|
||||
// Progress Bar Component
|
||||
// ASCII Loading Screen Component
|
||||
// =============================================================================
|
||||
|
||||
function ProgressBar({ active }: { active: boolean }) {
|
||||
function LoadingBar({ active }: { active: boolean }) {
|
||||
const [progress, setProgress] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -204,7 +204,6 @@ function ProgressBar({ active }: { active: boolean }) {
|
||||
const tick = (now: number) => {
|
||||
const elapsed = now - start
|
||||
const pct = Math.min(elapsed / BOOT_CONFIG.timing.loadingDuration, 1)
|
||||
// Ease-out curve for natural feel
|
||||
setProgress(1 - Math.pow(1 - pct, 2.5))
|
||||
if (pct < 1) raf = requestAnimationFrame(tick)
|
||||
}
|
||||
@@ -216,24 +215,41 @@ function ProgressBar({ active }: { active: boolean }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 16,
|
||||
height: 2,
|
||||
backgroundColor: 'rgba(0, 255, 65, 0.1)',
|
||||
borderRadius: 1,
|
||||
width: 'calc(100vw - 48px)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
maxWidth: 280,
|
||||
height: '1.2em',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 14,
|
||||
letterSpacing: '0.02em',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
width: `${progress * 100}%`,
|
||||
backgroundColor: COLORS.bright,
|
||||
boxShadow: `0 0 8px ${COLORS.bright}40`,
|
||||
borderRadius: 1,
|
||||
transition: 'none',
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
color: `${COLORS.bright}30`,
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
/>
|
||||
>
|
||||
{'\u2591'.repeat(500)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
width: `${progress * 100}%`,
|
||||
color: COLORS.bright,
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
textShadow: `0 0 4px ${COLORS.bright}30`,
|
||||
}}
|
||||
>
|
||||
{'\u2588'.repeat(500)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -369,10 +385,11 @@ export function BootSequence({ onComplete }: BootSequenceProps) {
|
||||
const charsForLine = Math.min(Math.max(0, remaining), line.totalChars)
|
||||
remaining -= charsForLine
|
||||
|
||||
// Cursor goes on the line currently being typed, or the last line in non-typing phases
|
||||
// During typing: cursor inline on the line being typed
|
||||
// During holding/loading: cursor handled after the loop (on a new line)
|
||||
const isCursorLine = phase === 'typing'
|
||||
? !cursorPlaced && (charsForLine < line.totalChars || remaining <= 0)
|
||||
: lineIdx === TYPED_LINES.length - 1
|
||||
: false
|
||||
|
||||
// Render segments
|
||||
let charBudget = phase === 'typing' ? charsForLine : line.totalChars
|
||||
@@ -430,6 +447,25 @@ export function BootSequence({ onComplete }: BootSequenceProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// After typing completes: cursor on new line, or loading bar replacing it
|
||||
if (phase === 'holding') {
|
||||
renderedLines.push(
|
||||
<div key="cursor-line" className="font-mono text-sm leading-relaxed">
|
||||
<span
|
||||
ref={cursorAnchorRef}
|
||||
className="inline-block align-middle"
|
||||
style={{ width: 8, height: 16 }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
} else if (phase === 'loading' || phase === 'fading') {
|
||||
renderedLines.push(
|
||||
<div key="bar-line" style={{ marginTop: 4 }}>
|
||||
<LoadingBar active={phase === 'loading'} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return renderedLines
|
||||
}
|
||||
|
||||
@@ -496,9 +532,8 @@ export function BootSequence({ onComplete }: BootSequenceProps) {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Content container */}
|
||||
<div ref={containerRef} className="flex flex-col gap-1 max-w-[640px] transform -translate-y-1/2 relative z-10">
|
||||
{/* Text content — slides up and fades during exit */}
|
||||
{/* Content container — text always visible, bar appears below during loading */}
|
||||
<div ref={containerRef} className="flex flex-col gap-1 transform -translate-y-1/2 relative z-10">
|
||||
<motion.div
|
||||
animate={{
|
||||
opacity: isFadingOut ? 0 : 1,
|
||||
@@ -510,28 +545,18 @@ export function BootSequence({ onComplete }: BootSequenceProps) {
|
||||
}}
|
||||
>
|
||||
{renderLines()}
|
||||
|
||||
{/* Progress bar — appears during loading phase */}
|
||||
{(phase === 'loading' || phase === 'fading') && (
|
||||
<ProgressBar active={phase === 'loading'} />
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Cursor rendered outside fading wrapper — shrinks into progress bar */}
|
||||
{cursorPos && !isFadingOut && (
|
||||
{/* Cursor — blinks during typing/holding, hidden when bar takes over */}
|
||||
{cursorPos && phase !== 'loading' && !isFadingOut && (
|
||||
<span
|
||||
className="absolute animate-blink"
|
||||
style={{
|
||||
left: cursorPos.left,
|
||||
top: cursorPos.top,
|
||||
width: 8,
|
||||
height: phase === 'loading' ? 4 : 16,
|
||||
height: 16,
|
||||
backgroundColor: COLORS.bright,
|
||||
filter: phase === 'loading' ? 'blur(1px)' : 'none',
|
||||
boxShadow: phase === 'loading' ? `0 0 12px ${COLORS.bright}E6` : 'none',
|
||||
transition: phase === 'loading'
|
||||
? `height ${BOOT_CONFIG.timing.cursorShrinkDuration}ms ease-out, filter ${BOOT_CONFIG.timing.cursorShrinkDuration}ms ease-out, box-shadow ${BOOT_CONFIG.timing.cursorShrinkDuration}ms ease-out`
|
||||
: 'none',
|
||||
animationDuration: `${BOOT_CONFIG.timing.cursorBlinkInterval}ms`,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { TimelineInterventionsSubsection } from './TimelineInterventionsSubsecti
|
||||
import { RepeatMedicationsSubsection } from './RepeatMedicationsSubsection'
|
||||
import { LastConsultationCard } from './LastConsultationCard'
|
||||
import { ChatWidget } from './ChatWidget'
|
||||
import { MobilePatientBanner } from './MobilePatientBanner'
|
||||
import { MobileOverviewHeader } from './MobileOverviewHeader'
|
||||
import { useActiveSection } from '@/hooks/useActiveSection'
|
||||
import { useIsMobileNav } from '@/hooks/useIsMobileNav'
|
||||
import { useDetailPanel } from '@/contexts/DetailPanelContext'
|
||||
@@ -300,7 +300,7 @@ export function DashboardLayout() {
|
||||
paddingBottom: isMobileNav ? 'calc(56px + env(safe-area-inset-bottom) + 16px)' : undefined,
|
||||
}}
|
||||
>
|
||||
{isMobileNav && <MobilePatientBanner />}
|
||||
{isMobileNav && <MobileOverviewHeader onSearchClick={handleSearchClick} />}
|
||||
<div className="dashboard-grid">
|
||||
{/* PatientSummaryTile — full width (includes Latest Results subsection) */}
|
||||
<div ref={patientSummaryRef}>
|
||||
@@ -361,7 +361,6 @@ export function DashboardLayout() {
|
||||
<MobileBottomNav
|
||||
activeSection={activeSection}
|
||||
onNavigate={scrollToSection}
|
||||
onSearchClick={handleSearchClick}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -17,7 +17,6 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
|
||||
const [activeField, setActiveField] = useState<'username' | 'password' | 'done' | null>('username')
|
||||
const [buttonPressed, setButtonPressed] = useState(false)
|
||||
const [isExiting, setIsExiting] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [typingComplete, setTypingComplete] = useState(false)
|
||||
const [buttonHovered, setButtonHovered] = useState(false)
|
||||
const [connectionState, setConnectionState] = useState<'connecting' | 'connected'>('connecting')
|
||||
@@ -48,20 +47,16 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
|
||||
const canLogin = typingComplete && connectionState === 'connected'
|
||||
|
||||
const handleLogin = useCallback(() => {
|
||||
if (!canLogin || isExiting || isLoading) return
|
||||
if (!canLogin || isExiting) return
|
||||
setButtonPressed(true)
|
||||
addTimeout(() => {
|
||||
setIsLoading(true)
|
||||
setIsExiting(true)
|
||||
addTimeout(() => {
|
||||
setIsExiting(true)
|
||||
// After dissolve completes (~400ms), remove overlay and reveal dashboard
|
||||
addTimeout(() => {
|
||||
requestFocusAfterLogin()
|
||||
onComplete()
|
||||
}, prefersReducedMotion ? 0 : 400)
|
||||
}, prefersReducedMotion ? 0 : 600)
|
||||
requestFocusAfterLogin()
|
||||
onComplete()
|
||||
}, prefersReducedMotion ? 0 : 400)
|
||||
}, 100)
|
||||
}, [canLogin, isExiting, isLoading, onComplete, requestFocusAfterLogin, prefersReducedMotion, addTimeout])
|
||||
}, [canLogin, isExiting, onComplete, requestFocusAfterLogin, prefersReducedMotion, addTimeout])
|
||||
|
||||
const startLoginSequence = useCallback(() => {
|
||||
if (prefersReducedMotion) {
|
||||
@@ -201,40 +196,6 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
|
||||
animate={isExiting ? { scale: 1.03, opacity: 0 } : { scale: 1, opacity: 1 }}
|
||||
transition={isExiting ? { duration: 0.4, ease: 'easeOut' } : { duration: 0.2, ease: 'easeOut' }}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '48px 0',
|
||||
gap: '16px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="login-spinner"
|
||||
style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
border: '3px solid var(--border-light, #E4EDEB)',
|
||||
borderTopColor: 'var(--accent, #0D6E6E)',
|
||||
borderRadius: '50%',
|
||||
}}
|
||||
role="status"
|
||||
aria-label="Loading clinical records"
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: "var(--font-ui)",
|
||||
fontSize: '12px',
|
||||
color: 'var(--text-secondary, #5B7A78)',
|
||||
}}
|
||||
>
|
||||
Loading clinical records...
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Branding Header */}
|
||||
<div
|
||||
@@ -442,7 +403,6 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)
|
||||
|
||||
@@ -1,388 +1,70 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import {
|
||||
Menu,
|
||||
Search,
|
||||
UserRound,
|
||||
Workflow,
|
||||
Wrench,
|
||||
X,
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react'
|
||||
import { CvmisLogo } from './CvmisLogo'
|
||||
import { PhoneCaptcha } from './PhoneCaptcha'
|
||||
import { patient } from '@/data/patient'
|
||||
import { tags } from '@/data/tags'
|
||||
import { alerts } from '@/data/alerts'
|
||||
import { getSidebarCopy } from '@/lib/profile-content'
|
||||
import type { Tag, Alert } from '@/types/pmr'
|
||||
import { prefersReducedMotion } from '@/lib/utils'
|
||||
import { ClipboardList, UserRound, Workflow, Wrench } from 'lucide-react'
|
||||
import { useIsMobileNav } from '@/hooks/useIsMobileNav'
|
||||
|
||||
interface MobileBottomNavProps {
|
||||
activeSection: string
|
||||
onNavigate: (tileId: string) => void
|
||||
onSearchClick: () => void
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
{ id: 'overview', label: 'Overview', tileId: 'patient-summary', Icon: UserRound },
|
||||
{ id: 'overview', label: 'Overview', tileId: 'mobile-overview', Icon: UserRound },
|
||||
{ id: 'summary', label: 'Summary', tileId: 'patient-summary', Icon: ClipboardList },
|
||||
{ id: 'experience', label: 'Experience', tileId: 'section-experience', Icon: Workflow },
|
||||
{ id: 'skills', label: 'Skills', tileId: 'section-skills', Icon: Wrench },
|
||||
]
|
||||
|
||||
function TagPill({ tag }: { tag: Tag }) {
|
||||
const styles: Record<Tag['colorVariant'], React.CSSProperties> = {
|
||||
teal: {
|
||||
background: 'var(--accent-light)',
|
||||
color: 'var(--accent)',
|
||||
border: '1px solid var(--accent-border)',
|
||||
},
|
||||
amber: {
|
||||
background: 'var(--amber-light)',
|
||||
color: 'var(--amber)',
|
||||
border: '1px solid var(--amber-border)',
|
||||
},
|
||||
green: {
|
||||
background: 'var(--success-light)',
|
||||
color: 'var(--success)',
|
||||
border: '1px solid var(--success-border)',
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
padding: '4px 10px',
|
||||
borderRadius: '4px',
|
||||
lineHeight: 1.3,
|
||||
...styles[tag.colorVariant],
|
||||
}}
|
||||
>
|
||||
{tag.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertFlag({ alert }: { alert: Alert }) {
|
||||
const Icon = alert.icon === 'AlertTriangle' ? AlertTriangle : AlertCircle
|
||||
|
||||
const styles: Record<Alert['severity'], React.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 function MobileBottomNav({ activeSection, onNavigate, onSearchClick }: MobileBottomNavProps) {
|
||||
export function MobileBottomNav({ activeSection, onNavigate }: MobileBottomNavProps) {
|
||||
const isMobileNav = useIsMobileNav()
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
const sidebarCopy = getSidebarCopy()
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobileNav) setDrawerOpen(false)
|
||||
}, [isMobileNav])
|
||||
|
||||
const handleDrawerKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setDrawerOpen(false)
|
||||
}, [])
|
||||
|
||||
if (!isMobileNav) return null
|
||||
|
||||
const handleNav = (tileId: string) => {
|
||||
onNavigate(tileId)
|
||||
setDrawerOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Bottom tab bar */}
|
||||
<nav
|
||||
aria-label="Mobile navigation"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '56px',
|
||||
background: 'var(--sidebar-bg)',
|
||||
borderTop: '1px solid var(--border)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-around',
|
||||
zIndex: 100,
|
||||
paddingBottom: 'env(safe-area-inset-bottom)',
|
||||
}}
|
||||
>
|
||||
{navItems.map((item) => {
|
||||
const isActive = activeSection === item.id
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => handleNav(item.tileId)}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
aria-label={item.label}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '2px',
|
||||
width: '44px',
|
||||
height: '44px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: isActive ? 'var(--accent)' : 'var(--text-tertiary)',
|
||||
transition: 'color 150ms',
|
||||
}}
|
||||
>
|
||||
<item.Icon size={20} strokeWidth={isActive ? 2.4 : 2} />
|
||||
<span style={{ fontSize: '10px', fontWeight: isActive ? 600 : 400 }}>{item.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
aria-label="Open menu"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '2px',
|
||||
width: '44px',
|
||||
height: '44px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--text-tertiary)',
|
||||
transition: 'color 150ms',
|
||||
}}
|
||||
>
|
||||
<Menu size={20} strokeWidth={2} />
|
||||
<span style={{ fontSize: '10px', fontWeight: 400 }}>More</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* Drawer */}
|
||||
<AnimatePresence>
|
||||
{drawerOpen && (
|
||||
<>
|
||||
<motion.button
|
||||
type="button"
|
||||
aria-label="Close menu"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={prefersReducedMotion ? { duration: 0 } : { duration: 0.2 }}
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(26,43,42,0.28)',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
zIndex: 200,
|
||||
}}
|
||||
/>
|
||||
<motion.div
|
||||
initial={prefersReducedMotion ? { y: 0 } : { y: '100%' }}
|
||||
animate={{ y: 0 }}
|
||||
exit={prefersReducedMotion ? { y: 0 } : { y: '100%' }}
|
||||
transition={prefersReducedMotion ? { duration: 0 } : { type: 'spring', damping: 28, stiffness: 300 }}
|
||||
className="pmr-scrollbar"
|
||||
onKeyDown={handleDrawerKeyDown}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
maxHeight: '70vh',
|
||||
background: 'var(--sidebar-bg)',
|
||||
borderTop: '1px solid var(--border)',
|
||||
borderRadius: '16px 16px 0 0',
|
||||
overflowY: 'auto',
|
||||
padding: '16px',
|
||||
zIndex: 201,
|
||||
}}
|
||||
>
|
||||
{/* Drawer handle */}
|
||||
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: '12px' }}>
|
||||
<div style={{ width: '36px', height: '4px', borderRadius: '2px', background: 'var(--border)' }} />
|
||||
</div>
|
||||
|
||||
{/* Close button */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '8px' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
aria-label="Close menu"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '44px',
|
||||
height: '44px',
|
||||
background: 'transparent',
|
||||
border: '1px solid var(--border-light)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--text-secondary)',
|
||||
}}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Logo + search */}
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: '6px', marginBottom: '12px' }}>
|
||||
<CvmisLogo cssHeight="40px" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { onSearchClick(); setDrawerOpen(false) }}
|
||||
className="sidebar-control"
|
||||
aria-label={sidebarCopy.searchAriaLabel}
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: '44px',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: 'var(--surface)',
|
||||
color: 'var(--text-secondary)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '0 10px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Search size={16} style={{ color: 'var(--text-tertiary)', flexShrink: 0 }} />
|
||||
<span style={{ flex: 1, textAlign: 'left', fontSize: '13px' }}>{sidebarCopy.searchLabel}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Patient info */}
|
||||
<section style={{ borderBottom: '2px solid var(--accent)', paddingBottom: '12px', marginBottom: '12px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '8px' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '44px',
|
||||
height: '44px',
|
||||
borderRadius: '50%',
|
||||
background: 'linear-gradient(135deg, var(--accent), #0A8080)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#FFFFFF',
|
||||
fontSize: '16px',
|
||||
fontWeight: 700,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
AC
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '15px', fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||
CHARLWOOD, Andrew
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', fontFamily: 'Geist Mono, monospace', color: 'var(--text-secondary)' }}>
|
||||
{sidebarCopy.roleTitle}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gap: '6px' }}>
|
||||
{[
|
||||
{ label: sidebarCopy.gphcLabel, value: patient.nhsNumber.replace(/\s/g, ''), mono: true },
|
||||
{ label: sidebarCopy.educationLabel, value: patient.qualification },
|
||||
{ label: sidebarCopy.locationLabel, value: patient.address },
|
||||
{ label: sidebarCopy.registeredLabel, value: patient.registrationYear },
|
||||
].map(({ label, value, mono }) => (
|
||||
<div key={label} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '13px', padding: '2px 0' }}>
|
||||
<span style={{ color: 'var(--text-tertiary)' }}>{label}</span>
|
||||
<span style={{ color: 'var(--text-primary)', fontWeight: 500, textAlign: 'right', fontFamily: mono ? 'Geist Mono, monospace' : undefined, fontSize: mono ? '12px' : undefined, letterSpacing: mono ? '0.12em' : undefined }}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '13px', padding: '2px 0' }}>
|
||||
<span style={{ color: 'var(--text-tertiary)' }}>{sidebarCopy.phoneLabel}</span>
|
||||
<PhoneCaptcha phone={patient.phone} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '13px', padding: '2px 0' }}>
|
||||
<span style={{ color: 'var(--text-tertiary)' }}>{sidebarCopy.emailLabel}</span>
|
||||
<a
|
||||
href={`mailto:${patient.email}`}
|
||||
style={{ color: 'var(--accent)', fontWeight: 500, textDecoration: 'none', textAlign: 'right' }}
|
||||
>
|
||||
{patient.email}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Tags */}
|
||||
<section style={{ marginBottom: '12px' }}>
|
||||
<div style={{ fontSize: '11px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'var(--text-tertiary)', marginBottom: '8px' }}>
|
||||
{sidebarCopy.tagsTitle}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '5px' }}>
|
||||
{tags.map((tag) => (
|
||||
<TagPill key={tag.label} tag={tag} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Alerts */}
|
||||
<section>
|
||||
<div style={{ fontSize: '11px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'var(--text-tertiary)', marginBottom: '8px' }}>
|
||||
{sidebarCopy.alertsTitle}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||
{alerts.map((alert, index) => (
|
||||
<AlertFlag key={index} alert={alert} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
<nav
|
||||
aria-label="Mobile navigation"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '56px',
|
||||
background: 'var(--sidebar-bg)',
|
||||
borderTop: '1px solid var(--border)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-around',
|
||||
zIndex: 100,
|
||||
paddingBottom: 'env(safe-area-inset-bottom)',
|
||||
}}
|
||||
>
|
||||
{navItems.map((item) => {
|
||||
const isActive = activeSection === item.id
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => onNavigate(item.tileId)}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
aria-label={item.label}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '2px',
|
||||
width: '44px',
|
||||
height: '44px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: isActive ? 'var(--accent)' : 'var(--text-tertiary)',
|
||||
transition: 'color 150ms',
|
||||
}}
|
||||
>
|
||||
<item.Icon size={20} strokeWidth={isActive ? 2.4 : 2} />
|
||||
<span style={{ fontSize: '10px', fontWeight: isActive ? 600 : 400 }}>{item.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
import { useState } from 'react'
|
||||
import type { CSSProperties } from 'react'
|
||||
import { Download, Github, Linkedin, Search, Send } from 'lucide-react'
|
||||
import { CvmisLogo } from './CvmisLogo'
|
||||
import { PhoneCaptcha } from './PhoneCaptcha'
|
||||
import { ReferralFormModal } from './ReferralFormModal'
|
||||
import { patient } from '@/data/patient'
|
||||
import { tags } from '@/data/tags'
|
||||
import { getSidebarCopy } from '@/lib/profile-content'
|
||||
import type { Tag } from '@/types/pmr'
|
||||
|
||||
interface MobileOverviewHeaderProps {
|
||||
onSearchClick: () => void
|
||||
}
|
||||
|
||||
function TagPill({ tag }: { tag: Tag }) {
|
||||
const styles: Record<Tag['colorVariant'], CSSProperties> = {
|
||||
teal: {
|
||||
background: 'var(--accent-light)',
|
||||
color: 'var(--accent)',
|
||||
border: '1px solid var(--accent-border)',
|
||||
},
|
||||
amber: {
|
||||
background: 'var(--amber-light)',
|
||||
color: 'var(--amber)',
|
||||
border: '1px solid var(--amber-border)',
|
||||
},
|
||||
green: {
|
||||
background: 'var(--success-light)',
|
||||
color: 'var(--success)',
|
||||
border: '1px solid var(--success-border)',
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
padding: '4px 10px',
|
||||
borderRadius: '4px',
|
||||
lineHeight: 1.3,
|
||||
...styles[tag.colorVariant],
|
||||
}}
|
||||
>
|
||||
{tag.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function MobileOverviewHeader({ onSearchClick }: MobileOverviewHeaderProps) {
|
||||
const sidebarCopy = getSidebarCopy()
|
||||
const [showReferralForm, setShowReferralForm] = useState(false)
|
||||
|
||||
return (
|
||||
<div
|
||||
data-tile-id="mobile-overview"
|
||||
style={{
|
||||
padding: '16px',
|
||||
background: 'var(--sidebar-bg)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
border: '1px solid var(--border)',
|
||||
marginBottom: '16px',
|
||||
}}
|
||||
>
|
||||
{/* Logo + Search row */}
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: '6px', marginBottom: '12px' }}>
|
||||
<CvmisLogo cssHeight="40px" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSearchClick}
|
||||
className="sidebar-control"
|
||||
aria-label={sidebarCopy.searchAriaLabel}
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: '44px',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: 'var(--surface)',
|
||||
color: 'var(--text-secondary)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '0 10px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Search size={16} style={{ color: 'var(--text-tertiary)', flexShrink: 0 }} />
|
||||
<span style={{ flex: 1, textAlign: 'left', fontSize: '13px' }}>{sidebarCopy.searchLabel}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Patient info */}
|
||||
<section style={{ borderBottom: '2px solid var(--accent)', paddingBottom: '12px', marginBottom: '12px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '8px' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '44px',
|
||||
height: '44px',
|
||||
borderRadius: '50%',
|
||||
background: 'linear-gradient(135deg, var(--accent), #0A8080)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#FFFFFF',
|
||||
fontSize: '16px',
|
||||
fontWeight: 700,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
AC
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '15px', fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||
CHARLWOOD, Andrew
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', fontFamily: 'Geist Mono, monospace', color: 'var(--text-secondary)' }}>
|
||||
{sidebarCopy.roleTitle}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gap: '6px' }}>
|
||||
{[
|
||||
{ label: sidebarCopy.gphcLabel, value: patient.nhsNumber.replace(/\s/g, ''), mono: true },
|
||||
{ label: sidebarCopy.educationLabel, value: patient.qualification },
|
||||
{ label: sidebarCopy.locationLabel, value: patient.address },
|
||||
{ label: sidebarCopy.registeredLabel, value: patient.registrationYear },
|
||||
].map(({ label, value, mono }) => (
|
||||
<div key={label} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '13px', padding: '2px 0' }}>
|
||||
<span style={{ color: 'var(--text-tertiary)' }}>{label}</span>
|
||||
<span style={{ color: 'var(--text-primary)', fontWeight: 500, textAlign: 'right', fontFamily: mono ? 'Geist Mono, monospace' : undefined, fontSize: mono ? '12px' : undefined, letterSpacing: mono ? '0.12em' : undefined }}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '13px', padding: '2px 0' }}>
|
||||
<span style={{ color: 'var(--text-tertiary)' }}>{sidebarCopy.phoneLabel}</span>
|
||||
<PhoneCaptcha phone={patient.phone} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '13px', padding: '2px 0' }}>
|
||||
<span style={{ color: 'var(--text-tertiary)' }}>{sidebarCopy.emailLabel}</span>
|
||||
<a
|
||||
href={`mailto:${patient.email}`}
|
||||
style={{ color: 'var(--accent)', fontWeight: 500, textDecoration: 'none', textAlign: 'right' }}
|
||||
>
|
||||
{patient.email}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Tags */}
|
||||
<section style={{ marginBottom: '12px' }}>
|
||||
<div style={{ fontSize: '11px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'var(--text-tertiary)', marginBottom: '8px' }}>
|
||||
{sidebarCopy.tagsTitle}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '5px' }}>
|
||||
{tags.map((tag) => (
|
||||
<TagPill key={tag.label} tag={tag} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||
{/* Download CV — full width */}
|
||||
<a
|
||||
href="/References/CV_v4.md"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Download CV"
|
||||
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,
|
||||
letterSpacing: '0.03em',
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
<Download size={14} />
|
||||
Download CV
|
||||
</a>
|
||||
|
||||
{/* Three icon buttons row */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '6px' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowReferralForm(true)}
|
||||
aria-label="Contact patient"
|
||||
style={{
|
||||
minHeight: '40px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
border: '1px solid var(--accent-border)',
|
||||
background: 'var(--surface)',
|
||||
color: 'var(--accent)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Send size={16} />
|
||||
</button>
|
||||
<a
|
||||
href="https://linkedin.com/in/andycharlwood"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="LinkedIn profile"
|
||||
style={{
|
||||
minHeight: '40px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
border: '1px solid var(--border-light)',
|
||||
background: 'var(--surface)',
|
||||
color: 'var(--text-secondary)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
<Linkedin size={16} />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/andycharlwood"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="GitHub profile"
|
||||
style={{
|
||||
minHeight: '40px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
border: '1px solid var(--border-light)',
|
||||
background: 'var(--surface)',
|
||||
color: 'var(--text-secondary)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
<Github size={16} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ReferralFormModal isOpen={showReferralForm} onClose={() => setShowReferralForm(false)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -84,6 +84,7 @@ export function ReferralFormModal({ isOpen, onClose }: ReferralFormModalProps) {
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
minHeight: '44px',
|
||||
fontFamily: 'var(--font-ui)',
|
||||
fontSize: '14px',
|
||||
color: 'var(--text-primary, #1A2B2A)',
|
||||
@@ -133,7 +134,7 @@ export function ReferralFormModal({ isOpen, onClose }: ReferralFormModalProps) {
|
||||
transition={{ duration: 0.25, ease: 'easeOut' }}
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: '540px',
|
||||
maxWidth: 'min(540px, calc(100vw - 32px))',
|
||||
maxHeight: 'calc(100vh - 32px)',
|
||||
overflowY: 'auto',
|
||||
backgroundColor: 'var(--surface, #FFFFFF)',
|
||||
@@ -151,7 +152,7 @@ export function ReferralFormModal({ isOpen, onClose }: ReferralFormModalProps) {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '16px 24px',
|
||||
padding: '14px 16px',
|
||||
borderBottom: '2px solid var(--accent, #0D6E6E)',
|
||||
backgroundColor: 'var(--bg-dashboard, #F0F5F4)',
|
||||
}}
|
||||
@@ -190,8 +191,8 @@ export function ReferralFormModal({ isOpen, onClose }: ReferralFormModalProps) {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
width: '44px',
|
||||
height: '44px',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
borderRadius: 'var(--radius-sm, 6px)',
|
||||
@@ -215,7 +216,7 @@ export function ReferralFormModal({ isOpen, onClose }: ReferralFormModalProps) {
|
||||
{/* Form body */}
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
style={{ padding: '24px', display: 'flex', flexDirection: 'column', gap: '18px' }}
|
||||
style={{ padding: '16px', display: 'flex', flexDirection: 'column', gap: '18px' }}
|
||||
>
|
||||
{/* Referring Clinician */}
|
||||
<div>
|
||||
@@ -261,7 +262,7 @@ export function ReferralFormModal({ isOpen, onClose }: ReferralFormModalProps) {
|
||||
id="organisationTo"
|
||||
type="text"
|
||||
readOnly
|
||||
value="A. Charlwood"
|
||||
value="CV Managment Information System"
|
||||
style={readOnlyStyle}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
@@ -383,6 +384,7 @@ export function ReferralFormModal({ isOpen, onClose }: ReferralFormModalProps) {
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
minHeight: '44px',
|
||||
fontFamily: 'var(--font-ui)',
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
|
||||
@@ -532,14 +532,14 @@ export default function Sidebar({ activeSection, onNavigate, onSearchClick }: Si
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
cursor: 'pointer',
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
fontWeight: 500,
|
||||
//fontFamily: 'var(--font-geist-mono)',
|
||||
letterSpacing: '0.03em',
|
||||
transition: 'border-color 150ms, color 150ms',
|
||||
}}
|
||||
>
|
||||
<Send size={14} />
|
||||
Refer Patient
|
||||
Contact patient
|
||||
</button>
|
||||
<div style={{ display: 'flex', gap: '6px' }}>
|
||||
<a
|
||||
|
||||
@@ -624,20 +624,20 @@ function ContinuousScrollCarousel() {
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--accent-light)',
|
||||
border: '1px solid var(--accent-border)',
|
||||
borderRadius: '50%',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 1px 4px rgba(0,0,0,0.08)',
|
||||
color: 'var(--text-secondary)',
|
||||
transition: 'opacity 150ms, background-color 150ms',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
|
||||
color: 'var(--accent)',
|
||||
transition: 'opacity 150ms, background-color 150ms, border-color 150ms',
|
||||
zIndex: 2,
|
||||
opacity: 0.7,
|
||||
opacity: 0.85,
|
||||
padding: 0,
|
||||
}
|
||||
|
||||
@@ -684,26 +684,58 @@ function ContinuousScrollCarousel() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Edge fade masks */}
|
||||
<div style={{
|
||||
position: 'absolute', top: 0, left: 0, bottom: 0, width: '48px',
|
||||
background: 'linear-gradient(to right, var(--surface), transparent)',
|
||||
pointerEvents: 'none', zIndex: 1,
|
||||
}} />
|
||||
<div style={{
|
||||
position: 'absolute', top: 0, right: 0, bottom: 0, width: '48px',
|
||||
background: 'linear-gradient(to left, var(--surface), transparent)',
|
||||
pointerEvents: 'none', zIndex: 1,
|
||||
}} />
|
||||
|
||||
{/* Left arrow */}
|
||||
<button
|
||||
onClick={() => jumpByCards(-1)}
|
||||
aria-label="Previous project"
|
||||
style={{ ...arrowStyle, left: '-4px' }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.opacity = '1' }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.opacity = '0.7' }}
|
||||
style={{ ...arrowStyle, left: '2px' }}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.opacity = '1'
|
||||
e.currentTarget.style.background = 'var(--accent)'
|
||||
e.currentTarget.style.color = '#fff'
|
||||
e.currentTarget.style.borderColor = 'var(--accent)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.opacity = '0.85'
|
||||
e.currentTarget.style.background = 'var(--accent-light)'
|
||||
e.currentTarget.style.color = 'var(--accent)'
|
||||
e.currentTarget.style.borderColor = 'var(--accent-border)'
|
||||
}}
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
|
||||
{/* Right arrow */}
|
||||
<button
|
||||
onClick={() => jumpByCards(1)}
|
||||
aria-label="Next project"
|
||||
style={{ ...arrowStyle, right: '-4px' }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.opacity = '1' }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.opacity = '0.7' }}
|
||||
style={{ ...arrowStyle, right: '2px' }}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.opacity = '1'
|
||||
e.currentTarget.style.background = 'var(--accent)'
|
||||
e.currentTarget.style.color = '#fff'
|
||||
e.currentTarget.style.borderColor = 'var(--accent)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.opacity = '0.85'
|
||||
e.currentTarget.style.background = 'var(--accent-light)'
|
||||
e.currentTarget.style.color = 'var(--accent)'
|
||||
e.currentTarget.style.borderColor = 'var(--accent-border)'
|
||||
}}
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -238,15 +238,6 @@ html {
|
||||
}
|
||||
}
|
||||
|
||||
/* Login spinner */
|
||||
@keyframes login-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.login-spinner {
|
||||
animation: login-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
/* Login button pulse — draws attention when button becomes clickable */
|
||||
@keyframes login-pulse {
|
||||
0%, 60%, 100% { transform: scale(1); }
|
||||
@@ -682,12 +673,6 @@ textarea:focus-visible {
|
||||
to { transform: none; opacity: 1; }
|
||||
}
|
||||
|
||||
/* Static login spinner indicator */
|
||||
.login-spinner {
|
||||
animation: none;
|
||||
border-top-color: #0D6E6E;
|
||||
}
|
||||
|
||||
/* No pulse animation */
|
||||
.login-pulse-active {
|
||||
animation: none;
|
||||
|
||||
Reference in New Issue
Block a user