/* LIBRANCY — Parent Dashboard v2 (premium brand redesign) * Navy brand header with the real logo · rank-coloured child identity · * certificate showcase (the aspiration artifact) · gradient Beacons economy · * styled leaderboards · the Celebrate loop. Same API, same routes. */ const { useState, useEffect, useCallback } = React; const API = ""; const T = { navy:"#001F3F", navy2:"#0A3055", navy3:"#113B63", coral:"#FF6B6B", teal:"#00A88E", tealDeep:"#00715E", tealTint:"#E6F7F3", yellow:"#FFC93C", yellowTint:"#FFF6DC", gold:"#B8860B", purple:"#6C5CE7", purpleDeep:"#4C3FD1", purpleTint:"#EEEBFD", pink:"#FF6B9D", pinkTint:"#FFEDF3", blue:"#0066FF", blueTint:"#E8F0FF", ink:"#1B2733", slate:"#5C6B7A", mist:"#8597A6", line:"#E4EAF0", paper:"#FFFFFF", cloud:"#F5F8FB", }; const FONT = `"Poppins","Inter",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif`; const NGN = (k) => "₦" + Math.round(k/100).toLocaleString("en-NG"); const REGIONS = ["Lagos","Abuja (FCT)","Rivers","Oyo","Kano","Enugu","Anambra","Delta","Kaduna", "Other Nigeria","Diaspora — UK","Diaspora — US/Canada","Diaspora — Other"]; const RANKS = { Explorer:{ icon:"✦" }, Builder:{ icon:"🚀" }, Creator:{ icon:"★" }, Founder:{ icon:"👑" }, }; const AVATARS = ["🦁","🐘","🦋","🚀","🌟","🎨"]; const store = { get:(k)=>{ try{ return JSON.parse(localStorage.getItem("lbrp_"+k)); }catch{ return null; } }, set:(k,v)=>localStorage.setItem("lbrp_"+k, JSON.stringify(v)), del:(k)=>localStorage.removeItem("lbrp_"+k), }; async function api(path, opts={}) { const token = store.get("token"); 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; } const btnS=(bg,extra={})=>({ border:"none", borderRadius:14, padding:"14px 18px", width:"100%", cursor:"pointer", background:bg, color:"#fff", fontSize:15, fontWeight:600, fontFamily:FONT, ...extra }); const chip=(bg,c)=>({ display:"inline-flex", alignItems:"center", gap:5, background:bg, color:c, fontSize:11.5, fontWeight:700, padding:"4px 11px", borderRadius:20 }); const inputS={ width:"100%", padding:"14px 15px", borderRadius:13, border:`2px solid ${T.line}`, fontSize:15.5, fontFamily:FONT, boxSizing:"border-box", background:"#fff" }; function Card({children,style}){ return
{children}
; } /* ── Auth ── */ function Login({ onDone }) { const params = new URLSearchParams(location.search); const resetToken = params.get("reset"); const [mode,setMode] = useState(resetToken ? "reset" : "login"); const [email,setEmail]=useState(""); const [pw,setPw]=useState(""); const [msg,setMsg]=useState(""); const [busy,setBusy]=useState(false); const run = async () => { setBusy(true); setMsg(""); try { if (mode==="login") { const r = await api("/auth/login",{method:"POST",body:{email,password:pw}}); store.set("token", r.token); onDone(); } else if (mode==="forgot") { await api("/auth/forgot",{method:"POST",body:{email}}); setMsg("If that email has an account, a reset link is on its way. Check your inbox (and spam)."); } else { const r = await api("/auth/reset",{method:"POST",body:{token:resetToken,password:pw}}); store.set("token", r.token); history.replaceState(null,"","/parent/"); onDone(); } } catch(e){ setMsg(e.message); } finally { setBusy(false); } }; return (
Librancy AI
The Future is Built by Curious Minds

{mode==="login"?"Parent dashboard":mode==="forgot"?"Reset your password":"Choose a new password"}

{mode==="login"?"See your child’s progress, celebrate their wins, and grow your Beacons." :mode==="forgot"?"Enter your account email and we’ll send a reset link." :"Enter a new password for your account."}

{mode!=="reset" && setEmail(e.target.value)} type="email" placeholder="Parent email" style={{...inputS, marginBottom:12}} />} {mode!=="forgot" && setPw(e.target.value)} type="password" placeholder={mode==="reset"?"New password (8+ characters)":"Password"} onKeyDown={e=>e.key==="Enter"&&run()} style={{...inputS, marginBottom:12}} />} {msg &&
{msg}
} {mode==="login" && } {mode==="forgot" && }
); } /* ── Child card ── */ function ChildCard({ c, i, onCelebrate }) { const [sent,setSent]=useState(false); const [msg,setMsg]=useState(""); const R = RANKS[c.rank] || RANKS.Explorer; const celebrate = async () => { await onCelebrate(c.id, msg || undefined); setSent(true); setMsg(""); setTimeout(()=>setSent(false), 3500); }; const earned = c.courses.filter(co=>co.percent>=100).length; return ( {/* identity band */}
{c.avatar || AVATARS[i % AVATARS.length]}
{c.firstName}
{R.icon} {c.rank} ⚡ {c.sparks.toLocaleString()} 🔥 {c.streak}-day
{/* stat tiles */}
{[["✅", c.lessonsThisWeek, "lessons this week", T.tealTint, T.tealDeep], ["🅰️", c.aGrades, "A-grades", T.yellowTint, T.gold], ["🎨", c.creations, "creations", T.pinkTint, "#C2185B"]].map(([ic,v,k,bg,cc])=>(
{ic}
{v}
{k}
))}
{c.latestCreation && (
LATEST CREATION · {c.latestCreation.lesson.toUpperCase()}
“{c.latestCreation.text}”
)} {/* course → certificate progress */}
{c.courses.map(co=>{ const full = co.percent>=100; return (
{co.title} {full ? "🏆 Certificate earned" : co.percent+"% to certificate"}
{!full && (
)}
); })}
{earned>0 && (
Librancy certificate
{c.firstName} has earned {earned} certificate{earned>1?"s":""} 🎉
A frame-worthy award — share the pride from {c.firstName}’s A-card in the learning space.
)} {/* celebrate */}
setMsg(e.target.value)} maxLength={140} placeholder={`Say something to ${c.firstName}…`} style={{...inputS, padding:"12px 13px", fontSize:13.5, background:T.cloud, border:`1.5px solid ${T.line}`}} />
They’ll see it next time they open Librancy.
); } /* ── Leaderboards ── */ function Boards() { const [scope,setScope]=useState("family"); const [period,setPeriod]=useState("month"); const [data,setData]=useState(null); useEffect(()=>{ setData(null); api(`/leaderboard?scope=${scope}&period=${period}`).then(setData).catch(()=>setData({rows:[]})); },[scope,period]); return (
🏆 Leaderboards
{[["family","Families"],["region","Regions"]].map(([k,l])=>( ))} {[["month","Monthly race"],["all","All-time"]].map(([k,l])=>( ))}
{!data ?
Loading…
: data.rows.length===0 ?
No activity yet this period — be the first family on the board!
: data.rows.map((r,i)=>(
{r.rank===1?"🥇":r.rank===2?"🥈":r.rank===3?"🥉":(r.rank??"—")} {r.name}{r.me?" · you":""} {Number(r.pts).toLocaleString()} ⚡
))}
); } /* ── Dashboard ── */ function Dash({ onLogout }) { const [d,setD]=useState(null); const [err,setErr]=useState(""); const [showAccount,setShowAccount]=useState(false); const load = useCallback(()=>api("/parent/overview").then(setD).catch(e=>setErr(e.message)),[]); useEffect(()=>{ load(); },[load]); if (err) return
{err}
; if (!d) return
Loading your family…
; const celebrate = (childId, message)=>api("/parent/celebrate",{method:"POST",body:{childId,message}}); const family = (d.parent.name||"").split(" ")[0]; return (
{/* Brand header */}
Librancy AI 🔥 {d.beacons.balance} Beacons
PARENT DASHBOARD
The {family} family
{d.children.map((c,i)=>)}
{/* Beacons economy — gradient card */}
Your Beacons
{d.beacons.balance} = {NGN(d.beacons.creditMinor)} credit
Earned from {d.beacons.referrals} famil{d.beacons.referrals===1?"y":"ies"} who joined through your child’s shared wins ({d.beacons.shares} shares). Credit auto-applies up to {d.beacons.capPct}% at your next checkout — perfect for the next course or a sibling.
{/* Region */}
📍 Your region (for the leaderboard)
{/* Account */} {showAccount && }
); } function AccountPanel({ email, onLogout }) { const [cur,setCur]=useState(""); const [nw,setNw]=useState(""); const [msg,setMsg]=useState(""); const change = async ()=>{ setMsg(""); try { await api("/auth/change-password",{method:"POST",body:{current:cur,new:nw}}); setMsg("Password changed ✓"); setCur(""); setNw(""); } catch(e){ setMsg(e.message); } }; return (
Signed in as {email}
setCur(e.target.value)} type="password" placeholder="Current password" style={{...inputS, marginBottom:8, padding:"11px 13px", fontSize:13.5}} /> setNw(e.target.value)} type="password" placeholder="New password (8+)" style={{...inputS, marginBottom:8, padding:"11px 13px", fontSize:13.5}} /> {msg &&
{msg}
}
); } /* ── App ── */ function App(){ const [authed,setAuthed]=useState(!!store.get("token")); return (
{authed ? {store.del("token");setAuthed(false);}} /> : setAuthed(true)} />}
); } ReactDOM.createRoot(document.getElementById("root")).render();