>} size={20} />,
star: } size={15} />,
};
/* ============================ PRIMITIVES ============================ */
function Shell({ children }) {
return (
);
}
function Progress({ step, total }) {
const pct = Math.round((step / total) * 100);
return (
);
}
function TopBar({ onBack, showBack }) {
return (
{showBack ? (
{I.back}Back
) : }
);
}
function Page({ children }) {
return {children}
;
}
function Eyebrow({ children, color = T.teal }) {
return {children}
;
}
function H1({ children }) {
return {children} ;
}
function Sub({ children }) {
return {children}
;
}
function PrimaryBtn({ children, onClick, disabled }) {
return (
!disabled && (e.currentTarget.style.transform = "scale(.985)")}
onMouseUp={(e) => (e.currentTarget.style.transform = "scale(1)")}
onMouseLeave={(e) => (e.currentTarget.style.transform = "scale(1)")}>
{children}{!disabled && I.arrow}
);
}
const ghostBtn = { background: "none", border: "none", display: "inline-flex", alignItems: "center",
fontSize: 14, fontWeight: 500, cursor: "pointer", fontFamily: FONT, padding: 4 };
function Footer({ children }) {
return {children}
;
}
/* Selectable card (single or multi) */
function Choice({ active, onClick, children, compact }) {
return (
{children}
{active && I.check}
);
}
function TrustStrip() {
const item = (icon, label) => (
{icon} {label}
);
return (
{item(I.shield, "Secure Paystack")}
{item(I.check, "30-day guarantee")}
{item(I.star, "12,000+ families")}
);
}
/* ============================ SCREENS ============================ */
// 1 — HERO
function Hero({ v, next }) {
return (
<>
{v.hero.eyebrow}
{v.hero.headline}
{v.hero.sub}
{/* Signature visual: a child's learning dashboard, illustrated, not stock */}
Takes about {v.hero.timeMins} minutes
Plans from {NGN(v.hero.priceFrom)}
Discover their potential
{I.play}Watch a free sample lesson
>
);
}
// 2 — CHILD BASICS
function ChildBasics({ v, data, set, next }) {
const ok = data.childName.trim().length >= 2 && data.ageBand;
return (
<>
Let’s start with the basics
Tell us a little about your child so we can shape their journey.
Child’s first name
set({ childName: e.target.value })}
placeholder="e.g. Ada" autoFocus
style={inputStyle} />
Age range
{v.ageBands.map((b) => {
const active = data.ageBand === b.id;
return (
set({ ageBand: b.id })} style={{
padding: "18px 8px", borderRadius: 16, cursor: "pointer", fontFamily: FONT,
border: `2px solid ${active ? T.teal : T.line}`, background: active ? T.tealTint : T.paper,
display: "flex", flexDirection: "column", alignItems: "center", gap: 8, transition: "all .15s",
}}>
{I[b.icon]}
{b.label}
{b.tag}
);
})}
>
);
}
// 3 — INTERESTS
function Interests({ v, data, set, next }) {
const toggle = (x) => {
const has = data.interests.includes(x);
set({ interests: has ? data.interests.filter((i) => i !== x) : [...data.interests, x] });
};
return (
<>
What does {nameOf(data)} naturally enjoy?
Pick all that fit — there are no wrong answers.
{v.interests.map((x) => {
const active = data.interests.includes(x);
return (
toggle(x)} style={{
padding: "16px 14px", borderRadius: 14, cursor: "pointer", fontFamily: FONT, textAlign: "left",
border: `2px solid ${active ? T.teal : T.line}`, background: active ? T.tealTint : T.paper,
fontWeight: 600, fontSize: 14.5, color: active ? T.tealDeep : T.ink, transition: "all .15s",
display: "flex", alignItems: "center", justifyContent: "space-between", gap: 6,
}}>
{x}{active && {I.check} }
);
})}
{data.interests.length ? `Continue · ${data.interests.length} chosen` : "Choose at least one"}
>
);
}
// 4 — EXPERIENCE
function Experience({ data, set, next }) {
const opts = [
{ id: "frequent", t: "Yes, often", s: "Ready for intermediate content" },
{ id: "little", t: "A little", s: "Ready for beginner content" },
{ id: "none", t: "Not yet", s: "Perfect — 80% of our learners start here" },
];
return (
<>
Has {nameOf(data)} used AI tools before?
This helps us set the right starting point.
{opts.map((o) => (
set({ experience: o.id })}>
))}
{data.experience === "none" && (
Excellent! Most of our learners start exactly where {nameOf(data)} is. We guide them step by step from the very beginning.
)}
>
);
}
// 5 — ASPIRATIONS
function Aspirations({ v, data, set, next }) {
const toggle = (x) => {
const has = data.aspirations.includes(x);
set({ aspirations: has ? data.aspirations.filter((i) => i !== x) : [...data.aspirations, x] });
};
return (
<>
What future would you love for {nameOf(data)}?
Choose all that resonate with you.
{v.aspirations.map((x) => {
const active = data.aspirations.includes(x);
return (
toggle(x)} style={{
padding: "15px 14px", borderRadius: 14, cursor: "pointer", fontFamily: FONT, textAlign: "left",
border: `2px solid ${active ? T.teal : T.line}`, background: active ? T.tealTint : T.paper,
fontWeight: 600, fontSize: 13.8, color: active ? T.tealDeep : T.ink, transition: "all .15s",
lineHeight: 1.3,
}}>{x}
);
})}
>
);
}
// 6 — VALUE REFRAME (the anchor)
function ValueReframe({ data, next }) {
return (
<>
A quick thought
Some gifts last a season. Others last a lifetime.
Children outgrow shoes in months. Future skills shape the rest of their lives.
A new pair of shoes
Outgrown in months
Future-ready skills {I.star}
Last a lifetime
Which investment could shape {nameOf(data)} longer?
>
);
}
// 7 — COMMITMENT SLIDER
function Commitment({ data, set, next }) {
const val = data.commitment;
const labels = ["Not important", "Moderately", "Very important", "Extremely important"];
const idx = Math.min(3, Math.floor(val / 34));
return (
<>
How important is future-ready education for {nameOf(data)}?
Help us understand your commitment.
set({ commitment: Number(e.target.value) })}
style={{ width: "100%", accentColor: T.navy, height: 6 }} />
{labels.map((l, i) => (
{l}
))}
{idx >= 2 && (
Perfect — you’re committed to {nameOf(data)}’s future.
)}
>
);
}
// 8 — PARENT DETAILS (+ NDPA consent, writes Lead)
function ParentDetails({ data, set, next }) {
const emailOk = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email);
const ok = data.parentName.trim().length >= 2 && emailOk && data.consent;
return (
<>
Where should we send {nameOf(data)}’s learning plan?
We’ll use this to create the account and send {nameOf(data)}’s curriculum.
Your full name
set({ parentName: e.target.value })}
placeholder="Your name" style={inputStyle} />
Email address
set({ email: e.target.value })}
placeholder="you@email.com" type="email" style={inputStyle} />
We’ll send account login details here.
Phone number (optional)
set({ phone: e.target.value })}
placeholder="+234…" style={inputStyle} />
set({ consent: e.target.checked })}
style={{ width: 20, height: 20, accentColor: T.teal, marginTop: 1, flexShrink: 0 }} />
I agree to Librancy creating an account for my child and contacting me about their learning. I’ve read the{" "}
Privacy Policy . (NDPA consent)
>
);
}
// 9 — OFFER (real personalization + entry tier + split pay)
function Offer({ v, data, set, next }) {
const rec = useMemo(() => recommend(v, data), [v, data]);
const sel = data.selection;
const setSel = (s) => set({ selection: s });
const bundleSelected = sel?.type === "bundle";
const total = sel?.type === "bundle" ? v.bundle.price : sel?.type === "course"
? v.courses.find((c) => c.id === sel.id)?.price ?? 0 : sel?.type === "entry" ? v.entry.price : 0;
return (
<>
Choose {nameOf(data)}’s learning path
{rec.reason}
{/* Recommended bundle (anchor + default) */}
★ Recommended for {nameOf(data)}
setSel({ type: "bundle" })} style={{
width: "100%", textAlign: "left", padding: "22px 18px 18px", borderRadius: 18, cursor: "pointer",
fontFamily: FONT, border: `2.5px solid ${bundleSelected ? T.navy : T.line}`,
background: bundleSelected ? "#fff" : T.paper, transition: "all .15s",
boxShadow: bundleSelected ? "0 10px 26px rgba(0,31,63,.12)" : "none",
}}>
{I.gift}
{v.bundle.name}
SAVE {NGN(v.bundle.anchor - v.bundle.price)}
{NGN(v.bundle.price)}
{NGN(v.bundle.anchor)}
All {v.bundle.count} courses — {nameOf(data)}’s complete path to becoming a confident AI creator.
{/* Entry tier — the easy first yes */}
setSel({ type: "entry" })} style={{
width: "100%", textAlign: "left", padding: "15px 16px", borderRadius: 14, cursor: "pointer",
fontFamily: FONT, marginBottom: 18, transition: "all .15s",
border: `2px solid ${sel?.type === "entry" ? T.teal : T.line}`,
background: sel?.type === "entry" ? T.tealTint : T.cloud,
}}>
Just getting started?
Begin with {v.courses.find((c) => c.id === v.entry.courseId)?.name} — one course
{NGN(v.entry.price)}
{rec.ordered.map((c) => {
const active = sel?.type === "course" && sel.id === c.id;
return (
setSel({ type: "course", id: c.id })} style={{
width: "100%", textAlign: "left", padding: "14px 16px", borderRadius: 14, cursor: "pointer",
fontFamily: FONT, border: `2px solid ${active ? T.teal : T.line}`,
background: active ? T.tealTint : T.paper, transition: "all .15s",
}}>
{c.name}
{NGN(c.price)}
Ages {c.ages}
{c.blurb}
);
})}
{/* Split-pay option appears when a paid path is selected */}
{total >= 37500 && (
set({ splitPay: e.target.checked })}
style={{ width: 19, height: 19, accentColor: T.teal }} />
Spread the cost — pay {NGN(Math.ceil(total / 3 / 100) * 100)} × 3 instead of all at once
)}
>
);
}
// 10 — CHECKOUT
function Checkout({ v, data, onPay }) {
const sel = data.selection;
const total = sel?.type === "bundle" ? v.bundle.price : sel?.type === "course"
? v.courses.find((c) => c.id === sel.id)?.price ?? 0 : sel?.type === "entry" ? v.entry.price : 0;
const perInstallment = Math.ceil(total / 3 / 100) * 100;
return (
<>
Ready to give {nameOf(data)} an unfair advantage?
Review the plan below to finish enrolment.
{I.shield} Your selection
Total {data.splitPay ? "(today)" : "investment"}
{NGN(data.splitPay ? perInstallment : total)}
{data.splitPay && (
then {NGN(perInstallment)} × 2 — one course unlocks per payment
)}
{I.shield} 30-day satisfaction guarantee
If you’re not thrilled with {nameOf(data)}’s progress, we’ll refund you in full. No questions asked.
Complete payment · {NGN(data.splitPay ? perInstallment : total)}
>
);
}
// 11 — HANDOFF (Sprint 2 seam, shown honestly)
function Handoff({ data }) {
return (
} size={34} stroke={T.tealDeep} />
Lead captured — Paystack opens here
In production this hands off to Paystack to take payment, then creates {nameOf(data)}’s account and grants the
course entitlement. That wiring is Sprint 2 of Phase 1 — the funnel engine you just clicked through is Sprint 1.
{`POST /api/leads → lead saved (done at step 8)
POST /api/checkout → Paystack init (Sprint 2)
POST /webhooks/paystack→ verify + grant (Sprint 2)
→ entitlement(child → course) created
→ account email sent`}
);
}
/* ============================ SUB-COMPONENTS ============================ */
function Logo() {
return (
{["#FF6B6B", "#00D4AA", "#FFC93C", "#0066FF", "#FF6B9D"].map((c, i) => {
const a = (i / 5) * Math.PI * 2;
return ;
})}
Librancy AI
);
}
function DashboardArt({ accent }) {
return (
Welcome back, Champion 👋
Your AI journey 70%
{["Intro to AI", "Prompt basics", "Build a tool"].map((t, i) => (
))}
);
}
const ShoeArt = () => (
);
const FutureArt = ({ accent }) => (
);
function Label({ children, style }) {
return {children}
;
}
const inputStyle = {
width: "100%", padding: "15px 16px", borderRadius: 14, border: `2px solid ${T.line}`,
fontSize: 16, fontFamily: FONT, color: T.ink, outline: "none", boxSizing: "border-box", background: T.paper,
};
function Divider({ label }) {
return (
{label}
);
}
function Row({ k, val }) {
return (
{k}
{val}
);
}
function Perk({ text }) {
return (
{I.check} {text}
);
}
/* ============================ LOGIC ============================ */
function nameOf(d) {
const n = (d.childName || "").trim();
return n.length ? n : "your child";
}
// Real personalization: age band picks the lead course; interests reorder; reason uses actual inputs.
function recommend(v, data) {
const band = data.ageBand;
const lead = v.courses.find((c) => c.band === band) || v.courses[0];
const ordered = [lead, ...v.courses.filter((c) => c.id !== lead.id)];
const picks = data.interests.slice(0, 3).map((s) => s.toLowerCase());
const interestBit = picks.length
? `${nameOf(data)}’s love of ${listify(data.interests.slice(0, 3))}`
: `${nameOf(data)}’s profile`;
const reason = `Based on ${interestBit} and age (${data.ageBand}), we’d start with ${lead.name} — and the bundle covers the full path as ${nameOf(data)} grows.`;
return { lead, ordered, reason };
}
function listify(arr) {
const a = arr.map((s) => s.toLowerCase());
if (a.length === 1) return a[0];
if (a.length === 2) return `${a[0]} and ${a[1]}`;
return `${a.slice(0, -1).join(", ")} and ${a[a.length - 1]}`;
}
function selLabel(v, sel) {
if (!sel) return "";
if (sel.type === "bundle") return `${v.bundle.name} (${v.bundle.count} courses)`;
if (sel.type === "entry") return `${v.courses.find((c) => c.id === v.entry.courseId)?.name} (starter)`;
if (sel.type === "course") return v.courses.find((c) => c.id === sel.id)?.name || "";
return "";
}
// Maps the funnel's local course ids to the backend course slugs.
const SLUG_FOR = { explorer: "explorers", builders: "builders", prompt: "prompt-mastery", founders: "ai-founders" };
// Starts a real Paystack checkout: server prices the SKU, returns a redirect URL.
async function startCheckout(data) {
const sku = data.selection.type === "bundle" ? "bundle"
: data.selection.type === "entry" ? "entry"
: "course:" + (SLUG_FOR[data.selection.id] || data.selection.id);
const payload = {
email: data.email, parentName: data.parentName, phone: data.phone,
childName: data.childName, ageBand: data.ageBand, interests: data.interests,
sku, plan: data.splitPay ? "split" : "full",
};
try {
const res = await fetch(API_BASE + "/api/checkout", {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload),
});
const j = await res.json();
if (j.authorization_url) { window.location.href = j.authorization_url; return; }
alert(j.error || "Could not start checkout. Please try again.");
} catch (e) {
alert("Network error starting checkout. Please try again.");
}
}
// Posts the lead to the live PHP API (api/index.php → /leads).
function saveLead(data) {
const lead = {
childName: data.childName, ageBand: data.ageBand, interests: data.interests,
experience: data.experience, aspirations: data.aspirations, commitment: data.commitment,
parentName: data.parentName, email: data.email, phone: data.phone, consentAt: new Date().toISOString(),
vertical: "ai",
};
fetch(API_BASE + "/api/leads", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(lead),
}).catch((e) => console.warn("lead save failed (non-blocking):", e));
return lead;
}
/* ============================ ENGINE ============================ */
const STEPS = ["hero", "child", "interests", "experience", "aspirations",
"reframe", "commitment", "parent", "offer", "checkout", "done"];
function LibrancyFunnel() {
const v = VERTICALS.ai;
const [step, setStep] = useState(0);
const [data, setData] = useState({
childName: "", ageBand: "", interests: [], experience: "", aspirations: [],
commitment: 50, parentName: "", email: "", phone: "", consent: false,
selection: { type: "bundle" }, splitPay: false,
});
const set = (patch) => setData((d) => ({ ...d, ...patch }));
const topRef = useRef(null);
// Pull live catalog config from the API; fall back to the embedded config offline.
useEffect(() => {
fetch(API_BASE + "/api/verticals/ai")
.then((r) => (r.ok ? r.json() : null))
.then((cfg) => { if (cfg && cfg.config && cfg.config.hero) { Object.assign(VERTICALS.ai.hero, cfg.config.hero); } })
.catch(() => {});
}, []);
const go = (dir) => {
setStep((s) => {
const nextStep = Math.max(0, Math.min(STEPS.length - 1, s + dir));
if (STEPS[s] === "parent" && dir === 1) saveLead(data); // Lead persists here
return nextStep;
});
};
useEffect(() => { topRef.current && topRef.current.scrollTo(0, 0); }, [step]);
const screen = STEPS[step];
const showBack = step > 0 && screen !== "done";
return (
{screen !== "hero" &&
go(-1)} showBack={showBack} />}
{screen === "hero" && go(1)} />}
{screen === "child" && go(1)} />}
{screen === "interests" && go(1)} />}
{screen === "experience" && go(1)} />}
{screen === "aspirations" && go(1)} />}
{screen === "reframe" && go(1)} />}
{screen === "commitment" && go(1)} />}
{screen === "parent" && go(1)} />}
{screen === "offer" && go(1)} />}
{screen === "checkout" && startCheckout(data)} />}
{screen === "done" && }
);
}
// Mount (no build step — React + Babel load from CDN in index.html).
ReactDOM.createRoot(document.getElementById("root")).render( );