Skip to content

Commit bb709bb

Browse files
Harden browser compatibility for in-app browsers
- Wrap all localStorage access in try/catch (Messenger/Instagram block storage) - Add typeof guards for navigator.clipboard and document APIs - Add ErrorBoundary at root to prevent white-screen crashes - Add InAppBrowserBanner detecting Facebook/Messenger/Instagram WebViews - Add noscript fallback and /_health diagnostic route - All dark mode compatible, no visual changes
1 parent 329deec commit bb709bb

9 files changed

Lines changed: 247 additions & 79 deletions

File tree

app/globals.css

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,15 @@
125125
.dark .bg-\[\#f8fafc\] { background-color: #1e2a30 !important; }
126126
.dark .decoration-\[\#c7b598\] { text-decoration-color: var(--sj-border) !important; }
127127

128+
/* Sources page */
129+
.dark .text-\[\#3f2a5f\] { color: #b48cde !important; }
130+
.dark .text-\[\#7a6f60\] { color: var(--sj-text-faint) !important; }
131+
.dark .bg-\[\#f8fbfd\] { background-color: #1e2a30 !important; }
132+
.dark .border-\[\#cfe0ea\] { border-color: #2a4060 !important; }
133+
.dark .hover\:bg-\[\#efe8fb\]:hover { background-color: #2e2540 !important; }
134+
.dark .hover\:bg-\[\#f5f0e5\]:hover { background-color: #3a3020 !important; }
135+
.dark .hover\:bg-\[\#e8f0f5\]:hover { background-color: #1e2e3a !important; }
136+
128137
/* CTA buttons */
129138
.dark .bg-\[\#1b1a17\] { background-color: #e8e4dc !important; color: #1b1a17 !important; }
130139
.dark .bg-\[\#7e622a\] { background-color: var(--sj-accent) !important; }

app/health/route.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { NextResponse } from "next/server"
2+
3+
export function GET() {
4+
return NextResponse.json({ ok: true })
5+
}

app/layout.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import Header from "@/components/Header"
55
import ServiceWorkerRegistration from "@/components/ServiceWorkerRegistration"
66
import AuthProvider from "@/components/AuthProvider"
77
import ThemeProvider from "@/components/ThemeProvider"
8+
import { ErrorBoundary } from "@/components/ErrorBoundary"
9+
import { InAppBrowserBanner } from "@/components/InAppBrowserBanner"
810

911
export const metadata: Metadata = {
1012
title: "Scripture Journey",
@@ -55,11 +57,19 @@ export default function RootLayout({
5557
return (
5658
<html lang="en" suppressHydrationWarning>
5759
<body className="min-h-screen bg-[#fefcf8] text-[#1b1a17] antialiased">
60+
<ErrorBoundary>
5861
<AuthProvider>
5962
<ThemeProvider>
6063

64+
<InAppBrowserBanner />
6165
<a href="#main-content" className="skip-link">Skip to content</a>
6266

67+
<noscript>
68+
<div style={{ padding: "1rem", textAlign: "center" }}>
69+
Please enable JavaScript to use Scripture Journey.
70+
</div>
71+
</noscript>
72+
6373
<Header />
6474

6575
<main id="main-content" className="mx-auto w-full max-w-6xl px-4 py-10">
@@ -81,6 +91,7 @@ export default function RootLayout({
8191

8292
</ThemeProvider>
8393
</AuthProvider>
94+
</ErrorBoundary>
8495
</body>
8596
</html>
8697
)

components/CopyVerseLink.tsx

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,21 +19,22 @@ export default function CopyVerseLink({ lesson, size = 'md' }: Props) {
1919
const text = `${lesson.title}\n${lesson.otReference}${lesson.ntReference}\n${url}`
2020

2121
try {
22-
await navigator.clipboard.writeText(text)
22+
if (typeof navigator !== 'undefined' && navigator.clipboard) {
23+
await navigator.clipboard.writeText(text)
24+
} else if (typeof document !== 'undefined') {
25+
const ta = document.createElement('textarea')
26+
ta.value = text
27+
ta.style.position = 'fixed'
28+
ta.style.opacity = '0'
29+
document.body.appendChild(ta)
30+
ta.select()
31+
document.execCommand('copy')
32+
document.body.removeChild(ta)
33+
}
2334
setCopied(true)
2435
setTimeout(() => setCopied(false), 2000)
2536
} catch {
26-
// Fallback for older browsers
27-
const ta = document.createElement('textarea')
28-
ta.value = text
29-
ta.style.position = 'fixed'
30-
ta.style.opacity = '0'
31-
document.body.appendChild(ta)
32-
ta.select()
33-
document.execCommand('copy')
34-
document.body.removeChild(ta)
35-
setCopied(true)
36-
setTimeout(() => setCopied(false), 2000)
37+
// Clipboard unavailable — silent failure
3738
}
3839
}
3940

components/ErrorBoundary.tsx

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"use client"
2+
3+
import { Component, ReactNode } from "react"
4+
5+
interface Props {
6+
children: ReactNode
7+
}
8+
9+
interface State {
10+
hasError: boolean
11+
}
12+
13+
export class ErrorBoundary extends Component<Props, State> {
14+
constructor(props: Props) {
15+
super(props)
16+
this.state = { hasError: false }
17+
}
18+
19+
static getDerivedStateFromError(): State {
20+
return { hasError: true }
21+
}
22+
23+
componentDidCatch(error: Error) {
24+
console.error("ErrorBoundary caught:", error)
25+
}
26+
27+
render() {
28+
if (this.state.hasError) {
29+
return (
30+
<div className="min-h-screen flex flex-col items-center justify-center px-6 text-center bg-[#fefcf8]">
31+
<h1 className="text-2xl font-semibold text-[#1b1a17] mb-4">
32+
Something didn&apos;t load correctly
33+
</h1>
34+
<p className="text-[#4a4338] mb-2">
35+
We&apos;re sorry for the trouble. Please try refreshing the page.
36+
</p>
37+
<p className="text-[#9a8e7e] text-sm mb-6">
38+
If you&apos;re opening this inside Facebook, Messenger, or Instagram,
39+
try opening it directly in Safari or Chrome for the best experience.
40+
</p>
41+
<button
42+
onClick={() => {
43+
this.setState({ hasError: false })
44+
if (typeof window !== "undefined") {
45+
window.location.reload()
46+
}
47+
}}
48+
className="rounded-xl bg-[#1b1a17] px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-[#333]"
49+
>
50+
Refresh Page
51+
</button>
52+
</div>
53+
)
54+
}
55+
56+
return this.props.children
57+
}
58+
}

components/InAppBrowserBanner.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"use client"
2+
3+
import { useEffect, useState } from "react"
4+
5+
function detectInAppBrowser(): boolean {
6+
try {
7+
if (typeof window === "undefined") return false
8+
const ua = navigator.userAgent || ""
9+
return /FBAN|FBAV|Messenger|Instagram|; wv\)/.test(ua)
10+
} catch {
11+
return false
12+
}
13+
}
14+
15+
export function InAppBrowserBanner() {
16+
const [show, setShow] = useState(false)
17+
18+
useEffect(() => {
19+
setShow(detectInAppBrowser())
20+
}, [])
21+
22+
if (!show) return null
23+
24+
return (
25+
<div className="border-b border-amber-200 bg-amber-50 px-4 py-2 text-sm text-amber-800 flex items-center justify-between dark:bg-amber-950/40 dark:border-amber-700 dark:text-amber-200">
26+
<span>
27+
For the best experience,{" "}
28+
<strong>open this page in Safari or Chrome</strong>.
29+
</span>
30+
<button
31+
onClick={() => setShow(false)}
32+
className="ml-4 text-amber-600 hover:text-amber-800 font-medium dark:text-amber-400 dark:hover:text-amber-200"
33+
aria-label="Dismiss browser notice"
34+
>
35+
36+
</button>
37+
</div>
38+
)
39+
}

components/SearchAutocomplete.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,14 @@ function getRecentSlugs(): string[] {
2424
}
2525

2626
function addRecentSlug(slug: string) {
27-
const slugs = getRecentSlugs().filter(s => s !== slug)
28-
slugs.unshift(slug)
29-
localStorage.setItem(RECENT_KEY, JSON.stringify(slugs.slice(0, MAX_RECENT)))
27+
if (typeof window === 'undefined') return
28+
try {
29+
const slugs = getRecentSlugs().filter(s => s !== slug)
30+
slugs.unshift(slug)
31+
localStorage.setItem(RECENT_KEY, JSON.stringify(slugs.slice(0, MAX_RECENT)))
32+
} catch {
33+
// localStorage may be unavailable in in-app browsers
34+
}
3035
}
3136

3237
function highlightMatch(text: string, query: string): React.ReactNode {

components/ThemeProvider.tsx

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,21 +17,31 @@ export default function ThemeProvider({ children }: { children: React.ReactNode
1717
const [theme, setTheme] = useState<Theme>('light')
1818

1919
useEffect(() => {
20-
const stored = localStorage.getItem('sj-theme') as Theme | null
21-
if (stored === 'dark') {
22-
setTheme('dark')
23-
document.documentElement.classList.add('dark')
20+
try {
21+
const stored = localStorage.getItem('sj-theme') as Theme | null
22+
if (stored === 'dark') {
23+
setTheme('dark')
24+
document.documentElement.classList.add('dark')
25+
}
26+
} catch {
27+
// localStorage may be unavailable in in-app browsers
2428
}
2529
}, [])
2630

2731
function toggleTheme() {
2832
const next = theme === 'light' ? 'dark' : 'light'
2933
setTheme(next)
30-
localStorage.setItem('sj-theme', next)
31-
if (next === 'dark') {
32-
document.documentElement.classList.add('dark')
33-
} else {
34-
document.documentElement.classList.remove('dark')
34+
try {
35+
localStorage.setItem('sj-theme', next)
36+
} catch {
37+
// localStorage may be unavailable in in-app browsers
38+
}
39+
if (typeof document !== 'undefined') {
40+
if (next === 'dark') {
41+
document.documentElement.classList.add('dark')
42+
} else {
43+
document.documentElement.classList.remove('dark')
44+
}
3545
}
3646
}
3747

0 commit comments

Comments
 (0)