Initial commit: RM Circle team sponsor router (bridge page, /start, /admin, Docker)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-08-11 07:03:35 -05:00
commit 76bd288bf6
20 changed files with 512 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules
npm-debug.log
.env
.git
.DS_Store
+4
View File
@@ -0,0 +1,4 @@
PORT=3000
NODE_ENV=production
ADMIN_PASSWORD=replace-with-a-long-unique-password
DATA_DIR=/app/data
+4
View File
@@ -0,0 +1,4 @@
node_modules/
data/
.env
.claude/
+23
View File
@@ -0,0 +1,23 @@
# Current Build Snapshot
The included seed queue is a starting point only. Verify each sponsor's actual qualification before sending live traffic.
Current seeded order:
1. ID 30 — Orlando — active
2. ID 36 — Mad Dog — waiting
3. ID 35 — Michael Camire — waiting
4. ID 32 — Melissa — waiting
5. ID 34 — Janie — waiting
The app does not include Marty's owned positions in the public qualification queue.
## Team strategy represented by the site
- Premium only — 362 POL entry.
- Get exactly two directs.
- Retire a referral link once that position is qualified.
- Move team recruiting effort down to the qualified member's two directs.
- Build depth instead of extra shallow legs.
- Use earned POL to upgrade when practical; members may optionally self-fund within their own risk tolerance.
- Watch active downline levels because falling behind can cause missed subsequent payments until the upline catches back up according to the team's current compensation-plan guidance.
+65
View File
@@ -0,0 +1,65 @@
# Coolify Deployment — Quick Steps
## 1. Upload the project
Put this folder in a Git repository, or upload it to the server where Coolify can build it.
## 2. Create the Coolify application
Use **Dockerfile** deployment (simplest) or the included `docker-compose.yml`.
## 3. Set environment variables
```text
NODE_ENV=production
PORT=3000
ADMIN_PASSWORD=YOUR-LONG-UNIQUE-PASSWORD
DATA_DIR=/app/data
```
`ADMIN_PASSWORD` is required for the `/admin` sponsor manager. Do not leave it as `changeme`.
## 4. Persist `/app/data`
Create a persistent volume mounted at:
```text
/app/data
```
This preserves the sponsor queue and settings across deployments/restarts.
## 5. Add the domain
Attach your chosen domain/subdomain in Coolify and point it to container port `3000`.
Suggested pattern:
```text
team.yourdomain.com
```
Coolify can handle the reverse proxy and TLS certificate.
## 6. Verify the public pages
- `/` — evergreen bridge page
- `/start` — live sponsor/onboarding page
- `/admin` — sponsor manager
- `/health` — returns `{ "ok": true }`
## 7. Configure before advertising
In `/admin`:
1. Confirm the current active sponsor.
2. Confirm the RM dApp referral base URL.
3. Set the team support/Telegram URL if desired.
4. Confirm Premium entry is 362 POL.
5. Open `/start` in an incognito window and click the join button to verify the final referral ID.
## 8. Normal rotation workflow
1. Current sponsor receives a verified direct.
2. In `/admin`, click **+1 Direct**.
3. When the second direct is verified, click **Qualified**.
4. The next waiting sponsor automatically becomes active.
5. `/start` changes immediately; advertising links do not change.
## Important
The app intentionally does not auto-rotate based on ad clicks. Always verify actual RM Circle placement before marking a sponsor qualified.
+8
View File
@@ -0,0 +1,8 @@
FROM node:22-alpine
WORKDIR /app
COPY . .
RUN mkdir -p /app/data
ENV NODE_ENV=production
ENV PORT=3000
EXPOSE 3000
CMD ["node","server.js"]
+83
View File
@@ -0,0 +1,83 @@
# RM Circle Premium — Crypto Team Build Sponsor Router
A small deployable Node/Express app with:
- `/` — evergreen advertising/bridge page
- `/start` — dynamic onboarding page with the **current team sponsor**
- `/admin` — password-protected sponsor queue manager
- JSON-file persistence suitable for a Docker volume
## Core workflow
1. Traffic lands on the evergreen bridge page.
2. Visitors click **Get Started**.
3. `/start` reads the active sponsor from the server and builds the RM Circle referral link dynamically.
4. In `/admin`, increment a sponsor's direct count as joins are verified.
5. When a sponsor reaches 2, click **Qualified**.
6. The next waiting sponsor becomes active immediately—no HTML or ad changes required.
## Default seeded queue
The seed data reflects the working Crypto Team Build priority list at build time:
1. ID 30 — Orlando (active)
2. ID 36 — Mad Dog
3. ID 35 — Michael Camire
4. ID 32 — Melissa
5. ID 34 — Janie
You can change, reorder, add, or delete sponsors in `/admin`.
## Local run
```bash
cp .env.example .env
# edit .env and set a strong ADMIN_PASSWORD + SESSION_SECRET
set -a && . ./.env && set +a
npm start
```
Open:
- `http://localhost:3000/`
- `http://localhost:3000/start`
- `http://localhost:3000/admin`
## Docker / Coolify
This project includes a zero-dependency Node server, `Dockerfile`, and `docker-compose.yml`. No npm package download is required.
### Coolify recommendation
1. Create a new application from this source/repository.
2. Use the Dockerfile or Compose deployment.
3. Add environment variables:
- `ADMIN_PASSWORD` — strong unique password
- `SESSION_SECRET` — long random string
- `NODE_ENV=production`
- `DATA_DIR=/app/data`
4. Persist `/app/data` using a volume.
5. Point your domain/subdomain at port `3000` through Coolify's normal proxy/domain configuration.
6. Open `/admin`, sign in, and verify the queue before sending traffic.
## Sponsor referral URL
The default base is:
`https://app.thermcircle.com?ref=`
The app appends the current sponsor ID, for example:
`https://app.thermcircle.com?ref=30`
Change the base URL at any time in **Admin → Public Page Settings** without editing code.
## Important operational behavior
The app intentionally does **not** auto-qualify sponsors based on clicks or blockchain activity. A team admin verifies the actual placement and clicks **Qualified**. This prevents an ad click or abandoned transaction from rotating the sponsor queue incorrectly.
## Safety and compliance notes
The public pages include risk language, avoid guaranteed-income claims, and remind users never to disclose a MetaMask Secret Recovery Phrase. The onboarding page points to the official MetaMask website and identifies Polygon Mainnet as chain ID 137 with POL as the native gas token.
This app does not custody crypto, request wallet seed phrases, execute transactions, or store private keys.
+15
View File
@@ -0,0 +1,15 @@
services:
rm-circle-team-router:
build: .
restart: unless-stopped
environment:
NODE_ENV: production
PORT: 3000
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
DATA_DIR: /app/data
volumes:
- rm-circle-team-data:/app/data
ports:
- "3000:3000"
volumes:
rm-circle-team-data:
+9
View File
@@ -0,0 +1,9 @@
{
"name": "rm-circle-team-router",
"version": "1.0.0",
"private": true,
"description": "Evergreen bridge page and dynamic Crypto Team Build sponsor router for RM Circle Premium.",
"main": "server.js",
"scripts": {"start":"node server.js","dev":"node --watch server.js"},
"engines": {"node": ">=20"}
}
+1
View File
@@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Not Found</title><link rel="stylesheet" href="/styles.css"></head><body><main class="wrap hero"><div class="eyebrow">404</div><h1>That page isn't here.</h1><p>Return to the Crypto Team Build strategy page.</p><a class="btn btn-primary" href="/">Go Home</a></main></body></html>
+7
View File
@@ -0,0 +1,7 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex,nofollow"><title>Crypto Team Build Admin</title><link rel="stylesheet" href="/styles.css"></head><body class="admin-bg">
<section id="loginView" class="login-panel"><div class="brand" style="margin-bottom:22px"><div class="brand-mark">RM</div><span>Crypto Team Build<small>Sponsor Router Admin</small></span></div><h1>Team Admin</h1><p>Manage the current sponsor, qualification queue, and onboarding settings.</p><form id="loginForm"><div class="field"><label for="password">Admin password</label><input id="password" class="input" type="password" autocomplete="current-password" required></div><button class="btn btn-primary" style="width:100%">Sign in</button><div id="loginError" class="micro" style="color:var(--danger)"></div></form></section>
<section id="adminView" class="admin hidden"><div class="admin-top"><div><div class="eyebrow">Sponsor router</div><h1>Crypto Team Build Admin</h1></div><div class="nav-actions"><a class="btn btn-secondary" href="/start" target="_blank">View Live Page ↗</a><button id="logoutBtn" class="btn btn-secondary">Log out</button></div></div>
<div class="admin-grid"><div class="table-card"><div style="display:flex;justify-content:space-between;gap:12px;align-items:center;margin-bottom:12px"><div><h2 style="margin:0">Sponsor Queue</h2><p style="color:var(--muted);margin:4px 0 0;font-size:13px">Mark a sponsor qualified to automatically activate the next waiting position.</p></div></div><div class="table-wrap"><table class="table"><thead><tr><th>Order</th><th>Sponsor</th><th>Parent</th><th>Directs</th><th>Level</th><th>Status</th><th>Clicks</th><th>Actions</th></tr></thead><tbody id="sponsorRows"></tbody></table></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>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">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>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>
+16
View File
@@ -0,0 +1,16 @@
const loginView=document.getElementById('loginView'),adminView=document.getElementById('adminView'),rows=document.getElementById('sponsorRows'),toast=document.getElementById('toast');let state=null;
function showToast(msg){toast.textContent=msg;toast.classList.add('show');setTimeout(()=>toast.classList.remove('show'),1800)}
async function api(url,opts={}){const r=await fetch(url,{headers:{'Content-Type':'application/json',...(opts.headers||{})},...opts});const d=await r.json().catch(()=>({}));if(!r.ok)throw new Error(d.error||'Request failed');return d}
async function loadState(){try{state=await api('/api/admin/state');loginView.classList.add('hidden');adminView.classList.remove('hidden');render()}catch(e){loginView.classList.remove('hidden');adminView.classList.add('hidden')}}
function statusClass(s){return `status status-${s}`}
function esc(s){return String(s??'').replace(/[&<>'"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c]))}
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></td><td>${esc(s.parentId||'—')}</td><td><strong>${s.directs}/2</strong></td><td>${esc(s.level)}</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="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>';
const f=document.getElementById('configForm'),c=state.config;for(const k of ['siteName','programName','bridgeHeadline','bridgeSubheadline','premiumEntryPol','dappReferralBaseUrl','telegramUrl','supportLabel'])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}});
document.getElementById('logoutBtn').addEventListener('click',async()=>{await api('/api/admin/logout',{method:'POST'});location.reload()});
document.getElementById('addSponsorForm').addEventListener('submit',async e=>{e.preventDefault();const form=e.currentTarget,obj=Object.fromEntries(new FormData(form));try{const d=await api('/api/admin/sponsors',{method:'POST',body:JSON.stringify(obj)});state.sponsors=d.sponsors;form.reset();render();showToast('Sponsor added')}catch(x){showToast(x.message)}});
document.getElementById('configForm').addEventListener('submit',async e=>{e.preventDefault();const f=e.currentTarget,obj=Object.fromEntries(new FormData(f));obj.showSponsorName=f.elements.showSponsorName.checked;obj.showQueueProgress=f.elements.showQueueProgress.checked;obj.premiumEntryPol=Number(obj.premiumEntryPol);try{const d=await api('/api/admin/config',{method:'PATCH',body:JSON.stringify(obj)});state.config=d.config;render();showToast('Settings saved')}catch(x){showToast(x.message)}});
rows.addEventListener('click',async e=>{const b=e.target.closest('button[data-action]');if(!b)return;const id=b.dataset.id,a=b.dataset.action;try{let d;if(a==='delete'){if(!confirm(`Delete sponsor ID ${id}?`))return;d=await api(`/api/admin/sponsors/${id}`,{method:'DELETE'})}else if(a==='inc')d=await api(`/api/admin/sponsors/${id}/increment`,{method:'POST'});else if(a==='qualify')d=await api(`/api/admin/sponsors/${id}/qualify`,{method:'POST'});else if(a==='activate')d=await api(`/api/admin/sponsors/${id}/activate`,{method:'POST'});else if(a==='reset')d=await api(`/api/admin/sponsors/${id}/reset`,{method:'POST'});else if(a==='up'||a==='down')d=await api(`/api/admin/sponsors/${id}/move`,{method:'POST',body:JSON.stringify({direction:a})});if(d&&d.sponsors){state.sponsors=d.sponsors;render();showToast('Updated')}}catch(x){showToast(x.message)}});
loadState();
+17
View File
@@ -0,0 +1,17 @@
(async function(){
try{
const r=await fetch('/api/public/config');
if(!r.ok)return;
const c=await r.json();
document.title=`${c.siteName} | ${c.programName}`;
const sub=document.getElementById('bridgeSubheadline');
if(sub&&c.bridgeSubheadline)sub.textContent=c.bridgeSubheadline;
const h=document.getElementById('bridgeHeadline');
if(h&&c.bridgeHeadline)h.textContent=c.bridgeHeadline;
const qs=window.location.search; if(qs){document.querySelectorAll('a[href="/start"]').forEach(a=>a.href='/start'+qs)}
}catch(e){}
for(const id of ['p8','p16']){
const node=document.getElementById(id); if(!node)continue;
const count=id==='p8'?8:16; for(let i=0;i<count;i++){const s=document.createElement('span');s.className='person';node.appendChild(s)}
}
})();
+11
View File
@@ -0,0 +1,11 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="description" content="Crypto Team Build evergreen RM Circle Premium strategy bridge page."><title>Crypto Team Build | RM Circle Premium</title><link rel="stylesheet" href="/styles.css"></head>
<body>
<header class="wrap nav"><div class="brand"><div class="brand-mark">RM</div><span>Crypto Team Build<small>RM Circle Premium</small></span></div><div class="nav-actions"><a class="btn btn-secondary hide-mobile" href="#strategy">How it works</a><a class="btn btn-primary" href="/start">Get Started</a></div></header>
<main>
<section class="hero wrap"><div class="eyebrow">Premium • Polygon • Team Duplication</div><h1 id="bridgeHeadline"><span class="gold">Get Your 2.</span><br>Help Your 2 Get Their 2.</h1><p id="bridgeSubheadline">A team-first strategy built around qualification, depth, and duplication.</p><div class="hero-actions"><a class="btn btn-primary" href="/start">Show Me the Current Team Placement →</a><a class="btn btn-secondary" href="#strategy">See the Strategy</a></div><div class="micro">Independent team training resource • Participation involves risk • No income is guaranteed</div></section>
<section id="strategy" class="section"><div class="wrap"><div class="section-head"><div class="eyebrow">The strategy</div><h2>Simple enough to duplicate.</h2><p>The goal is not endless personal recruiting. Each position gets two directs, retires that referral link, then helps the next two positions repeat the process.</p></div><div class="grid-3"><article class="card"><div class="card-icon">2</div><h3>Get exactly two</h3><p>Use your position's link until two direct positions are placed beneath you and your position is qualified.</p></article><article class="card"><div class="card-icon">↘</div><h3>Move the effort down</h3><p>Retire the qualified link. Help each of your two directs use their links until they each have two.</p></article><article class="card"><div class="card-icon">↑</div><h3>Upgrade responsibly</h3><p>Use earned POL to advance when practical. Active builders may also choose to self-fund, but only within their own risk tolerance.</p></article></div></div></section>
<section class="section"><div class="wrap"><div class="section-head"><div class="eyebrow">Moving-link workflow</div><h2>Links retire. Recruiting keeps moving.</h2></div><div class="flow"><div class="flow-step"><b>Step 1</b><strong>Use link</strong><p>Share the current position's referral link.</p></div><div class="flow-step"><b>Step 2</b><strong>Get 2</strong><p>Place exactly two direct positions.</p></div><div class="flow-step"><b>Step 3</b><strong>Retire link</strong><p>Stop creating extra shallow legs.</p></div><div class="flow-step"><b>Step 4</b><strong>Help your 2</strong><p>Shift the team effort to their links.</p></div><div class="flow-step"><b>Step 5</b><strong>Repeat</strong><p>Keep the qualification wave moving down.</p></div></div></div></section>
<section class="section"><div class="wrap"><div class="section-head"><div class="eyebrow">Depth over width</div><h2>2 → 4 → 8 → 16 → 32</h2><p>The first major team milestone is 30 correctly placed positions across the first four generations: 2 + 4 + 8 + 16.</p></div><div class="matrix" aria-label="Matrix growth illustration"><div class="matrix-group"><div class="people"><span class="person"></span><span class="person"></span></div><b>2</b></div><div class="matrix-group"><div class="people"><span class="person"></span><span class="person"></span><span class="person"></span><span class="person"></span></div><b>4</b></div><div class="matrix-group"><div class="people" id="p8"></div><b>8</b></div><div class="matrix-group"><div class="people" id="p16"></div><b>16</b></div></div><div class="notice"><strong>Team principle:</strong> once your two are in place, do not keep adding more directs to the same qualified link. Help the next positions become qualified so the matrix develops depth instead of extra shallow legs.</div></div></section>
<section class="section"><div class="wrap"><div class="card" style="text-align:center;padding:34px"><div class="eyebrow">Ready to start?</div><h2 style="font-size:38px;margin:10px 0">See the current team placement.</h2><p style="max-width:680px;margin:0 auto 20px;color:var(--muted)">The onboarding page automatically shows the sponsor position the team is currently helping. Always use the sponsor shown there instead of an old screenshot or saved link.</p><a class="btn btn-primary" href="/start">Open Getting Started Instructions →</a></div></div></section>
</main><footer class="wrap disclaimer">This independent team page is educational and is not an earnings guarantee or investment advice. Cryptocurrency and smart-contract participation involve risk, including possible loss of funds. Never use funds you cannot afford to lose. Results depend on actual participation, qualification, upgrades, smart-contract behavior, and the market value of POL.<div class="footer-links"><a href="/start">Getting Started</a><a href="/admin">Team Admin</a></div></footer>
<script src="/bridge.js"></script></body></html>
+15
View File
@@ -0,0 +1,15 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="description" content="Current Crypto Team Build onboarding and sponsor placement."><title>Get Started | Crypto Team Build</title><link rel="stylesheet" href="/styles.css"></head>
<body>
<header class="wrap nav"><div class="brand"><div class="brand-mark">RM</div><span>Crypto Team Build<small>Getting Started</small></span></div><div class="nav-actions"><a class="btn btn-secondary" href="/">← Strategy</a></div></header>
<main class="sponsor-shell"><div class="wrap"><div class="section-head"><div class="eyebrow">Current placement</div><h1 style="font-size:clamp(34px,5vw,54px);margin:8px 0 10px">Use the sponsor shown on <span class="gold">this page.</span></h1><p>Team placements rotate as positions become qualified. Do not use an old screenshot or saved referral link.</p></div>
<div class="sponsor-grid"><aside class="sponsor-card"><span class="live-badge"><span class="dot"></span> Current team sponsor</span><div class="sponsor-id" id="sponsorId">—</div><div class="sponsor-name" id="sponsorName">Loading current placement…</div><div id="progressArea"><div style="display:flex;justify-content:space-between;color:var(--muted);font-size:13px"><span>Qualification progress</span><strong id="progressText">0 / 2</strong></div><div class="progress-line"><span id="progressBar" style="width:0%"></span></div></div><div class="facts"><div class="fact"><small>Program</small><strong>Premium</strong></div><div class="fact"><small>Entry</small><strong><span id="entryPol">362</span> POL</strong></div><div class="fact"><small>Network</small><strong>Polygon</strong></div><div class="fact"><small>Gas token</small><strong>POL</strong></div></div><a id="joinButton" class="btn btn-primary" style="width:100%" href="#">Join With Current Sponsor →</a><button id="copyButton" class="btn btn-secondary" style="width:100%;margin-top:9px">Copy Sponsor ID</button><p class="micro" id="queueText" style="text-align:center"></p></aside>
<section><div class="instruction-list"><article class="instruction"><div class="num">1</div><div><h3>Install or open MetaMask</h3><p>Use the official MetaMask website or your device's official app store. Never install a wallet from a link sent by a stranger.</p><div style="margin-top:10px"><a class="btn btn-secondary btn-sm" href="https://metamask.io/" target="_blank" rel="noopener noreferrer">Official MetaMask Site ↗</a></div></div></article>
<article class="instruction"><div class="num">2</div><div><h3>Use Polygon Mainnet</h3><p>In MetaMask, select Polygon. Polygon Mainnet uses chain ID <strong>137</strong>, and POL is the native gas token.</p></div></article>
<article class="instruction"><div class="num">3</div><div><h3>Fund your wallet with POL</h3><p>Premium entry is <strong><span id="entryPol2">362</span> POL</strong>. Keep a small additional POL balance available for network gas. POL's USD value changes with the market.</p></div></article>
<article class="instruction"><div class="num">4</div><div><h3>Use the current team sponsor</h3><p>Confirm that the sponsor ID shown by the RM Circle dApp matches <strong>ID <span id="sponsorIdInline">—</span></strong> before completing the transaction.</p></div></article>
<article class="instruction"><div class="num">5</div><div><h3>Select Premium and review the transaction</h3><p>Read the wallet transaction before signing. Never share your Secret Recovery Phrase or seed words with this site, a sponsor, or a dApp.</p></div></article>
<article class="instruction"><div class="num">6</div><div><h3>Tell the team when you're in</h3><p>Once your position is confirmed, let the team know your new RM Circle ID and sponsor ID so your placement can be mapped correctly.</p></div></article></div>
<div class="callout" style="margin-top:16px"><strong>What happens next:</strong> get exactly 2 directs, retire your qualified referral link, help your 2 get their 2, and upgrade with earned POL when practical. The goal is depth and duplication—not endless directs on one link.</div>
<div class="callout warning" style="margin-top:12px"><strong>Risk reminder:</strong> participation involves cryptocurrency and smart-contract risk. No income is guaranteed. Use only funds you can afford to lose.</div><div id="supportBox" class="notice" style="margin-top:12px"></div><div id="supportLinkWrap" class="hidden" style="margin-top:10px"><a id="supportLink" class="btn btn-secondary" target="_blank" rel="noopener noreferrer">Open Team Support ↗</a></div></section></div></div></main>
<footer class="wrap disclaimer">This is an independent Crypto Team Build onboarding resource, not an owner/principal page. Always confirm transaction details in your wallet before signing. Never disclose your Secret Recovery Phrase.</footer>
<script src="/start.js"></script></body></html>
+29
View File
@@ -0,0 +1,29 @@
let currentSponsor=null;
async function load(){
try{
const [cr,sr]=await Promise.all([fetch('/api/public/config'),fetch('/api/public/current-sponsor')]);
const c=await cr.json(); const s=await sr.json();
if(!sr.ok)throw new Error(s.error||'No sponsor assigned');
currentSponsor=s.sponsor;
document.getElementById('sponsorId').textContent=`ID ${currentSponsor.id}`;
document.getElementById('sponsorIdInline').textContent=currentSponsor.id;
document.getElementById('sponsorName').textContent=currentSponsor.name||'Current Crypto Team Build placement';
document.getElementById('progressText').textContent=`${currentSponsor.directs} / 2`;
document.getElementById('progressBar').style.width=`${Math.min(100,(currentSponsor.directs/2)*100)}%`;
document.getElementById('entryPol').textContent=c.premiumEntryPol;
document.getElementById('entryPol2').textContent=c.premiumEntryPol;
document.getElementById('joinButton').href=currentSponsor.referralUrl;
document.getElementById('queueText').textContent=c.showQueueProgress?`${s.waitingCount} team placement${s.waitingCount===1?'':'s'} waiting behind the current sponsor.`:'';
document.getElementById('supportBox').textContent=c.supportLabel||'Contact your team sponsor if you need help before joining.';
if(c.telegramUrl){const w=document.getElementById('supportLinkWrap'),a=document.getElementById('supportLink');a.href=c.telegramUrl;w.classList.remove('hidden')}
}catch(e){
document.getElementById('sponsorName').textContent=e.message;
document.getElementById('joinButton').classList.add('hidden');
document.getElementById('copyButton').classList.add('hidden');
}
}
document.getElementById('joinButton').addEventListener('click',()=>{fetch('/api/public/join-click',{method:'POST'}).catch(()=>{})});
document.getElementById('copyButton').addEventListener('click',async()=>{
if(!currentSponsor)return; await navigator.clipboard.writeText(currentSponsor.id); const b=document.getElementById('copyButton'); const old=b.textContent;b.textContent='Copied ✓';setTimeout(()=>b.textContent=old,1400)
});
load();
+10
View File
File diff suppressed because one or more lines are too long
+13
View File
@@ -0,0 +1,13 @@
{
"siteName": "Crypto Team Build",
"programName": "RM Circle Premium",
"bridgeHeadline": "Get Your 2. Help Your 2 Get Their 2.",
"bridgeSubheadline": "A team-first strategy built around qualification, depth, and duplication.",
"premiumEntryPol": 362,
"dappReferralBaseUrl": "https://app.thermcircle.com?ref=",
"telegramUrl": "",
"supportLabel": "Contact your Crypto Team Build sponsor if you need help before joining.",
"showSponsorName": true,
"showQueueProgress": true,
"updatedAt": "2026-08-11T00:00:00.000Z"
}
+57
View File
@@ -0,0 +1,57 @@
[
{
"id": "30",
"name": "Orlando",
"parentId": "24",
"directs": 0,
"level": "Scintilla",
"status": "active",
"sortOrder": 10,
"clicks": 0,
"notes": "Current team qualification focus"
},
{
"id": "36",
"name": "Mad Dog",
"parentId": "28",
"directs": 0,
"level": "Scintilla",
"status": "waiting",
"sortOrder": 20,
"clicks": 0,
"notes": "Under ID 28"
},
{
"id": "35",
"name": "Michael Camire",
"parentId": "28",
"directs": 0,
"level": "Scintilla",
"status": "waiting",
"sortOrder": 30,
"clicks": 0,
"notes": "Under ID 28"
},
{
"id": "32",
"name": "Melissa",
"parentId": "27",
"directs": 0,
"level": "Scintilla",
"status": "waiting",
"sortOrder": 40,
"clicks": 0,
"notes": "Under ID 27"
},
{
"id": "34",
"name": "Janie",
"parentId": "27",
"directs": 0,
"level": "Scintilla",
"status": "waiting",
"sortOrder": 50,
"clicks": 0,
"notes": "Under ID 27"
}
]
+120
View File
@@ -0,0 +1,120 @@
const http = require('http');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { URL } = require('url');
const PORT = Number(process.env.PORT || 3000);
const ROOT = __dirname;
const PUBLIC_DIR = path.join(ROOT, 'public');
const DATA_DIR = process.env.DATA_DIR || path.join(ROOT, 'data');
const SEED_DIR = path.join(ROOT, 'seed');
const SPONSORS_FILE = path.join(DATA_DIR, 'sponsors.json');
const CONFIG_FILE = path.join(DATA_DIR, 'config.json');
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'changeme';
const IS_PROD = process.env.NODE_ENV === 'production';
const SESSION_TTL = 8 * 60 * 60 * 1000;
const sessions = new Map();
function ensureDataFile(name) {
fs.mkdirSync(DATA_DIR, { recursive: true });
const target = path.join(DATA_DIR, name);
if (!fs.existsSync(target)) fs.copyFileSync(path.join(SEED_DIR, name), target);
}
ensureDataFile('sponsors.json');
ensureDataFile('config.json');
function readJson(file) { return JSON.parse(fs.readFileSync(file, 'utf8')); }
function writeJson(file, data) {
const temp = `${file}.${crypto.randomUUID()}.tmp`;
fs.writeFileSync(temp, JSON.stringify(data, null, 2));
fs.renameSync(temp, file);
}
function getSponsors() { return readJson(SPONSORS_FILE).sort((a,b)=>(a.sortOrder||0)-(b.sortOrder||0)); }
function saveSponsors(s) { writeJson(SPONSORS_FILE, s); }
function getConfig() { return readJson(CONFIG_FILE); }
function activeSponsor(sponsors) { return sponsors.find(s=>s.status==='active') || sponsors.find(s=>s.status==='waiting') || null; }
function normalizeStatuses(sponsors, preferredActiveId=null) {
const eligible=sponsors.filter(s=>s.status!=='qualified');
let activeId=preferredActiveId;
if(!activeId || !eligible.some(s=>s.id===activeId)){
const existing=eligible.find(s=>s.status==='active');
activeId=existing?existing.id:(eligible[0]?.id||null);
}
return sponsors.map(s=>s.status==='qualified'?s:{...s,status:s.id===activeId?'active':'waiting'});
}
function publicSponsorPayload(sponsor, config) {
if(!sponsor)return null;
return {id:sponsor.id,name:config.showSponsorName?sponsor.name:null,directs:sponsor.directs,goal:2,level:sponsor.level,referralUrl:`${config.dappReferralBaseUrl}${encodeURIComponent(sponsor.id)}`};
}
function securityHeaders(extra={}) {
return {
'X-Content-Type-Options':'nosniff','X-Frame-Options':'DENY','Referrer-Policy':'strict-origin-when-cross-origin',
'Permissions-Policy':'camera=(), microphone=(), geolocation=()',
'Content-Security-Policy':"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; font-src 'self' data:; form-action 'self'; frame-ancestors 'none'",
...extra
};
}
function send(res,status,body,headers={}) { res.writeHead(status,securityHeaders(headers));res.end(body); }
function json(res,status,obj,headers={}) { send(res,status,JSON.stringify(obj),{'Content-Type':'application/json; charset=utf-8',...headers}); }
function parseCookies(req){const out={};for(const p of (req.headers.cookie||'').split(';')){const i=p.indexOf('=');if(i>0)out[p.slice(0,i).trim()]=decodeURIComponent(p.slice(i+1).trim())}return out}
function getSession(req){const token=parseCookies(req)['ctb.sid'];if(!token)return null;const s=sessions.get(token);if(!s)return null;if(s.expires<Date.now()){sessions.delete(token);return null}return {token,...s}}
function requireAdmin(req,res){if(!getSession(req)){json(res,401,{error:'Unauthorized'});return false}return true}
async function bodyJson(req){return await new Promise((resolve,reject)=>{let data='';req.on('data',c=>{data+=c;if(data.length>100000){reject(new Error('Payload too large'));req.destroy()}});req.on('end',()=>{if(!data)return resolve({});try{resolve(JSON.parse(data))}catch(e){reject(new Error('Invalid JSON'))}});req.on('error',reject)})}
function contentType(file){const ext=path.extname(file);return ({'.html':'text/html; charset=utf-8','.css':'text/css; charset=utf-8','.js':'application/javascript; charset=utf-8','.json':'application/json; charset=utf-8','.png':'image/png','.jpg':'image/jpeg','.jpeg':'image/jpeg','.svg':'image/svg+xml','.ico':'image/x-icon'}[ext]||'application/octet-stream')}
function staticFile(res,file,status=200){if(!fs.existsSync(file)||!fs.statSync(file).isFile())return false;send(res,status,fs.readFileSync(file),{'Content-Type':contentType(file),'Cache-Control':path.extname(file)==='.html'?'no-cache':'public, max-age=3600'});return true}
async function handleApi(req,res,pathname){
if(req.method==='GET'&&pathname==='/health') return json(res,200,{ok:true});
if(req.method==='GET'&&pathname==='/api/public/config'){
const c=getConfig();return json(res,200,{siteName:c.siteName,programName:c.programName,bridgeHeadline:c.bridgeHeadline,bridgeSubheadline:c.bridgeSubheadline,premiumEntryPol:c.premiumEntryPol,telegramUrl:c.telegramUrl,supportLabel:c.supportLabel,showQueueProgress:c.showQueueProgress});
}
if(req.method==='GET'&&pathname==='/api/public/current-sponsor'){
const sponsors=getSponsors(),c=getConfig(),a=activeSponsor(sponsors);if(!a)return json(res,404,{error:'No active sponsor is currently assigned.'});
return json(res,200,{sponsor:publicSponsorPayload(a,c),waitingCount:sponsors.filter(s=>s.status==='waiting').length,message:'Always use the current sponsor shown on this page. Team placement rotates as members qualify.'});
}
if(req.method==='POST'&&pathname==='/api/public/join-click'){
let sponsors=getSponsors();const a=activeSponsor(sponsors);if(a){sponsors=sponsors.map(s=>s.id===a.id?{...s,clicks:(s.clicks||0)+1}:s);saveSponsors(sponsors)}return json(res,200,{ok:true});
}
if(req.method==='POST'&&pathname==='/api/admin/login'){
const b=await bodyJson(req).catch(e=>null);if(!b)return json(res,400,{error:'Invalid request'});if(typeof b.password!=='string'||b.password!==ADMIN_PASSWORD)return json(res,401,{error:'Invalid password'});
const token=crypto.randomBytes(32).toString('hex');sessions.set(token,{expires:Date.now()+SESSION_TTL});const cookie=`ctb.sid=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_TTL/1000}${IS_PROD?'; Secure':''}`;return json(res,200,{ok:true},{'Set-Cookie':cookie});
}
if(req.method==='POST'&&pathname==='/api/admin/logout'){
const s=getSession(req);if(s)sessions.delete(s.token);return json(res,200,{ok:true},{'Set-Cookie':'ctb.sid=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0'});
}
if(pathname.startsWith('/api/admin/')&&!requireAdmin(req,res))return;
if(req.method==='GET'&&pathname==='/api/admin/state')return json(res,200,{sponsors:getSponsors(),config:getConfig()});
if(req.method==='POST'&&pathname==='/api/admin/sponsors'){
const b=await bodyJson(req);const {id,name,parentId='',level='Scintilla',notes=''}=b;if(!id||!name)return json(res,400,{error:'ID and name are required.'});let sponsors=getSponsors();if(sponsors.some(s=>String(s.id)===String(id)))return json(res,409,{error:'That sponsor ID already exists.'});
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()});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'])if(Object.prototype.hasOwnProperty.call(b,k))next[k]=b[k];next.premiumEntryPol=Number(next.premiumEntryPol)||362;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.'});
if(req.method==='PATCH'&&!action){const b=await bodyJson(req);for(const k of ['name','parentId','directs','level','notes'])if(Object.prototype.hasOwnProperty.call(b,k))sponsors[idx][k]=b[k];sponsors[idx].directs=Math.max(0,Math.min(2,Number(sponsors[idx].directs)||0));saveSponsors(sponsors);return json(res,200,{sponsors});}
if(req.method==='DELETE'&&!action){const wasActive=sponsors[idx].status==='active';sponsors.splice(idx,1);if(wasActive)sponsors=normalizeStatuses(sponsors);saveSponsors(sponsors);return json(res,200,{sponsors});}
if(req.method==='POST'&&action==='increment'){sponsors[idx].directs=Math.min(2,(Number(sponsors[idx].directs)||0)+1);saveSponsors(sponsors);return json(res,200,{sponsors});}
if(req.method==='POST'&&action==='activate'){if(sponsors[idx].status==='qualified')return json(res,400,{error:'Qualified sponsors cannot be activated until reset.'});sponsors=normalizeStatuses(sponsors,id);saveSponsors(sponsors);return json(res,200,{sponsors});}
if(req.method==='POST'&&action==='qualify'){sponsors[idx]={...sponsors[idx],directs:2,status:'qualified'};sponsors=normalizeStatuses(sponsors);saveSponsors(sponsors);return json(res,200,{sponsors,active:activeSponsor(sponsors)});}
if(req.method==='POST'&&action==='reset'){sponsors[idx]={...sponsors[idx],directs:0,status:'waiting'};sponsors=normalizeStatuses(sponsors);saveSponsors(sponsors);return json(res,200,{sponsors});}
if(req.method==='POST'&&action==='move'){const b=await bodyJson(req);const swap=b.direction==='up'?idx-1:idx+1;if(swap>=0&&swap<sponsors.length){const t=sponsors[idx].sortOrder;sponsors[idx].sortOrder=sponsors[swap].sortOrder;sponsors[swap].sortOrder=t;saveSponsors(sponsors)}return json(res,200,{sponsors:getSponsors()});}
}
return json(res,404,{error:'API endpoint not found'});
}
const server=http.createServer(async(req,res)=>{
try{
const u=new URL(req.url,`http://${req.headers.host||'localhost'}`),pathname=decodeURIComponent(u.pathname);
if(pathname==='/health'||pathname.startsWith('/api/'))return await handleApi(req,res,pathname);
if(req.method!=='GET'&&req.method!=='HEAD')return send(res,405,'Method Not Allowed',{'Content-Type':'text/plain; charset=utf-8'});
let file;
if(pathname==='/')file=path.join(PUBLIC_DIR,'index.html');else if(pathname==='/start'||pathname==='/start/')file=path.join(PUBLIC_DIR,'start.html');else if(pathname==='/admin'||pathname==='/admin/')file=path.join(PUBLIC_DIR,'admin.html');else{
const safe=path.normalize(pathname).replace(/^([.][.][/\\])+/, '').replace(/^[/\\]+/,'');file=path.join(PUBLIC_DIR,safe);if(!file.startsWith(PUBLIC_DIR))file='';
}
if(file&&staticFile(res,file))return;return staticFile(res,path.join(PUBLIC_DIR,'404.html'),404);
}catch(e){console.error(e);json(res,500,{error:'Internal server error'});}
});
server.listen(PORT,()=>{console.log(`Crypto Team Build sponsor router running on http://localhost:${PORT}`);if(ADMIN_PASSWORD==='changeme')console.warn('WARNING: Set ADMIN_PASSWORD before production deployment.');});