2 independent online courses in Communication. Practical lessons built on real situations; every assessment question traced to the lesson it came from.
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
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
Corporate pricing depends on how many people you train and how many documents you bring. Tell us both and we will quote.
×
Corporate LMS
Tell us the size of the team and we will come back with a quote and a walkthrough.
Competrain produces training records and competency evidence. It does not make an organisation compliant, and it is not a certification body.
What people are buying
Counted from real orders.
In our customers' words
Published with their permission. They can ask us to take these down at any time.
×
1 / 1
×
×
Welcome back
or with email
By continuing you agree to Competrain's terms. We'll never post without your say-so.
×
Share your experience
We publish nothing until you consent and we have read it. You can ask us to remove it at any time.
", "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 = '
`;
}).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)}`:''}
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"}
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 && !/
`;
$("#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=>
"
").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?'
";
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();