/* 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 (
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}
}
{busy?"Working…":mode==="login"?"Sign in":mode==="forgot"?"Send reset link":"Set new password"}
{mode==="login" &&
{setMode("forgot");setMsg("");}}
style={{...btnS("transparent",{color:T.teal, fontSize:13.5, marginTop:8})}}>Forgot password? }
{mode==="forgot" &&
{setMode("login");setMsg("");}}
style={{...btnS("transparent",{color:T.slate, fontSize:13.5, marginTop:8})}}>Back to sign in }
);
}
/* ── 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])=>(
))}
{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 && (
{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}`}} />
{sent?"Sent 💛":"🎉 Celebrate"}
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])=>(
setScope(k)} style={{...chip(scope===k?T.navy:T.cloud, scope===k?"#fff":T.slate),
border:"none", cursor:"pointer", fontFamily:FONT}}>{l}
))}
{[["month","Monthly race"],["all","All-time"]].map(([k,l])=>(
setPeriod(k)} style={{...chip(period===k?T.teal:T.cloud, period===k?"#fff":T.slate),
border:"none", cursor:"pointer", fontFamily:FONT}}>{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}
Sign in again
;
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 */}
🔥 {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.
Enrol another child →
{/* Region */}
📍 Your region (for the leaderboard)
{
if(e.target.value){ await api("/parent/region",{method:"POST",body:{region:e.target.value}}); }}}
style={{...inputS, padding:"12px 13px"}}>
Choose your region…
{REGIONS.map(r=>{r} )}
{/* Account */}
setShowAccount(!showAccount)} style={{...btnS("transparent",{color:T.navy,
textAlign:"left", padding:0, fontSize:14, fontWeight:700, width:"100%"})}}>
⚙️ Account {showAccount?"▴":"▾"}
{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}
}
Change password
Log out
);
}
/* ── 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( );