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

Use the email and password from your purchase.

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}
}
); 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); 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

{[0,1,2,3].map(i=>
)}
{err &&
{err}
}
{[1,2,3,4,5,6,7,8,9,"",0,"⌫"].map((k,i)=>( ))}
); } /* ── 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}
}

Your parent has your code. Works on any phone, tablet or computer.

); } /* ── 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 (
🏆 See my certificate
Show a grown-up — they can share it! 💛
); } return ( ); } 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}
) : 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.title}
{c.percent}%
{c.lessonsDone}/{c.lessonsTotal} lessons {c.percent===100 ? "Review ↺" : c.started ? "Continue →" : "Start →"}
))}
); } /* ── 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} ⚡}
) : ( <>