📖

Fables Library

Gentle stories for growing hearts

Begin
// in the parent page BEFORE the iframe tag. const API_ENDPOINT = window.FABLES_API_ENDPOINT || '/api/fable'; // In production: change to your full server URL if cross-origin, // e.g. 'https://fableslibrary.com/api/fable' or your Cloudflare Worker URL. // ── Flow definition ──────────────────────────────────────────────────────── // Each step: { id, botMsg(state), inputType, options, placeholder, storeKey, nextId } const FLOW = [ { id: 'welcome', botMsg: () => "Create a gentle personalized fable for your child.\n\nReady to begin?", inputType: 'quick', options: ['Begin ✦'], nextId: 'name', }, { id: 'name', botMsg: () => "What is your child's name?", inputType: 'text', placeholder: 'e.g. Mila, Leo, Sofia…', storeKey: 'childName', nextId: 'age', }, { id: 'age', botMsg: (s) => `How old is ${s.childName}?`, inputType: 'quick', options: ['2–3 years', '4–5 years', '6–7 years', '8–10 years'], storeKey: 'ageGroup', nextId: 'animal', }, { id: 'animal', botMsg: (s) => `Which animal does ${s.childName} love most?`, inputType: 'quick', options: ['Bear 🐻', 'Fox 🦊', 'Deer 🦌', 'Owl 🦉', 'Rabbit 🐇', 'Other…'], storeKey: 'animal', nextId: (v) => v === 'Other…' ? 'animalText' : 'theme', }, { id: 'animalText', botMsg: () => 'Name your animal:', inputType: 'text', placeholder: 'e.g. goose, hedgehog, heron…', storeKey: 'animal', nextId: 'theme', }, { id: 'theme', botMsg: (s) => `What feeling shall ${s.childName}'s story carry?`, inputType: 'quick', options: ['Courage', 'Kindness', 'Calm & rest', 'Sharing', 'Bedtime', 'Being different'], storeKey: 'theme', nextId: 'email', }, { id: 'email', botMsg: (s) => `Your fable for ${s.childName} is almost ready.\n\nWould you like it sent to your email? (optional)`, inputType: 'email', storeKey: 'email', nextId: 'generate', }, ]; // Build lookup map const STEP_MAP = {}; FLOW.forEach(s => { STEP_MAP[s.id] = s; }); // ── State ────────────────────────────────────────────────────────────────── const state = { childName: '', ageGroup: '', animal: '', theme: '', email: '' }; const TOTAL_STEPS = 5; // name, age, animal, theme, email let stepsCompleted = 0; // ── DOM ──────────────────────────────────────────────────────────────────── const chatArea = document.getElementById('chatArea'); const inputArea = document.getElementById('inputArea'); const progressFill = document.getElementById('progressFill'); const progressLabel = document.getElementById('progressLabel'); function scrollBottom() { chatArea.scrollTop = chatArea.scrollHeight; } function setProgress(done, total, label) { progressFill.style.width = `${Math.round((done / total) * 100)}%`; progressLabel.textContent = label; } // ── Message rendering ────────────────────────────────────────────────────── function addMsg(role, html, extra='') { const wrap = document.createElement('div'); wrap.className = `msg ${role} ${extra}`.trim(); if (role !== 'system') { const lbl = document.createElement('div'); lbl.className = 'sender-label'; lbl.textContent = role === 'bot' ? 'Story Weaver' : 'You'; wrap.appendChild(lbl); } const bbl = document.createElement('div'); bbl.className = 'bubble'; bbl.innerHTML = html.replace(/\n/g, '
'); wrap.appendChild(bbl); chatArea.appendChild(wrap); scrollBottom(); return wrap; } function addStoryCard(story) { const wrap = document.createElement('div'); wrap.className = 'story-card'; const inner = document.createElement('div'); inner.className = 'story-inner'; inner.innerHTML = ` ✦ ${esc(story.title)}
${esc(story.fable)}
${story.reflection ? `
A question to share together

${esc(story.reflection)}

` : ''} ${story.illustrationPrompt ? `
Illustration prompt (for later)

${esc(story.illustrationPrompt)}

` : ''} `; wrap.appendChild(inner); chatArea.appendChild(wrap); scrollBottom(); } function esc(str) { return String(str ?? '') .replace(/&/g,'&').replace(//g,'>') .replace(/"/g,'"').replace(/\n/g,'
'); } function showTyping() { const t = document.createElement('div'); t.className = 'typing-wrap'; t.id = 'typing'; t.innerHTML = '
'; chatArea.appendChild(t); scrollBottom(); } function removeTyping() { document.getElementById('typing')?.remove(); } // ── Input rendering ──────────────────────────────────────────────────────── function clearInput() { inputArea.innerHTML = ''; } function renderQuick(options, onSelect) { clearInput(); const grid = document.createElement('div'); grid.className = 'quick-grid'; options.forEach(opt => { const b = document.createElement('button'); b.className = 'qbtn'; b.textContent = opt; b.onclick = () => onSelect(opt); grid.appendChild(b); }); inputArea.appendChild(grid); } function renderTextInput(placeholder, onSubmit) { clearInput(); const row = document.createElement('div'); row.className = 'text-row'; const ta = document.createElement('textarea'); ta.className = 'text-input'; ta.placeholder = placeholder || 'Type here…'; ta.rows = 1; ta.oninput = () => { ta.style.height='auto'; ta.style.height = Math.min(ta.scrollHeight,80)+'px'; }; ta.onkeydown = (e) => { if (e.key==='Enter'&&!e.shiftKey){ e.preventDefault(); submit(); } }; const btn = document.createElement('button'); btn.className = 'send-btn'; btn.innerHTML = ''; btn.onclick = submit; row.appendChild(ta); row.appendChild(btn); inputArea.appendChild(row); setTimeout(() => ta.focus(), 50); function submit() { const val = ta.value.trim(); if (!val) return; onSubmit(val); } } function renderEmailInput(onSubmit, onSkip) { clearInput(); const wrap = document.createElement('div'); wrap.className = 'email-row'; const lbl = document.createElement('div'); lbl.className = 'email-label'; lbl.textContent = 'Optional — we never share your email.'; const row = document.createElement('div'); row.className = 'email-input-wrap'; const inp = document.createElement('input'); inp.type = 'email'; inp.className = 'email-input'; inp.placeholder = 'your@email.com'; inp.onkeydown = (e) => { if (e.key==='Enter') submit(); }; const btn = document.createElement('button'); btn.className = 'send-btn'; btn.innerHTML = ''; btn.onclick = submit; const skip = document.createElement('button'); skip.className = 'skip-btn'; skip.textContent = 'Skip'; skip.onclick = () => onSkip(); row.appendChild(inp); row.appendChild(btn); row.appendChild(skip); wrap.appendChild(lbl); wrap.appendChild(row); inputArea.appendChild(wrap); setTimeout(() => inp.focus(), 50); function submit() { const val = inp.value.trim(); onSubmit(val); // pass even if empty; caller decides } } function renderRestart() { clearInput(); const btn = document.createElement('button'); btn.className = 'restart-btn'; btn.textContent = '✦ Create another fable'; btn.onclick = restart; inputArea.appendChild(btn); } // ── Flow engine ──────────────────────────────────────────────────────────── function advance(stepId) { const step = STEP_MAP[stepId]; if (!step) { console.error('Unknown step:', stepId); return; } // Show bot message after slight delay setTimeout(() => { addMsg('bot', typeof step.botMsg === 'function' ? step.botMsg(state) : step.botMsg); if (step.inputType === 'quick') { renderQuick(step.options, (val) => handleValue(step, val)); } else if (step.inputType === 'text') { renderTextInput(step.placeholder, (val) => handleValue(step, val)); } else if (step.inputType === 'email') { renderEmailInput( (val) => handleEmail(val), () => handleEmail('') ); } }, 340); } function handleValue(step, value) { addMsg('user', value); if (step.storeKey) state[step.storeKey] = value; stepsCompleted++; setProgress(stepsCompleted, TOTAL_STEPS, `Step ${stepsCompleted} of ${TOTAL_STEPS}`); clearInput(); const nextId = typeof step.nextId === 'function' ? step.nextId(value) : step.nextId; setTimeout(() => advance(nextId), 320); } function handleEmail(value) { if (value) { addMsg('user', value); state.email = value; // TODO: POST email to your own email collection endpoint, e.g.: // fetch('/api/save-email', { method:'POST', body: JSON.stringify({ email: value, childName: state.childName }), headers:{'Content-Type':'application/json'} }); } else { addMsg('user', 'No email — that's fine.'); } stepsCompleted++; setProgress(stepsCompleted, TOTAL_STEPS, 'Weaving your story…'); clearInput(); setTimeout(() => generateStory(), 320); } // ── Story generation ─────────────────────────────────────────────────────── async function generateStory() { addMsg('bot', `Choosing just the right words for ${state.childName}…`); showTyping(); try { const resp = await fetch(API_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ childName: state.childName, ageGroup: state.ageGroup, animal: state.animal, theme: state.theme, }), }); removeTyping(); if (!resp.ok) { const err = await resp.json().catch(() => ({})); addMsg('bot', `Something interrupted the story (${err.error || resp.status}). Please try again.`, 'error'); renderRestart(); return; } const data = await resp.json(); if (!data.success || !data.story) { addMsg('bot', 'The story arrived incomplete. Please try again.', 'error'); renderRestart(); return; } addStoryCard(data.story); setProgress(TOTAL_STEPS, TOTAL_STEPS, 'Complete ✦'); setTimeout(() => { addMsg('bot', `This fable belongs to ${state.childName} now. 🌿`); renderRestart(); }, 700); } catch (err) { removeTyping(); addMsg('bot', `The library lamps flickered (${err.message}). Please refresh and try again.`, 'error'); renderRestart(); } } // ── Lifecycle ────────────────────────────────────────────────────────────── function restart() { Object.keys(state).forEach(k => state[k] = ''); stepsCompleted = 0; chatArea.innerHTML = ''; clearInput(); setProgress(0, TOTAL_STEPS, 'Begin'); advance('welcome'); } // Boot advance('welcome');