/* 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:"#001F3F", navy2:"#0A3055", coral:"#FF6B6B", teal:"#00A88E", tealDeep:"#00715E",
tealTint:"#E6F7F3", yellow:"#FFC93C", 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 = ["🦁","🐘","🦋","🚀","🌟","🎨","🤖","⚽"];
/* 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}) {
return
{children}
;
}
function Logo({ small }) {
return
;
}
function Spinner() {
return Loading…
;
}
function Toast({ msg }) {
if (!msg) return null;
return {msg}
;
}
/* ── Screen: family unlock ── */
function Unlock({ onDone }) {
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}
}
Use the email and password from your purchase.
);
}
/* ── 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 (
{setup.avatar || "🦁"}
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}
}
);
return (
Who's learning today?
Tap your profile
{children.map((c,i)=>(
))}
);
}
/* ── 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); onDone();
} catch(e){ setErr("That PIN isn't right — try again!"); setPin(""); }
})(); }, [pin]);
return (
{child.avatar || "🦁"}
Hi {child.firstName}!
Enter your secret PIN
{err &&
{err}
}
{[1,2,3,4,5,6,7,8,9,"",0,"⌫"].map((k,i)=>(
))}
);
}
/* ── Screen: learner home ── */
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} {data.child.avatar||"🦁"}
⚡ {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.continue ? (
{data.continue.courseTitle}
) : data.courses.length > 0 && (
Everything complete — you're becoming future-ready!
)}
My courses
{data.courses.length===0 && (
No courses unlocked yet — ask a parent to complete enrolment on librancy.com.
)}
{data.courses.map(c=>(
{c.lessonsDone}/{c.lessonsTotal} lessons
))}
);
}
/* ── Screen: lesson player (Lesson Experience v2) ── */
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}
;
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;
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 */}
{ov && (
⚡ {ov.sparks.toLocaleString()}
🔥 Day {ov.streak}
)}
{l.courseTitle} · {l.moduleTitle}
{l.title}
{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
?
:
▶ 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 ⚡
Your mission is unlocked ↓
)}
{videoDone && task && (
🚀 Today's mission {l.progress.taskDone && "· done ✓"}
{!l.progress.taskDone && +10 ⚡}
{task.payload.prompt}
{!l.progress.taskDone && (
<>
)}
{videoDone && quiz && (
l.progress.grade
?
Quiz result
{l.progress.grade}
:
)}
{videoDone && upNext && (l.progress.grade || l.progress.taskDone) && (
Up next · {upNext.courseTitle}
)}
{/* Libra's rotating safety tip */}
Libra's safety tip: {tipFor(id)}
);
}
/* ── Screen: quiz + A-card ── */
function Quiz({ lessonId, quizBlock, back, toast, childName }) {
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) {
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
Your child can build the future too → librancy.com
)}
{isA && navigator.share && (
)}
{!isA &&
So close — rewatch the lesson and try again. You've got this!
}
);
}
if (!q) return ;
return (
Mastery quiz · {i+1} of {items.length}
⚡ +25 for an A
{q.prompt}
{q.options.map((o,idx)=>(
))}
Pick the answer you believe — there's no penalty for trying.
);
}
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 (
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")} />}
{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"} />}
{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])=>(
))}
)}
);
}
ReactDOM.createRoot(document.getElementById("root")).render();