import { useEffect, useRef, useState } from 'react' // Turns scripture references ("Titus 1:5-9") in answer text into hover/tap verse popups and URLs into links. function escapeRegExp(value: string) { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } function renderHighlightedText(text: string, query: string) { const trimmed = query.trim() if (!trimmed) return text const regex = new RegExp(`(${escapeRegExp(trimmed)})`, 'ig') const parts = text.split(regex) return parts.map((part, index) => { if (part.toLowerCase() === trimmed.toLowerCase()) { return {part} } return {part} }) } function renderTextWithLinks(text: string, highlightQuery = '') { const BOOK_ID_MAP: Record = { gen: 'GEN', genesis: 'GEN', exo: 'EXO', exodus: 'EXO', lev: 'LEV', leviticus: 'LEV', num: 'NUM', numbers: 'NUM', deut: 'DEU', deuteronomy: 'DEU', josh: 'JOS', joshua: 'JOS', judg: 'JDG', judges: 'JDG', ruth: 'RUT', '1 sam': 'SA1', '2 sam': 'SA2', '1 kgs': 'KI1', '1 kings': 'KI1', '2 kgs': 'KI2', '2 kings': 'KI2', '1 chr': 'CH1', '1 chron': 'CH1', '1 chronicles': 'CH1', '2 chr': 'CH2', '2 chron': 'CH2', '2 chronicles': 'CH2', ezra: 'EZR', neh: 'NEH', nehemiah: 'NEH', esth: 'EST', esther: 'EST', job: 'JOB', ps: 'PSA', psalms: 'PSA', psalm: 'PSA', prov: 'PRO', proverbs: 'PRO', eccl: 'ECC', ecclesiastes: 'ECC', song: 'SNG', 'song of sol': 'SNG', 'song of solomon': 'SNG', isa: 'ISA', isaiah: 'ISA', jer: 'JER', jeremiah: 'JER', lam: 'LAM', lamentations: 'LAM', ezek: 'EZK', ezekiel: 'EZK', dan: 'DAN', daniel: 'DAN', hos: 'HOS', hosea: 'HOS', joel: 'JOL', amos: 'AMO', obad: 'OBA', obadiah: 'OBA', jonah: 'JNA', mic: 'MIC', micah: 'MIC', nah: 'NAH', nahum: 'NAH', hab: 'HAB', habakkuk: 'HAB', zeph: 'ZEP', zephaniah: 'ZEP', hag: 'HAG', haggai: 'HAG', zech: 'ZEC', zechariah: 'ZEC', mal: 'MAL', malachi: 'MAL', matt: 'MAT', matthew: 'MAT', mark: 'MRK', luke: 'LUK', john: 'JHN', acts: 'ACT', rom: 'ROM', romans: 'ROM', '1 cor': 'CO1', '1 corinthians': 'CO1', '2 cor': 'CO2', '2 corinthians': 'CO2', gal: 'GAL', galatians: 'GAL', eph: 'EPH', ephesians: 'EPH', phil: 'PHP', philippians: 'PHP', col: 'COL', colossians: 'COL', '1 thess': 'TH1', '1 thessalonians': 'TH1', '2 thess': 'TH2', '2 thessalonians': 'TH2', '1 tim': 'TI1', '1 timothy': 'TI1', '2 tim': 'TI2', '2 timothy': 'TI2', titus: 'TIT', philem: 'PHM', philemon: 'PHM', heb: 'HEB', hebrews: 'HEB', jas: 'JAM', james: 'JAM', '1 pet': 'PE1', '1 peter': 'PE1', '2 pet': 'PE2', '2 peter': 'PE2', '1 john': 'JO1', '2 john': 'JO2', '3 john': 'JO3', jude: 'JDE', rev: 'REV', revelation: 'REV', } function parseScriptureRef(refText: string): { bookId: string; chapter: number; verseStart: number; verseEnd: number } | null { const match = refText.match(/^(.*?)\s+(\d+):(\d+)(?:-(\d+))?$/) if (!match) return null const [, bookRaw, chapterStr, verseStartStr, verseEndStr] = match const bookKey = bookRaw.toLowerCase().replace(/\.\s*/g, ' ').trim() const bookId = BOOK_ID_MAP[bookKey] if (!bookId) return null return { bookId, chapter: parseInt(chapterStr, 10), verseStart: parseInt(verseStartStr, 10), verseEnd: verseEndStr ? parseInt(verseEndStr, 10) : parseInt(verseStartStr, 10), } } // Split the text by both scripture refs and URLs const combined = /(https?:\/\/[^\s]+)|\b((?:(?:1|2|3)\s)?(?:Gen(?:esis)?|Exo(?:dus)?|Lev(?:iticus)?|Num(?:bers)?|Deut(?:eronomy)?|Josh(?:ua)?|Judg(?:es)?|Ruth|1\s?Sam|2\s?Sam|1\s?Kgs?|2\s?Kgs?|1\s?Chr(?:on)?|2\s?Chr(?:on)?|Ezra|Neh(?:emiah)?|Esth(?:er)?|Job|Ps(?:alms?)?|Prov(?:erbs)?|Eccl(?:esiastes)?|Song(?:\s?of\s?Sol(?:omon)?)?|Isa(?:iah)?|Jer(?:emiah)?|Lam(?:entations)?|Ezek(?:iel)?|Dan(?:iel)?|Hos(?:ea)?|Joel|Amos|Obad(?:iah)?|Jonah|Mic(?:ah)?|Nah(?:um)?|Hab(?:akkuk)?|Zeph(?:aniah)?|Hag(?:gai)?|Zech(?:ariah)?|Mal(?:achi)?|Matt(?:hew)?|Mark|Luke|John|Acts|Rom(?:ans)?|1\s?Cor(?:inthians)?|2\s?Cor(?:inthians)?|Gal(?:atians)?|Eph(?:esians)?|Phil(?:ippians)?|Col(?:ossians)?|1\s?Thess|2\s?Thess|1\s?Tim(?:othy)?|2\s?Tim(?:othy)?|Titus|Philem(?:on)?|Heb(?:rews)?|Jas(?:mes)?|1\s?Pet(?:er)?|2\s?Pet(?:er)?|1\s?John|2\s?John|3\s?John|Jude|Rev(?:elation)?)\.?\s+\d+:\d+(?:-\d+)?)\b/gi const parts: string[] = [] let lastIndex = 0 let m: RegExpExecArray | null // reset lastIndex for combined combined.lastIndex = 0 while ((m = combined.exec(text)) !== null) { if (m.index > lastIndex) parts.push(text.slice(lastIndex, m.index)) parts.push(m[0]) lastIndex = m.index + m[0].length } if (lastIndex < text.length) parts.push(text.slice(lastIndex)) return parts.map((part, index) => { if (/^https?:\/\//i.test(part)) { const safeHref = part.replace(/[),.;!?]+$/g, '') const trailing = part.slice(safeHref.length) return ( {safeHref} {trailing} ) } const parsed = parseScriptureRef(part.replace(/\.$/, '')) if (parsed) { return } return {renderHighlightedText(part, highlightQuery)} }) } interface VerseData { verse: number value: string } // Maps helloao API book IDs → bible.com book codes const BIBLE_COM_BOOK_IDS: Record = { GEN: 'GEN', EXO: 'EXO', LEV: 'LEV', NUM: 'NUM', DEU: 'DEU', JOS: 'JOS', JDG: 'JDG', RUT: 'RUT', SA1: '1SA', SA2: '2SA', KI1: '1KI', KI2: '2KI', CH1: '1CH', CH2: '2CH', EZR: 'EZR', NEH: 'NEH', EST: 'EST', JOB: 'JOB', PSA: 'PSA', PRO: 'PRO', ECC: 'ECC', SNG: 'SNG', ISA: 'ISA', JER: 'JER', LAM: 'LAM', EZK: 'EZK', DAN: 'DAN', HOS: 'HOS', JOL: 'JOL', AMO: 'AMO', OBA: 'OBA', JNA: 'JON', MIC: 'MIC', NAH: 'NAH', HAB: 'HAB', ZEP: 'ZEP', HAG: 'HAG', ZEC: 'ZEC', MAL: 'MAL', MAT: 'MAT', MRK: 'MRK', LUK: 'LUK', JHN: 'JHN', ACT: 'ACT', ROM: 'ROM', CO1: '1CO', CO2: '2CO', GAL: 'GAL', EPH: 'EPH', PHP: 'PHP', COL: 'COL', TH1: '1TH', TH2: '2TH', TI1: '1TI', TI2: '2TI', TIT: 'TIT', PHM: 'PHM', HEB: 'HEB', JAM: 'JAS', PE1: '1PE', PE2: '2PE', JO1: '1JN', JO2: '2JN', JO3: '3JN', JDE: 'JUD', REV: 'REV', } function ScriptureTooltip({ refText, bookId, chapter, verseStart, verseEnd }: { refText: string bookId: string chapter: number verseStart: number verseEnd: number }) { const [open, setOpen] = useState(false) const [verses, setVerses] = useState([]) const [loading, setLoading] = useState(false) const [error, setError] = useState(false) const [pos, setPos] = useState<{ top: number; left: number } | null>(null) const wrapRef = useRef(null) const btnRef = useRef(null) useEffect(() => { if (!open) return if (verses.length > 0) return setLoading(true) setError(false) fetch(`https://bible.helloao.org/api/BSB/${bookId}/${chapter}.json`) .then(r => { if (!r.ok) throw new Error('Not found') return r.json() as Promise<{ chapter: { content: Array<{ type: string; number?: number; content?: Array<{ text?: string; poem?: number } | string> }> } }> }) .then(data => { const content = data?.chapter?.content ?? [] const found: VerseData[] = [] for (const item of content) { if (item.type === 'verse' && item.number != null && item.number >= verseStart && item.number <= verseEnd) { const text = (item.content ?? []) .map(c => (typeof c === 'string' ? c : (c.text ?? ''))) .join('') .trim() if (text) found.push({ verse: item.number, value: text }) } } setVerses(found) setLoading(false) }) .catch(() => { setError(true) setLoading(false) }) }, [open, bookId, chapter, verseStart, verseEnd, verses.length]) // Compute fixed position from the trigger button so the popup escapes overflow:hidden parents useEffect(() => { if (!open || !btnRef.current) { setPos(null); return } const rect = btnRef.current.getBoundingClientRect() const POPUP_W = 360 const left = Math.max(8, Math.min(rect.left, window.innerWidth - POPUP_W - 12)) setPos({ top: rect.bottom + 8, left }) }, [open]) useEffect(() => { if (!open) return function handleClickOutside(e: MouseEvent) { if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) setOpen(false) } function handleDismiss() { setOpen(false) } document.addEventListener('mousedown', handleClickOutside) window.addEventListener('scroll', handleDismiss, { passive: true, capture: true }) window.addEventListener('resize', handleDismiss) return () => { document.removeEventListener('mousedown', handleClickOutside) window.removeEventListener('scroll', handleDismiss, { capture: true }) window.removeEventListener('resize', handleDismiss) } }, [open]) return ( {open && pos && ( {refText} {loading && Loading…} {error && Could not load verse.} {!loading && !error && verses.length === 0 && Verse not found.} {!loading && !error && verses.map(v => ( {verseStart !== verseEnd && {v.verse} }{v.value} ))} Read on Bible.com ↗ Berean Standard Bible )} ) } export function AnswerText({ text, highlight = '' }: { text: string; highlight?: string }) { return <>{renderTextWithLinks(text, highlight)} }