/* 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:"#101450", navy2:"#1B2270", navy3:"#26307E", coral:"#FF6B6B", teal:"#02B5AA",
tealDeep:"#00715E", tealTint:"#E6F7F3", yellow:"#FEC323", yellowTint:"#FFF6DC",
gold:"#B8860B", purple:"#6C5CE7", purpleDeep:"#4C3FD1", purpleTint:"#EEEBFD",
pink:"#FF6B9D", pinkTint:"#FFEDF3", blue:"#1C6BF6", 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, onOpen, refLink }) {
const [sent,setSent]=useState(false); const [msg,setMsg]=useState("");
const R = RANKS[c.rank] || RANKS.Explorer;
const shareAchievement = async (child) => {
// Use the parent's REAL affiliate link (loaded by Dash). Falls back to the
// plain site if it hasn't loaded yet — never shares a broken empty ?ref=.
const url = refLink || "https://librancy.com";
const text = `${child.firstName} just scored an A on Librancy AI! 🚀 My child is learning to build with AI — yours can too. Join with my link: ${url}`;
try {
if (navigator.share) { await navigator.share({ title: "Librancy AI", text, url }); return; }
} catch (e) { /* fell through to WhatsApp */ }
window.open("https://wa.me/?text=" + encodeURIComponent(text), "_blank");
};
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.
)}
{/* Parent shares the child's achievement — carries the parent's referral link. */}
{c.aGrades > 0 && (
shareAchievement(c)} style={{ ...btnS("#25D366",{ marginTop:12, padding:"12px", fontSize:13.5 }) }}>
📣 Share {c.firstName}'s A — and earn when a friend joins
)}
{/* 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.
View full progress & PIN →
);
}
/* ── 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()} ⚡
))}
);
}
/* ── Read-only deep view of one child + Reset PIN (parent-facing) ── */
function ChildDetail({ childId, onBack }) {
const [d,setD] = useState(null); const [err,setErr] = useState("");
const [pinOpen,setPinOpen] = useState(false);
const [pin,setPin] = useState(""); const [pin2,setPin2] = useState("");
const [pinMsg,setPinMsg] = useState(""); const [busy,setBusy] = useState(false);
const [copied,setCopied] = useState(false);
const [loginCode,setLoginCode] = useState(null);
useEffect(()=>{ api(`/parent/child/${childId}/code`).then(r=>setLoginCode(r.loginCode)).catch(()=>{}); },[childId]);
const load = useCallback(()=>api(`/parent/child/${childId}`).then(setD).catch(e=>setErr(e.message)),[childId]);
useEffect(()=>{ load(); },[load]);
const savePin = async () => {
setPinMsg("");
if (!/^\d{4}$/.test(pin)) { setPinMsg("PIN must be exactly 4 digits."); return; }
if (pin !== pin2) { setPinMsg("The two PINs don't match."); return; }
setBusy(true);
try {
await api(`/parent/child/${childId}/pin`, { method:"POST", body:{ pin } });
setPinMsg("✓ New PIN saved. Your child can sign in with it now.");
setPin(""); setPin2(""); setTimeout(()=>{ setPinOpen(false); setPinMsg(""); load(); }, 1400);
} catch(e){ setPinMsg(e.message); } finally { setBusy(false); }
};
if (err) return
← Back
{err}
;
if (!d) return Loading…
;
const c = d.child; const R = RANKS[c.rank] || RANKS.Explorer;
return (
{/* header */}
← All children
{c.avatar || "🌟"}
{c.firstName}
{R.icon} {c.rank}
⚡ {c.sparks.toLocaleString()}
🔥 {c.streak}-day
{/* Reset PIN card */}
🔐 Login PIN
{c.hasPin ? "A PIN is set. Forgot it? Set a new one — the old one can't be shown for safety."
: "No PIN set yet. Create one so only your child opens their space."}
{!pinOpen &&
setPinOpen(true)}
style={{ ...btnS(T.navy,{ width:"auto", padding:"10px 14px", fontSize:13 }) }}>
{c.hasPin ? "Reset PIN" : "Set PIN"} }
{pinOpen && (
setPin(e.target.value.replace(/\D/g,"").slice(0,4))}
placeholder="New 4-digit PIN" inputMode="numeric" type="password"
style={{ ...inputS, marginBottom:8, letterSpacing:6, textAlign:"center" }} />
setPin2(e.target.value.replace(/\D/g,"").slice(0,4))}
placeholder="Confirm PIN" inputMode="numeric" type="password"
style={{ ...inputS, letterSpacing:6, textAlign:"center" }} />
{pinMsg &&
{pinMsg}
}
{busy?"Saving…":"Save new PIN"}
{ setPinOpen(false); setPin(""); setPin2(""); setPinMsg(""); }}
style={{ ...btnS("transparent",{ padding:"11px", color:T.slate, border:`1.5px solid ${T.line}` }) }}>Cancel
)}
{/* Login code — child signs in from any device with code + PIN */}
🎟️ Login code
{c.firstName} can sign in at librancy.com/learn from any device with this
code and their PIN — no email needed. Perfect if they’re on another device or in another city.
{loginCode ? (
<>
{loginCode}
{ navigator.clipboard?.writeText(loginCode);
setCopied(true); setTimeout(()=>setCopied(false),1500); }}
style={{ ...btnS(T.navy,{ padding:"11px", fontSize:13 }) }}>
{copied ? "✓ Copied" : "📋 Copy code"}
{
const msg = `Hi! Here’s your Librancy login 🎟️\nGo to librancy.com/learn\nCode: ${loginCode}\nThen enter your PIN. Have fun learning! 🚀`;
window.open("https://wa.me/?text="+encodeURIComponent(msg), "_blank"); }}
style={{ ...btnS("#25D366",{ padding:"11px", fontSize:13 }) }}>
📲 Send on WhatsApp
🔒 Send the code and PIN separately for safety. The code alone can’t sign in.
>
) : (
No code yet — run the code migration once and it appears here.
)}
{/* Course-by-course lesson breakdown */}
{d.courses.map((co,ci)=>(
{co.title}
{co.done}/{co.total} · {co.percent}%
{co.lessons.map((l,li)=>(
{l.completed ? "✅" : l.videoDone ? "▶️" : "⬜"}
{l.title}
{l.grade && {l.grade} }
))}
))}
{/* All creations, full text */}
🎨 All creations ({d.creations.length})
{d.creations.length===0
? No creations yet — they'll appear here as {c.firstName} builds.
: d.creations.map((cr,i)=>(
{(cr.lesson||"").toUpperCase()}{cr.source==="ai_build"?" · ✨ AI BUILD":""}
"{cr.text}"
))}
);
}
/* ── Dashboard ── */
/* ── Partner earnings: parent's affiliate link + balance, in-dashboard ── */
function PartnerEarnings() {
const [a,setA] = useState(null); const [err,setErr] = useState("");
const [detail,setDetail] = useState(""); const [msg,setMsg] = useState(""); const [busy,setBusy] = useState(false);
const load = useCallback(()=>api("/parent/affiliate").then(setA).catch(e=>setErr(e.message)),[]);
useEffect(()=>{ load(); },[load]);
if (err) return null; // fail quietly — never break the dashboard
if (!a) return null; // still loading
const s = a.stats, cfg = a.config, link = a.affiliate.link;
const canWithdraw = s.payableMinor >= cfg.minPayoutMinor && a.affiliate.payoutSet;
const copy = (e)=>{ navigator.clipboard.writeText(link);
const b=e.target, t=b.textContent; b.textContent="Copied!"; setTimeout(()=>b.textContent=t,1200); };
const shareWA = ()=>{ const m="I found Librancy — it teaches kids to build with AI. Join with my link: "+link;
window.open("https://wa.me/?text="+encodeURIComponent(m),"_blank"); };
const savePayout = async ()=>{ setMsg("");
if(detail.trim().length<6){ setMsg("Enter your bank name + account number"); return; }
setBusy(true);
try{ await api("/affiliate/payout-detail",{method:"POST",body:{detail}}); setMsg("✓ Payout details saved"); setDetail(""); load(); }
catch(e){ setMsg(e.message); } finally{ setBusy(false); }
};
const withdraw = async ()=>{ setMsg(""); setBusy(true);
try{ const r=await api("/affiliate/request-payout",{method:"POST"}); setMsg("✓ Payout requested: "+NGN(r.amountMinor)); load(); }
catch(e){ setMsg(e.message); } finally{ setBusy(false); }
};
return (
💚 Earn cash as a partner
{cfg.ratePct}% per sale
Share Librancy with other parents and teachers. Earn {cfg.ratePct}% of every course they buy —
paid out {cfg.matureDays} days after each sale.
{NGN(s.payableMinor)}
READY TO WITHDRAW
{NGN(s.pendingMinor)}
MATURING
Copy
📲 Share on WhatsApp
{!a.affiliate.payoutSet && (
setDetail(e.target.value)}
placeholder="Bank name + account number (to get paid)"
style={{ width:"100%", boxSizing:"border-box", padding:"11px 12px", borderRadius:10, border:"none",
fontSize:13, fontFamily:FONT }} />
Save payout details
)}
{a.affiliate.payoutSet && (
{s.payableMinor >= cfg.minPayoutMinor ? "Request payout" : "Min "+NGN(cfg.minPayoutMinor)+" to withdraw"}
)}
{msg &&
{msg}
}
);
}
function Dash({ onLogout }) {
const [d,setD]=useState(null); const [err,setErr]=useState("");
const [showAccount,setShowAccount]=useState(false);
const [openChildId,setOpenChildId]=useState(null);
const [refLink,setRefLink]=useState(""); // parent's real affiliate link (single source of truth)
const load = useCallback(()=>api("/parent/overview").then(setD).catch(e=>setErr(e.message)),[]);
useEffect(()=>{ load(); },[load]);
useEffect(()=>{ api("/parent/affiliate").then(a=>setRefLink(a.affiliate.link)).catch(()=>{}); },[]);
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];
if (openChildId) return { setOpenChildId(null); load(); }} />;
return (
{/* Brand header */}
🔥 {d.beacons.balance} Beacons
PARENT DASHBOARD
The {family} family
{d.children.map((c,i)=>setOpenChildId(c.id)} refLink={refLink} />)}
{/* 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( );