/* LIBRANCY — Child LMS frontend (Phase 2 · Lesson Experience v2 with Libra)
* Implements the approved prototype against the live PHP API.
* Flow: family unlock (parent) → profile picker (+ set PIN once) → child PIN
* → learner home → course path → lesson (video/task/quiz) → A-card → creations
* Sessions persist on the device (this is the deployed app, not a sandbox).
*/
const { useState, useEffect, useRef, useCallback } = React;
const API = "";
const T = {
navy:"#101450", navy2:"#1B2270", coral:"#FF6B6B", blue:"#1C6BF6", teal:"#02B5AA", tealDeep:"#00715E",
tealTint:"#E6F7F3", yellow:"#FEC323", yellowTint:"#FFF6DC", purple:"#6C5CE7",
ink:"#1B2733", slate:"#5C6B7A", mist:"#8597A6", line:"#E4EAF0", paper:"#FFFFFF",
cloud:"#F5F8FB", good:"#1B9E6B",
};
const FONT = `"Poppins","Inter",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif`;
const RANK_ICONS = { Explorer:"✦", Builder:"🚀", Creator:"★", Founder:"👑" };
const AVATARS = ["🦁","🐘","🦋","🚀","🌟","🎨","🤖","⚽"];
/* Same child = same animal on every screen: stored avatar wins, else a stable pick from the id. */
function avatarFor(c) {
if (!c) return "🦁";
if (c.avatar) return c.avatar;
let h = 0; for (const ch of String(c.id || c.firstName || "")) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
return AVATARS[h % AVATARS.length];
}
/* Libra — the Librancy companion. Fixed filenames: swap a file on the server to update a pose. */
function Libra({ pose, size=76, style }) {
return ;
}
const SAFETY_TIPS = [
"Never share your real name, school or address with any AI tool.",
"AI makes mistakes too — always check important answers.",
"Ask a grown-up before sharing any photo online.",
"If something online feels strange or scary, tell a trusted adult.",
"Be kind online — real people read what you write.",
"Don't believe everything you read — verify with a book or a teacher.",
"Keep your passwords and PIN secret, even from friends.",
"Keep personal stories private — AI is a helper, not a friend who needs them.",
"Take breaks! Great builders rest their eyes and stretch.",
"If AI says something unkind or wrong, stop and ask an adult.",
];
const tipFor = (id) => {
let h = 0; for (const ch of String(id)) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
return SAFETY_TIPS[h % SAFETY_TIPS.length];
};
/* session helpers (device persistence is intended here) */
const store = {
get: (k) => { try { return JSON.parse(localStorage.getItem("lbr_"+k)); } catch { return null; } },
set: (k,v) => localStorage.setItem("lbr_"+k, JSON.stringify(v)),
del: (k) => localStorage.removeItem("lbr_"+k),
};
async function api(path, opts = {}) {
const token = opts.child === false ? store.get("parentToken") : store.get("childToken");
const res = await fetch(API + "/api" + path, {
method: opts.method || "GET",
headers: { "Content-Type":"application/json", ...(token ? { Authorization:"Bearer "+token } : {}) },
body: opts.body ? JSON.stringify(opts.body) : undefined,
});
const j = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(j.error || "Something went wrong");
return j;
}
/* ── atoms ── */
const btnS = (bg, extra={}) => ({ border:"none", borderRadius:14, padding:"15px 18px", width:"100%",
cursor:"pointer", background:bg, color:"#fff", fontSize:15.5, fontWeight:600, fontFamily:FONT,
display:"flex", alignItems:"center", justifyContent:"center", gap:8, ...extra });
const chip = (bg,c) => ({ display:"inline-flex", alignItems:"center", gap:5, background:bg, color:c,
fontSize:11.5, fontWeight:700, padding:"4px 10px", borderRadius:20 });
function Card({children, style, onClick}) {
return
{children}
;
}
function Logo({ small, white }) {
return ;
}
function Spinner() {
return Loading…
;
}
function Toast({ msg }) {
if (!msg) return null;
return {msg}
;
}
/* ── Screen: family unlock ── */
function Unlock({ onDone, onCode }) {
const [email,setEmail] = useState(""); const [pw,setPw] = useState("");
const [err,setErr] = useState(""); const [busy,setBusy] = useState(false);
const go = async () => {
setBusy(true); setErr("");
try {
const r = await api("/learn/family", { method:"POST", body:{ email, password: pw } });
store.set("parentToken", r.parentToken); store.set("children", r.children);
onDone();
} catch(e){ setErr(e.message); } finally { setBusy(false); }
};
return (
Welcome to the learning space
A parent unlocks this device once — then your child signs in with just their PIN.
setEmail(e.target.value)} placeholder="Parent email" type="email"
style={{ width:"100%", padding:"15px 16px", borderRadius:14, border:`2px solid ${T.line}`, fontSize:16, fontFamily:FONT, marginBottom:12, boxSizing:"border-box" }} />
setPw(e.target.value)} placeholder="Password" type="password"
onKeyDown={e=>e.key==="Enter"&&go()}
style={{ width:"100%", padding:"15px 16px", borderRadius:14, border:`2px solid ${T.line}`, fontSize:16, fontFamily:FONT, marginBottom:14, boxSizing:"border-box" }} />
{err &&
{err}
}
{busy?"Unlocking…":"Unlock family"}
Use the email and password from your purchase.
🎟️ My child has a code
Forgot password? Reset it here →
);
}
/* ── Screen: profile picker (+ first-time PIN setup) ── */
function Profiles({ onChild, onReset }) {
const children = store.get("children") || [];
const [setup,setSetup] = useState(null); // child needing a PIN
const [pin,setPin] = useState(""); const [err,setErr] = useState(""); const [busy,setBusy] = useState(false);
const savePin = async () => {
if (!/^\d{4}$/.test(pin)) { setErr("PIN must be 4 digits"); return; }
setBusy(true); setErr("");
try {
await api(`/children/${setup.id}/pin`, { method:"POST", child:false, body:{ pin } });
const kids = children.map(c => c.id===setup.id ? { ...c, hasPin:true } : c);
store.set("children", kids); setSetup(null); setPin("");
} catch(e){ setErr(e.message); } finally { setBusy(false); }
};
if (setup) return (
{avatarFor(setup)}
Set {setup.firstName}’s PIN
A parent creates a 4-digit PIN {setup.firstName} will use to sign in.
setPin(e.target.value.replace(/\D/g,"").slice(0,4))} inputMode="numeric"
placeholder="4-digit PIN" style={{ width:180, textAlign:"center", letterSpacing:8, padding:"15px 16px",
borderRadius:14, border:`2px solid ${T.line}`, fontSize:22, fontFamily:FONT, marginBottom:14 }} />
{err &&
{err}
}
Save PIN
{setSetup(null);setPin("");}}>Back
);
return (
Who's learning today?
Tap your profile
{children.map((c,i)=>(
c.hasPin ? onChild(c) : setSetup(c)}
style={{ padding:"22px 10px", borderRadius:18, border:`2px solid ${T.line}`, background:T.paper,
cursor:"pointer", fontFamily:FONT }}>
{avatarFor(c)}
{c.firstName}
{c.hasPin ? `Ages ${c.ageBand}` : "Set PIN (parent)"}
))}
Switch family
);
}
/* ── Screen: child PIN ── */
function Pin({ child, onDone, onBack }) {
const [pin,setPin] = useState(""); const [err,setErr] = useState("");
useEffect(()=>{ if (pin.length===4) (async()=>{
try {
const r = await api("/auth/child-login", { method:"POST", body:{ childId: child.id, pin } });
store.set("childToken", r.token); store.set("childName", child.firstName);
store.set("activeChild", { id: child.id, avatar: child.avatar || null, firstName: child.firstName }); onDone();
} catch(e){ setErr("That PIN isn't right — try again!"); setPin(""); }
})(); }, [pin]);
return (
{avatarFor(child)}
Hi {child.firstName}!
Enter your secret PIN
{err &&
{err}
}
{[1,2,3,4,5,6,7,8,9,"",0,"⌫"].map((k,i)=>(
k==="⌫" ? setPin(pin.slice(0,-1)) : pin.length<4 && setPin(pin+k)}
style={{ height:58, borderRadius:16, border:`1px solid ${T.line}`, background:k===""?"transparent":T.paper,
fontSize:20, fontWeight:600, color:T.navy, cursor:k===""?"default":"pointer", fontFamily:FONT }}>{k}
))}
Not {child.firstName}?
);
}
/* ── Screen: universal code login (any device, no parent needed) ── */
function CodeLogin({ onDone, onBack }) {
const [code,setCode] = useState("");
const [pin,setPin] = useState("");
const [err,setErr] = useState(""); const [busy,setBusy] = useState(false);
const onCode = (v) => {
let s = v.toUpperCase().replace(/[^A-Z0-9]/g, "").slice(0, 7);
if (s.length > 3) s = s.slice(0,3) + "-" + s.slice(3);
setCode(s);
};
const go = async () => {
if (!/^[A-Z]{3}-\d{4}$/.test(code)) { setErr("Enter your code, like UBA-4823"); return; }
if (!/^\d{4}$/.test(pin)) { setErr("Enter your 4-digit PIN"); return; }
setBusy(true); setErr("");
try {
const r = await api("/auth/code-login", { method:"POST", body:{ code, pin } });
store.set("childToken", r.token);
store.set("childName", r.childName || "");
store.set("activeChild", { id: r.childId, avatar: null, firstName: r.childName || "" });
onDone();
} catch(e){ setErr("That code or PIN isn’t right — try again!"); setPin(""); }
finally { setBusy(false); }
};
const inputS = { width:"100%", padding:"15px 16px", borderRadius:14, border:`2px solid ${T.line}`,
fontSize:18, fontFamily:FONT, marginBottom:12, boxSizing:"border-box", textAlign:"center" };
return (
Welcome to Librancy
Enter your code and PIN to start learning — on any device.
onCode(e.target.value)} placeholder="Your code (e.g. UBA-4823)"
autoCapitalize="characters" style={{ ...inputS, letterSpacing:2, fontWeight:700 }} />
setPin(e.target.value.replace(/\D/g,"").slice(0,4))}
placeholder="4-digit PIN" inputMode="numeric" type="password"
onKeyDown={e=>e.key==="Enter"&&go()}
style={{ ...inputS, letterSpacing:8 }} />
{err &&
{err}
}
{busy?"Starting…":"Start learning →"}
Your parent has your code. Works on any phone, tablet or computer.
← A parent? Unlock this device instead
);
}
/* ── Screen: learner home ── */
function ChallengeCert() {
const [state,setState] = useState("idle"); // idle | loading | done
const [url,setUrl] = useState("");
const claim = async () => {
setState("loading");
try {
const r = await api("/learn/challenge/claim", { method:"POST" });
setUrl(r.url); setState("done");
} catch(e){ setState("idle"); }
};
if (state === "done") {
return (
);
}
return (
{state==="loading" ? "Making your certificate…" : "🏆 Get my certificate"}
);
}
function Home({ data, openLesson, openCreations, refresh }) {
const r = data.rank;
const pct = Math.min(100, Math.round(((data.sparks - r.at) / Math.max(1,(r.nextAt - r.at))) * 100));
return (
Welcome back,
{data.child.firstName} {avatarFor(store.get("activeChild") || data.child)}
⚡ {data.sparks.toLocaleString()} Sparks
🔥 {data.streak}-day streak
{data.cheers && data.cheers.length > 0 && (
💛
A message from home!
{data.cheers.map((m,i)=>(
“{m}”
))}
)}
{["Explorer","Builder","Creator","Founder"].map((name,i)=>(
{RANK_ICONS[name]}
{name}
))}
{r.index===3 ? <>You made it — Founder rank! 👑>
: <>{Math.max(0, r.nextAt - data.sparks)} Sparks to {r.next} — keep building!>}
{data.challenge && (
data.challenge.complete ? (
🏆
30-Day AI Challenge complete!
You finished all 30 days, {data.child.firstName}. You didn't just use AI —
you learned to build with it. 🎉
) : (
🔥 30-Day AI Challenge
Day {data.challenge.day} of 30
{data.challenge.day === 0
? "Finish your first lesson to start the challenge!"
: `${30 - data.challenge.day} day${30 - data.challenge.day === 1 ? "" : "s"} to go — one lesson a day keeps your streak alive.`}
)
)}
{data.continue ? (
{data.continue.courseTitle}
openLesson(data.continue.lessonId)}>
▶ Continue · {data.continue.lessonTitle}
) : data.courses.length > 0 && (
Everything complete — you're becoming future-ready!
)}
{data.certificates && data.certificates.length > 0 && (
<>
My trophy shelf 🏆
{data.certificates.map(ct=>(
window.open(ct.url, "_blank")}>
🎓
{ct.courseTitle} — Complete!
Certificate {ct.displayId} · {ct.issuedOn}
View →
))}
{(() => {
const nxt = data.courses.find(c => c.percent < 100 &&
!data.certificates.some(ct => ct.courseId === c.id));
return nxt ? (
nxt.resumeLessonId && openLesson(nxt.resumeLessonId)}
style={{ marginBottom:10, display:"flex", alignItems:"center", gap:12, cursor:"pointer",
background:T.tealTint, border:`1px solid #BFE7DD` }}>
{nxt.started ? "📘" : "✨"}
{nxt.title}
{nxt.started ? "Keep going to earn this certificate" : "Start this course whenever you like"}
{nxt.started ? "Continue →" : "Start →"}
) : null;
})()}
>
)}
My courses
{data.courses.length===0 && (
No courses unlocked yet — ask a parent to complete enrolment on librancy.com.
)}
{data.courses.map(c=>(
c.resumeLessonId && openLesson(c.resumeLessonId)}
style={{ marginBottom:10, cursor: c.resumeLessonId ? "pointer" : "default" }}>
{c.lessonsDone}/{c.lessonsTotal} lessons
{c.percent===100 ? "Review ↺" : c.started ? "Continue →" : "Start →"}
))}
🎨 My creations · {data.creations}
);
}
/* ── Screen: lesson player (Lesson Experience v2) ── */
/* ─── AI Build Studio: build endpoint helper (query-string route, child JWT) ─── */
async function buildApi(action, body) {
const token = store.get("childToken");
const res = await fetch(API + "/api/build.php?action=" + action, {
method: body ? "POST" : "GET",
headers: { "Content-Type":"application/json", ...(token ? { Authorization:"Bearer "+token } : {}) },
body: body ? JSON.stringify(body) : undefined,
});
return res.json();
}
/* ─── The child's AI build loop: prompt → generate (5-gate server) → output →
refine (up to 3 tries) → keep. A structured build tool, never a chatbot. ─── */
const BUILD_MAX_TRIES = 3;
// Classify a mission: some are "do this now" instructions, some are "reflect on what you made".
// Also redirect any off-platform AI mention to the safe in-app Build box.
function missionKind(text) {
const t = (text || "").toLowerCase();
const isInstruction = /\b(open|go to|type this|here is your|use the|paste)\b/.test(t)
|| /\btask\b/.test(t);
return isInstruction ? "instruction" : "reflect";
}
function sanitizeMission(text) {
if (!text) return text;
// Never send a child off-platform: point ChatGPT/other tools at the in-app Build box.
return text
.replace(/open chatgpt[.,]?/gi, "Use your Build box below.")
.replace(/\bchatgpt\b/gi, "your Build box")
.replace(/\b(open|go to)\s+(gemini|claude|bard|copilot)[.,]?/gi, "use your Build box below")
.replace(/\b(gemini|bard|copilot)\b/gi, "your Build box");
}
function BuildMission({ lessonId, task, onKept, stepLabel }) {
const [enabled,setEnabled] = useState(null); // null=checking, false=off, true=on
const [prompt,setPrompt] = useState("");
const [output,setOutput] = useState(null); // { text, eventId }
const [tries,setTries] = useState(0);
const [busy,setBusy] = useState(false);
const [libra,setLibra] = useState("");
const [kept,setKept] = useState(false);
const [sparks,setSparks] = useState(0);
useEffect(()=>{
let alive = true;
buildApi("status").then(r=>{ if (alive) setEnabled(!!(r && r.enabled && r.remainingToday > 0)); })
.catch(()=>{ if (alive) setEnabled(false); });
return ()=>{ alive = false; };
}, [lessonId]);
if (enabled === null || enabled === false) return null; // silently absent when off/capped
const triesLeft = BUILD_MAX_TRIES - tries;
const canGenerate = prompt.trim().length >= 3 && !busy && triesLeft > 0 && !output && !kept;
const generate = async () => {
if (!canGenerate) return;
setBusy(true); setLibra("");
try {
const r = await buildApi("generate", { lessonId, prompt, triesUsed: tries });
if (r.ok && r.kind === "clean") { setOutput({ text:r.output, eventId:r.eventId }); setTries(r.triesUsed); }
else if (r.kind === "redirected") { setLibra(r.childMsg); if (typeof r.triesUsed === "number") setTries(r.triesUsed); }
else { setLibra(r.childMsg || "Let's try again in a moment! 🎈"); if (typeof r.triesUsed === "number") setTries(r.triesUsed); }
} catch(e) { setLibra("The AI got a bit shy — tap generate again! 🎈"); }
setBusy(false);
};
const keep = async () => {
if (!output) return;
setBusy(true);
try {
const r = await buildApi("keep", { eventId: output.eventId });
if (r.ok) { setKept(true); setSparks(r.sparksAwarded || 0); if (onKept) onKept(); }
} catch(e) {}
setBusy(false);
};
const tryAgain = () => { setOutput(null); setLibra(""); };
return (
{stepLabel &&
{stepLabel}
}
✨ Build it with AI
Now try it for real — write your prompt and see what the AI makes!
{kept ? (
🎉
Saved to your creations!
{sparks > 0 &&
+{sparks} ⚡ }
) : (
<>
);
}
function Lesson({ id, back, toast, onACard, openLesson }) {
const [l,setL] = useState(null); const [ov,setOv] = useState(null); const [err,setErr] = useState("");
const [taskText,setTaskText] = useState(""); const [busy,setBusy] = useState(false);
const [canComplete,setCanComplete] = useState(false);
const [celebrate,setCelebrate] = useState(null); // "lesson" | "mission" | null
const load = useCallback(()=> api(`/learn/lesson/${id}`).then(setL).catch(e=>setErr(e.message)), [id]);
useEffect(()=>{ setL(null); setCelebrate(null); setTaskText(""); load();
api("/learn/overview").then(setOv).catch(()=>{}); }, [load]);
/* the complete button appears after a short focus period when a video exists */
useEffect(()=>{
setCanComplete(false);
const t = setTimeout(()=>setCanComplete(true), 45000);
return ()=>clearTimeout(t);
}, [id]);
useEffect(()=>{ if (l && !l.videoUrl) setCanComplete(true); }, [l]);
if (err) return {err}
Back
;
if (!l) return ;
const task = l.blocks.find(b=>b.type==="build_task");
const quiz = l.blocks.find(b=>b.type==="quiz");
const brief = l.blocks.find(b=>b.type==="brief");
const videoDone = l.progress.videoDone;
const course = ov && ov.courses ? ov.courses.find(c=>c.title===l.courseTitle) : null;
const upNext = ov && ov.continue && ov.continue.lessonId !== id ? ov.continue : null;
const markWatched = async () => {
const r = await api(`/learn/lesson/${id}/video`, { method:"POST" });
if (r.sparksAwarded) { toast(`+${r.sparksAwarded} ⚡ Sparks!`); setCelebrate("lesson"); }
load(); api("/learn/overview").then(setOv).catch(()=>{});
};
const sendTask = async () => {
if (!taskText.trim()) return;
if (taskText.trim().length < 10) {
toast("Libra says: ooh, tell me more! What did you make? ✨");
return;
}
setBusy(true);
try {
const r = await api(`/learn/lesson/${id}/task`, { method:"POST", body:{ text: taskText.trim() } });
if (r.sparksAwarded) { toast(`+${r.sparksAwarded} ⚡ Great building!`); setCelebrate("mission"); }
setTaskText(""); load();
} catch(e){ toast(e.message); } finally { setBusy(false); }
};
const lockedCard = (icon, title, sub) => (
{icon} {title}
{sub}
);
return (
{/* header: orientation + ambient progress */}
← My path
{ov && (
⚡ {ov.sparks.toLocaleString()}
🔥 Day {ov.streak}
)}
{l.courseTitle} · {l.moduleTitle}
{l.title}
{l.progress.completed && (
✓
You've completed this lesson — you're reviewing it. Your Sparks and grade are safe.
)}
{course && (
Course {course.percent}% · {course.lessonsDone}/{course.lessonsTotal}
)}
{/* Stage 1 — Libra greets with the mission brief */}
{videoDone ? "Great to see you again, builder!" : "Hi builder! Today you'll discover"}
{!videoDone && brief && brief.payload.bullets && (
{brief.payload.bullets.map((b,i)=>
✓ {b}
)}
)}
{!videoDone && !brief && (
Watch, build, then earn your A!
)}
⚡ up to 45 Sparks in this lesson
{/* Stage 2 — learn (video) */}
{l.videoUrl ? (
) : (
🎬 This lesson's video arrives here once it's attached in the admin. Carry on with your mission and quiz below!
)}
🔒 Protected stream
{!videoDone && (
canComplete
?
Complete lesson ✓
:
▶ Watch with Libra — your complete button is on its way…
)}
{videoDone &&
✓ Lesson complete
}
{/* pre-completion: locked anticipation cards */}
{!videoDone && lockedCard("🔒", "Today's mission", "Unlocks when the video ends")}
{!videoDone && quiz && lockedCard("🔒", "Mastery A-card", "Complete the lesson to take the quiz")}
{/* Stage 3 — celebrate, build, master */}
{videoDone && celebrate === "lesson" && (
Nice work, builder! 🎉
Libra awarded you +10 Sparks ⚡
Now take your quiz, build with AI, then reflect ↓
)}
{videoDone && quiz && (
l.progress.grade
?
STEP 2 · QUIZ
Quiz result
{l.progress.grade}
onACard(id, quiz)}>
{l.progress.grade==="A" ? "Review your A-card 🎉" : "Try the quiz again"}
:
onACard(id, quiz)}>
🏆 Step 2 · Earn your mastery A-card · up to +25 ⚡
)}
{/* Instruction-type missions render as a numbered "do this now" card BEFORE the Build box. */}
{videoDone && task && missionKind(task.payload.prompt) === "instruction" && (
STEP 3 · YOUR MISSION
{sanitizeMission(task.payload.prompt)}
👇 Do it right here in your Build box — safe, and Libra checks every prompt.
)}
{videoDone && task && (
)}
{videoDone && task && (
STEP 4 · REFLECT
🚀 What did you make? {l.progress.taskDone && "· done ✓"}
{!l.progress.taskDone && +10 ⚡ }
Tell Libra what you built and what you learned.
{!l.progress.taskDone && (
<>
)}
{videoDone && upNext && (l.progress.grade || l.progress.taskDone) && (
Up next · {upNext.courseTitle}
openLesson(upNext.lessonId)}>
▶ {upNext.lessonTitle}
)}
{/* Prev / Next lesson navigation (within this course) */}
{(l.prev || l.next) && (
l.prev && openLesson(l.prev.lessonId)} disabled={!l.prev}
style={{ flex:1, background:l.prev?T.paper:"#F2F5F8", border:`1.5px solid ${T.line}`,
borderRadius:12, padding:"11px 12px", cursor:l.prev?"pointer":"default", fontFamily:FONT,
textAlign:"left", opacity:l.prev?1:.5 }}>
← Previous
{l.prev ? l.prev.title : "Start of course"}
l.next && l.next.unlocked && openLesson(l.next.lessonId)}
disabled={!l.next || !l.next.unlocked}
style={{ flex:1, background:(l.next&&l.next.unlocked)?T.paper:"#F2F5F8", border:`1.5px solid ${T.line}`,
borderRadius:12, padding:"11px 12px", cursor:(l.next&&l.next.unlocked)?"pointer":"default",
fontFamily:FONT, textAlign:"right", opacity:(l.next&&l.next.unlocked)?1:.5 }}>
{l.next && !l.next.unlocked ? "🔒 Next" : "Next →"}
{!l.next ? "End of course" : l.next.unlocked ? l.next.title : "Finish this lesson first"}
)}
{/* Libra's rotating safety tip */}
Libra's safety tip: {tipFor(id)}
);
}
/* ── Screen: quiz + A-card ── */
function Quiz({ lessonId, quizBlock, back, toast, childName, goHome }) {
const items = quizBlock.payload.items || [];
const [i,setI] = useState(0); const [answers,setAnswers] = useState({});
const [picked,setPicked] = useState(null); const [result,setResult] = useState(null);
const q = items[i];
const pick = (idx) => {
if (picked!==null) return;
setPicked(idx);
const next = { ...answers, [q.id]: idx };
setAnswers(next);
setTimeout(async ()=>{
if (i+1 < items.length) { setI(i+1); setPicked(null); }
else {
try {
const r = await api(`/learn/lesson/${lessonId}/quiz`, { method:"POST", body:{ answers: next } });
setResult(r);
if (r.sparksAwarded) toast(`+${r.sparksAwarded} ⚡ Sparks!`);
} catch(e){ toast(e.message); back(); }
}
}, 650);
};
if (result && result.certificate) {
return ;
}
if (result) {
const isA = result.grade === "A";
return (
{isA &&
}
{isA &&
}
Quiz complete
{result.grade}
{result.score}/{result.total} mastered
{isA && (
★ Achievement
{childName} scored an A!
Mastery quiz · Librancy AI Academy
You're learning to build the future 🚀 · Librancy AI
)}
{isA && (
💌
Your grown-up will see this! Your A goes to their dashboard so they can celebrate you
and show your family. 🎉
)}
{!isA &&
So close — rewatch the lesson and try again. You've got this!
}
← Back to the lesson
);
}
if (!q) return ;
return (
Mastery quiz · {i+1} of {items.length}
⚡ +25 for an A
{q.prompt}
{q.options.map((o,idx)=>(
pick(idx)} style={{ width:"100%", textAlign:"left", padding:"15px 16px",
borderRadius:14, border:`2px solid ${picked===idx?T.teal:T.line}`,
background:picked===idx?T.tealTint:T.paper, marginBottom:10, cursor:"pointer",
fontFamily:FONT, fontSize:14.5, fontWeight:600, color:T.ink }}>{o}
))}
Pick the answer you believe — there's no penalty for trying.
);
}
/* ── Course-complete certificate celebration (Wave 1) ── */
function CertCelebration({ cert, childName, goHome }) {
const share = async () => {
const text = `${childName} just completed ${cert.courseTitle} at Librancy! 🎓 See the verified certificate:`;
if (navigator.share) {
try { await navigator.share({ title: "Librancy", text, url: cert.url }); return; } catch (e) { /* cancelled */ }
}
window.open("https://wa.me/?text=" + encodeURIComponent(text + " " + cert.url), "_blank");
};
return (
COURSE COMPLETE
{childName}, you did it! 🎉
You finished every lesson of {cert.courseTitle}
⚡ Libra awarded you +200 Sparks
✓ Real verified certificate · yours forever
Share my certificate 📲
window.open(cert.url, "_blank")}>See it full size
Keep building →
It's on your trophy shelf now 🏆 Mum & Dad can see and share it from their dashboard too.
);
}
function Confetti() {
return (
{Array.from({length:26}).map((_,i)=>(
))}
);
}
/* ── Screen: creations ── */
function Creations({ back }) {
const [rows,setRows] = useState(null);
useEffect(()=>{ api("/learn/creations").then(setRows).catch(()=>setRows([])); },[]);
return (
← Home
My creations 🎨
{!rows ?
: rows.length===0
?
Nothing here yet — finish a Builder task and it appears on your pride wall!
: rows.map(r=>(
{r.lessonTitle}
“{r.content}”
))}
);
}
/* ── App shell ── */
function App() {
const [screen,setScreen] = useState("boot"); // boot|unlock|profiles|pin|home|lesson|quiz|creations
const [child,setChild] = useState(null);
const [home,setHome] = useState(null);
const [lessonId,setLessonId] = useState(null);
const [quizCtx,setQuizCtx] = useState(null);
const [toastMsg,setToastMsg] = useState("");
const toastTimer = useRef(null);
const toast = (m)=>{ setToastMsg(m); clearTimeout(toastTimer.current);
toastTimer.current = setTimeout(()=>setToastMsg(""), 2200); };
const loadHome = () => api("/learn/overview").then(d=>{ setHome(d); setScreen("home"); })
.catch(()=>{ store.del("childToken"); setScreen(store.get("children") ? "profiles" : "unlock"); });
useEffect(()=>{
if (store.get("childToken")) loadHome();
else if (store.get("children")) setScreen("profiles");
else setScreen("unlock");
},[]);
const openLesson = (id)=>{ setLessonId(id); setScreen("lesson"); };
return (
{screen==="boot" &&
}
{screen==="unlock" &&
setScreen("profiles")} onCode={()=>setScreen("code")} />}
{screen==="code" && setScreen("unlock")} />}
{screen==="profiles" && {setChild(c);setScreen("pin");}}
onReset={()=>{["parentToken","children","childToken","childName"].forEach(store.del);setScreen("unlock");}} />}
{screen==="pin" && setScreen("profiles")} />}
{screen==="home" && (home ? setScreen("creations")} refresh={loadHome} /> : )}
{screen==="lesson" && { setQuizCtx({ id, qb }); setScreen("quiz"); }} />}
{screen==="quiz" && setScreen("lesson")} toast={toast} childName={store.get("childName")||"Champion"}
goHome={loadHome} />}
{screen==="creations" && }
{/* Bottom nav (child session only) */}
{["home","lesson","quiz","creations"].includes(screen) && (
{[["home","🏠 Home",loadHome],["creations","🎨 Creations",()=>setScreen("creations")],
["switch","👤 Switch",()=>{store.del("childToken");setScreen("profiles");}]].map(([k,l,fn])=>(
{l}
))}
)}
);
}
ReactDOM.createRoot(document.getElementById("root")).render( );