Business training · AI · Life sciences

Communication courses

2 independent online courses in Communication. Practical lessons built on real situations; every assessment question traced to the lesson it came from.

2Courses
24Lessons
Featured course

Effective Business Writing

Certificate included
✓ Lifetime access on purchased courses ✓ Certificate of completion ✓ No subscription — pay per course ✓ Free curated learning paths

🆕 Fresh this week

New original courses published automatically every day — always something new to learn.

Explore our courses

Written from scratch by Competrain — practical, jargon-free, and up to date.

⚙️ FEATURED NICHE · FOR TEAMS

AI-Driven SOP & Compliance Training

The niche that started Competrain, now part of the family. Turn any standard operating procedure into a ready-to-assign training module — drafted by AI, checked by you — with quizzes, sign-off, and an audit trail your regulators will love.

  • ✓ Generate a full SOP training from a short brief in minutes
  • ✓ Assign to your team, track completion, capture e-signatures
  • ✓ Audit-ready records for ISO, GMP, HIPAA and more
Open the SOP trainer →
🤖 AI COURSE · PAY PER DOCUMENT

Build your own course.

Upload a procedure, a policy or a work instruction. We read it, show you exactly what we found in it, and quote before you pay. What comes back is training whose every question points at the clause it came from — and a record of who took it, when, and against which version of your document.

Upload a document

PDF with real text in it — not a photo of a page. Nothing is charged until you have seen what we read.

Drop a PDF here, or choose a file

Up to 25 MB and 300 pages.

What it costs

One price per document. Not per page — a cover sheet and a revision history are the pages with the least training in them.

Curated open courses

Hand-picked open courses from other creators, embedded with full credit. Not part of the Competrain catalogue.

Finish strong — with proof.

Every Competrain course ends with a certificate that carries its own reference — so the person you show it to can check it, rather than take your word for it.

  • ✓ One button fills in LinkedIn’s own “Add to profile” form
  • ✓ A public check page anyone can open, no account needed
  • ✓ Where a course has an assessment, your score and the pass mark are on it
See plans
🏅
Certificate of Completion
AI at Work
Awarded by Competrain
Presented to a lifelong learner

Simple pricing

One payment, one course, yours for life. No subscription and no trial to cancel.

How Competrain works

Single course

from $29

One-time payment · yours forever

  • ✓ Lifetime access to that course
  • ✓ All lessons & future updates
  • ✓ Certificate of completion
  • ✓ No subscription required
Browse courses

Corporate LMS

Talk to us

Train your people on your own documents

  • ✓ Your own private workspace
  • ✓ Turn your SOPs into assessed training
  • ✓ Every question traced to its source clause
  • ✓ SME approval with e-signature at signing
  • ✓ Role-based assignment
  • ✓ Tamper-evident, hash-chained audit trail
  • ✓ Competency evidence, not attendance
  • ✓ Revise a document, see who needs retraining
  • ✓ The full course catalogue for your staff

Corporate pricing depends on how many people you train and how many documents you bring. Tell us both and we will quote.

×
", "text/html"); root = doc.getElementById("lsroot"); }catch(e){ return out; } if(!root) return ""; root.querySelectorAll("script,style,object,embed,form,link,meta,base,noscript").forEach(function(n){ n.remove(); }); root.querySelectorAll("iframe").forEach(function(n){ if(!YT_EMBED_OK.test(n.getAttribute("src") || "")) n.remove(); else { n.setAttribute("loading","lazy"); n.setAttribute("allowfullscreen",""); } }); root.querySelectorAll("*").forEach(function(el){ Array.prototype.slice.call(el.attributes).forEach(function(a){ var n = a.name.toLowerCase(), v = String(a.value || ""); if(n.indexOf("on") === 0) el.removeAttribute(a.name); else if(n === "srcdoc" || n === "formaction") el.removeAttribute(a.name); else if((n === "href" || n === "src" || n === "xlink:href" || n === "action") && /^\s*(javascript|vbscript)\s*:/i.test(v)) el.removeAttribute(a.name); }); if(el.tagName === "A" && el.getAttribute("target") === "_blank") el.setAttribute("rel","noopener noreferrer"); }); // wide tables get their own horizontal scroller so the page never scrolls root.querySelectorAll("table").forEach(function(t){ var par = t.parentElement; if(par && par.classList && par.classList.contains("tbl")) return; var w = doc.createElement("div"); w.className = "tbl"; t.parentNode.insertBefore(w, t); w.appendChild(t); }); return root.innerHTML; } /* == lesson-sanitiser:end == */ /* ---------- course modal ---------- */ let CUR=null; /* Every course has an address: /c/. Opening one pushes that address so it can be copied, bookmarked and sent; closing returns to the storefront. The server renders the same page at that address with the course's own title and preview tags, so a link shared into an email or a chat unfurls properly and a search engine can index it — neither of those ever runs this script. fromHistory is set when the browser moved us (back/forward button), where pushing again would trap the visitor in the modal. */ function courseHref(slug){ return "/c/" + encodeURIComponent(slug); } async function openCourse(slug, opts){ const o = opts || {}; const ov=$("#courseOverlay"); ov.dataset.slug=slug; ov.classList.add("open"); document.body.style.overflow="hidden"; if(!o.fromHistory){ try{ history.pushState({course:slug}, "", courseHref(slug)); }catch(e){} } $("#courseContent").innerHTML = '
Loading…
'; const {ok,data:p} = await api("/store/products/"+slug); if(!ok){ $("#courseContent").innerHTML='
Could not load this course.
'; return; } CUR=p; renderCourse(); } function renderCourse(){ const p=CUR; const idx=Math.max(0,STATE.products.findIndex(x=>x.slug===p.slug)); const [bg,fg]=catColor(idx); const allAccess = STATE.all&&STATE.all.all_access; // legacy plan holders only const unlocked = p.owned || p.free || allAccess; const done=new Set(p.completed_lessons||[]); const lessons=(p.lessons||[]).map((l,i)=>{ const yt = l.youtube_id ? `
${l.creator?`
Video by ${l.creator} · shown with credit
`:''}` : ''; const tools = (!l.locked && l.body) ? `
` : ''; const body = l.locked ? '' : `
${yt}${l.body?`
${sanitizeLessonHTML(l.body)}
`:''}${tools}
`; let right=''; if(l.locked) right='🔒'; else if(!unlocked) right='Preview'; else if(STATE.me) right=``; return `
${i+1}${l.title}${right}
${body}
`; }).join(""); // progress block (only when the learner can actually make progress) let progressBlock=''; if(unlocked && STATE.me){ const pct=p.progress_pct||0; progressBlock=`
Your progress${pct}%
`; progressBlock += assessmentBlock(p); // The certificate is offered once the course is finished AND, where the // course has an assessment, once it has been passed. Ticking every lesson // is a self-declaration; it cannot evidence that the training worked, which // is the whole reason a compliance buyer is here. const a = p.assessment || {}; const certReady = p.course_completed && (!a.available || a.passed); if(certReady){ progressBlock+=`
🏅 Course complete — you've earned your certificate!
`; } } // Slides / Handout are only offered where there is readable content to show. const firstReadable = (p.lessons||[]).findIndex(l=>!l.locked && l.body); const toolbar = firstReadable >= 0 ? `
${unlocked?``:''}
` : ''; let actions=""; if(p.free){ actions=``; } else if(p.owned){ actions=`✓ You own this course`; } else if(allAccess){ actions=`✓ Included in your plan`; } else { actions=``; } const priceLine = p.free ? 'Free' : ((p.owned||allAccess) ? '' : `
$${p.price.toFixed(0)}${p.list_price?'Launch price':''} one-time · lifetime access${p.list_price?`$${p.list_price.toFixed(0)} from ${fmtDate(p.launch_ends)}`:''}
`); $("#courseContent").innerHTML = `
${p.category} · ${p.level}

${p.emoji||''} ${p.title}

${p.description||p.subtitle||''}

${p.lesson_count} lessons${p.duration_min} min🏅 Certificate
${progressBlock} ${detailsToKnow(p)}

${p.free?'What you\'ll watch':'Course lessons'}

${toolbar} ${lessons} ${!unlocked?`

Preview the first lesson free. Buy the course once to unlock all ${p.lesson_count} lessons, keep them for life, and earn your certificate.

`:''}
${priceLine}
${actions}
`; } /* What a buyer wants to know before they buy, stated as facts about THIS course rather than as marketing. Every line here is read off the course itself, so a course with no assessment says so instead of quietly omitting the row. */ function detailsToKnow(p){ const a = p.assessment || {}; const mins = Number(p.duration_min||0); const dur = mins >= 60 ? Math.floor(mins/60)+"h "+(mins%60?(mins%60)+"m":"") : mins+" min"; const rows = [ ["🏅","Shareable certificate", "Finish the course to earn one. It carries a reference anyone can check, and a button that fills in LinkedIn\u2019s own \u201cAdd to profile\u201d form."], a.available ? ["📝","Assessment", a.questions+" questions \u00b7 "+a.pass_pct+"% to pass \u00b7 retake as often as you need"] : ["📝","No assessment", "This course has no test. The certificate records that you completed every lesson."], ["🌐","Taught in English","Written from scratch by Competrain."], ["⏱","About "+dur, p.lesson_count+" lessons \u00b7 learn at your own pace, keep it for life"], ]; return '

Details to know

'+rows.map(r=> '
'+r[0]+''+escHtml(r[1])+''+escHtml(r[2])+'
' ).join("")+'
'; } async function toggleComplete(i){ if(!CUR||!STATE.me) return; const done=new Set(CUR.completed_lessons||[]); const want=!done.has(i); const {ok,data}=await api("/store/progress",{method:"POST",body:JSON.stringify({slug:CUR.slug,lesson_index:i,done:want})}); if(ok&&data.ok){ CUR.completed_lessons=data.completed_lessons; CUR.progress_pct=data.progress_pct; CUR.course_completed=data.course_completed; const wasComplete = document.querySelector('#courseModal .cert-cta'); renderCourse(); if(data.course_completed && !wasComplete){ toast("🎉 Course complete — certificate unlocked!"); } } else { toast(data.error||"Couldn't save progress."); } } function closeCourse(opts){ const o = opts || {}; $("#courseOverlay").classList.remove("open"); document.body.style.overflow=""; CUR=null; if(!o.fromHistory && location.pathname.indexOf("/c/")===0){ try{ history.pushState({}, "", "/"); }catch(e){} } } /* Back and forward move between the storefront and a course, as they would on any site with real pages. */ function slugFromPath(){ const m = /^\/c\/([^/?#]+)/.exec(location.pathname); return m ? decodeURIComponent(m[1]) : null; } window.addEventListener("popstate", function(){ const slug = slugFromPath(); if(slug){ if(!CUR || CUR.slug!==slug) openCourse(slug, {fromHistory:true}); } else if($("#courseOverlay").classList.contains("open")) closeCourse({fromHistory:true}); }); /* A cold visit straight to /c/: no history to read, so open from the path. */ function openFromPath(){ const slug = slugFromPath(); if(slug) openCourse(slug, {fromHistory:true}); } /* Copy the address of the course that is open. Falls back to a prompt where the clipboard API is unavailable (it needs a secure context, and refuses in some embedded browsers), because a button that silently does nothing is worse than one that shows you the text to copy yourself. */ function copyCourseLink(){ if(!CUR) return; const url = location.origin + courseHref(CUR.slug); const done = function(){ toast("Link copied"); }; if(navigator.clipboard && window.isSecureContext){ navigator.clipboard.writeText(url).then(done, function(){ window.prompt("Copy this link:", url); }); } else { window.prompt("Copy this link:", url); } } /* ---------- assessment ---------- The questions arrive without their correct answers and the attempt is scored on the server, so nothing here can be read off the page or edited into a pass. What this code does is ask, submit, and show what the server said. */ let QUIZ = null; function assessmentBlock(p){ const a = p.assessment || {}; if(!a.available) return ""; let state, cls, action; if(a.passed){ state = `Passed · ${a.passed_score}%`; cls = "pass"; action = "Review or retake"; } else if(a.attempts){ state = `Last attempt ${a.last_score}%`; cls = "fail"; action = "Try again"; } else { state = "Not taken"; cls = "none"; action = "Take the assessment"; } return `
Assessment

${a.questions} questions · ${a.pass_pct}% to pass · every attempt is recorded

${esc(state)}
`; } async function openAssessment(slug){ const {ok,data} = await api("/store/assessment/"+encodeURIComponent(slug)); if(!ok){ toast(data.error||"Could not load the assessment."); return; } QUIZ = {slug:slug, questions:data.questions, pass_pct:data.pass_pct, answers:[], result:null}; renderAssessment(); } function renderAssessment(){ const q = QUIZ; if(!q) return; const marks = q.result ? q.result.marks : null; const body = q.questions.map((item,i)=>{ const mark = marks ? marks[i] : null; const cls = mark ? (mark.correct ? "q right" : "q wrong") : "q"; const opts = item.options.map((o,j)=>{ const chosen = (mark ? mark.chosen : q.answers[i]) === j; const isKey = mark && mark.answer === j; const klass = [chosen?"chosen":"", isKey?"key":""].filter(Boolean).join(" "); const dis = marks ? " disabled" : ""; return ``; }).join(""); const why = (mark && mark.why) ? `

${esc(mark.why)}

` : ""; return `

${i+1}. ${esc(item.q)}

${opts}${why}
`; }).join(""); let head = "", foot = ""; if(q.result){ const r = q.result; head = `
${r.score_pct}% ${r.correct} of ${r.total} correct · ${r.pass_pct}% needed · ${r.passed?"passed":"not passed"}
`; foot = ` `; } else { const answered = q.answers.filter(x=>x!==undefined&&x!==null).length; foot = `${answered} of ${q.questions.length} answered `; } $("#courseContent").innerHTML = `
Assessment

${esc(CUR?CUR.title:"")}

Answered on your own. Every attempt is recorded against your training record, including the ones you do not pass.

${head}${body}
${foot}
`; } function pickAnswer(i, j){ if(QUIZ && !QUIZ.result){ QUIZ.answers[i]=j; renderAssessment(); } } async function submitAssessment(){ const q = QUIZ; if(!q || q.result) return; const answers = q.questions.map((_,i)=> (q.answers[i]===undefined ? -1 : q.answers[i])); const {ok,data} = await api("/store/assessment/"+encodeURIComponent(q.slug), {method:"POST", body:JSON.stringify({answers:answers})}); if(!ok){ toast(data.error||"Could not submit."); return; } q.result = data; if(CUR) CUR.assessment = data.summary; renderAssessment(); toast(data.passed ? "Passed — " + data.score_pct + "%" : "Not passed — " + data.score_pct + "%"); } function closeAssessment(){ QUIZ=null; if(CUR) renderCourse(); else closeCourse(); } /* ---------- slide deck: one slide per

in the lesson body ---------- */ let CUR_LESSON = 0; let DECK = { slides: [], i: 0 }; function buildSlides(lesson){ const html = sanitizeLessonHTML(lesson.body || ""); let doc = null; try{ doc = new DOMParser().parseFromString("
"+html+"
","text/html"); }catch(e){} const root = doc && doc.getElementById("dk"); if(!root) return [{ head: lesson.title || "", html: html }]; // Walk childNODES, not children: an older lesson body is bare text with no // tags at all, and element-only iteration produced a deck of one empty slide. const blocks = []; Array.prototype.slice.call(root.childNodes).forEach(function(n){ if(n.nodeType === 3){ // text node const t = (n.textContent || "").trim(); if(t) blocks.push({ h3: null, html: "

" + t.replace(/[&<>]/g, c => ({"&":"&","<":"<",">":">"}[c])) + "

" }); } else if(n.nodeType === 1){ if(n.tagName === "H3") blocks.push({ h3: n.textContent || "", html: "" }); else blocks.push({ h3: null, html: n.outerHTML }); } }); if(!blocks.length) return [{ head: lesson.title || "", html: html }]; const out = []; let cur = { head: lesson.title || "", html: "" }; blocks.forEach(function(b){ if(b.h3 !== null){ if(cur.html.trim()) out.push(cur); cur = { head: b.h3, html: "" }; } else { cur.html += b.html; } }); if(cur.html.trim() || out.length === 0) out.push(cur); // A lesson with no headings still deserves a readable deck rather than one // wall of text, so split it into slides of a few blocks each. if(out.length === 1 && !/

b.h3 === null).map(b => b.html); if(parts.length > 3){ const per = Math.ceil(parts.length / Math.ceil(parts.length / 3)); const chunked = []; for(let i = 0; i < parts.length; i += per){ chunked.push({ head: i === 0 ? (lesson.title || "") : "", html: parts.slice(i, i + per).join("") }); } return chunked; } } return out; } function openSlides(i){ if(!CUR) return; const list = CUR.lessons || []; let idx = (i === undefined || i === null) ? CUR_LESSON : i; if(!list[idx] || list[idx].locked || !list[idx].body){ idx = list.findIndex(l => !l.locked && l.body); } if(idx < 0 || !list[idx]) { toast("This lesson has no slides yet."); return; } CUR_LESSON = idx; const lesson = list[idx]; DECK = { slides: buildSlides(lesson), i: 0 }; $("#slideTitle").textContent = (idx+1) + ". " + (lesson.title || CUR.title || ""); $("#slideStage").innerHTML = DECK.slides.map(function(sl, n){ return '
' + '
' + escapeText(CUR.title || "") + '
' + (sl.head ? '

' + escapeText(sl.head) + '

' : '') + '
' + sl.html + '
'; }).join(""); $("#slideView").classList.add("open"); document.body.style.overflow = "hidden"; paintSlide(); } function escapeText(t){ return String(t==null?"":t).replace(/[&<>"]/g, c => ({"&":"&","<":"<",">":">",'"':"""}[c])); } function paintSlide(){ const secs = document.querySelectorAll("#slideStage .slide"); secs.forEach((el,n)=>el.classList.toggle("on", n === DECK.i)); const total = secs.length || 1; $("#slideCount").textContent = (DECK.i+1) + " / " + total; $("#slidePrev").disabled = DECK.i <= 0; $("#slideNext").disabled = DECK.i >= total-1; $("#slideProg").style.width = Math.round(100*(DECK.i+1)/total) + "%"; $("#slideStage").scrollTop = 0; } function slideGo(step){ const total = document.querySelectorAll("#slideStage .slide").length || 1; DECK.i = Math.min(total-1, Math.max(0, DECK.i + step)); paintSlide(); } function closeSlides(){ $("#slideView").classList.remove("open"); document.body.style.overflow = $("#courseOverlay").classList.contains("open") ? "hidden" : ""; } function slidesOpen(){ return $("#slideView").classList.contains("open"); } /* ---------- handout: the whole course, print-optimised ---------- */ function openHandout(){ if(!CUR) return; const p = CUR; const readable = (p.lessons||[]).filter(l => !l.locked); if(!readable.length){ toast("Unlock the course to print the handout."); return; } const today = new Date().toLocaleDateString(undefined,{year:'numeric',month:'long',day:'numeric'}); const body = (p.lessons||[]).map(function(l, i){ if(l.locked) return ""; return '

' + (i+1) + '.' + escapeText(l.title || "") + '

' + '
' + sanitizeLessonHTML(l.body || "") + '
'; }).join(""); $("#handoutSheet").innerHTML = '
Competrain
' + '

' + escapeText(p.title || "") + '

' + (p.subtitle ? '

' + escapeText(p.subtitle) + '

' : '') + '
' + escapeText(p.category||"") + '' + escapeText(p.level||"") + '' + (p.lesson_count||0) + ' lessons' + (p.duration_min||0) + ' min' + today + '
' + body + '
Competrain course handout · ' + escapeText(p.title||"") + '
'; $("#handoutView").classList.add("open"); document.body.classList.add("printing-handout"); document.body.style.overflow = "hidden"; setTimeout(function(){ try{ window.print(); }catch(e){} }, 120); } function closeHandout(){ $("#handoutView").classList.remove("open"); document.body.classList.remove("printing-handout"); document.body.style.overflow = $("#courseOverlay").classList.contains("open") ? "hidden" : ""; } /* keyboard: arrows move the deck, Escape leaves whatever is on top */ document.addEventListener("keydown", function(e){ if(slidesOpen()){ if(e.key === "Escape"){ e.preventDefault(); closeSlides(); } else if(e.key === "ArrowRight" || e.key === "PageDown" || e.key === " "){ e.preventDefault(); slideGo(1); } else if(e.key === "ArrowLeft" || e.key === "PageUp"){ e.preventDefault(); slideGo(-1); } else if(e.key === "Home"){ e.preventDefault(); DECK.i = 0; paintSlide(); } else if(e.key === "End"){ e.preventDefault(); DECK.i = 999; slideGo(0); } return; } if(e.key === "Escape" && $("#handoutView").classList.contains("open")){ e.preventDefault(); closeHandout(); } }); window.addEventListener("afterprint", function(){ if($("#handoutView").classList.contains("open")) document.body.classList.add("printing-handout"); }); /* ---------- certificate ---------- */ async function openCertificate(slug){ const {ok,data}=await api("/store/certificate/"+slug); if(!ok){ toast(data.error||"Finish all lessons first."); return; } const issued=data.issued_at ? new Date(data.issued_at).toLocaleDateString(undefined,{year:'numeric',month:'long',day:'numeric'}) : ""; const score = (data.score_pct!=null) ? `Scored ${data.score_pct}% \u00b7 pass mark ${data.pass_pct}%` : ""; $("#certBody").innerHTML=`
Certificate of Completion
Competrain
This certifies that
${escHtml(data.name)}
has successfully completed
${escHtml(data.course)}
${data.lessons} lessons${score}${escHtml(issued)}
🏅
Certificate reference ${escHtml(data.serial||"")}

${escHtml(data.identity_note||"")}

${escHtml(data.linkedin_note||"")}

`; $("#certOverlay").classList.add("open"); document.body.style.overflow="hidden"; } function closeCert(){ $("#certOverlay").classList.remove("open"); document.body.style.overflow=""; } /* ---------- buy / subscribe ---------- */ async function buyCourse(slug){ if(!STATE.me){ pending={type:"buy",slug}; openAuth("register"); return; } const {ok,data} = await api("/store/checkout",{method:"POST",body:JSON.stringify({slug})}); if(ok&&data.url){ window.location.href=data.url; } else { toast(data.error||"Couldn't start checkout."); } } async function startSubscribe(){ if(!STATE.all || !STATE.all.subscriptions_enabled){ toast("Competrain is sold one course at a time."); return; } if(!STATE.me){ pending={type:"sub"}; openAuth("register"); return; } const {ok,data} = await api("/store/subscribe",{method:"POST",body:JSON.stringify({plan:STATE.plan})}); if(ok&&data.url){ window.location.href=data.url; } else { toast(data.error||"Couldn't start your plan."); } } /* ---------- build your own course (code name: studybuddy) ---------- */ /* The buyer sees what was actually read out of their file before a price is shown, and the refusals are shown in full — hint and all — because a refusal they cannot act on is worse than no answer. */ const SB = {file:null, intake:null, confirmed:false}; function sbEsc(x){ return escHtml(x); } function sbMoney(cents,cur){ return (cur==="eur"?"€":"$") + (cents/100).toFixed(0); } async function sbPricing(){ const {ok,data} = await api("/store/studybuddy/pricing"); if(!ok) return; $("#sbLimits").textContent = "Up to "+data.max_mb+" MB and "+data.max_pages+" pages."; $("#sbBands").innerHTML = data.bands.map(b=> "
"+(b.from_pages===b.up_to_pages?b.from_pages+" page":b.from_pages+"–"+b.up_to_pages+" pages")+ ""+sbMoney(b.price_cents,data.currency)+"
").join(""); $("#sbRetention").innerHTML = data.retention.map(sbEsc).join("
"); } function sbChoose(f){ if(!f) return; SB.file=f; SB.intake=null; SB.confirmed=false; $("#sbName").textContent=f.name; $("#sbGo").disabled=false; $("#sbOut").innerHTML=""; } async function sbRead(){ if(!SB.file) return; if(!STATE.me){ pending={type:"studybuddy"}; openAuth("register"); toast("Create an account first — the training is built into it."); return; } const go=$("#sbGo"); go.disabled=true; go.textContent="Reading…"; const fd=new FormData(); fd.append("file", SB.file); let r, data={}; try{ r=await fetch("/store/studybuddy/preview",{method:"POST",body:fd}); data=await r.json(); } catch(e){ data={error:"That upload did not go through."}; r={ok:false}; } go.disabled=false; go.textContent="Read it"; if(!r.ok){ sbRefusal(data); return; } SB.intake=data.intake; sbQuote(data); } function sbRefusal(d, scroll){ $("#sbOut").innerHTML = '
'+sbEsc(d.error||"That file could not be read.")+""+ (d.hint?""+sbEsc(d.hint)+"":"")+"
"; /* A buyer coming back from Stripe to a failure lands at the top of the page, and the one message they most need to see is the one furthest down it. */ if(scroll) sbScroll(); } function sbScroll(){ const sec=document.getElementById("build"); if(sec&&sec.scrollIntoView) sec.scrollIntoView({behavior:"smooth"}); } function sbQuote(d){ const p=d.preview, q=d.quote, i=d.intake; const secs=(p.sections||[]).slice(0,40).map(s=> "
"+sbEsc(s.heading)+""+s.characters+" chars
").join(""); $("#sbOut").innerHTML = '
'+ '
'+i.pages+'pages
'+ '
'+i.words.toLocaleString()+'words
'+ '
'+i.sections+'sections
'+ '
'+ (p.warnings||[]).map(w=>'
'+sbEsc(w)+"
").join("")+ sbRevisionNote(d.revises)+ '
'+secs+"
"+ ''+ '"+ '
'+sbMoney(q.price_cents,q.currency)+""+sbEsc(q.band)+" · one document
"+ "
    "+(q.includes||[]).map(x=>"
  • "+sbEsc(x)+"
  • ").join("")+"
"+ '
'+ '

'+(q.excludes||[]).map(sbEsc).join("
")+"

"; $("#sbBuy").addEventListener("click", sbBuy); sbUploads(); } /* Filing a procedure over an unrelated one supersedes its training and puts everyone who took it back into re-training. Said before paying, not after. */ function sbRevisionNote(t){ if(!t) return ""; return '
This code already names '+sbEsc(t.title)+" ("+ sbEsc(t.doc_code)+"), currently at version "+sbEsc(t.current_version||"1.0")+ ". Buying this files your upload as the next version of it"+ (t.people_trained?", and flags "+t.people_trained+" person"+(t.people_trained===1?"":"s")+ " who completed its training for re-training":"")+ ". If this is a different document, change the code above.
"; } async function sbBuy(){ if(!SB.intake) return; const b=$("#sbBuy"); b.disabled=true; b.textContent="Opening checkout…"; const {ok,data}=await api("/store/studybuddy/checkout",{method:"POST",body:JSON.stringify({ intake_id:SB.intake.id, title:$("#sbTitle").value, doc_code:$("#sbCode").value, confirm_revision:!!SB.confirmed})}); if(ok&&data.url){ window.location.href=data.url; return; } b.disabled=false; b.textContent="Buy this document"; if(data&&data.needs_confirmation){ /* The server refuses until the buyer has been shown, specifically, which document this will supersede. One deliberate second click. */ SB.confirmed=true; $("#sbOut").insertBefore(sbNode( '
'+sbEsc(data.error)+""+sbEsc(data.hint)+ " Press Buy again to go ahead.
"), $("#sbOut").firstChild); b.textContent="Buy and file as a revision"; sbScroll(); return; } sbRefusal(data); } function sbNode(html){ const d=document.createElement("div"); d.innerHTML=html; return d.firstChild; } /* What the buyer sees when they come back from Stripe. It names the next step, because a generated module nobody approves trains nobody. */ function sbBuilt(d){ const r=d.revision; $("#sbOut").innerHTML = '
'+ "Your training is built. It is version "+sbEsc(d.version_label)+ " and it is waiting for you to approve it.
"+ (r?'
Filed as revision '+sbEsc(r.from_version)+" → "+sbEsc(r.to_version)+ ". "+sbChangeList(r)+" "+r.people_to_retrain+" person"+(r.people_to_retrain===1?"":"s")+ " who completed the superseded training "+(r.people_to_retrain===1?"is":"are")+ " flagged for re-training.
":"")+ /* The pitch is that every question points at a clause. When some of them do not, the buyer hears it here rather than noticing it themselves. */ (d.untraceable?'
'+sbEsc(d.untraceable_note)+"
":"")+ (d.note?'
'+sbEsc(d.note)+"
":"")+ "
    "+(d.next||[]).map(x=>"
  • "+sbEsc(x)+"
  • ").join("")+"
"+ '
Open the SOP trainer →
'+ '

'+sbEsc(d.sign_in_hint||"")+"

"; sbUploads(); sbScroll(); } /* Naming the sections beats counting them: "3. Procedure changed" is something a document owner can check, and a number is not. */ function sbChangeList(r){ const parts=[]; if(r.changed&&r.changed.length) parts.push("Changed: "+r.changed.map(sbEsc).join(", ")+"."); if(r.added&&r.added.length) parts.push("Added: "+r.added.map(sbEsc).join(", ")+"."); if(r.removed&&r.removed.length) parts.push("Removed: "+r.removed.map(sbEsc).join(", ")+"."); return parts.length?parts.join(" "):"No section headings changed."; } /* The retry the error messages tell people to use. Without this list, "press Build again" names a button that does not exist. */ async function sbUploads(){ const box=$("#sbUploads"); if(!box) return; if(!STATE.me){ box.innerHTML=""; return; } const {ok,data}=await api("/store/studybuddy"); if(!ok||!data.uploads||!data.uploads.length){ box.innerHTML=""; return; } box.innerHTML='

Your uploads

'+ '
'+data.uploads.map(u=> '
'+sbEsc(u.title||u.filename)+"
"+sbEsc(u.doc_code)+" · "+sbEsc(u.status)+ (u.error?" · "+sbEsc(u.error):"")+"
"+ (u.status==="paid"?'': u.status==="built"?'v'+sbEsc(u.version_label)+"":"")+ /* Only an upload nobody paid for. A purchase is a record, not clutter. */ (u.removable?' ':"")+ "
").join("")+"
"; box.querySelectorAll("[data-build]").forEach(b=> b.addEventListener("click",()=>sbBuildAgain(b.getAttribute("data-build"), b))); box.querySelectorAll("[data-remove]").forEach(b=> b.addEventListener("click",()=>sbRemove(b.getAttribute("data-remove"), b))); } /* Removing is two clicks only when it needs to be: an upload that reached a checkout may have been paid for, and the buyer should hear that before it goes rather than afterwards. */ async function sbRemove(id, btn){ const confirmed = btn.dataset.confirmed === "1"; btn.disabled = true; const {ok,data} = await api("/store/studybuddy/remove",{method:"POST", body: JSON.stringify({intake_id:Number(id), confirm: confirmed})}); if(ok&&data.ok){ toast(data.message||"Removed."); sbUploads(); return; } btn.disabled = false; if(data&&data.needs_confirmation){ btn.dataset.confirmed = "1"; btn.textContent = "Remove anyway"; btn.classList.add("sb-x-warn"); sbRefusal({error:data.error, hint:data.hint}); return; } sbRefusal(data||{}); } async function sbBuildAgain(id, btn){ btn.disabled=true; btn.textContent="Building…"; const {ok,data}=await api("/store/studybuddy/build",{method:"POST",body:JSON.stringify({intake_id:Number(id)})}); if(ok&&data.ok){ sbBuilt(data); return; } btn.disabled=false; btn.textContent="Build"; sbRefusal(data, true); sbUploads(); } function sbWire(){ const drop=$("#sbDrop"), input=$("#sbInput"); if(!drop) return; $("#sbPick").addEventListener("click",()=>input.click()); $("#sbGo").addEventListener("click", sbRead); input.addEventListener("change",e=>sbChoose(e.target.files[0])); ["dragenter","dragover"].forEach(ev=>drop.addEventListener(ev,e=>{e.preventDefault();drop.classList.add("over");})); ["dragleave","drop"].forEach(ev=>drop.addEventListener(ev,e=>{e.preventDefault();drop.classList.remove("over");})); drop.addEventListener("drop",e=>sbChoose(e.dataTransfer.files[0])); sbPricing(); sbUploads(); } /* ---------- fulfillment on return ---------- */ async function handleFulfill(){ const u=new URL(location.href); if(u.searchParams.get("fulfilled")!=="1") return; const sid=u.searchParams.get("session_id"); const sbId=u.searchParams.get("studybuddy"); history.replaceState({},"","/store"); if(!sid) return; const {ok,data}=await api("/store/fulfill",{method:"POST",body:JSON.stringify({session_id:sid})}); if(ok&&data.ok&&data.kind==="studybuddy"){ toast("Payment received — your training is built."); sbBuilt(data); return; } if(sbId&&!(data&&data.ok)){ sbRefusal(data||{}); return; } if(ok&&data.ok){ toast(data.kind==="subscription"?"🎉 You're all set — everything's unlocked!":"🎉 Purchase complete — enjoy your course!"); const [prods,me]=await Promise.all([api("/store/products"),api("/api/account/me")]); if(prods.ok){STATE.all=prods.data;STATE.products=prods.data.products;} if(me.ok)STATE.me=me.data.authenticated?me.data:STATE.me; renderGrid(); renderNav(); renderPricing(); renderBundle(); } else { toast(data.error||"We couldn't confirm that payment."); } } /* ---------- corporate enquiry ---------- */ function openEnterprise(){ $("#entErr").classList.remove("show"); $("#entOverlay").classList.add("open"); document.body.style.overflow="hidden"; } function closeEnterprise(){ $("#entOverlay").classList.remove("open"); document.body.style.overflow=""; } async function submitEnterprise(e){ e.preventDefault(); const err=$("#entErr"); err.classList.remove("show"); const email=$("#ent_email").value.trim(), company=$("#ent_company").value.trim(), size=$("#ent_size").value; $("#entSubmit").disabled=true; // source carries the tier and the team size so the lead arrives with the // context needed to quote, without corrupting the company name. const {ok,data}=await api("/leads",{method:"POST",body:JSON.stringify( {email, company, source:"enterprise:"+size, consent_marketing:true})}); $("#entSubmit").disabled=false; if(!ok){ err.textContent=(data&&data.error)||"Could not send that. Please try again."; err.classList.add("show"); return false; } closeEnterprise(); toast("Thanks. We will be in touch about your corporate workspace."); return false; } /* ---------- auth ---------- */ let authMode="login", pending=null; /* The course the visitor was trying to open or buy when they were asked to sign up. Optional now: the account is free, so registration succeeds with or without one. When there is one, we send them straight to its checkout. */ function trialSlug(){ if(pending && pending.slug) return pending.slug; const open = document.querySelector("#courseOverlay.open"); if(open && open.dataset && open.dataset.slug) return open.dataset.slug; return ""; } function openAuth(mode){ authMode=mode; $("#authErr").classList.remove("show"); $("#authTitle").textContent = mode==="register"?"Create your account":"Welcome back"; $("#nameField").style.display = mode==="register"?"block":"none"; $("#authSubmit").textContent = mode==="register"?"Create account":"Log in"; $("#authSwitch").innerHTML = mode==="register" ? `Already have an account? Log in` : `New to Competrain? Create an account`; const oa=$("#oauthArea"); let html=""; if(STATE.providers.google) html+=``; oa.innerHTML=html; $("#authDivider").style.display = html?"flex":"none"; $("#authOverlay").classList.add("open"); document.body.style.overflow="hidden"; } function closeAuth(){ $("#authOverlay").classList.remove("open"); document.body.style.overflow=""; pending=null; } async function submitAuth(e){ e.preventDefault(); const err=$("#authErr"); err.classList.remove("show"); const email=$("#f_email").value.trim(), pass=$("#f_pass").value, name=$("#f_name").value.trim(); const path=authMode==="register"?"/account/register":"/account/login"; let body={email,password:pass}; if(authMode==="register"){ // Creating an account is free. If they picked a course first, the server // returns that course's checkout URL and we go straight there; if not, // they land in the catalogue signed in. body={email,password:pass,name,course_slug:trialSlug()}; } $("#authSubmit").disabled=true; const {ok,data}=await api(path,{method:"POST",body:JSON.stringify(body)}); $("#authSubmit").disabled=false; if(!ok){ err.textContent=data.error||"Something went wrong."; err.classList.add("show"); return false; } if(data.checkout_url){ // They picked a course: hand off to checkout and leave the page. location.href=data.checkout_url; return false; } if(data.notice) toast(data.notice); const me=await api("/api/account/me"); STATE.me = me.ok&&me.data.authenticated?me.data:{email}; // Captured BEFORE closeAuth(), which clears `pending` — otherwise whatever // the person was in the middle of is thrown away at the moment they sign in. const act=pending; closeAuth(); renderNav(); renderGrid(); pending=null; if(act&&act.type==="buy") buyCourse(act.slug); else if(act&&act.type==="sub") startSubscribe(); else if(act&&act.type==="studybuddy"){ sbUploads(); sbScroll(); sbRead(); } else toast("Welcome"+(name?", "+name.split(" ")[0]:"")+"! 🎉"); return false; } document.addEventListener("keydown",e=>{ if(e.key!=="Escape") return; // the deck and the handout sit above the course modal and handle Escape themselves if(slidesOpen()||$("#handoutView").classList.contains("open")) return; closeCourse();closeAuth();closeCert();closeTell(); }); load();