From 359bfee3dba6fdd10b52f221de325c22b2006206 Mon Sep 17 00:00:00 2001 From: martbost Date: Wed, 9 Sep 2026 17:02:07 -0500 Subject: [PATCH] Event pop-up: Add-to-calendar buttons (Google Calendar link + .ics for Apple/Outlook) Co-Authored-By: Claude Fable 5.1 --- public/admin.html | 2 +- public/admin.js | 2 +- public/announce.js | 21 +++++++++++++++++++++ server.js | 26 +++++++++++++++++++++++--- 4 files changed, 46 insertions(+), 5 deletions(-) diff --git a/public/admin.html b/public/admin.html index fbdaeee..fb0fcfc 100644 --- a/public/admin.html +++ b/public/admin.html @@ -1,5 +1,5 @@ RM Circle Team Build Admin +

Sponsor Queue

Mark a sponsor qualified to automatically activate the next waiting position.

OrderSponsorParentDirectsLevelStatusClicksActions

Add Sponsor

Put a new position into the qualification queue.
Toggle

Coaching Radar

Live triage of your whole org: who to nudge, what to tell them, and how much POL is on the line. Computed fresh from the chain index on every refresh.

Loading…

Award Suite Capacity

Give any position extra Suite allowance as a team bonus — issued by the team, not taken from anyone.
Open
Bonuses awarded this month
ToWhatAmountReasonWhen

Member Messages

Wallet-verified member-to-member messages, newest first. Admin can review for abuse — members are told this in the UI.

WhenFromToMessageRead by
Loading…

Traffic & Conversions

First-touch source per visitor session (referring domain or utm_source). Funnel: bridge page → start page → join click.

SourceBridge viewsStart viewsTraining viewsInvite viewsJoin-now viewsJoin clicksConfirmedStart → Join

Your Organization vs. the Network

How your team — rooted at your top ID — stacks up against the entire RM Circle smart contract. Live on-chain.

Reading the blockchain…

Member ID Submissions

New members who confirmed their purchase on the start page. Each one was posted to your Hermes Telegram chat — add them to the rotation.

WhenName / HandleNew IDJoined underSourceOn-chain

On-Chain Member Lookup

Enter an RM Circle ID to read its registration, lineage, and every payment it has received — live from the smart contract.

Matrix View

The entire on-chain matrix — who landed where, with tier, level, directs, and earnings per position. Click a position to drill down.

My Positions — Income

Every payment received by your own positions, live from the contract. Comma-separated IDs — saved for next time.

AI Chat

Paste an OpenRouter API key to switch the help chat from canned answers to AI (). Clear it to switch back.

Payment Emails (SendGrid)

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).

Public Page Settings

Their /join/<id> links place new joins directly under them instead of rotating to the next member needing directs — so they keep the entry reward. Add ?direct=0 to a link to force rotation for that one share.

Event pop-up

A dismissible overlay shown site-wide (never on join pages). Turn it off after the event, or edit it for the next huddle. Bump the Event ID to re-show it to everyone who already dismissed it.

diff --git a/public/admin.js b/public/admin.js index 91f2fca..f8c78a8 100644 --- a/public/admin.js +++ b/public/admin.js @@ -46,7 +46,7 @@ function render(){ if(!grantAutoLoaded){grantAutoLoaded=true;if(window.agBoot)window.agBoot();} if(!msgsAutoLoaded){msgsAutoLoaded=true;loadAdminMsgs();} 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','teamAlertEmail','ownerAlertEmail','directDefaultIds','announceEyebrow','announceImg','announceMeetUrl','announceDateLabel','announceTimes','announceExpiresUTC','announceId'])if(f.elements[k])f.elements[k].value=c[k]??'';f.elements.showSponsorName.checked=!!c.showSponsorName;f.elements.showQueueProgress.checked=!!c.showQueueProgress;if(f.elements.announceEnabled)f.elements.announceEnabled.checked=c.announceEnabled===undefined?true:!!c.announceEnabled; + 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','teamAlertEmail','ownerAlertEmail','directDefaultIds','announceEyebrow','announceImg','announceMeetUrl','announceDateLabel','announceTimes','announceExpiresUTC','announceStartUTC','announceDurationMin','announceId'])if(f.elements[k])f.elements[k].value=c[k]??'';f.elements.showSponsorName.checked=!!c.showSponsorName;f.elements.showQueueProgress.checked=!!c.showQueueProgress;if(f.elements.announceEnabled)f.elements.announceEnabled.checked=c.announceEnabled===undefined?true:!!c.announceEnabled; } 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}}); document.getElementById('logoutBtn').addEventListener('click',async()=>{await api('/api/admin/logout',{method:'POST'});location.reload()}); diff --git a/public/announce.js b/public/announce.js index 665192e..8d87c71 100644 --- a/public/announce.js +++ b/public/announce.js @@ -90,6 +90,27 @@ join.addEventListener('click', done); foot.appendChild(join); + var st = EV.startUTC ? Date.parse(EV.startUTC) : 0; + if (st) { + var en = st + (Number(EV.durationMin) || 60) * 60000; + var f = function (ms) { return new Date(ms).toISOString().replace(/[-:]|\.\d{3}/g, ''); }; + var title = EV.eyebrow || 'Team RM Circle · Weekly Huddle'; + var details = (EV.meetUrl ? 'Join: ' + EV.meetUrl + '\n' : '') + String(EV.times || '').replace(/\|/g, ' '); + var gcal = 'https://calendar.google.com/calendar/render?action=TEMPLATE&text=' + encodeURIComponent(title) + + '&dates=' + f(st) + '/' + f(en) + '&details=' + encodeURIComponent(details) + '&location=' + encodeURIComponent(EV.meetUrl || ''); + var row = document.createElement('div'); + row.style.cssText = 'display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:10px;'; + var btnCss = 'display:block;text-align:center;text-decoration:none;background:rgba(212,175,55,.08);border:1px solid rgba(212,175,55,.35);' + + 'color:#f3c34a;font-size:13px;font-weight:700;padding:10px 8px;border-radius:10px;'; + var g = document.createElement('a'); + g.href = gcal; g.target = '_blank'; g.rel = 'noopener'; g.textContent = '📅 Google Calendar'; g.style.cssText = btnCss; + g.addEventListener('click', done); + var a = document.createElement('a'); + a.href = '/announce.ics'; a.setAttribute('download', 'rm-circle-huddle.ics'); a.textContent = '📅 Apple / Outlook'; a.style.cssText = btnCss; + a.addEventListener('click', done); + row.appendChild(g); row.appendChild(a); foot.appendChild(row); + } + if (EV.meetUrl) { var link = document.createElement('div'); link.textContent = EV.meetUrl.replace(/^https?:\/\//, ''); diff --git a/server.js b/server.js index ca3e727..149652c 100644 --- a/server.js +++ b/server.js @@ -420,7 +420,9 @@ const ANNOUNCE_DEFAULTS = { announceEyebrow: 'Team RM Circle · Weekly Huddle', announceDateLabel: 'Tuesday, September 8', announceTimes: '7:00 PM CST | USA / Canada\n8:00 PM EST |\n8:00 PM AST | Caribbean\n1:00 AM | UK', - announceExpiresUTC: '2026-09-09T02:30:00Z' + announceExpiresUTC: '2026-09-09T02:30:00Z', + announceStartUTC: '', + announceDurationMin: 60 }; function seedAnnounceDefaults() { try { const c = readJson(CONFIG_FILE) || {}; let ch = false; @@ -582,9 +584,27 @@ async function handleApi(req,res,pathname){ enabled: c.announceEnabled===undefined ? true : !!c.announceEnabled, id: c.announceId||'', img: c.announceImg||'', meetUrl: c.announceMeetUrl||'', eyebrow: c.announceEyebrow||'', dateLabel: c.announceDateLabel||'', - times: c.announceTimes||'', expiresUTC: c.announceExpiresUTC||'' + times: c.announceTimes||'', expiresUTC: c.announceExpiresUTC||'', + startUTC: c.announceStartUTC||'', durationMin: Number(c.announceDurationMin)||60 }); } + // calendar file for the event pop-up (Apple Calendar / Outlook); Google uses a render URL built client-side + if(req.method==='GET'&&pathname==='/announce.ics'){ + const c=getConfig();const st=Date.parse(c.announceStartUTC||''); + if(!st)return json(res,404,{error:'No event start set.'}); + const dur=(Number(c.announceDurationMin)||60)*60000; + const fmt=d=>new Date(d).toISOString().replace(/[-:]|\.\d{3}/g,''); + const esc=v=>String(v||'').replace(/\\/g,'\\\\').replace(/;/g,'\\;').replace(/,/g,'\\,').replace(/\r?\n/g,'\\n'); + const title=c.announceEyebrow||'Team RM Circle · Weekly Huddle'; + const desc=(c.announceMeetUrl?'Join: '+c.announceMeetUrl+'\n':'')+String(c.announceTimes||'').replace(/\|/g,' '); + const ics=['BEGIN:VCALENDAR','VERSION:2.0','PRODID:-//RM Circle//Event//EN','CALSCALE:GREGORIAN','METHOD:PUBLISH','BEGIN:VEVENT', + 'UID:'+(c.announceId||'event')+'@rmcircle.team','DTSTAMP:'+fmt(Date.now()),'DTSTART:'+fmt(st),'DTEND:'+fmt(st+dur), + 'SUMMARY:'+esc(title),'DESCRIPTION:'+esc(desc),'LOCATION:'+esc(c.announceMeetUrl||''), + ...(c.announceMeetUrl?['URL:'+c.announceMeetUrl]:[]), + 'BEGIN:VALARM','TRIGGER:-PT30M','ACTION:DISPLAY','DESCRIPTION:'+esc(title),'END:VALARM','END:VEVENT','END:VCALENDAR'].join('\r\n')+'\r\n'; + res.writeHead(200,{'Content-Type':'text/calendar; charset=utf-8','Content-Disposition':'attachment; filename="rm-circle-huddle.ics"','Cache-Control':'no-store'}); + return res.end(ics); + } if(req.method==='GET'&&pathname==='/api/public/member'){ const ip=String(req.headers['x-forwarded-for']||req.socket.remoteAddress||'').split(',')[0].trim(); if(memberLookupLimited(ip))return json(res,429,{error:'Too many lookups — give it a minute.'}); @@ -1580,7 +1600,7 @@ async function handleApi(req,res,pathname){ const maxOrder=sponsors.reduce((m,s)=>Math.max(m,s.sortOrder||0),0);sponsors.push({id:String(id).trim(),name:String(name).trim(),parentId:String(parentId||'').trim(),directs:0,level,status:sponsors.some(s=>s.status==='active')?'waiting':'active',sortOrder:maxOrder+10,clicks:0,notes:String(notes||'').trim(),email:String(email||'').trim().slice(0,120)});sponsors=normalizeStatuses(sponsors);saveSponsors(sponsors);return json(res,201,{sponsors}); } if(req.method==='PATCH'&&pathname==='/api/admin/config'){ - const b=await bodyJson(req),cur=getConfig(),next={...cur};for(const k of ['siteName','programName','bridgeHeadline','bridgeSubheadline','premiumEntryPol','dappReferralBaseUrl','telegramUrl','supportLabel','showSponsorName','showQueueProgress','bemobPostbackUrl','telegramBotToken','companionBotToken','miniAppShortName','telegramChatId','telegramTopicId','telegramRecruitTopicId','telegramProofChatId','telegramProofTopicId','telegramProofEvents','telegramProofCtaUrl','teamRootId','emailFrom','teamAlertEmail','ownerIds','ownerAlertEmail','orgRootId','ctbOfferPostbackUrl','ctbOfferSecret','recruitCtaUrl','walletNotice','tweetEnabled','tweetCtaUrl','tweetHashtags','blotatoTwitterId','dappFallbackPublic','moonpayPublicKey','moonpaySecretKey','publicRotationMode','publicRotationRootId','rotationExcludeIds','suiteAllowlist','suiteToolsInAlerts','suiteLevelOverride','directDefaultIds','announceEnabled','announceId','announceImg','announceMeetUrl','announceEyebrow','announceDateLabel','announceTimes','announceExpiresUTC'])if(Object.prototype.hasOwnProperty.call(b,k))next[k]=b[k];next.premiumEntryPol=Number(next.premiumEntryPol)||362;if('announceEnabled' in next)next.announceEnabled=!!next.announceEnabled;next.updatedAt=new Date().toISOString();writeJson(CONFIG_FILE,next);return json(res,200,{config:next}); + const b=await bodyJson(req),cur=getConfig(),next={...cur};for(const k of ['siteName','programName','bridgeHeadline','bridgeSubheadline','premiumEntryPol','dappReferralBaseUrl','telegramUrl','supportLabel','showSponsorName','showQueueProgress','bemobPostbackUrl','telegramBotToken','companionBotToken','miniAppShortName','telegramChatId','telegramTopicId','telegramRecruitTopicId','telegramProofChatId','telegramProofTopicId','telegramProofEvents','telegramProofCtaUrl','teamRootId','emailFrom','teamAlertEmail','ownerIds','ownerAlertEmail','orgRootId','ctbOfferPostbackUrl','ctbOfferSecret','recruitCtaUrl','walletNotice','tweetEnabled','tweetCtaUrl','tweetHashtags','blotatoTwitterId','dappFallbackPublic','moonpayPublicKey','moonpaySecretKey','publicRotationMode','publicRotationRootId','rotationExcludeIds','suiteAllowlist','suiteToolsInAlerts','suiteLevelOverride','directDefaultIds','announceEnabled','announceId','announceImg','announceMeetUrl','announceEyebrow','announceDateLabel','announceTimes','announceExpiresUTC','announceStartUTC','announceDurationMin'])if(Object.prototype.hasOwnProperty.call(b,k))next[k]=b[k];if('announceDurationMin' in next)next.announceDurationMin=Math.max(15,Number(next.announceDurationMin)||60);next.premiumEntryPol=Number(next.premiumEntryPol)||362;if('announceEnabled' in next)next.announceEnabled=!!next.announceEnabled;next.updatedAt=new Date().toISOString();writeJson(CONFIG_FILE,next);return json(res,200,{config:next}); } const m=pathname.match(/^\/api\/admin\/sponsors\/([^/]+)(?:\/(increment|activate|qualify|reset|move))?$/); if(m){const id=decodeURIComponent(m[1]),action=m[2]||null;let sponsors=getSponsors(),idx=sponsors.findIndex(s=>s.id===id);if(idx<0)return json(res,404,{error:'Sponsor not found.'});