/* global React, Icons, AI, AutoText, Field, Section, AIBulletBtn, ResumePaper, PagedPaper, ExportControls, ExportButton, Pro, useProState, resumeToText, isProTemplate */ // ============================================================ // Builder — editable form (left) + live preview (right) // ============================================================ function uid() { return Math.random().toString(36).slice(2, 9); } // Apply an inline-edit (from clicking text on the résumé) back into the résumé // data. Path grammar: // "name" | "summary" | "email" | … top-level scalar // "skills::name" skill name (keeps its level) // "
:" chips array of strings // "experience::role|company|location" keyed object field // "experience::bullet:" one bullet // "education::school|degree", "projects::name|desc", etc. function setByPath(d, path, val) { const p = path.split(":"); if (p.length === 1) { d[p[0]] = val; return; } const sec = p[0]; const arr = d[sec]; if (!Array.isArray(arr)) return; if (p.length === 2) { // chips: section:index const i = Number(p[1]); if (Number.isInteger(i)) d[sec] = arr.map((s, j) => (j === i ? val : s)); return; } if (sec === "skills") { // skills:index:name const i = Number(p[1]); d.skills = arr.map((s, j) => (j === i ? (s && typeof s === "object" ? { ...s, name: val } : val) : s)); return; } const id = p[1], field = p[2]; d[sec] = arr.map((it) => { if (!it || it.id !== id) return it; if (field === "bullet") { const bi = Number(p[3]); return { ...it, bullets: (it.bullets || []).map((b, j) => (j === bi ? val : b)) }; } return { ...it, [field]: val }; }); } function Toast({ msg }) { if (!msg) return null; return
{msg}
; } // ---- résumé strength score (a whole-résumé "scale") ---------------------- function scoreResume(d) { const checks = []; const txt = (v) => v && String(v).trim(); const contactN = ["email", "phone", "location", "website"].filter((k) => txt(d[k])).length; checks.push({ key: "basics", label: "Contact details", got: Math.min(contactN, 3), max: 3, weight: 11, hint: "Add email, phone & location" }); checks.push({ key: "title", label: "Headline", got: txt(d.title) ? 1 : 0, max: 1, weight: 6, hint: "Add a target-role headline" }); checks.push({ key: "summary", label: "Summary", got: (txt(d.summary) || "").length > 50 ? 1 : 0, max: 1, weight: 14, hint: "Write a 2–3 line summary" }); const exp = d.experience || []; const bulletN = exp.reduce((n, e) => n + (e.bullets || []).filter((b) => txt(b)).length, 0); const quantN = exp.reduce((n, e) => n + (e.bullets || []).filter((b) => /\d/.test(b || "")).length, 0); checks.push({ key: "exp", label: "Experience", got: Math.min(exp.length, 2), max: 2, weight: 18, hint: "Add at least 2 roles" }); checks.push({ key: "bullets", label: "Impact bullets", got: Math.min(bulletN, 4), max: 4, weight: 15, hint: "Write 4+ achievement bullets" }); checks.push({ key: "quant", label: "Quantified results", got: Math.min(quantN, 2), max: 2, weight: 11, hint: "Add metrics (%, $, #) to bullets" }); checks.push({ key: "skills", label: "Rated skills", got: Math.min((d.skills || []).length, 6), max: 6, weight: 9, hint: "List 6+ skills with levels" }); checks.push({ key: "edu", label: "Education", got: (d.education || []).length ? 1 : 0, max: 1, weight: 6, hint: "Add your education" }); const totalW = checks.reduce((s, c) => s + c.weight, 0); const got = checks.reduce((s, c) => s + c.weight * Math.min(1, c.got / c.max), 0); return { score: Math.round((got / totalW) * 100), checks }; } const scoreBand = (s) => (s >= 85 ? "Excellent" : s >= 68 ? "Strong" : s >= 45 ? "Good" : "Needs work"); function ScoreRing({ score }) { const R = 14, C = 2 * Math.PI * R; return ( ); } function ResumeScore({ resume }) { const [open, setOpen] = useState(false); const ref = useRef(null); useEffect(() => { const h = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; document.addEventListener("mousedown", h); return () => document.removeEventListener("mousedown", h); }, []); const { score, checks } = scoreResume(resume); const todo = checks.filter((c) => c.got < c.max).length; return (
{open && (
Résumé strength{scoreBand(score)} · {score}/100
{score}
{checks.map((c) => { const done = c.got >= c.max; return (
{done ? : null} {c.label}{!done && {c.hint}} {c.got}/{c.max}
); })}
{todo === 0 ? "Looking sharp — every box checked." : `${todo} ${todo === 1 ? "thing" : "things"} left to strengthen your résumé.`}
)}
); } function Builder({ resume, setResume, templateId, setTemplateId, accent, setAccent, layout, skillStyle, onGallery, onCoverStudio }) { const tpls = window.TEMPLATES; const tpl = tpls.find((t) => t.id === templateId) || tpls[0]; const { isPro, aiRemaining } = useProState(); const [busy, setBusy] = useState({}); // keyed loading flags const [toast, setToast] = useState(""); const [zoom, setZoom] = useState(1); const [paperH, setPaperH] = useState(1000); const stageRef = useRef(null); const innerRef = useRef(null); const editingRef = useRef(false); // true while an inline edit is focused const flash = (m) => { setToast(m); setTimeout(() => setToast(""), 2600); }; // ---- inline editing on the paper + click-to-locate the form section ---- const commitInline = (path, val) => { setResume((r) => { const d = JSON.parse(JSON.stringify(r)); setByPath(d, path, val); return d; }); }; const onPreviewMouseDown = (e) => { // only act on the visible page content (never the offscreen measuring copy) if (!e.target.closest(".pg-clip")) return; // 1) light up the form section that controls whatever was clicked const idEl = e.target.closest(".rid"); const secEl = e.target.closest("[data-sec]"); const key = idEl ? "basics" : (secEl ? secEl.getAttribute("data-sec") : null); if (key) window.dispatchEvent(new CustomEvent("rb-focus-section", { detail: { key } })); // 2) make the clicked text directly editable in place const ed = e.target.closest("[data-edit]"); if (!ed || ed.getAttribute("contenteditable") === "true") return; const path = ed.getAttribute("data-edit"); const orig = ed.textContent; const multi = path === "summary" || /:bullet:/.test(path); ed.setAttribute("contenteditable", "true"); ed.classList.add("rb-edit-on"); editingRef.current = true; const finish = () => { ed.removeEventListener("blur", finish); ed.removeEventListener("keydown", onKey); ed.removeAttribute("contenteditable"); ed.classList.remove("rb-edit-on"); editingRef.current = false; const val = ed.textContent.replace(/\u00a0/g, " ").replace(/[ \t]+/g, " ").trim(); if (val !== orig.trim()) commitInline(path, val); }; const onKey = (ev) => { if (ev.key === "Enter" && !(multi && ev.shiftKey)) { ev.preventDefault(); ed.blur(); } else if (ev.key === "Escape") { ev.preventDefault(); ed.textContent = orig; ed.blur(); } }; ed.addEventListener("blur", finish); ed.addEventListener("keydown", onKey); // let the browser place the caret from this same mousedown (no preventDefault) }; // accent for the resume: follow template unless user overrode const pa = accent.custom ? accent : { a: tpl.accent, b: tpl.accent2 }; // ---- fit preview to stage ---- useEffect(() => { const fit = () => { const stage = stageRef.current; if (!stage) return; const pad = 56; const avail = stage.clientWidth - pad; const s = Math.min(0.95, Math.max(0.3, avail / 768)); setZoom(s); }; fit(); const ro = new ResizeObserver(fit); if (stageRef.current) ro.observe(stageRef.current); return () => ro.disconnect(); }, [layout]); // ---- measure natural paper height to size the scaled wrapper ---- useEffect(() => { const el = innerRef.current; if (!el) return; const measure = () => { if (editingRef.current) return; setPaperH(el.offsetHeight || 1000); }; measure(); const ro = new ResizeObserver(measure); ro.observe(el); return () => ro.disconnect(); }, [templateId, resume]); // ---- update helpers ---- const set = (patch) => setResume((r) => ({ ...r, ...patch })); const setExp = (id, patch) => set({ experience: resume.experience.map((e) => e.id === id ? { ...e, ...patch } : e) }); const setBullet = (eid, idx, val) => setExp(eid, { bullets: resume.experience.find((e) => e.id === eid).bullets.map((b, i) => i === idx ? val : b) }); // ---- AI actions ---- const aiBullet = async (eid, idx, mode) => { if (!Pro.guardAI()) return; const exp = resume.experience.find((e) => e.id === eid); const key = `b-${eid}-${idx}`; setBusy((b) => ({ ...b, [key]: true })); const out = await AI.rewriteBullet(exp.bullets[idx], exp.role, mode); setBusy((b) => ({ ...b, [key]: false })); if (out) { setBullet(eid, idx, out); Pro.recordAI(); flash("Bullet rewritten with AI"); } else flash("Couldn't reach AI — try again"); }; const aiSummary = async () => { if (!Pro.guardAI()) return; setBusy((b) => ({ ...b, summary: true })); const out = await AI.writeSummary(resume); setBusy((b) => ({ ...b, summary: false })); if (out) { set({ summary: out }); Pro.recordAI(); flash("Summary generated"); } else flash("Couldn't reach AI — try again"); }; const aiSkills = async () => { if (!Pro.guardAI()) return; setBusy((b) => ({ ...b, skills: true })); const out = await AI.suggestSkills(resume); setBusy((b) => ({ ...b, skills: false })); if (out) { const have = new Set(resume.skills.map((s) => String(window.skillName(s)).toLowerCase())); const adds = out.filter((n) => !have.has(String(n).toLowerCase())).map((n) => ({ name: n, level: 3 })); set({ skills: [...resume.skills, ...adds] }); Pro.recordAI(); flash(`Added ${adds.length} suggested skills`); } else flash("Couldn't reach AI — try again"); }; // ---- experience CRUD ---- const addJob = () => set({ experience: [...resume.experience, { id: uid(), role: "New role", company: "Company", start: "2024", end: "Present", location: "", bullets: ["Describe what you did and the impact."] }] }); const delJob = (id) => set({ experience: resume.experience.filter((e) => e.id !== id) }); const addBullet = (eid) => setExp(eid, { bullets: [...resume.experience.find((e) => e.id === eid).bullets, ""] }); const delBullet = (eid, idx) => setExp(eid, { bullets: resume.experience.find((e) => e.id === eid).bullets.filter((_, i) => i !== idx) }); const addEdu = () => set({ education: [...resume.education, { id: uid(), school: "School", degree: "Degree", start: "2020", end: "2024" }] }); const delEdu = (id) => set({ education: resume.education.filter((e) => e.id !== id) }); // ---- skills (now carry a proficiency level on a 1..5 scale) ---- const [skillDraft, setSkillDraft] = useState(""); const addSkill = () => { const v = skillDraft.trim(); if (v) { set({ skills: [...resume.skills, { name: v, level: 4 }] }); setSkillDraft(""); } }; const delSkill = (i) => set({ skills: resume.skills.filter((_, x) => x !== i) }); const setSkillName = (i, name) => set({ skills: resume.skills.map((s, x) => x === i ? { name, level: window.normSkill(s, i).level } : s) }); const setSkillLevel = (i, level) => set({ skills: resume.skills.map((s, x) => x === i ? { name: window.skillName(s), level } : s) }); const accentOptions = [ { a: "#6d5cff", b: "#ff5ca8" }, { a: "#13b6a8", b: "#5b8cff" }, { a: "#f4663a", b: "#ffb43d" }, { a: "#7c3aed", b: "#06b6d4" }, { a: "#111111", b: "#6b6b6b" }, { a: "#e0467c", b: "#ff9e6d" }, ]; return (
{/* ===== toolbar ===== */}
{accentOptions.map((o, i) => (
{!isPro && ( {aiRemaining > 0 ? <>{aiRemaining} AI left : "AI used up"} )} innerRef.current} getText={() => resumeToText(resume)} fileName={`${(resume.name || "Resume").trim()} — Résumé`} reason="pdf" />
{/* ===== panels ===== */}
{/* ---- FORM ---- */}

Edit your résumé

Edit here, or click any text on the résumé to change it in place — clicking also jumps you to the matching section below. Use the button to sharpen any line with AI.

set({ photo: v })} name={resume.name} /> set({ name: v })} placeholder="Your name" /> set({ title: v })} placeholder="e.g. Product Designer" />
set({ email: v })} /> set({ phone: v })} />
set({ website: v })} /> set({ location: v })} />
Professional summary
set({ summary: v })} placeholder="A few lines about who you are and what you're looking for." />
{resume.experience.map((j) => (
setExp(j.id, { role: v })} />
setExp(j.id, { company: v })} /> setExp(j.id, { location: v })} />
setExp(j.id, { start: v })} mono /> setExp(j.id, { end: v })} mono />
Highlights
{j.bullets.map((b, i) => (
setBullet(j.id, i, v)} placeholder="Achievement or responsibility…" />
aiBullet(j.id, i, m)} />
))}
))}
Your skills
{resume.skills.map((s, i) => { const ns = window.normSkill(s, i); return (
setSkillName(i, e.target.value)} /> setSkillLevel(i, lv)} /> {window.LEVEL_WORDS[ns.level]}
); })}
setSkillDraft(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addSkill(); } }} />

Set how much you've used each one — the level shows as a scale on graphic templates (dots, segments, signal).

{resume.education.map((e) => (
set({ education: resume.education.map((x) => x.id === e.id ? { ...x, school: v } : x) })} /> set({ education: resume.education.map((x) => x.id === e.id ? { ...x, degree: v } : x) })} />
set({ education: resume.education.map((x) => x.id === e.id ? { ...x, start: v } : x) })} mono /> set({ education: resume.education.map((x) => x.id === e.id ? { ...x, end: v } : x) })} mono />
))}
{effectiveOrder(resume).filter((k) => EXTRA_KEYS.includes(k)).map((k) => { const def = SECTION_DEFS[k]; const Ic = Icons[def.icon] || Icons.tag; const count = Array.isArray(resume[k]) ? resume[k].length : undefined; return (
); })}
Your résumé, ready to download. Need a cover letter? Open the Cover letters tab.
{/* ---- PREVIEW ---- */}
{tpl.name} Click any text to edit {Math.round(zoom * 100)}%
); } window.Builder = Builder;