/* 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 Librancy AI; } 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

{[0,1,2,3].map(i=>
)}
{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.title}
{c.percent}%
{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 ? (