// Chart Growth Hub — digital agreement gate. // // Renders one document from portal/agreements.js, requires a tick on every // clause individually plus the member's typed full name, then files an // immutable acceptance row. The row is the evidence; n8n turns it into a PDF // on Drive and emails hq@. // // Per-clause ticks rather than one master tick is deliberate. A single "I agree" // box is trivially disputed; a record showing the member ticked "I understand a // stop-loss is not guaranteed" is not. // // Exposes: // window.AgreementGate({client, session, member, formKey, onDone, onCancel, embedded}) // window.cgAgreementStatus(client, studentId) -> {community:'1.0'|null, ...} // window.cgAgreementNeeded(status, formKey) -> bool (function(){ const { useState, useEffect, useRef, useMemo } = React; /* ── which versions has this member accepted? ────────────────────────────── */ // Returns the highest accepted version per form. Rows are immutable and // append-only, so "latest accepted" is simply the most recent row per key. // Returns, per form: the version currently in force for this member, plus any // hq@ waiver. Three things can change the answer beyond a plain signature: // · a VOID cancels one specific acceptance record // · a REQUIRE invalidates everything signed before it (forced re-sign) // · a WAIVE removes the requirement entirely until a later REQUIRE // Overrides are separate append-only facts, so none of this edits the archive. async function agreementStatus(client, studentId){ const out = { community:null, vip_signals:null, bot:null, waived:{}, records:{} }; const [accRes, ovrRes] = await Promise.all([ client.from('agreement_acceptances') .select('*').eq('student_id', studentId).order('accepted_at', {ascending:false}), client.from('agreement_overrides') .select('*').eq('student_id', studentId).order('created_at', {ascending:false}), ]); const acc = (accRes && accRes.data) || []; const ovr = (ovrRes && ovrRes.data) || []; // absent or blocked -> behave as none const voided = {}; ovr.forEach(o => { if(o.action === 'void' && o.target_id) voided[o.target_id] = o; }); // Latest waive/require per form decides the requirement; rows arrive newest // first, so the first one seen for a form is the one that stands. const stance = {}; ovr.forEach(o => { if(o.action === 'void') return; if(!stance[o.form_key]) stance[o.form_key] = o; }); acc.forEach(r => { if(voided[r.id]) return; const st = stance[r.form_key]; // A forced re-sign only invalidates signatures made before it was issued. if(st && st.action === 'require' && String(r.accepted_at) < String(st.created_at)) return; if(out.records[r.form_key]) return; // newest valid wins out.records[r.form_key] = r; out[r.form_key] = r.version; }); Object.keys(stance).forEach(k => { if(stance[k].action === 'waive') out.waived[k] = stance[k]; }); out.voided = voided; out.overrides = ovr; return out; } // A member needs the form unless hq@ has waived it, or they already hold the // current version. function agreementNeeded(status, formKey){ const A = window.CG_AGREEMENTS; if(!A || !A.docs[formKey]) return false; if(status && status.waived && status.waived[formKey]) return false; const have = status ? status[formKey] : null; return have !== A.docs[formKey].version; } /* ── styles ──────────────────────────────────────────────────────────────── */ (function injectCSS(){ if(document.getElementById('cg-agree-css')) return; const s = document.createElement('style'); s.id = 'cg-agree-css'; s.textContent = ` .ag-wrap{border:1px solid var(--border-gold);border-radius:var(--radius); background:linear-gradient(165deg,rgba(212,175,55,.05),transparent 55%),var(--navy-950);overflow:hidden} .ag-head{padding:20px 24px;border-bottom:1px solid var(--border);display:flex;gap:14px;align-items:flex-start} .ag-head img{width:40px;height:40px;object-fit:contain;flex-shrink:0} .ag-head h2{margin:0;font-size:1.16rem;line-height:1.3} .ag-meta{font-family:var(--font-mono);font-size:.68rem;color:var(--ink-faint);margin-top:4px; letter-spacing:.03em} .ag-body{max-height:340px;overflow-y:auto;padding:20px 24px;border-bottom:1px solid var(--border); scroll-behavior:smooth} .ag-body h3{font-size:.87rem;margin:18px 0 6px;color:var(--gold-2)} .ag-body h3:first-child{margin-top:0} .ag-body p{font-size:.85rem;line-height:1.62;color:var(--ink-dim);margin:0 0 9px} .ag-intro{font-size:.87rem;line-height:1.6;color:var(--ink);margin:0 0 4px} .ag-scrollnote{padding:9px 24px;font-size:.73rem;color:var(--ink-faint); border-bottom:1px solid var(--border);display:flex;align-items:center;gap:8px} .ag-clauses{padding:18px 24px;display:grid;gap:2px} .ag-clause{display:flex;gap:11px;align-items:flex-start;padding:10px 12px;border-radius:9px; cursor:pointer;border:1px solid transparent;transition:background .15s,border-color .15s} .ag-clause:hover{background:var(--surface)} .ag-clause.on{border-color:rgba(74,222,128,.28);background:rgba(74,222,128,.05)} .ag-clause.miss{border-color:rgba(248,113,113,.45);background:rgba(248,113,113,.06)} .ag-box{width:19px;height:19px;flex-shrink:0;margin-top:1px;border-radius:5px; border:1.5px solid var(--border);display:grid;place-items:center;transition:.15s} .ag-clause.on .ag-box{border-color:var(--green);background:var(--green)} .ag-box svg{width:12px;height:12px;fill:none;stroke:var(--navy-950);stroke-width:2.6; stroke-linecap:round;stroke-linejoin:round;opacity:0} .ag-clause.on .ag-box svg{opacity:1} .ag-clause span{font-size:.83rem;line-height:1.5;color:var(--ink-dim)} .ag-clause.on span{color:var(--ink)} .ag-sign{padding:4px 24px 22px} .ag-sign label{display:block;font-size:.73rem;text-transform:uppercase;letter-spacing:.06em; color:var(--ink-faint);margin-bottom:6px} .ag-sign input{width:100%;padding:13px 15px;border-radius:var(--radius-sm);background:var(--navy-950); border:1px solid var(--border);color:var(--ink);font-family:var(--font-body);font-size:1.02rem; letter-spacing:.02em} .ag-sign input:focus{outline:none;border-color:var(--gold);box-shadow:0 0 0 3px var(--gold-soft)} .ag-hint{font-size:.73rem;color:var(--ink-faint);margin-top:7px} .ag-siglabel{display:block;font-size:.73rem;text-transform:uppercase;letter-spacing:.06em; color:var(--ink-faint);margin-bottom:6px} .ag-sigbox{position:relative;border:1px solid var(--border);border-radius:var(--radius-sm); background:var(--navy-950);height:150px;overflow:hidden} .ag-sigbox:focus-within{border-color:var(--gold)} /* touch-action:none stops the browser scrolling the page instead of drawing */ .ag-sigcanvas{position:absolute;inset:0;width:100%;height:100%;touch-action:none;cursor:crosshair; display:block} .ag-sigline{position:absolute;left:22px;right:22px;bottom:34px;border-bottom:1px dashed var(--border); pointer-events:none} .ag-sigclear{position:absolute;right:9px;bottom:8px;background:var(--surface);border:1px solid var(--border); color:var(--ink-faint);border-radius:7px;padding:4px 10px;font-size:.7rem;cursor:pointer; font-family:inherit} .ag-sigclear:hover{border-color:var(--border-gold);color:var(--gold-2)} .ag-foot{padding:0 24px 22px;display:flex;gap:10px;align-items:center;flex-wrap:wrap} .ag-err{color:var(--red);font-size:.8rem;font-family:var(--font-mono);padding:0 24px 14px} .ag-modal{position:fixed;inset:0;z-index:400;background:rgba(4,7,14,.88);backdrop-filter:blur(6px); display:grid;place-items:center;padding:20px;overflow-y:auto} .ag-modal>div{width:100%;max-width:720px;margin:auto} .ag-progress{max-width:720px;margin:0 auto 12px;padding:14px 18px;border-radius:12px; border:1px solid var(--border);background:var(--navy-900)} .ag-progress-top{display:flex;justify-content:space-between;align-items:center; font-size:.86rem;margin-bottom:9px} .ag-progress-top span{font-family:var(--font-mono);font-size:.74rem;color:var(--ink-faint)} .ag-progress-bar{height:5px;border-radius:999px;background:var(--border);overflow:hidden} .ag-progress-bar>div{height:100%;border-radius:999px;transition:width .35s; background:linear-gradient(90deg,var(--gold),var(--gold-2))} .ag-progress-list{display:flex;gap:7px;flex-wrap:wrap;margin-top:10px} .ag-step{font-size:.7rem;padding:3px 9px;border-radius:999px;border:1px solid var(--border); color:var(--ink-faint)} .ag-step.done{color:var(--green);border-color:rgba(74,222,128,.35)} .ag-step.now{color:var(--gold-2);border-color:var(--border-gold)} @media(max-width:600px){ .ag-head,.ag-body,.ag-clauses,.ag-sign,.ag-foot{padding-left:16px;padding-right:16px} .ag-body{max-height:44vh} }`; document.head.appendChild(s); })(); const TICK = ; /* ── name matching ───────────────────────────────────────────────────────── */ // The typed name must match the registered name, but not so strictly that a // middle initial or a double space blocks a genuine member. Compare on letters // only, case- and accent-insensitive. function normName(s){ return String(s||'') .normalize('NFD').replace(/[̀-ͯ]/g,'') .toLowerCase().replace(/[^a-z]/g,''); } function nameMatches(typed, registered){ const t = normName(typed), r = normName(registered); if(!t) return false; if(!r) return t.length >= 4; // no name on file — just require something real if(t === r) return true; // Allow a middle name on one side but not on the other. return r.indexOf(t) === 0 || t.indexOf(r) === 0; } /* ── signature pad ─────────────────────────────────────────────────────── A drawn signature or initials. Pointer events cover mouse, pen and touch in one code path; the canvas is scaled by devicePixelRatio so the stroke is not a blurry mess on a phone. */ function SignaturePad({onChange, label}){ const ref = useRef(null); const drawing = useRef(false); const dirty = useRef(false); useEffect(()=>{ const c = ref.current; if(!c) return; const fit = () => { const r = c.getBoundingClientRect(); const dpr = window.devicePixelRatio || 1; // Resizing a canvas clears it, so only do it when the size really changed. if(c.width === Math.round(r.width*dpr) && c.height === Math.round(r.height*dpr)) return; c.width = Math.round(r.width*dpr); c.height = Math.round(r.height*dpr); const ctx = c.getContext('2d'); ctx.scale(dpr, dpr); ctx.lineWidth = 2.2; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.strokeStyle = '#e8dcc0'; }; fit(); window.addEventListener('resize', fit); return ()=>window.removeEventListener('resize', fit); },[]); function pos(e){ const r = ref.current.getBoundingClientRect(); return { x: e.clientX - r.left, y: e.clientY - r.top }; } function start(e){ e.preventDefault(); const c = ref.current, ctx = c.getContext('2d'), p = pos(e); drawing.current = true; ctx.beginPath(); ctx.moveTo(p.x, p.y); try { c.setPointerCapture(e.pointerId); } catch(err){} } function move(e){ if(!drawing.current) return; e.preventDefault(); const ctx = ref.current.getContext('2d'), p = pos(e); ctx.lineTo(p.x, p.y); ctx.stroke(); dirty.current = true; } function end(){ if(!drawing.current) return; drawing.current = false; if(dirty.current && onChange){ try { onChange(ref.current.toDataURL('image/png')); } catch(e){ onChange(null); } } } function clear(){ const c = ref.current, ctx = c.getContext('2d'); ctx.clearRect(0, 0, c.width, c.height); dirty.current = false; if(onChange) onChange(null); } return
; } /* ── the gate ────────────────────────────────────────────────────────────── */ function AgreementGate({client, session, member, formKey, onDone, onCancel, embedded}){ const A = window.CG_AGREEMENTS; const doc = A && A.docs[formKey]; const clauses = useMemo(()=> doc ? A.allClauses(doc) : [], [doc]); const [ticks,setTicks] = useState({}); const [typed,setTyped] = useState(''); const [busy,setBusy] = useState(false); const [err,setErr] = useState(''); const [showMissing,setShowMissing] = useState(false); const [readToEnd,setReadToEnd] = useState(false); const [sig,setSig] = useState(null); // drawn signature, data URL const [dob,setDob] = useState(null); // date of birth, from member_private const bodyRef = useRef(null); // The birthdate is copied onto the acceptance so the 18+ declaration is // backed by a date rather than by a tick alone. It lives in member_private, // which staff cannot read, so it comes through a security-definer function // scoped to the signed-in member. useEffect(()=>{ let stop=false; (async ()=>{ try{ const { data } = await client.rpc('my_birthdate'); if(!stop && data) setDob(String(data).slice(0,10)); }catch(e){ /* not set yet — the form still works without it */ } })(); return ()=>{ stop=true; }; /* eslint-disable-next-line */ },[]); // A member cannot tick clauses they have not scrolled past. Not legally // decisive on its own, but it removes the "I never saw it" argument and it // costs the honest member two seconds. useEffect(()=>{ const el = bodyRef.current; if(!el) return; const check = () => { // If the document is shorter than the box there is nothing to scroll. if(el.scrollHeight - el.clientHeight <= 8){ setReadToEnd(true); return; } if(el.scrollTop + el.clientHeight >= el.scrollHeight - 24) setReadToEnd(true); }; check(); el.addEventListener('scroll', check); return ()=>el.removeEventListener('scroll', check); },[doc]); if(!doc) return
Agreement “{formKey}” is not available. Please refresh the page.
; const allTicked = clauses.every(c => ticks[c.id]); const nameOk = nameMatches(typed, member && member.full_name); const ready = allTicked && nameOk && readToEnd && !!sig; function toggle(id){ setTicks(t => { const n = {...t}; if(n[id]) delete n[id]; else n[id] = true; return n; }); } async function submit(){ if(!ready){ setShowMissing(true); if(!readToEnd) setErr('Please scroll to the end of the agreement before accepting.'); else if(!allTicked) setErr('Please confirm every point — each one must be ticked individually.'); else if(!nameOk) setErr('Type your full registered name exactly as it appears above.'); else setErr('Please sign or initial in the signature box.'); return; } setErr(''); setBusy(true); // Best-effort client IP. If the lookup fails or is blocked we still file the // acceptance — an acceptance without an IP is far better than no acceptance. let ip = null; try { const ctl = new AbortController(); const t = setTimeout(()=>ctl.abort(), 2500); const r = await fetch('https://api.ipify.org?format=json', {signal:ctl.signal}); clearTimeout(t); if(r.ok){ const j = await r.json(); ip = j && j.ip ? String(j.ip).slice(0,64) : null; } } catch(e){ /* offline, blocked, or timed out — proceed without it */ } const now = new Date().toISOString(); let tz = null; try { tz = Intl.DateTimeFormat().resolvedOptions().timeZone; } catch(e){} const ctx = { full_name: (member && member.full_name) || '', typed_name: typed.trim(), member_id: (member && member.member_id) || '', email: session.user.email || '', accepted_at: now, ip, user_agent: navigator.userAgent, birthdate: dob, signed: !!sig, clauses: ticks, }; const payload = { student_id: session.user.id, member_id: (member && member.member_id) || null, email: session.user.email, full_name: (member && member.full_name) || typed.trim(), typed_name: typed.trim(), form_key: doc.key, form_title: doc.title, version: doc.version, effective_date: doc.effective, clauses: clauses.map(c => ({id:c.id, text:c.text, accepted:!!ticks[c.id]})), confirmed_18: !!ticks.u_18, document_text: A.text(doc, ctx), ip, user_agent: (navigator.userAgent||'').slice(0,500), timezone: tz, signature_png: sig, birthdate: dob || null, accepted_at: now, }; const { error } = await client.from('agreement_acceptances').insert(payload); setBusy(false); if(error){ setErr(error.message); return; } if(onDone) onDone(doc.key, doc.version); } const logo = 'assets/brand/cgh-256.png'; const card =
{ e.target.style.display='none'; }}/>

{doc.title}

VERSION {doc.version} · EFFECTIVE {doc.effective} · {A.company.name}

{doc.intro}

{doc.sections.map((s,i)=>(

{s.h}

{s.p.map((p,j)=>

{p}

)}
))}
{!readToEnd &&
Scroll to the end of the agreement to continue.
}
{clauses.map(c=>{ const on = !!ticks[c.id]; return
toggle(c.id)} role="checkbox" aria-checked={on} tabIndex={0} onKeyDown={e=>{ if(e.key===' '||e.key==='Enter'){ e.preventDefault(); toggle(c.id); } }}> {TICK} {c.text}
; })}
setTyped(e.target.value)} placeholder={(member && member.full_name) || 'Your full name'} autoComplete="off" spellCheck="false"/>
{member && member.full_name ? <>Must match your registered name: {member.full_name} : 'Enter your full legal name.'} {typed && !nameOk && — does not match} {typed && nameOk && — matches}
{sig ? Signature captured. : 'Use your finger on a phone, or your mouse on a computer.'} {dob && <> · Date of birth on file: {dob}} {!dob && <> · No date of birth on file — add it under Account settings so it appears on your signed copy.}
{err &&
{err}
}
{onCancel && } A signed copy is filed with {A.company.email}
; return embedded ? card :
{card}
; } /* ── signed-copy PDF ───────────────────────────────────────────────────── The member's own copy of what they signed. Rendered from the stored snapshot, never re-generated from the current template, so a later revision of the terms cannot change what an old signature appears to say. */ function esc(v){ return String(v===null||v===undefined?'':v) .replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } function signedCopyHTML(row, opts){ opts = opts || {}; const CO = (window.CG_AGREEMENTS && window.CG_AGREEMENTS.company) || {}; // Absolute, so the logo resolves inside the print iframe wherever the portal // page happens to live. let logo = ''; try { logo = new URL('assets/brand/cgh-256.png', document.baseURI).href; } catch(e){} // Declared up front, emitted last: in print it is a running footer pinned to // the page margin, and on screen it closes the document. const foot = '
'+esc(CO.reg || '')+'' + 'Signed copy · record '+esc(String(row.id||'').slice(0,8))+'' + ''+esc(CO.email || '')+'
'; const clauses = Array.isArray(row.clauses) ? row.clauses : []; const rows = clauses.map((c,i)=> ''+(c.accepted?'✓':'✗')+''+(i+1) +''+esc(c.text)+'').join(''); return ''+esc(row.form_title)+'' + '
' + (logo ? '' : '') + '
'+esc(CO.name || 'Chart Growth Learning Hub')+'
' + '
'+esc(row.form_title)+' · v'+esc(row.version)+'
' + '
'+esc(row.member_id || '')+'
' + '
' + '
' + '

'+esc(row.form_title)+'

Chart Growth Hub · ' + 'signed copy · version '+esc(row.version)+'
' + '' + '' + '' + '' + '' + '' + '' + '' + '' + (opts.showIp ? '' + '' : '') + '' + '
Signed by'+esc(row.full_name)+'Member ID'+esc(row.member_id||'-')+'
Typed signature'+esc(row.typed_name)+'Email'+esc(row.email)+'
Date of birth'+esc(row.birthdate||'not on file')+'Age 18+'+(row.confirmed_18?'confirmed':'NOT CONFIRMED')+'
Accepted at'+esc(row.accepted_at)+'Timezone'+esc(row.timezone||'-')+'
Network address'+esc(row.ip||'not recorded')+'Source'+esc(row.source==='offline'?'paper copy':'portal')+'
Record ID' + esc(row.id)+'
' + '
' + '
' + '
' + (row.signature_png ? 'signature' : 'no drawn signature on this record') + '
' + '
' + '
Signature / initials
' + '
' + '
' + '
'+esc(row.full_name)+'
' + '
' + '
Full name — electronic signature
' + '
' + '
' + '

Confirmations ticked

'+rows+'
' + '

Document as presented

'+esc(row.document_text)+'
' + '
' + foot + ''; } // Off-screen iframe rather than window.open(): popup blockers kill the latter // even on a click, and an iframe keeps the portal itself out of the printout. function printSignedCopy(row, opts){ const old = document.getElementById('cg-agree-print'); if(old) old.remove(); const f = document.createElement('iframe'); f.id = 'cg-agree-print'; f.setAttribute('aria-hidden','true'); f.style.cssText = 'position:fixed;left:-10000px;top:0;width:1024px;height:768px;border:0'; document.body.appendChild(f); const d = f.contentDocument; d.open(); d.write(signedCopyHTML(row, opts)); d.close(); const go = () => { try{ f.contentWindow.focus(); f.contentWindow.print(); }catch(e){} }; if(d.readyState === 'complete') setTimeout(go, 250); else f.onload = () => setTimeout(go, 250); } /* ── sequential flow ───────────────────────────────────────────────────── Every student — existing and new — signs all three documents. They are presented one at a time with a progress line rather than as one enormous scroll, because a member who is shown 30 clauses at once ticks them without reading, which is exactly what these forms exist to prevent. */ function AgreementFlow({client, session, member, status, onDone, onAllDone}){ const A = window.CG_AGREEMENTS; const pending = A ? A.order.filter(k => agreementNeeded(status, k)) : []; useEffect(()=>{ if(pending.length === 0 && onAllDone) onAllDone(); /* eslint-disable-next-line */ }, [pending.length]); if(!A || pending.length === 0) return null; const total = A.order.length; const doneCount = total - pending.length; const current = pending[0]; return
Before you continue {doneCount + 1} of {total}
{A.order.map(k => { const d = A.docs[k]; const signed = !agreementNeeded(status, k); const isNow = k === current; return {signed ? '✓ ' : ''}{d.short} ; })}
{/* key forces a fresh mount per document — without it React reuses the instance and the previous document's ticks and typed name carry over. */}
; } window.AgreementFlow = AgreementFlow; window.cgSignedCopyPrint = printSignedCopy; window.cgSignedCopyHTML = signedCopyHTML; window.AgreementGate = AgreementGate; window.cgAgreementStatus = agreementStatus; window.cgAgreementNeeded = agreementNeeded; window.cgAgreementNameOk = nameMatches; // exported for tests })();