Send automatic "you've been paid" emails via SendGrid
When the chain tail sees a payout to a member whose sponsor record has a contact email, the site emails them the amount, source, and Polygonscan link (plain text — deliverability). SendGrid key is pasted in a new admin panel (stored in the data volume, like the OpenRouter key) or set via SENDGRID_API_KEY. From address defaults to no-reply@mybrandedvoice.com — the domain SendGrid is DKIM-authenticated for — and is editable in the same panel. Not gated on teamRootId; any member with an email on file. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,5 +8,6 @@
|
||||
<div class="table-card"><div style="display:flex;justify-content:space-between;gap:12px;align-items:center;margin-bottom:12px;flex-wrap:wrap"><div><h2 style="margin:0">Matrix View</h2><p style="color:var(--muted);margin:4px 0 0;font-size:13px">The entire on-chain matrix — who landed where, with tier, level, directs, and earnings per position. Click a position to drill down.</p></div><div style="display:flex;gap:8px"><button id="treeLoadBtn" class="btn btn-secondary btn-sm">Load Matrix</button><button id="treeToggleBtn" class="btn btn-secondary btn-sm hidden">List view</button></div></div><div id="matrixNav" class="hidden" style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:12px"></div><div id="matrixTree"></div></div></div>
|
||||
<div class="stack"><div class="table-card"><h2 style="margin-top:0">Add Sponsor</h2><form id="addSponsorForm"><div class="form-grid"><div class="field"><label>ID</label><input name="id" class="input" required></div><div class="field"><label>Name</label><input name="name" class="input" required></div><div class="field"><label>Parent ID</label><input name="parentId" class="input"></div><div class="field"><label>Level</label><select name="level" class="select"><option>Scintilla</option><option>Ascensus</option><option>Fabrica</option><option>Culmen</option><option>Apex</option><option>Fastigium</option><option>Vertex</option><option>Corona</option></select></div></div><div class="field"><label>Contact email (optional)</label><input name="email" class="input" type="email" placeholder="member@example.com"></div><div class="field"><label>Notes</label><input name="notes" class="input"></div><button class="btn btn-teal" style="width:100%">Add to Queue</button></form></div>
|
||||
<div class="table-card"><h2 style="margin-top:0">AI Chat</h2><p style="color:var(--muted);font-size:13px;margin:4px 0 12px">Paste an OpenRouter API key to switch the help chat from canned answers to AI (<span id="aiModel"></span>). Clear it to switch back.</p><div id="aiStatus" class="micro" style="margin-bottom:10px"></div><form id="aiKeyForm"><div class="field"><label>OpenRouter API key</label><input name="key" class="input" type="password" placeholder="sk-or-v1-…" autocomplete="off"></div><button class="btn btn-teal" style="width:100%">Save Key</button><button type="button" id="aiKeyClear" class="btn btn-secondary" style="width:100%;margin-top:8px">Clear Key (use canned answers)</button></form></div>
|
||||
<div class="table-card"><h2 style="margin-top:0">Payment Emails (SendGrid)</h2><p style="color:var(--muted);font-size:13px;margin:4px 0 12px">When a payout hits a member whose sponsor record has a contact email, they get a "you've been paid" email automatically. Paste your SendGrid API key (Branded Voice Coolify app → Environment Variables → SENDGRID_API_KEY).</p><div id="emailStatus" class="micro" style="margin-bottom:10px"></div><form id="sgKeyForm"><div class="field"><label>SendGrid API key</label><input name="key" class="input" type="password" placeholder="SG.…" autocomplete="off"></div><button class="btn btn-teal" style="width:100%">Save Key</button><button type="button" id="sgKeyClear" class="btn btn-secondary" style="width:100%;margin-top:8px">Clear Key (disable emails)</button></form><div class="field" style="margin-top:12px"><label>From address (must stay on mybrandedvoice.com — DKIM)</label><input id="emailFromInput" class="input" placeholder="The RM Circle Team <no-reply@mybrandedvoice.com>"><button type="button" id="emailFromSave" class="btn btn-secondary" style="width:100%;margin-top:8px">Save From Address</button></div></div>
|
||||
<div class="table-card"><h2 style="margin-top:0">Public Page Settings</h2><form id="configForm"><div class="field"><label>Site name</label><input name="siteName" class="input"></div><div class="field"><label>Program name</label><input name="programName" class="input"></div><div class="field"><label>Bridge headline</label><input name="bridgeHeadline" class="input"></div><div class="field"><label>Bridge subheadline</label><textarea name="bridgeSubheadline" class="input" rows="3"></textarea></div><div class="field"><label>Premium entry (POL)</label><input name="premiumEntryPol" class="input" type="number"></div><div class="field"><label>RM dApp referral base URL</label><input name="dappReferralBaseUrl" class="input" placeholder="https://app.thermcircle.com?ref="></div><div class="field"><label>BeMob postback URL (paid traffic)</label><input name="bemobPostbackUrl" class="input" placeholder="https://xxxxx.bemobtrcks.com/postback"></div><div class="field"><label>Telegram bot token (Hermes notifications)</label><input name="telegramBotToken" class="input" type="password" autocomplete="off" placeholder="123456:ABC…"></div><div class="field"><label>Telegram chat ID (group or user)</label><input name="telegramChatId" class="input" placeholder="-1001234567890"></div><div class="field"><label>Telegram topic ID (optional, for forum groups)</label><input name="telegramTopicId" class="input" placeholder="55"></div><div class="field"><label>Team root ID (on-chain team-build alerts)</label><input name="teamRootId" class="input" inputmode="numeric" placeholder="21 — alerts fire for any activity at or below this member ID"></div><div class="field"><label>Telegram/support URL (optional)</label><input name="telegramUrl" class="input"></div><div class="field"><label>Support message</label><textarea name="supportLabel" class="input" rows="3"></textarea></div><label style="text-transform:none;letter-spacing:0;margin:10px 0"><input type="checkbox" name="showSponsorName"> Show sponsor name publicly</label><label style="text-transform:none;letter-spacing:0;margin:10px 0"><input type="checkbox" name="showQueueProgress"> Show number waiting in queue</label><button class="btn btn-primary" style="width:100%;margin-top:8px">Save Settings</button></form></div></div></div></section>
|
||||
<div id="toast" class="toast"></div><script src="/admin.js"></script></body></html>
|
||||
|
||||
@@ -31,6 +31,8 @@ function render(){
|
||||
const sponsors=[...state.sponsors].sort((a,b)=>a.sortOrder-b.sortOrder);rows.innerHTML=sponsors.map((s,i)=>`<tr><td>${i+1}</td><td><strong>${esc(s.name)}</strong><div class="micro" style="margin:2px 0 0">ID ${esc(s.id)}</div>${s.email?`<div class="micro" style="margin:2px 0 0">✉ ${esc(s.email)}</div>`:''}</td><td>${esc(s.parentId||'—')}</td><td><strong>${s.directs}/2</strong></td><td>${levelSelect(s)}</td><td><span class="${statusClass(s.status)}">${esc(s.status)}</span></td><td>${s.clicks||0}</td><td><div class="actions">${s.status!=='qualified'?`<button class="btn btn-secondary btn-sm" data-action="inc" data-id="${esc(s.id)}">+1 Direct</button><button class="btn btn-teal btn-sm" data-action="qualify" data-id="${esc(s.id)}">Qualified</button>`:`<button class="btn btn-secondary btn-sm" data-action="reset" data-id="${esc(s.id)}">Reset</button>`}${s.status==='waiting'?`<button class="btn btn-secondary btn-sm" data-action="activate" data-id="${esc(s.id)}">Make Active</button>`:''}<button class="btn btn-secondary btn-sm" data-action="email" data-id="${esc(s.id)}" title="Set contact email">✉</button><button class="btn btn-secondary btn-sm" data-action="up" data-id="${esc(s.id)}">↑</button><button class="btn btn-secondary btn-sm" data-action="down" data-id="${esc(s.id)}">↓</button><button class="btn btn-danger btn-sm" data-action="delete" data-id="${esc(s.id)}">×</button></div></td></tr>`).join('')||'<tr><td colspan="8" class="empty">No sponsors in the queue.</td></tr>';
|
||||
renderAnalytics();
|
||||
const ai=state.aiChat||{};document.getElementById('aiModel').textContent=ai.model||'OpenRouter';document.getElementById('aiStatus').innerHTML=ai.configured?'<span style="color:var(--ok)">● AI chat is ON</span> — key is set':'<span style="color:var(--muted)">○ AI chat is OFF</span> — using built-in answers';
|
||||
const em=state.email||{};document.getElementById('emailStatus').innerHTML=em.configured?'<span style="color:var(--ok)">● Payment emails are ON</span> — key is set':'<span style="color:var(--muted)">○ Payment emails are OFF</span> — no SendGrid key';
|
||||
const efi=document.getElementById('emailFromInput');if(efi&&!efi.value)efi.value=state.config.emailFrom||em.from||'';
|
||||
const f=document.getElementById('configForm'),c=state.config;for(const k of ['siteName','programName','bridgeHeadline','bridgeSubheadline','premiumEntryPol','dappReferralBaseUrl','telegramUrl','supportLabel','bemobPostbackUrl','telegramBotToken','telegramChatId','telegramTopicId','teamRootId'])if(f.elements[k])f.elements[k].value=c[k]??'';f.elements.showSponsorName.checked=!!c.showSponsorName;f.elements.showQueueProgress.checked=!!c.showQueueProgress;
|
||||
}
|
||||
document.getElementById('loginForm').addEventListener('submit',async e=>{e.preventDefault();const err=document.getElementById('loginError');err.textContent='';try{await api('/api/admin/login',{method:'POST',body:JSON.stringify({password:document.getElementById('password').value})});document.getElementById('password').value='';await loadState()}catch(x){err.textContent=x.message}});
|
||||
@@ -41,6 +43,9 @@ rows.addEventListener('click',async e=>{const b=e.target.closest('button[data-ac
|
||||
document.getElementById('aiKeyForm').addEventListener('submit',async e=>{e.preventDefault();const inp=e.currentTarget.elements.key,key=inp.value.trim();if(!key){showToast('Paste a key first');return}try{const d=await api('/api/admin/openrouter-key',{method:'POST',body:JSON.stringify({key})});state.aiChat={...(state.aiChat||{}),configured:d.configured};inp.value='';render();showToast('AI chat enabled')}catch(x){showToast(x.message)}});
|
||||
document.getElementById('aiKeyClear').addEventListener('click',async()=>{if(!confirm('Turn off AI chat and go back to built-in answers?'))return;try{const d=await api('/api/admin/openrouter-key',{method:'POST',body:JSON.stringify({key:''})});state.aiChat={...(state.aiChat||{}),configured:d.configured};render();showToast('AI chat disabled')}catch(x){showToast(x.message)}});
|
||||
rows.addEventListener('change',async e=>{const sel=e.target.closest('select[data-action="level"]');if(!sel)return;try{const d=await api(`/api/admin/sponsors/${sel.dataset.id}`,{method:'PATCH',body:JSON.stringify({level:sel.value})});state.sponsors=d.sponsors;render();showToast('Level updated')}catch(x){showToast(x.message);render()}});
|
||||
document.getElementById('sgKeyForm').addEventListener('submit',async e=>{e.preventDefault();const inp=e.currentTarget.elements.key,key=inp.value.trim();if(!key){showToast('Paste a key first');return}try{const d=await api('/api/admin/sendgrid-key',{method:'POST',body:JSON.stringify({key})});state.email={...(state.email||{}),configured:d.configured};inp.value='';render();showToast('Payment emails enabled')}catch(x){showToast(x.message)}});
|
||||
document.getElementById('sgKeyClear').addEventListener('click',async()=>{if(!confirm('Disable "you\'ve been paid" emails?'))return;try{const d=await api('/api/admin/sendgrid-key',{method:'POST',body:JSON.stringify({key:''})});state.email={...(state.email||{}),configured:d.configured};render();showToast('Payment emails disabled')}catch(x){showToast(x.message)}});
|
||||
document.getElementById('emailFromSave').addEventListener('click',async()=>{const v=document.getElementById('emailFromInput').value.trim();try{const d=await api('/api/admin/config',{method:'PATCH',body:JSON.stringify({emailFrom:v})});state.config=d.config;showToast('From address saved')}catch(x){showToast(x.message)}});
|
||||
document.getElementById('lookupForm').addEventListener('submit',async e=>{
|
||||
e.preventDefault();
|
||||
const id=document.getElementById('lookupId').value.trim(),out=document.getElementById('lookupResult');
|
||||
|
||||
Reference in New Issue
Block a user