From 76bd288bf6f5892d194f13e7b9fa245b799f0642 Mon Sep 17 00:00:00 2001 From: martbost Date: Tue, 11 Aug 2026 07:03:35 -0500 Subject: [PATCH] Initial commit: RM Circle team sponsor router (bridge page, /start, /admin, Docker) Co-Authored-By: Claude Fable 5 --- .dockerignore | 5 ++ .env.example | 4 ++ .gitignore | 4 ++ CURRENT_BUILD_NOTES.md | 23 ++++++++ DEPLOYMENT_COOLIFY.md | 65 ++++++++++++++++++++++ Dockerfile | 8 +++ README.md | 83 ++++++++++++++++++++++++++++ docker-compose.yml | 15 ++++++ package.json | 9 ++++ public/404.html | 1 + public/admin.html | 7 +++ public/admin.js | 16 ++++++ public/bridge.js | 17 ++++++ public/index.html | 11 ++++ public/start.html | 15 ++++++ public/start.js | 29 ++++++++++ public/styles.css | 10 ++++ seed/config.json | 13 +++++ seed/sponsors.json | 57 ++++++++++++++++++++ server.js | 120 +++++++++++++++++++++++++++++++++++++++++ 20 files changed, 512 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 CURRENT_BUILD_NOTES.md create mode 100644 DEPLOYMENT_COOLIFY.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 package.json create mode 100644 public/404.html create mode 100644 public/admin.html create mode 100644 public/admin.js create mode 100644 public/bridge.js create mode 100644 public/index.html create mode 100644 public/start.html create mode 100644 public/start.js create mode 100644 public/styles.css create mode 100644 seed/config.json create mode 100644 seed/sponsors.json create mode 100644 server.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f6c7b7d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +node_modules +npm-debug.log +.env +.git +.DS_Store diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0c8dc3c --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +PORT=3000 +NODE_ENV=production +ADMIN_PASSWORD=replace-with-a-long-unique-password +DATA_DIR=/app/data diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..afc5584 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +data/ +.env +.claude/ diff --git a/CURRENT_BUILD_NOTES.md b/CURRENT_BUILD_NOTES.md new file mode 100644 index 0000000..6724de5 --- /dev/null +++ b/CURRENT_BUILD_NOTES.md @@ -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. diff --git a/DEPLOYMENT_COOLIFY.md b/DEPLOYMENT_COOLIFY.md new file mode 100644 index 0000000..12e4243 --- /dev/null +++ b/DEPLOYMENT_COOLIFY.md @@ -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. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7057669 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..9fb3d62 --- /dev/null +++ b/README.md @@ -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. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0e2362f --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/package.json b/package.json new file mode 100644 index 0000000..b9f92a6 --- /dev/null +++ b/package.json @@ -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"} +} diff --git a/public/404.html b/public/404.html new file mode 100644 index 0000000..b4b4e89 --- /dev/null +++ b/public/404.html @@ -0,0 +1 @@ +Not Found
404

That page isn't here.

Return to the Crypto Team Build strategy page.

Go Home
diff --git a/public/admin.html b/public/admin.html new file mode 100644 index 0000000..52ca58f --- /dev/null +++ b/public/admin.html @@ -0,0 +1,7 @@ +Crypto Team Build Admin + + +
diff --git a/public/admin.js b/public/admin.js new file mode 100644 index 0000000..7c25ed0 --- /dev/null +++ b/public/admin.js @@ -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=>({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c]))} +function render(){ + const sponsors=[...state.sponsors].sort((a,b)=>a.sortOrder-b.sortOrder);rows.innerHTML=sponsors.map((s,i)=>`${i+1}${esc(s.name)}
ID ${esc(s.id)}
${esc(s.parentId||'—')}${s.directs}/2${esc(s.level)}${esc(s.status)}${s.clicks||0}
${s.status!=='qualified'?``:``}${s.status==='waiting'?``:''}
`).join('')||'No sponsors in the queue.'; + 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(); diff --git a/public/bridge.js b/public/bridge.js new file mode 100644 index 0000000..c6d08d1 --- /dev/null +++ b/public/bridge.js @@ -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;iCrypto Team Build | RM Circle Premium + + +
+
Premium • Polygon • Team Duplication

Get Your 2.
Help Your 2 Get Their 2.

A team-first strategy built around qualification, depth, and duplication.

Independent team training resource • Participation involves risk • No income is guaranteed
+
The strategy

Simple enough to duplicate.

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.

2

Get exactly two

Use your position's link until two direct positions are placed beneath you and your position is qualified.

Move the effort down

Retire the qualified link. Help each of your two directs use their links until they each have two.

Upgrade responsibly

Use earned POL to advance when practical. Active builders may also choose to self-fund, but only within their own risk tolerance.

+
Moving-link workflow

Links retire. Recruiting keeps moving.

Step 1Use link

Share the current position's referral link.

Step 2Get 2

Place exactly two direct positions.

Step 3Retire link

Stop creating extra shallow legs.

Step 4Help your 2

Shift the team effort to their links.

Step 5Repeat

Keep the qualification wave moving down.

+
Depth over width

2 → 4 → 8 → 16 → 32

The first major team milestone is 30 correctly placed positions across the first four generations: 2 + 4 + 8 + 16.

2
4
8
16
Team principle: 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.
+
Ready to start?

See the current team placement.

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.

Open Getting Started Instructions →
+
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.
+ diff --git a/public/start.html b/public/start.html new file mode 100644 index 0000000..d14631a --- /dev/null +++ b/public/start.html @@ -0,0 +1,15 @@ +Get Started | Crypto Team Build + + +
Current placement

Use the sponsor shown on this page.

Team placements rotate as positions become qualified. Do not use an old screenshot or saved referral link.

+
+
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.
+ diff --git a/public/start.js b/public/start.js new file mode 100644 index 0000000..727f72c --- /dev/null +++ b/public/start.js @@ -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(); diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..6b14dff --- /dev/null +++ b/public/styles.css @@ -0,0 +1,10 @@ +:root{ + --bg:#071421;--bg2:#0b1d2e;--panel:#0d2236;--panel2:#10283f;--gold:#f3be43;--gold2:#d89b19; + --teal:#4ed6cb;--text:#f7f9fc;--muted:#aebdca;--danger:#ff7e6b;--ok:#7be0a1;--line:#27445e; + --shadow:0 20px 50px rgba(0,0,0,.28);--radius:20px; +} +*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background: +radial-gradient(circle at 20% 0%,rgba(78,214,203,.10),transparent 34%),radial-gradient(circle at 90% 5%,rgba(243,190,67,.10),transparent 28%),linear-gradient(180deg,var(--bg),#06101b 75%);color:var(--text);min-height:100vh} +a{color:inherit}.wrap{width:min(1160px,calc(100% - 32px));margin:auto}.nav{height:76px;display:flex;align-items:center;justify-content:space-between;gap:20px}.brand{display:flex;align-items:center;gap:12px;font-weight:800;letter-spacing:.2px}.brand-mark{width:42px;height:42px;border:2px solid var(--gold);border-radius:50%;display:grid;place-items:center;color:var(--gold);font-family:Georgia,serif;font-size:19px;box-shadow:inset 0 0 18px rgba(243,190,67,.13)}.brand span small{display:block;font-weight:600;color:var(--muted);font-size:11px;letter-spacing:1.6px;text-transform:uppercase;margin-top:2px}.nav-actions{display:flex;gap:10px;align-items:center}.btn{appearance:none;border:0;border-radius:12px;padding:13px 18px;font-weight:800;cursor:pointer;text-decoration:none;display:inline-flex;justify-content:center;align-items:center;gap:8px;transition:.18s ease;font-size:15px}.btn:hover{transform:translateY(-1px)}.btn-primary{background:linear-gradient(135deg,var(--gold),#f7d173);color:#152033;box-shadow:0 10px 28px rgba(243,190,67,.18)}.btn-secondary{background:#132c43;color:var(--text);border:1px solid #294a65}.btn-teal{background:linear-gradient(135deg,var(--teal),#8ce9df);color:#0a1b27}.btn-danger{background:#3a2026;color:#ffb2a5;border:1px solid #6a3133}.btn-sm{padding:9px 11px;font-size:13px;border-radius:9px}.hero{padding:82px 0 70px;text-align:center}.eyebrow{color:var(--teal);text-transform:uppercase;letter-spacing:2.2px;font-weight:800;font-size:13px}.hero h1{font-size:clamp(42px,7vw,78px);line-height:.98;margin:14px auto 20px;max-width:1000px;letter-spacing:-3px}.gold{color:var(--gold)}.hero p{font-size:clamp(18px,2.3vw,23px);line-height:1.55;color:var(--muted);max-width:780px;margin:0 auto 30px}.hero-actions{display:flex;flex-wrap:wrap;gap:12px;justify-content:center}.micro{font-size:13px;color:#8498aa;margin-top:18px}.section{padding:46px 0}.section-head{max-width:760px;margin-bottom:26px}.section-head h2{font-size:clamp(28px,4vw,44px);margin:0 0 10px}.section-head p{color:var(--muted);font-size:17px;line-height:1.6;margin:0}.grid-3{display:grid;grid-template-columns:repeat(3,1fr);gap:18px}.card{background:linear-gradient(180deg,rgba(16,40,63,.94),rgba(9,27,43,.96));border:1px solid #23425d;border-radius:var(--radius);padding:24px;box-shadow:var(--shadow)}.card-icon{width:45px;height:45px;border-radius:13px;display:grid;place-items:center;background:#143850;border:1px solid #27617a;color:var(--teal);font-size:21px;margin-bottom:18px}.card h3{margin:0 0 8px;font-size:20px}.card p{margin:0;color:var(--muted);line-height:1.58}.flow{display:grid;grid-template-columns:repeat(5,1fr);gap:12px;align-items:stretch}.flow-step{position:relative;padding:18px;border-radius:16px;background:#0d2236;border:1px solid #27445e}.flow-step b{display:block;color:var(--gold);font-size:12px;text-transform:uppercase;letter-spacing:1px;margin-bottom:9px}.flow-step strong{font-size:17px}.flow-step p{font-size:13px;color:var(--muted);line-height:1.45}.flow-step:not(:last-child)::after{content:'→';position:absolute;right:-16px;top:50%;transform:translateY(-50%);color:var(--teal);font-weight:900;z-index:2}.matrix{display:flex;justify-content:center;align-items:flex-end;gap:clamp(8px,2vw,25px);padding:24px 0}.matrix-group{text-align:center;color:var(--muted);font-size:13px}.people{display:flex;flex-wrap:wrap;max-width:220px;justify-content:center;gap:5px;margin-bottom:8px}.person{width:17px;height:22px;border-radius:10px 10px 6px 6px;background:linear-gradient(var(--teal),#1c918b);position:relative}.person::before{content:'';position:absolute;width:9px;height:9px;border-radius:50%;background:var(--gold);left:4px;top:-6px}.notice{border:1px solid #594b24;background:rgba(243,190,67,.08);border-radius:16px;padding:18px;color:#ecdcae;line-height:1.55}.disclaimer{border-top:1px solid #1f3649;margin-top:55px;padding:28px 0 38px;color:#7f93a5;font-size:12px;line-height:1.6}.sponsor-shell{padding:36px 0 70px}.sponsor-grid{display:grid;grid-template-columns:1.1fr .9fr;gap:22px;align-items:start}.sponsor-card{background:linear-gradient(145deg,#132d45,#0a1b2b);border:1px solid #38556b;border-radius:24px;padding:28px;box-shadow:var(--shadow);position:sticky;top:18px}.live-badge{display:inline-flex;align-items:center;gap:7px;border-radius:999px;padding:7px 10px;background:rgba(123,224,161,.09);color:var(--ok);border:1px solid rgba(123,224,161,.28);font-size:12px;font-weight:900;text-transform:uppercase;letter-spacing:.8px}.dot{width:8px;height:8px;background:var(--ok);border-radius:50%;box-shadow:0 0 12px var(--ok)}.sponsor-id{font-size:64px;color:var(--gold);font-weight:900;letter-spacing:-2px;margin:12px 0 2px}.sponsor-name{font-size:24px;font-weight:800;margin-bottom:18px}.progress-line{height:12px;border-radius:99px;background:#162f45;overflow:hidden;margin:9px 0 8px}.progress-line span{height:100%;display:block;background:linear-gradient(90deg,var(--teal),var(--gold));border-radius:99px}.facts{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin:16px 0}.fact{background:#0b1c2c;border:1px solid #203d55;padding:12px;border-radius:13px}.fact small{display:block;color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.8px}.fact strong{display:block;margin-top:4px}.instruction-list{display:grid;gap:14px}.instruction{display:grid;grid-template-columns:42px 1fr;gap:14px;padding:18px;background:#0d2236;border:1px solid #233f56;border-radius:16px}.num{width:42px;height:42px;border-radius:12px;background:#183952;border:1px solid #29617b;color:var(--teal);font-weight:900;display:grid;place-items:center}.instruction h3{margin:0 0 6px}.instruction p{margin:0;color:var(--muted);line-height:1.55}.callout{padding:17px;border-radius:14px;background:rgba(78,214,203,.07);border:1px solid rgba(78,214,203,.23);color:#bdece8;line-height:1.55}.warning{background:rgba(255,126,107,.06);border-color:rgba(255,126,107,.22);color:#f0b4aa}.admin-bg{min-height:100vh;background:#06101a}.login-panel{width:min(430px,calc(100% - 32px));margin:12vh auto;background:#0e2235;border:1px solid #28465e;border-radius:22px;padding:30px;box-shadow:var(--shadow)}.login-panel h1{margin:0 0 8px}.login-panel p{color:var(--muted);margin-top:0}.input,.select{width:100%;background:#071827;color:var(--text);border:1px solid #29475f;border-radius:11px;padding:12px 13px;font:inherit;outline:none}.input:focus,.select:focus{border-color:var(--teal)}label{display:block;font-size:12px;color:#a7b8c6;text-transform:uppercase;letter-spacing:.7px;font-weight:800;margin:0 0 6px}.field{margin-bottom:14px}.admin{width:min(1300px,calc(100% - 30px));margin:auto;padding:24px 0 60px}.admin-top{display:flex;justify-content:space-between;gap:15px;align-items:center;margin-bottom:22px}.admin-top h1{margin:0}.admin-grid{display:grid;grid-template-columns:1.5fr .75fr;gap:18px}.table-card{background:#0b1e30;border:1px solid #27445e;border-radius:18px;padding:18px;overflow:hidden}.table-wrap{overflow:auto}.table{width:100%;border-collapse:collapse;min-width:850px}.table th{text-align:left;color:#8da3b5;font-size:11px;text-transform:uppercase;letter-spacing:.8px;border-bottom:1px solid #27445e;padding:10px}.table td{padding:12px 10px;border-bottom:1px solid #172f43;vertical-align:middle}.status{display:inline-block;border-radius:999px;padding:5px 9px;font-size:11px;font-weight:900;text-transform:uppercase}.status-active{background:rgba(123,224,161,.1);color:var(--ok)}.status-waiting{background:rgba(243,190,67,.1);color:#f2c768}.status-qualified{background:rgba(78,214,203,.1);color:var(--teal)}.actions{display:flex;flex-wrap:wrap;gap:6px}.stack{display:grid;gap:18px}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}.toast{position:fixed;right:20px;bottom:20px;background:#132d45;border:1px solid #31536d;padding:13px 16px;border-radius:11px;box-shadow:var(--shadow);display:none;z-index:20}.toast.show{display:block}.hidden{display:none!important}.empty{padding:20px;color:var(--muted);text-align:center}.footer-links{display:flex;justify-content:center;gap:14px;flex-wrap:wrap;margin-top:18px}.footer-links a{color:#9eb4c6;text-decoration:none;font-size:13px}.footer-links a:hover{color:var(--teal)} +@media(max-width:900px){.grid-3{grid-template-columns:1fr}.flow{grid-template-columns:1fr 1fr}.flow-step::after{display:none}.sponsor-grid,.admin-grid{grid-template-columns:1fr}.sponsor-card{position:static}.admin-top{align-items:flex-start;flex-direction:column}.hero{padding-top:48px}.hero h1{letter-spacing:-2px}.nav-actions .hide-mobile{display:none}} +@media(max-width:560px){.wrap{width:min(100% - 22px,1160px)}.nav{height:66px}.brand span{font-size:14px}.brand-mark{width:38px;height:38px}.hero{padding:40px 0 45px}.hero-actions{display:grid}.hero-actions .btn{width:100%}.flow{grid-template-columns:1fr}.facts{grid-template-columns:1fr}.form-grid{grid-template-columns:1fr}.sponsor-id{font-size:54px}.section{padding:32px 0}.matrix{overflow:auto;justify-content:flex-start;padding-left:10px}.admin{width:calc(100% - 20px)}} diff --git a/seed/config.json b/seed/config.json new file mode 100644 index 0000000..c13b02e --- /dev/null +++ b/seed/config.json @@ -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" +} diff --git a/seed/sponsors.json b/seed/sponsors.json new file mode 100644 index 0000000..01fa7eb --- /dev/null +++ b/seed/sponsors.json @@ -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" + } +] diff --git a/server.js b/server.js new file mode 100644 index 0000000..ee3497d --- /dev/null +++ b/server.js @@ -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{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{ + 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.');});