Select Page

Memorize

<!doctype html>
<html lang=”en”>
<head>
<meta charset=”utf-8″ />
<meta name=”viewport” content=”width=device-width, initial-scale=1″ />
<title>Bible Verse Memory Game — KJV</title>
<script src=”https://cdn.tailwindcss.com”></script>
<!– React 18 UMD –>
<script src=”https://unpkg.com/react@18/umd/react.production.min.js” crossorigin></script>
<script src=”https://unpkg.com/react-dom@18/umd/react-dom.production.min.js” crossorigin></script>
<!– Babel for in-browser JSX transform (no build) –>
<script src=”https://unpkg.com/@babel/standalone/babel.min.js”></script>
<meta name=”description” content=”Bible Verse Memory Game with KJV import, flashcards, quizzes, and printable certificate.” />
</head>
<body class=”min-h-screen bg-amber-50″>
<div id=”root”></div>

<script type=”text/babel” data-presets=”env,react”>
// Utilities
const { useEffect, useMemo, useState } = React;

const SEED_PASSAGES = {
“Psalm 23 (KJV)”: [
{ ref: “Psalm 23:1”, text: “The LORD is my shepherd; I shall not want.” },
{ ref: “Psalm 23:2”, text: “He maketh me to lie down in green pastures: he leadeth me beside the still waters.” },
{ ref: “Psalm 23:3”, text: “He restoreth my soul: he leadeth me in the paths of righteousness for his name's sake.” },
{ ref: “Psalm 23:4”, text: “Yea, though I walk through the valley of the shadow of death, I will fear no evil: for thou art with me; thy rod and thy staff they comfort me.” },
{ ref: “Psalm 23:5”, text: “Thou preparest a table before me in the presence of mine enemies: thou anointest my head with oil; my cup runneth over.” },
{ ref: “Psalm 23:6”, text: “Surely goodness and mercy shall follow me all the days of my life: and I will dwell in the house of the LORD for ever.” }
],
“John 3 (KJV)”: [
{ ref: “John 3:1”, text: “There was a man of the Pharisees, named Nicodemus, a ruler of the Jews:” },
{ ref: “John 3:2”, text: “The same came to Jesus by night, and said unto him, Rabbi, we know that thou art a teacher come from God: for no man can do these miracles that thou doest, except God be with him.” },
{ ref: “John 3:3”, text: “Jesus answered and said unto him, Verily, verily, I say unto thee, Except a man be born again, he cannot see the kingdom of God.” },
{ ref: “John 3:4”, text: “Nicodemus saith unto him, How can a man be born when he is old? can he enter the second time into his mother's womb, and be born?” },
{ ref: “John 3:5”, text: “Jesus answered, Verily, verily, I say unto thee, Except a man be born of water and of the Spirit, he cannot enter into the kingdom of God.” },
{ ref: “John 3:6”, text: “That which is born of the flesh is flesh; and that which is born of the Spirit is spirit.” },
{ ref: “John 3:7”, text: “Marvel not that I said unto thee, Ye must be born again.” },
{ ref: “John 3:8”, text: “The wind bloweth where it listeth, and thou hearest the sound thereof, but canst not tell whence it cometh, and whither it goeth: so is every one that is born of the Spirit.” },
{ ref: “John 3:9”, text: “Nicodemus answered and said unto him, How can these things be?” },
{ ref: “John 3:10”, text: “Jesus answered and said unto him, Art thou a master of Israel, and knowest not these things?” },
{ ref: “John 3:11”, text: “Verily, verily, I say unto thee, We speak that we do know, and testify that we have seen; and ye receive not our witness.” },
{ ref: “John 3:12”, text: “If I have told you earthly things, and ye believe not, how shall ye believe, if I tell you of heavenly things?” },
{ ref: “John 3:13”, text: “And no man hath ascended up to heaven, but he that came down from heaven, even the Son of man which is in heaven.” },
{ ref: “John 3:14”, text: “And as Moses lifted up the serpent in the wilderness, even so must the Son of man be lifted up:” },
{ ref: “John 3:15”, text: “That whosoever believeth in him should not perish, but have eternal life.” },
{ ref: “John 3:16”, text: “For God so loved the world, that he gave his only begotten Son, that whosoever believeth in him should not perish, but have everlasting life.” },
{ ref: “John 3:17”, text: “For God sent not his Son into the world to condemn the world; but that the world through him might be saved.” },
{ ref: “John 3:18”, text: “He that believeth on him is not condemned: but he that believeth not is condemned already, because he hath not believed in the name of the only begotten Son of God.” },
{ ref: “John 3:19”, text: “And this is the condemnation, that light is come into the world, and men loved darkness rather than light, because their deeds were evil.” },
{ ref: “John 3:20”, text: “For every one that doeth evil hateth the light, neither cometh to the light, lest his deeds should be reproved.” },
{ ref: “John 3:21”, text: “But he that doeth truth cometh to the light, that his deeds may be made manifest, that they are wrought in God.” },
{ ref: “John 3:22”, text: “After these things came Jesus and his disciples into the land of Judaea; and there he tarried with them, and baptized.” },
{ ref: “John 3:23”, text: “And John also was baptizing in Aenon near to Salim, because there was much water there: and they came, and were baptized.” },
{ ref: “John 3:24”, text: “For John was not yet cast into prison.” },
{ ref: “John 3:25”, text: “Then there arose a question between some of John's disciples and the Jews about purifying.” },
{ ref: “John 3:26”, text: “And they came unto John, and said unto him, Rabbi, he that was with thee beyond Jordan, to whom thou barest witness, behold, the same baptizeth, and all men come to him.” },
{ ref: “John 3:27”, text: “John answered and said, A man can receive nothing, except it be given him from heaven.” },
{ ref: “John 3:28”, text: “Ye yourselves bear me witness, that I said, I am not the Christ, but that I am sent before him.” },
{ ref: “John 3:29”, text: “He that hath the bride is the bridegroom: but the friend of the bridegroom, which standeth and heareth him, rejoiceth greatly because of the bridegroom's voice: this my joy therefore is fulfilled.” },
{ ref: “John 3:30”, text: “He must increase, but I must decrease.” },
{ ref: “John 3:31”, text: “He that cometh from above is above all: he that is of the earth is earthly, and speaketh of the earth: he that cometh from heaven is above all.” },
{ ref: “John 3:32”, text: “And what he hath seen and heard, that he testifieth; and no man receiveth his testimony.” },
{ ref: “John 3:33”, text: “He that hath received his testimony hath set to his seal that God is true.” },
{ ref: “John 3:34”, text: “For he whom God hath sent speaketh the words of God: for God giveth not the Spirit by measure unto him.” },
{ ref: “John 3:35”, text: “The Father loveth the Son, and hath given all things into his hand.” },
{ ref: “John 3:36”, text: “He that believeth on the Son hath everlasting life: and he that believeth not the Son shall not see life; but the wrath of God abideth on him.” }
]
};

const uid = () => Math.random().toString(36).slice(2);
const todayISO = () => new Date().toISOString().slice(0, 10);
const inDays = (n) => { const d = new Date(); d.setDate(d.getDate() + n); return d.toISOString().slice(0, 10); };

function normalizeWords(text) {
return String(text || “”)
.replace(/\n/g, ” “)
.replace(/[^A-Za-z0-9'\- ]/g, ” “)
.split(/\s+/)
.filter(Boolean);
}

function nextReview(ease) {
switch (ease) {
case 1: return { interval: 0, next: todayISO() };
case 2: return { interval: 2, next: inDays(2) };
case 3: return { interval: 5, next: inDays(5) };
default: return { interval: 1, next: inDays(1) };
}
}

function parseManualLines(title, raw) {
const lines = String(raw || “”).split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
if (!lines.length) return [];
const out = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.includes(“|”)) {
const [left, …rest] = line.split(“|”);
const ref = (left || ”).trim();
const text = rest.join(“|”).trim();
if (ref && text) { out.push({ ref, text }); continue; }
}
const m = line.match(/^\s*([1-3]?\s?[A-Za-z]+(?:\s+[A-Za-z]+)*)\s+(\d+):(\d+)\s+(.+)$/);
if (m) {
const ref = `${m[1].replace(/\s+/g, ‘ ‘).trim()} ${m[2]}:${m[3]}`;
const text = m[4].trim();
if (text) { out.push({ ref, text }); continue; }
}
out.push({ ref: `${title} v${out.length + 1}`, text: line });
}
return out;
}

// Self-tests (console)
(function testParser(){
try {
const a = parseManualLines(“Romans 8”, “Romans 8:1 | Therefore, there is now…\nRomans 8:2 | Because the law of the Spirit…”);
console.assert(a.length === 2 && a[0].ref === ‘Romans 8:1', ‘Pipe format parsed');
const b = parseManualLines(“Romans 8”, “Romans 8:3 For what the law could not do…”);
console.assert(b.length === 1 && b[0].ref === ‘Romans 8:3', ‘Space format parsed');
const c = parseManualLines(“Custom Title”, “Line one\nLine two”);
console.assert(c.length === 2 && c[0].ref === ‘Custom Title v1', ‘Text-only fallback');
} catch (e) { console.warn(‘Parser self-test warning:', e); }
})();

const STORAGE_KEY = “bvmg_state_v1”;
function loadState() { try { const raw = localStorage.getItem(STORAGE_KEY); return raw ? JSON.parse(raw) : null; } catch { return null; } }
function saveState(state) { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch {} }

function seedInitialState() {
const passages = Object.entries(SEED_PASSAGES).map(([title, verses]) => {
const pid = uid();
const withIds = verses.map((v) => ({ …v, id: uid() }));
const progress = Object.fromEntries(withIds.map((v) => [v.id, { easeHistory: [], next: todayISO(), intervalDays: 0, mastered: false }]));
return { id: pid, title, verses: withIds, progressByVerseId: progress, createdAt: Date.now() };
});
return { passages, selectedPassageId: passages[0]?.id || null };
}

async function fetchKJVPassage(reference) {
const url = `https://bible-api.com/${encodeURIComponent(reference)}?translation=kjv`;
const res = await fetch(url, { method: ‘GET' });
if (!res.ok) throw new Error(`API error ${res.status}`);
const data = await res.json();
if (!data || !Array.isArray(data.verses) || data.verses.length === 0) throw new Error(‘No verses returned for that reference.');
const title = `${data.reference} (KJV)`;
const verses = data.verses.map((v) => ({ ref: `${v.book_name} ${v.chapter}:${v.verse}`, text: String(v.text || ”).trim() }));
return { title, verses };
}

function Section({ title, children, actions }) {
return (
<div className=”p-4 bg-white/70 rounded-2xl shadow mb-4″>
<div className=”flex items-center justify-between mb-2″>
<h2 className=”text-xl font-semibold”>{title}</h2>
{actions}
</div>
{children}
</div>
);
}

function Pill({ children }) { return <span className=”inline-block text-xs px-2 py-1 rounded-full bg-gray-100 border”>{children}</span>; }
function Button({ children, onClick, className = “”, type = “button”, …rest }) {
return <button type={type} onClick={onClick} className={`px-3 py-2 rounded-2xl shadow bg-black text-white hover:opacity-90 active:opacity-80 ${className}`} {…rest}>{children}</button>;
}
function GhostButton({ children, onClick, className = “”, …rest }) {
return <button onClick={onClick} className={`px-3 py-2 rounded-2xl border shadow-sm bg-white hover:bg-gray-50 ${className}`} {…rest}>{children}</button>;
}

function App() {
const [state, setState] = useState(() => loadState() || seedInitialState());
useEffect(() => saveState(state), [state]);

const passages = state.passages;
const [confirmingDelete, setConfirmingDelete] = useState(false);
const selected = passages.find((p) => p.id === state.selectedPassageId) || passages[0];
useEffect(() => { setConfirmingDelete(false); }, [state.selectedPassageId]);

function addPassageFromPaste(title, raw) {
const verses = parseManualLines(title, raw);
if (!verses.length) {
alert(“I couldn't parse any verses. Use one verse per line. Formats: ‘Ref | Text', or ‘John 3:16 For God so loved…', or text-only lines.”);
return;
}
addPassageObject(title, verses);
}

function addPassageObject(title, verses) {
const pid = uid();
const withIds = verses.map((v) => ({ …v, id: uid() }));
const progress = Object.fromEntries(withIds.map((v) => [v.id, { easeHistory: [], next: todayISO(), intervalDays: 0, mastered: false }]));
setState((s) => ({
…s,
passages: […s.passages, { id: pid, title, verses: withIds, progressByVerseId: progress, createdAt: Date.now() }],
selectedPassageId: pid,
}));
}

async function addPassageFromKJV(reference) {
try {
const { title, verses } = await fetchKJVPassage(reference);
addPassageObject(title, verses);
} catch (err) {
alert(`Import failed: ${err.message}. If your network blocks the request or the reference is invalid, paste manually below.`);
}
}

function deletePassage(passageId) {
const p = state.passages.find((x) => x.id === passageId);
if (!p) return;
setState((s) => {
const newPassages = s.passages.filter((x) => x.id !== passageId);
const nextSelected = newPassages.length ? newPassages[0].id : null;
return { …s, passages: newPassages, selectedPassageId: nextSelected };
});
}

function updateProgress(passageId, verseId, ease) {
setState((s) => {
const pidx = s.passages.findIndex((p) => p.id === passageId);
if (pidx === -1) return s;
const p = s.passages[pidx];
const prev = p.progressByVerseId[verseId] || { easeHistory: [], next: todayISO(), intervalDays: 0, mastered: false };
const schedule = nextReview(ease);
const easeHistory = […prev.easeHistory, ease];
const mastered = easeHistory.length >= 3 && easeHistory.slice(-3).every((e) => e >= 2);
const updated = { …prev, easeHistory, next: schedule.next, intervalDays: schedule.interval, mastered };
const newPassage = { …p, progressByVerseId: { …p.progressByVerseId, [verseId]: updated } };
const newPassages = […s.passages]; newPassages[pidx] = newPassage;
return { …s, passages: newPassages };
});
}

function masteredAll(p) { return !!p && p.verses.every((v) => p.progressByVerseId[v.id]?.mastered); }

function exportData() {
const blob = new Blob([JSON.stringify(state, null, 2)], { type: “application/json” });
const url = URL.createObjectURL(blob);
const a = document.createElement(“a”); a.href = url; a.download = `bvmg-export-${Date.now()}.json`; a.click(); URL.revokeObjectURL(url);
}
function importData(file) {
const reader = new FileReader();
reader.onload = (e) => { try { const obj = JSON.parse(String(e.target?.result || “”)); setState(obj); } catch { alert(“Invalid JSON file.”); } }; reader.readAsText(file);
}

return (
<div className=”min-h-screen bg-gradient-to-b from-amber-50 to-emerald-50 p-4″>
<div className=”max-w-6xl mx-auto”>
<header className=”flex items-center justify-between mb-4″>
<h1 className=”text-2xl md:text-3xl font-bold”>Bible Verse Memory Game — KJV Import Build</h1>
<div className=”flex gap-2″>
<GhostButton onClick={exportData}>Export</GhostButton>
<label className=”px-3 py-2 rounded-2xl border shadow-sm bg-white hover:bg-gray-50 cursor-pointer”>
Import
<input type=”file” accept=”application/json” className=”hidden” onChange={(e) => e.target.files?.[0] && importData(e.target.files[0])} />
</label>
</div>
</header>

<div className=”grid md:grid-cols-3 gap-4″>
<div className=”md:col-span-1″>
<Section title=”Passages” actions={
<div className=”flex items-center gap-2″>
<select className=”border rounded-2xl px-3 py-2″ value={selected?.id || “”} onChange={(e) => setState((s) => ({ …s, selectedPassageId: e.target.value }))}>
{passages.map((p) => (<option key={p.id} value={p.id}>{p.title}</option>))}
</select>
<GhostButton
disabled={!selected || !passages.length}
onClick={() => {
if (!selected || !passages.length) return;
if (!confirmingDelete) {
setConfirmingDelete(true);
setTimeout(() => setConfirmingDelete(false), 4000);
return;
}
deletePassage(selected.id);
setConfirmingDelete(false);
}}
>{confirmingDelete ? ‘Confirm' : ‘Remove'}</GhostButton>
</div>
}>
{selected && (
<div className=”space-y-2″>
<div className=”flex flex-wrap gap-2 items-center”>
<Pill>{selected.verses.length} verses</Pill>
<Pill>{selected.verses.filter((v) => selected.progressByVerseId[v.id]?.mastered).length}/{selected.verses.length} mastered</Pill>
<Pill>Created {new Date(selected.createdAt).toLocaleDateString()}</Pill>
</div>
<div className=”max-h-64 overflow-auto border rounded-xl p-2 bg-white”>
{selected.verses.map((v) => (
<div key={v.id} className=”py-1 border-b last:border-b-0″>
<div className=”text-sm font-semibold”>{v.ref}</div>
<div className=”text-sm text-gray-700″>{v.text}</div>
</div>
))}
</div>
{masteredAll(selected) && (
<div className=”p-3 bg-emerald-100 border border-emerald-300 rounded-xl”>
<div className=”font-semibold”>Passage mastered!</div>
<div className=”text-sm”>Create your printable certificate below.</div>
</div>
)}
</div>
)}
</Section>

<Section title=”Import Passage (KJV API)”>
<ImportKJVForm onImport={addPassageFromKJV} />
</Section>

<Section title=”Add Passage Manually”>
<AddPassageForm onAdd={addPassageFromPaste} />
</Section>

<Section title=”Review Queue”>
{selected ? <ReviewQueue passage={selected} /> : <div>No passage selected.</div>}
</Section>
</div>

<div className=”md:col-span-2 space-y-4″>
<Section title=”Flashcards”>
{selected ? (<Flashcards passage={selected} onScore={updateProgress} />) : (<div>Select a passage to begin.</div>)}
</Section>

<Section title=”Quizzes”>
{selected ? <Quizzes passage={selected} onScore={updateProgress} /> : <div />}
</Section>

<Section title=”Certificate”>
{selected ? <CertificateCenter passage={selected} /> : <div />}
</Section>
</div>
</div>

<footer className=”text-xs text-gray-500 mt-6″>
<div>Tip: Import KJV above, or paste one verse per line (or “Ref | Text”).</div>
<div className=”mt-1″>Scripture quotations from the King James Version (Public Domain). Powered by bible-api.com.</div>
</footer>
</div>
</div>
);
}

function ImportKJVForm({ onImport }) {
const [ref, setRef] = React.useState(“”);
const [busy, setBusy] = React.useState(false);
async function go() {
if (!ref.trim() || busy) return;
setBusy(true);
try { await onImport(ref.trim()); setRef(“”); } finally { setBusy(false); }
}
return (
<div className=”space-y-2″>
<input className=”w-full border rounded-xl px-3 py-2″ placeholder=”Reference (e.g., John 3, Psalm 23, John 3:1-16)” value={ref} onChange={(e) => setRef(e.target.value)} />
<div className=”flex gap-2 justify-end”>
<GhostButton onClick={() => setRef(“”)}>Clear</GhostButton>
<Button onClick={go} disabled={!ref.trim() || busy}>{busy ? ‘Importing…' : ‘Import KJV'}</Button>
</div>
<div className=”text-xs text-gray-500″>Uses public‑domain KJV via bible-api.com. If blocked or invalid, paste manually below.</div>
</div>
);
}

function AddPassageForm({ onAdd }) {
const [title, setTitle] = React.useState(“”);
const [raw, setRaw] = React.useState(“”);
const canAdd = title.trim() && raw.trim();
const preview = React.useMemo(() => parseManualLines(title || “Untitled”, raw), [title, raw]);
return (
<div className=”space-y-2″>
<input className=”w-full border rounded-xl px-3 py-2″ placeholder=”Passage title (e.g., Romans 8)” value={title} onChange={(e) => setTitle(e.target.value)} />
<textarea
className=”w-full h-32 border rounded-xl px-3 py-2″
placeholder={`Supported formats (one verse per line):\n1) Ref | Text e.g. Romans 8:1 | Therefore, there is now…\n2) Ref Text e.g. Romans 8:1 Therefore, there is now…\n3) Text only e.g. Therefore, there is now…`}
value={raw}
onChange={(e) => setRaw(e.target.value)}
/>
<div className=”text-xs text-gray-600″>
Parsed preview: {preview.length} verse{preview.length === 1 ? “” : “s”}
{preview.length > 0 && (
<div className=”mt-1 max-h-20 overflow-auto border rounded-lg bg-white p-2″>
{preview.slice(0, 3).map((v, i) => (
<div key={i} className=”flex gap-2 text-[11px]”><span className=”font-semibold w-28 truncate”>{v.ref}</span><span className=”text-gray-700 truncate”>{v.text}</span></div>
))}
{preview.length > 3 && <div className=”text-[11px] text-gray-400″>…and {preview.length – 3} more</div>}
</div>
)}
</div>
<div className=”flex gap-2 justify-end”>
<GhostButton onClick={() => { setTitle(“”); setRaw(“”); }}>Clear</GhostButton>
<Button disabled={!canAdd} onClick={() => {
if (!canAdd) return;
const verses = parseManualLines(title.trim(), raw.trim());
if (!verses.length) { alert(“I couldn't parse any verses. Try a supported format.”); return; }
onAdd(title.trim(), raw.trim());
}}>Add Passage</Button>
</div>
</div>
);
}

function ReviewQueue({ passage }) {
const today = todayISO();
const due = passage.verses.filter((v) => (passage.progressByVerseId[v.id]?.next || today) <= today);
const upcoming = passage.verses.filter((v) => (passage.progressByVerseId[v.id]?.next || today) > today);
return (
<div className=”text-sm space-y-2″>
<div>Due today: <strong>{due.length}</strong></div>
<div>Upcoming: <strong>{upcoming.length}</strong></div>
<div className=”max-h-40 overflow-auto border rounded-xl p-2 bg-white”>
{due.map((v) => (
<div key={v.id} className=”flex items-start gap-2 py-1 border-b last:border-b-0″>
<Pill>Due</Pill>
<div>
<div className=”font-semibold”>{v.ref}</div>
<div className=”text-gray-700″>{v.text}</div>
</div>
</div>
))}
</div>
</div>
);
}

function Flashcards({ passage, onScore }) {
const today = todayISO();
const queue = React.useMemo(() => {
const arr = passage.verses
.filter((v) => (passage.progressByVerseId[v.id]?.next || today) <= today)
.sort((a, b) => a.ref.localeCompare(b.ref));
return arr.length ? arr : passage.verses.slice(0, 1);
}, [passage, today]);

const [idx, setIdx] = React.useState(0);
const [showBack, setShowBack] = React.useState(false);
const card = queue[idx];

React.useEffect(() => { setIdx(0); setShowBack(false); }, [passage.id]);

if (!card) return <div className=”text-sm”>Nothing due right now. Great job!</div>;

function score(ease) {
onScore(passage.id, card.id, ease);
setShowBack(false);
setIdx((i) => (i + 1 < queue.length ? i + 1 : 0));
}

return (
<div className=”flex flex-col md:flex-row gap-3″>
<div className=”flex-1″>
<div className=”rounded-2xl border bg-white p-6 min-h-[180px] cursor-pointer” onClick={() => setShowBack(!showBack)}>
<div className=”text-sm mb-2 text-gray-500″>{card.ref}</div>
{!showBack ? (
<div className=”text-lg md:text-xl font-medium select-none”>
<FirstLetters text={card.text} />
<div className=”mt-2 text-xs text-gray-500″>(Click to reveal)</div>
</div>
) : (
<div className=”text-lg md:text-xl font-medium”>{card.text}</div>
)}
</div>
</div>
<div className=”w-full md:w-60 flex flex-col gap-2″>
<GhostButton onClick={() => setShowBack(!showBack)}>{showBack ? “Hide” : “Reveal”}</GhostButton>
<div className=”grid grid-cols-3 gap-2″>
<button onClick={() => score(1)} className=”px-3 py-2 rounded-xl bg-rose-100 border border-rose-300″>Again</button>
<button onClick={() => score(2)} className=”px-3 py-2 rounded-xl bg-amber-100 border border-amber-300″>Good</button>
<button onClick={() => score(3)} className=”px-3 py-2 rounded-xl bg-emerald-100 border border-emerald-300″>Easy</button>
</div>
<div className=”text-xs text-gray-500″>Scoring updates your review schedule.</div>
</div>
</div>
);
}

function FirstLetters({ text }) { const words = normalizeWords(text); return (
<div className=”leading-relaxed”>{words.map((w, i) => (<span key={i} className=”inline-block mr-1″>{w[0]}{“\u200A”}<span className=”opacity-40″>•</span></span>))}</div>
); }

function Quizzes({ passage, onScore }) {
const [mode, setMode] = React.useState(“cloze”);
return (
<div className=”space-y-3″>
<div className=”flex gap-2 items-center”>
<GhostButton onClick={() => setMode(“cloze”)} className={mode === “cloze” ? “ring-2 ring-emerald-300” : “”}>Cloze</GhostButton>
<GhostButton onClick={() => setMode(“first”)} className={mode === “first” ? “ring-2 ring-emerald-300” : “”}>First Letters</GhostButton>
<GhostButton onClick={() => setMode(“typing”)} className={mode === “typing” ? “ring-2 ring-emerald-300” : “”}>Typing</GhostButton>
</div>
{mode === “cloze” && <ClozeQuiz passage={passage} onScore={onScore} />}
{mode === “first” && <FirstLettersQuiz passage={passage} onScore={onScore} />}
{mode === “typing” && <TypingQuiz passage={passage} onScore={onScore} />}
</div>
);
}

function getRandomVerse(passage) { if (!passage || !Array.isArray(passage.verses) || passage.verses.length===0) return { id: “”, ref: “”, text: “” }; return passage.verses[Math.floor(Math.random() * passage.verses.length)]; }

function ClozeQuiz({ passage, onScore }) {
const [gap, setGap] = React.useState(4);
const [current, setCurrent] = React.useState(() => getRandomVerse(passage));
const [guess, setGuess] = React.useState(“”);
const masked = React.useMemo(() => {
const words = current.text.split(/(\s+)/);
let nth = 0;
return words.map((token) => { if (/\s+/.test(token)) return token; nth += 1; return nth % gap === 0 ? “_____” : token; }).join(“”);
}, [current.text, gap]);

function check() {
const ok = guess.trim().toLowerCase() === current.text.trim().toLowerCase();
onScore(passage.id, current.id, ok ? 3 : 1);
alert(ok ? “Correct!” : “Not quite. Showing answer.”);
setGuess(“”); setCurrent(getRandomVerse(passage));
}

return (
<div className=”space-y-2″>
<div className=”text-sm text-gray-500″>{current.ref}</div>
<div className=”rounded-xl border bg-white p-3″>{masked}</div>
<textarea className=”w-full border rounded-xl p-2″ rows={3} placeholder=”Type the full verse here…” value={guess} onChange={(e) => setGuess(e.target.value)} />
<div className=”flex items-center justify-between”>
<div className=”flex items-center gap-2 text-sm”>
<span>Hide every</span>
<input type=”number” min={2} max={8} value={gap} onChange={(e) => setGap(Math.max(2, Math.min(8, parseInt(e.target.value || “4”))))} className=”w-16 border rounded-xl px-2 py-1″ />
<span>words</span>
</div>
<Button onClick={check}>Check</Button>
</div>
</div>
);
}

function FirstLettersQuiz({ passage, onScore }) {
const [current, setCurrent] = React.useState(() => getRandomVerse(passage));
const [reveal, setReveal] = React.useState(false);
function next(score) { onScore(passage.id, current.id, score); setReveal(false); setCurrent(getRandomVerse(passage)); }
return (
<div className=”space-y-2″>
<div className=”text-sm text-gray-500″>{current.ref}</div>
<div className=”rounded-xl border bg-white p-3 text-lg”>{!reveal ? <FirstLetters text={current.text} /> : current.text}</div>
<div className=”flex gap-2″>
<GhostButton onClick={() => setReveal(!reveal)}>{reveal ? “Hide” : “Reveal”}</GhostButton>
<Button onClick={() => next(2)}>Got it</Button>
<GhostButton onClick={() => next(1)}>Missed</GhostButton>
</div>
</div>
);
}

function TypingQuiz({ passage, onScore }) {
const [current, setCurrent] = React.useState(() => getRandomVerse(passage));
const [input, setInput] = React.useState(“”);
const [result, setResult] = React.useState(null);
function check() {
const a = normalizeWords(current.text).join(” “).toLowerCase();
const b = normalizeWords(input).join(” “).toLowerCase();
const ok = a === b;
setResult(ok ? “Perfect!” : “Close — keep at it.”);
onScore(passage.id, current.id, ok ? 3 : 1);
if (ok) { setTimeout(() => { setInput(“”); setResult(null); setCurrent(getRandomVerse(passage)); }, 600); }
}
return (
<div className=”space-y-2″>
<div className=”text-sm text-gray-500″>{current.ref}</div>
<textarea className=”w-full h-28 border rounded-xl p-2″ placeholder=”Type the verse from memory…” value={input} onChange={(e) => setInput(e.target.value)} />
<div className=”flex items-center justify-between”>
<GhostButton onClick={() => { setInput(“”); setResult(null); }}>Reset</GhostButton>
<Button onClick={check}>Check</Button>
</div>
{result && <div className=”text-sm text-gray-700″>{result}</div>}
</div>
);
}

function CertificateCenter({ passage }) {
const [name, setName] = React.useState(“”);
const mastered = passage.verses.every((v) => passage.progressByVerseId[v.id]?.mastered);
function printCert() {
const win = window.open(“”, “_blank”); if (!win) return;
const date = new Date().toLocaleDateString(); const verseCount = passage.verses.length;
const html = `<!doctype html><html><head><meta charset='utf-8′ /><title>Certificate</title><style>@page{size:A4;margin:20mm}body{font-family:ui-serif,Georgia,'Times New Roman',serif;color:#111}.frame{border:6px double #111;padding:24px;min-height:calc(100vh – 40mm);display:flex;flex-direction:column;justify-content:center}.title{text-align:center;font-size:28px;letter-spacing:1px;margin-bottom:6px}.subtitle{text-align:center;font-size:14px;color:#444;margin-bottom:28px}.name{text-align:center;font-size:40px;margin:10px 0;font-weight:700}.line{text-align:center;margin:8px 0;font-size:16px}.passage{text-align:center;font-size:18px;margin-top:8px;font-style:italic}.footer{display:flex;justify-content:space-between;margin-top:40px;font-size:14px}</style></head><body><div class='frame'><div class='title'>Certificate of Scripture Memorization</div><div class='subtitle'>Awarded for faithful study, meditation, and hiding God's Word in the heart</div><div class='line'>This certifies that</div><div class='name'>${(name||””).replace(/</g, “<”)}</div><div class='line'>has memorized</div><div class='passage'>${passage.title} — ${verseCount} verses</div><div class='footer'><div>Date: ${date}</div><div>Signature: ___________________________</div></div></div><script>window.onload=()=>window.print()<\/script></body></html>`;
win.document.write(html); win.document.close();
}
return (
<div className=”space-y-2″>
{!mastered && (<div className=”text-sm text-amber-700 bg-amber-100 border border-amber-300 rounded-xl p-2″>Master all verses to unlock the certificate.</div>)}
<div className=”flex items-center gap-2″>
<input className=”border rounded-xl px-3 py-2 flex-1″ placeholder=”Your name for the certificate” value={name} onChange={(e) => setName(e.target.value)} />
<Button onClick={printCert} disabled={!mastered || !name.trim()}>{mastered ? “Print Certificate” : “Locked”}</Button>
</div>
<div className=”text-xs text-gray-500″>Certificate prints to A4/Letter. Adjust margins in your print dialog if needed.</div>
</div>
);
}

// mount helpers
const root = ReactDOM.createRoot(document.getElementById(‘root'));
root.render(<App />);
</script>
</body>
</html>
/