|
| 1 | +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
1 | 2 | import { ArrowLeft, RotateCw, WifiOff } from "lucide-react"; |
2 | 3 |
|
3 | 4 | import { Button } from "@/components/ui/button"; |
4 | 5 |
|
| 6 | +const SNELLEN_LINES = [ |
| 7 | + "C", |
| 8 | + "O U", |
| 9 | + "L D N", |
| 10 | + "T L O A D", |
| 11 | + "T H E P A G E", |
| 12 | + "R I G H T N O W", |
| 13 | + "R E T U R N T O C A R E", |
| 14 | +]; |
| 15 | + |
| 16 | +const BLUR_RADIUS_PX = 180; |
| 17 | +const TOUCH_UNBLUR_DURATION = 6000; |
| 18 | +const BASE_FONT_SIZE = 56; |
| 19 | +const FONT_SIZE_DECREMENT = 7; |
| 20 | +const BLUR_INTENSITY = "2px"; |
| 21 | +const SCALE_DESKTOP = 1; |
| 22 | +const SCALE_MOBILE = 0.75; |
| 23 | +const MOUSE_THROTTLE_MS = 16; // ~60fps |
| 24 | + |
| 25 | +// Memoized Snellen lines component to prevent unnecessary re-renders |
| 26 | +const SnellenLines = ({ isUnblurred }: { isUnblurred: boolean }) => ( |
| 27 | + <div className="relative z-10 flex flex-col items-center px-6 pt-6 pb-8 font-bold text-center leading-none"> |
| 28 | + {SNELLEN_LINES.map((line, index) => { |
| 29 | + const fontSize = BASE_FONT_SIZE - index * FONT_SIZE_DECREMENT; |
| 30 | + return ( |
| 31 | + <p |
| 32 | + key={`snellen-${index}`} |
| 33 | + className="text-black select-none pb-3 transition-all duration-300 will-change-[filter]" |
| 34 | + style={{ |
| 35 | + fontSize: `${fontSize}px`, |
| 36 | + filter: isUnblurred ? "blur(0px)" : `blur(${BLUR_INTENSITY})`, |
| 37 | + }} |
| 38 | + > |
| 39 | + {line} |
| 40 | + </p> |
| 41 | + ); |
| 42 | + })} |
| 43 | + </div> |
| 44 | +); |
| 45 | + |
5 | 46 | export default function LoadFailedErrorPage() { |
| 47 | + const [mousePos, setMousePos] = useState({ x: 0, y: 0 }); |
| 48 | + const [isUnblurred, setIsUnblurred] = useState(false); |
| 49 | + const containerRef = useRef<HTMLDivElement>(null); |
| 50 | + const touchTimeoutRef = useRef<NodeJS.Timeout | null>(null); |
| 51 | + const mouseMoveTimeoutRef = useRef<NodeJS.Timeout | null>(null); |
| 52 | + const isTouchDeviceRef = useRef(false); |
| 53 | + |
| 54 | + // Detect touch device once on mount |
| 55 | + useEffect(() => { |
| 56 | + isTouchDeviceRef.current = |
| 57 | + typeof window !== "undefined" && |
| 58 | + ("ontouchstart" in window || navigator.maxTouchPoints > 0); |
| 59 | + }, []); |
| 60 | + |
| 61 | + // Cleanup on unmount |
| 62 | + useEffect(() => { |
| 63 | + return () => { |
| 64 | + if (touchTimeoutRef.current) clearTimeout(touchTimeoutRef.current); |
| 65 | + if (mouseMoveTimeoutRef.current) clearTimeout(mouseMoveTimeoutRef.current); |
| 66 | + }; |
| 67 | + }, []); |
| 68 | + |
| 69 | + // Throttled mouse tracking for blur mask effect (desktop only) |
| 70 | + const handleMouseMove = useCallback((e: MouseEvent) => { |
| 71 | + if (mouseMoveTimeoutRef.current || isTouchDeviceRef.current) return; |
| 72 | + |
| 73 | + const rect = containerRef.current?.getBoundingClientRect(); |
| 74 | + if (rect) { |
| 75 | + setMousePos({ |
| 76 | + x: e.clientX - rect.left, |
| 77 | + y: e.clientY - rect.top, |
| 78 | + }); |
| 79 | + |
| 80 | + mouseMoveTimeoutRef.current = setTimeout(() => { |
| 81 | + mouseMoveTimeoutRef.current = null; |
| 82 | + }, MOUSE_THROTTLE_MS); |
| 83 | + } |
| 84 | + }, []); |
| 85 | + |
| 86 | + const handlePointerEnter = useCallback(() => { |
| 87 | + if (!isTouchDeviceRef.current) { |
| 88 | + setIsUnblurred(true); |
| 89 | + } |
| 90 | + }, []); |
| 91 | + |
| 92 | + const handlePointerLeave = useCallback(() => { |
| 93 | + if (!isTouchDeviceRef.current && !touchTimeoutRef.current) { |
| 94 | + setIsUnblurred(false); |
| 95 | + } |
| 96 | + }, []); |
| 97 | + |
| 98 | + // Touch handler for mobile (trigger brief unblur) |
| 99 | + const handleTouchStart = useCallback(() => { |
| 100 | + if (touchTimeoutRef.current) { |
| 101 | + clearTimeout(touchTimeoutRef.current); |
| 102 | + } |
| 103 | + |
| 104 | + setIsUnblurred(true); |
| 105 | + touchTimeoutRef.current = setTimeout(() => { |
| 106 | + setIsUnblurred(false); |
| 107 | + touchTimeoutRef.current = null; |
| 108 | + }, TOUCH_UNBLUR_DURATION); |
| 109 | + }, []); |
| 110 | + |
| 111 | + // Clean up touch on touchend to allow state reset |
| 112 | + const handleTouchEnd = useCallback(() => { |
| 113 | + // Timeout continues to manage the blur |
| 114 | + }, []); |
| 115 | + |
| 116 | + // Setup event listeners with passive flag for better scroll performance |
| 117 | + useEffect(() => { |
| 118 | + const container = containerRef.current; |
| 119 | + if (!container) return; |
| 120 | + |
| 121 | + container.addEventListener("mousemove", handleMouseMove, { passive: true }); |
| 122 | + container.addEventListener("touchstart", handleTouchStart, { |
| 123 | + passive: true, |
| 124 | + }); |
| 125 | + container.addEventListener("touchend", handleTouchEnd, { passive: true }); |
| 126 | + |
| 127 | + return () => { |
| 128 | + container.removeEventListener("mousemove", handleMouseMove); |
| 129 | + container.removeEventListener("touchstart", handleTouchStart); |
| 130 | + container.removeEventListener("touchend", handleTouchEnd); |
| 131 | + }; |
| 132 | + }, [handleMouseMove, handleTouchStart, handleTouchEnd]); |
| 133 | + |
| 134 | + // Memoize mask image to prevent recalculation on every render |
| 135 | + const maskImage = useMemo( |
| 136 | + () => |
| 137 | + `radial-gradient(circle ${BLUR_RADIUS_PX}px at ${mousePos.x}px ${mousePos.y}px, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 70%)`, |
| 138 | + [mousePos.x, mousePos.y] |
| 139 | + ); |
| 140 | + |
6 | 141 | return ( |
7 | | - <div className="bg-background text-foreground flex min-h-screen w-full items-center justify-center px-6 py-16"> |
8 | | - <div className="w-full max-w-md text-center"> |
9 | | - <div className="bg-muted text-muted-foreground mx-auto flex size-14 items-center justify-center rounded-full"> |
10 | | - <WifiOff className="size-6" /> |
| 142 | + <main className="bg-background text-foreground flex min-h-screen w-full flex-col items-center justify-center px-4 py-16"> |
| 143 | + {/* Snellen Chart */} |
| 144 | + <div |
| 145 | + className="relative scale-[var(--scale-mobile)] rounded-xl border-6 border-yellow-950 transition-all duration-300 will-change-transform md:scale-[var(--scale-desktop)] md:justify-center" |
| 146 | + ref={containerRef} |
| 147 | + onPointerEnter={handlePointerEnter} |
| 148 | + onPointerLeave={handlePointerLeave} |
| 149 | + style={{ |
| 150 | + "--scale-mobile": SCALE_MOBILE, |
| 151 | + "--scale-desktop": SCALE_DESKTOP, |
| 152 | + boxShadow: ` |
| 153 | + hsl(57, 19%, 35%, 0.73) 0px 1px 0.8px, |
| 154 | + hsl(57, 19%, 35%, 0.67) 0px 2.3px 1.9px -0.5px, |
| 155 | + hsl(57, 19%, 35%, 0.6) 0px 4.6px 3.8px -1px, |
| 156 | + hsl(57, 19%, 35%, 0.54) 0.1px 9px 7.4px -1.5px, |
| 157 | + hsl(57, 19%, 35%, 0.47) 0.1px 16.8px 13.9px -2px, |
| 158 | + hsl(57, 19%, 35%, 0.41) 0.2px 29.3px 24.2px -2.5px, |
| 159 | + hsl(57, 19%, 35%, 0.34) 0.3px 47.6px 39.3px -3px, |
| 160 | + hsl(57, 19%, 35%, 0.28) 0.5px 73px 60.2px -3.5px, |
| 161 | + hsl(57, 19%, 35%, 0.21) 0.7px 106.8px 88.1px -4px, |
| 162 | + hsl(57, 19%, 35%, 0.15) 0.9px 150px 123.8px -4.5px |
| 163 | + `, |
| 164 | + } as React.CSSProperties} |
| 165 | + > |
| 166 | + {/* Background glow animation */} |
| 167 | + <div |
| 168 | + className={`absolute inset-0 z-0 rounded-xl bg-yellow-100 blur-3xl pointer-events-none transition-all duration-700 will-change-opacity ${ |
| 169 | + !isUnblurred ? "opacity-0 scale-90" : "opacity-50 scale-100" |
| 170 | + }`} |
| 171 | + aria-hidden="true" |
| 172 | + /> |
| 173 | + |
| 174 | + {/* Chart Container */} |
| 175 | + <div className="relative max-w-fit w-full bg-white rounded-xl ring-1 ring-white/60 backdrop-blur-md transition-all duration-300 hover:shadow-[inset_0_0_40px_rgba(255,255,150,0.5)] will-change-shadow"> |
| 176 | + {/* Radial cursor blur (desktop only) */} |
| 177 | + <div |
| 178 | + className="absolute inset-0 z-20 pointer-events-none rounded-xl will-change-[mask-image,webkit-mask-image]" |
| 179 | + style={{ |
| 180 | + WebkitMaskImage: maskImage, |
| 181 | + maskImage: maskImage, |
| 182 | + WebkitBackdropFilter: `blur(${BLUR_INTENSITY})`, |
| 183 | + backdropFilter: `blur(${BLUR_INTENSITY})`, |
| 184 | + }} |
| 185 | + aria-hidden="true" |
| 186 | + /> |
| 187 | + |
| 188 | + {/* Snellen Lines */} |
| 189 | + <SnellenLines isUnblurred={isUnblurred} /> |
11 | 190 | </div> |
| 191 | + </div> |
12 | 192 |
|
13 | | - <h1 className="mt-6 text-2xl font-semibold tracking-tight"> |
| 193 | + {/* Error Content */} |
| 194 | + <div className="w-full max-w-xl text-center mt-8 md:mt-16"> |
| 195 | + <div className="text-muted-foreground/70 font-mono text-sm tracking-widest uppercase"> |
| 196 | + Error 503 |
| 197 | + </div> |
| 198 | + <h1 className="mt-4 text-4xl font-bold tracking-tight text-balance sm:text-5xl"> |
14 | 199 | We couldn’t load this page |
15 | 200 | </h1> |
16 | | - <p className="text-muted-foreground mt-3 text-sm leading-6 text-balance"> |
| 201 | + <p className="text-muted-foreground mt-2 text-base leading-7 text-balance"> |
17 | 202 | Something went wrong while reaching our servers. This is usually a |
18 | 203 | temporary network issue. Please try again, or come back in a minute. |
19 | 204 | </p> |
20 | 205 |
|
21 | | - <div className="border-border/60 bg-card text-muted-foreground mt-8 rounded-lg border px-4 py-3 text-left font-mono text-xs"> |
22 | | - <div className="flex items-center justify-between"> |
23 | | - <span className="text-foreground/80">Request ID</span> |
24 | | - <span>req_8f3c2d1a</span> |
25 | | - </div> |
26 | | - <div className="mt-1.5 flex items-center justify-between"> |
27 | | - <span className="text-foreground/80">Status</span> |
28 | | - <span>Network error</span> |
29 | | - </div> |
30 | | - </div> |
31 | | - |
32 | | - <div className="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row"> |
33 | | - <Button size="lg" className="w-full sm:w-auto"> |
| 206 | + <div className="mt-6 flex flex-col items-center justify-center gap-3 sm:flex-row"> |
| 207 | + <Button className="w-full sm:w-auto"> |
34 | 208 | <RotateCw data-icon="inline-start" /> |
35 | 209 | Try again |
36 | 210 | </Button> |
37 | | - <Button size="lg" variant="outline" className="w-full sm:w-auto"> |
| 211 | + <Button variant="outline" className="w-full sm:w-auto"> |
38 | 212 | <ArrowLeft data-icon="inline-start" /> |
39 | 213 | Go back |
40 | 214 | </Button> |
41 | 215 | </div> |
42 | 216 |
|
43 | | - <p className="text-muted-foreground mt-8 text-xs"> |
44 | | - If this keeps happening, share the request ID with{" "} |
| 217 | + <p className="text-muted-foreground mt-10 inline-flex items-center gap-1.5 text-sm"> |
| 218 | + <WifiOff className="size-4" /> |
| 219 | + Still having issues?{" "} |
45 | 220 | <a |
46 | 221 | href="#" |
47 | 222 | className="text-foreground underline-offset-4 hover:underline" |
48 | 223 | > |
49 | | - support |
| 224 | + Contact support |
50 | 225 | </a> |
51 | | - . |
52 | 226 | </p> |
53 | 227 | </div> |
54 | | - </div> |
| 228 | + </main> |
55 | 229 | ); |
56 | 230 | } |
0 commit comments