// 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
{doc.intro}
{doc.sections.map((s,i)=>({p}
)}| Signed by | '+esc(row.full_name)+' | ' + 'Member ID | '+esc(row.member_id||'-')+' |
| Typed signature | '+esc(row.typed_name)+' | ' + ''+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)+' | ||
'+esc(row.document_text)+'' + '