LinkSpin test area: InstantAdPay engine fork rebranded, network registry, sponsor carry-over with engine activation and claim window, rotator with /r/ redirects, link-domain mini-sites
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@@ -0,0 +1,492 @@
|
||||
<!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>Admin | LinkSpin</title>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
|
||||
<link rel="icon" type="image/png" href="/logo-icon.png">
|
||||
<link rel="stylesheet" href="/assets/site.css?v=20260910h">
|
||||
<style>
|
||||
.adm-table{width:100%;table-layout:auto}
|
||||
.adm-table th{cursor:pointer;user-select:none;white-space:nowrap}
|
||||
#trfSources th,#trfAngles th{white-space:normal;text-align:center;line-height:1.2;vertical-align:bottom}
|
||||
#trfSources td:not(:first-child),#trfAngles td:not(:first-child){text-align:center;font-variant-numeric:tabular-nums}
|
||||
.adm-table th.sort-asc::after{content:' \25B2';font-size:9px;color:var(--mint)}
|
||||
.adm-table th.sort-desc::after{content:' \25BC';font-size:9px;color:var(--mint)}
|
||||
.adm-table.kv th{cursor:default;color:var(--muted);font-weight:500}
|
||||
.adm-table.kv th.sort-asc::after,.adm-table.kv th.sort-desc::after{content:''}
|
||||
.adm-table td.act,.adm-table th:last-child{width:1%;white-space:nowrap} /* the action column keeps its full width; text columns give way */
|
||||
.adm-table td:first-child{overflow-wrap:anywhere;word-break:break-word}
|
||||
.adm-table th,.adm-table td{padding:8px 10px;text-align:left;vertical-align:top;border-bottom:1px solid var(--line);font-size:13.5px}
|
||||
.adm-table td.when{white-space:nowrap}
|
||||
.adm-table th{color:var(--muted);font-size:11.5px;text-transform:uppercase;letter-spacing:.08em}
|
||||
.adm-table .act{white-space:nowrap}
|
||||
.adm-table .act button{margin-right:6px}
|
||||
.st{display:inline-block;border-radius:999px;padding:2px 9px;font-size:11.5px;font-weight:700;letter-spacing:.04em;text-transform:uppercase}
|
||||
.st.active{background:rgba(67,232,195,.14);color:var(--mint)}
|
||||
.st.paused{background:rgba(255,178,56,.14);color:var(--amber)}
|
||||
.st.out{background:rgba(255,255,255,.08);color:var(--muted)}
|
||||
.house-tag{display:inline-block;border:1px solid rgba(157,125,255,.5);color:var(--violet);border-radius:6px;padding:1px 6px;font-size:10.5px;font-weight:700;margin-left:6px;vertical-align:middle}
|
||||
.adm-form p{margin:0 0 10px}
|
||||
.adm-form label.small{display:block;margin-bottom:4px}
|
||||
.trunc{max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:inline-block;vertical-align:bottom}
|
||||
textarea.json{font-family:var(--mono);font-size:12.5px;width:100%;min-height:260px}
|
||||
.ph-chips{display:inline-flex;gap:6px;flex-wrap:wrap;margin-left:6px;vertical-align:middle}
|
||||
.ph-chips .chip-t{padding:2px 10px;font-size:11.5px;font-family:var(--mono);text-transform:none;letter-spacing:0;cursor:pointer;background:transparent}
|
||||
.drip-steps{display:grid;gap:12px}
|
||||
.drip-step{border:1px solid var(--line);border-radius:12px;padding:14px 16px;background:rgba(4,8,7,.4)}
|
||||
.drip-step .ds-head{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:8px}
|
||||
.drip-step .ds-n{font-family:var(--mono);font-size:12px;color:var(--mint);letter-spacing:.08em}
|
||||
.drip-step .ds-when{display:flex;align-items:center;gap:6px;font-size:13px;color:var(--muted)}
|
||||
.drip-step .ds-when input{width:80px;text-align:right}
|
||||
.drip-step .ds-when b{color:var(--ink)}
|
||||
.drip-step .ds-tools{margin-left:auto;display:flex;gap:6px;flex-wrap:wrap}
|
||||
.drip-step .ds-tools .btn{padding:6px 12px;font-size:12px}
|
||||
.drip-step input.ds-subject{width:100%;margin-bottom:8px;font-weight:700}
|
||||
.drip-step textarea.ds-body{width:100%;min-height:200px;font-size:13.5px;line-height:1.5}
|
||||
.rates-form{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:12px 22px}
|
||||
.rates-form .rf{display:flex;flex-direction:column;gap:4px}
|
||||
.rates-form .rf label{font-size:13px;font-weight:700}
|
||||
.rates-form .rf .hint{font-size:11.5px;color:var(--muted)}
|
||||
.rates-form .rf input[type=number]{max-width:160px}
|
||||
.rates-form .rf.wide{grid-column:1/-1}
|
||||
.rates-form .sub-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:8px}
|
||||
.rates-form .sub-grid label{font-size:12px;font-weight:600;color:var(--muted);display:flex;flex-direction:column;gap:3px}
|
||||
.rates-form table.tiers{width:auto;font-size:13px}
|
||||
.rates-form table.tiers td,.rates-form table.tiers th{padding:4px 8px;border:0}
|
||||
.rates-form table.tiers input{width:90px}
|
||||
.kv-form{display:grid;gap:8px}
|
||||
.kv-row{display:grid;grid-template-columns:220px 1fr auto;gap:10px;align-items:center}
|
||||
.kv-row .k{font-family:var(--mono);font-size:12.5px;overflow-wrap:anywhere}
|
||||
.kv-row input[type=checkbox]{width:auto;justify-self:start}
|
||||
@media(max-width:700px){.kv-row{grid-template-columns:1fr}}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bo-body">
|
||||
|
||||
<!-- signed-out: admin sign-in -->
|
||||
<div id="authArea">
|
||||
<div class="wrap" style="max-width:520px">
|
||||
<section class="hero" style="padding:64px 0 10px;text-align:left">
|
||||
<p class="eyebrow">LinkSpin</p>
|
||||
<h1 style="font-size:34px">Admin <em>sign in</em></h1>
|
||||
<p class="lead" style="font-size:16px">Only the admin address can sign in here. A one-time code goes to that inbox.</p>
|
||||
</section>
|
||||
<div class="card">
|
||||
<p><input id="adEmail" type="email" placeholder="Admin email" autocomplete="email" style="width:100%"></p>
|
||||
<p id="adCodeRow" hidden><input id="adCode" inputmode="numeric" placeholder="6-digit code from your email" style="width:100%"></p>
|
||||
<p id="adErr" class="small" style="color:var(--bad)" hidden></p>
|
||||
<p style="margin:0;display:flex;gap:10px">
|
||||
<button class="btn" id="adSend" type="button">Send code</button>
|
||||
<button class="btn" id="adVerify" type="button" hidden>Sign in</button>
|
||||
</p>
|
||||
<p class="small muted" style="margin:14px 0 0"><a href="/my">Back to the member area</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- signed-in: admin shell -->
|
||||
<div id="adminArea" class="bo" hidden>
|
||||
<aside class="bo-side" id="boSide">
|
||||
<a class="logo" href="/"><img src="/logo.png" alt="LinkSpin" style="height:32px;display:block"></a>
|
||||
<span class="byline">Brought to you by the <b>Crypto Team Build Network</b></span>
|
||||
<nav class="bo-menu" aria-label="Admin menu">
|
||||
<button data-pane="overview" class="on" type="button"><svg viewBox="0 0 24 24"><rect x="3" y="3" width="8" height="8" rx="2"/><rect x="13" y="3" width="8" height="8" rx="2"/><rect x="3" y="13" width="8" height="8" rx="2"/><rect x="13" y="13" width="8" height="8" rx="2"/></svg>Overview</button>
|
||||
<button data-pane="house" type="button"><svg viewBox="0 0 24 24"><path d="M3 11l14-5v12L3 13v-2z"/><path d="M17 8a4 4 0 0 1 0 8M7 13v5a2 2 0 0 0 4 0v-3"/></svg>House ads</button>
|
||||
<button data-pane="campaigns" type="button"><svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h10"/></svg>All campaigns</button>
|
||||
<button data-pane="members" type="button"><svg viewBox="0 0 24 24"><circle cx="12" cy="7" r="3.2"/><circle cx="5" cy="17" r="2.6"/><circle cx="19" cy="17" r="2.6"/><path d="M12 10v3M12 13l-5 2M12 13l5 2"/></svg>Members</button>
|
||||
<button data-pane="reports" type="button"><svg viewBox="0 0 24 24"><path d="M12 3l9 16H3z"/><path d="M12 10v4M12 17v.5"/></svg>Reports<span class="pill" id="repBadge" hidden></span></button>
|
||||
<button data-pane="traffic" type="button"><svg viewBox="0 0 24 24"><path d="M3 17l6-6 4 4 8-8"/><path d="M14 7h7v7"/></svg>Traffic</button>
|
||||
<button data-pane="blog" type="button"><svg viewBox="0 0 24 24"><path d="M4 4h12l4 4v12H4z"/><path d="M8 12h8M8 16h8M8 8h4"/></svg>Blog</button>
|
||||
<button data-pane="releases" type="button"><svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h10M4 18h7"/><circle cx="18" cy="17" r="3"/></svg>Releases</button>
|
||||
<button data-pane="pnl" type="button"><svg viewBox="0 0 24 24"><path d="M4 19V5M4 19h16"/><path d="M8 15l3-4 3 2 5-6"/></svg>P&L</button>
|
||||
<button data-pane="settings" type="button"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></svg>Settings</button>
|
||||
</nav>
|
||||
<div class="bo-links">
|
||||
<span class="bo-cap">Site</span>
|
||||
<a href="/my">Member area</a>
|
||||
<a href="/ledger" target="_blank" rel="noopener">Live ledger</a>
|
||||
<a href="/" target="_blank" rel="noopener">Home page</a>
|
||||
</div>
|
||||
<div class="bo-foot">
|
||||
<span id="adWho" class="small muted">…</span>
|
||||
<a href="#" id="adLogout" class="small">Log out</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="bo-main">
|
||||
<header class="bo-top">
|
||||
<button id="boBurger" aria-label="Menu" type="button">☰</button>
|
||||
<h2 id="boTitle" style="margin:0">Overview</h2>
|
||||
<span class="small muted" id="chainLine"></span>
|
||||
</header>
|
||||
<main class="bo-content">
|
||||
|
||||
<div class="pane" id="pane-overview">
|
||||
<div class="grid" style="grid-template-columns:repeat(auto-fit,minmax(200px,1fr));margin-top:0">
|
||||
<div class="statx"><div><div class="nv" id="ovAccounts">0</div><div class="lb">Accounts</div></div></div>
|
||||
<div class="statx c-cyan"><div><div class="nv" id="ovMembers">0</div><div class="lb">On-chain members</div></div></div>
|
||||
<div class="statx c-violet"><div><div class="nv" id="ovActive">0</div><div class="lb">Active campaigns</div><span class="chip flat" id="ovCampSub"></span></div></div>
|
||||
<div class="statx c-amber"><div><div class="nv" id="ovReports">0</div><div class="lb">Open reports</div><span class="chip flat" id="ovBurnSub"></span></div></div>
|
||||
<div class="statx"><div><div class="nv" id="ovDrips">0</div><div class="lb">Follow-ups in flight</div><span class="chip flat" id="ovDripSub"></span></div></div>
|
||||
</div>
|
||||
<div class="grid c2" style="margin-top:16px">
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Campaigns by format</h3><span class="sub">all owners</span></div>
|
||||
<div id="ovByType" class="small muted">…</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Chain</h3><span class="sub">live config</span></div>
|
||||
<div id="ovChain" class="small">…</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Quick actions</h3></div>
|
||||
<p style="display:flex;gap:10px;flex-wrap:wrap;margin:0">
|
||||
<button class="btn small" type="button" data-goto="house">Place a house ad</button>
|
||||
<button class="btn small sec" type="button" data-goto="campaigns">Review campaigns</button>
|
||||
<button class="btn small sec" type="button" data-goto="members">Members</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pane" id="pane-house" hidden>
|
||||
<div class="card adm-form">
|
||||
<div class="card-head"><h3>New house ad</h3><span class="sub">free: nothing is charged, the budget is only a delivery cap</span></div>
|
||||
<div class="grid c3">
|
||||
<p><select id="hType" style="width:100%">
|
||||
<option value="banner">Banner (per impression)</option>
|
||||
<option value="text">Text ad (per impression)</option>
|
||||
<option value="login">Login ad (full-screen after sign-in)</option>
|
||||
<option value="solo">Solo ad (inbox delivery)</option>
|
||||
<option value="video">Video ad (watch to earn)</option>
|
||||
<option value="featured">Featured link (rotation)</option>
|
||||
<option value="visits">Verified visits</option>
|
||||
</select></p>
|
||||
<p><input id="hName" placeholder="Campaign name" style="width:100%"></p>
|
||||
<p><input id="hBudget" type="number" min="10" placeholder="Delivery cap in credits (default 100,000)" style="width:100%"></p>
|
||||
</div>
|
||||
<p><input id="hTarget" placeholder="Target URL (https://…)" autocomplete="off" style="width:100%"></p>
|
||||
<p class="small muted" style="margin-top:-4px">Banner and text targets are framed in the ad viewer, so they must allow framing (checked on submit).</p>
|
||||
<div id="hBannerRow">
|
||||
<p><input id="hImage" placeholder="Image URL (https://… or /uploads/…)" autocomplete="off" style="width:100%"></p>
|
||||
<p><input type="file" id="hImageFile" accept="image/png,image/jpeg,image/webp,image/gif" hidden>
|
||||
<button type="button" class="btn small sec" id="hImageUploadBtn">Upload image</button>
|
||||
<span id="hImageInfo" class="small muted"></span></p>
|
||||
<p><select id="hSize" style="width:100%"></select></p>
|
||||
</div>
|
||||
<div id="hTextRow" hidden>
|
||||
<p><input id="hTitle" maxlength="60" placeholder="Headline (max 60)" style="width:100%"></p>
|
||||
<p><input id="hBody" maxlength="140" placeholder="Ad text (max 140)" style="width:100%"></p>
|
||||
</div>
|
||||
<div id="hSoloRow" hidden>
|
||||
<p><input id="hSoloTitle" maxlength="80" placeholder="Subject line (max 80)" style="width:100%"></p>
|
||||
<p><textarea id="hSoloBody" rows="8" placeholder="Message body. Simple HTML is fine (bold, lists, links, images)." style="width:100%"></textarea></p>
|
||||
<p><input id="hSoloCta" maxlength="30" placeholder="Call-to-action label (default: Learn more)" style="width:100%"></p>
|
||||
<p class="small muted" id="hSoloHint"></p>
|
||||
</div>
|
||||
<div id="hVideoRow" hidden>
|
||||
<p><input id="hVideoUrl" placeholder="Video URL (direct https link ending .mp4 or .webm, or /uploads/…)" autocomplete="off" style="width:100%"></p>
|
||||
<p><input type="file" id="hVideoFile" accept="video/mp4,video/webm" hidden>
|
||||
<button type="button" class="btn small sec" id="hVideoUploadBtn">Upload video</button>
|
||||
<span id="hVideoInfo" class="small muted"></span></p>
|
||||
<p><input id="hVideoTitle" maxlength="80" placeholder="Video title (optional)" style="width:100%"></p>
|
||||
<p><select id="hWatchSecs" style="width:100%"></select></p>
|
||||
<p><input id="hVideoCta" maxlength="30" placeholder="Call-to-action label (default: Learn more)" style="width:100%"></p>
|
||||
</div>
|
||||
<div id="hFeatRow" hidden>
|
||||
<p><input id="hFeatTitle" maxlength="70" placeholder="Headline for the featured link" style="width:100%"></p>
|
||||
<div class="grid c2">
|
||||
<p><select id="hFeatDays" style="width:100%"></select></p>
|
||||
<p><input id="hFeatStart" type="number" min="0" value="0" placeholder="Start in N days (0 = today)" style="width:100%"></p>
|
||||
</div>
|
||||
<p class="small muted" id="hFeatHint"></p>
|
||||
</div>
|
||||
<div id="hVisitsRow" hidden>
|
||||
<p><input id="hVisitTitle" maxlength="80" placeholder="Headline (what members see before visiting)" style="width:100%"></p>
|
||||
<p><input id="hVisitCount" type="number" min="20" placeholder="How many verified visits?" style="width:100%"></p>
|
||||
<p class="small muted" id="hVisitHint"></p>
|
||||
</div>
|
||||
<p id="hErr" class="small" style="color:var(--bad)" hidden></p>
|
||||
<button class="btn" id="hCreate" type="button">Place house ad</button>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>House ads</h3><span class="sub" id="houseSub">running free</span></div>
|
||||
<div class="tablewrap"><table class="adm-table" id="houseTable"></table></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Wall fallback ads</h3><span class="sub" id="wallAdsSub">shown on member walls in positions they have not earned or filled, when no upline banner exists</span></div>
|
||||
<p class="small muted">A member's wall has three positions. Position 1 is theirs. Positions 2 and 3 show an upline's banner until the member earns them (2 and 5 qualifying buyers); when there is no upline banner, one of these ads shows instead. They rotate in order across walls. Leave the list empty to fall back to a plain LinkSpin card.</p>
|
||||
<div id="wallAdsList" class="drip-steps"></div>
|
||||
<p style="display:flex;gap:10px;flex-wrap:wrap;margin:12px 0 0">
|
||||
<button class="btn small" id="wallAdsSave" type="button">Save wall ads</button>
|
||||
<button class="btn small sec" id="wallAdsAdd" type="button">+ Add a wall ad</button>
|
||||
</p>
|
||||
<p id="wallAdsErr" class="small" style="color:var(--bad)" hidden></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pane" id="pane-campaigns" hidden>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>All campaigns</h3><span class="sub" id="campSub">newest first</span></div>
|
||||
<p style="margin:0 0 10px"><input id="campFilter" placeholder="Filter by owner, name, type or status" style="width:100%"></p>
|
||||
<div class="tablewrap"><table class="adm-table" id="campTable"></table></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pane" id="pane-members" hidden>
|
||||
<div class="card" id="memSearchCard">
|
||||
<div class="card-head"><h3>Find a member</h3><span class="sub">matches as you type; Enter opens the first match</span></div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap"><input id="memSearch" autocomplete="off" placeholder="Start typing a name, username, email, member # or wallet…" style="flex:1;min-width:240px"><button type="button" class="btn small" id="memOpen">Open</button></div>
|
||||
<div id="memHits" hidden style="margin-top:6px;border:1px solid var(--line);border-radius:10px;overflow:hidden"></div>
|
||||
<p class="small" id="memSearchMsg" hidden style="margin:8px 0 0"></p>
|
||||
</div>
|
||||
<div class="card" id="memCard" hidden>
|
||||
<div class="card-head"><h3 id="mcName">Member</h3><span class="sub" id="mcSub"></span></div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin:0 0 12px">
|
||||
<button type="button" class="btn small sec" id="mcBack">← Back to list</button>
|
||||
<button type="button" class="btn small sec" data-mcact="username">Set username</button>
|
||||
<button type="button" class="btn small sec" data-mcact="sponsor">Set sponsor</button>
|
||||
<button type="button" class="btn small sec" data-mcact="wallet">Swap main wallet</button>
|
||||
<button type="button" class="btn small sec" data-mcact="credits">Grant credits</button>
|
||||
<a class="btn small sec" id="mcWall" href="#" target="_blank" rel="noopener">Public wall</a>
|
||||
<button type="button" class="btn small sec" data-mcact="delete" style="margin-left:auto">Delete account</button>
|
||||
</div>
|
||||
<div id="mcBody"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Holding tank</h3><span class="sub" id="tankAdmSub">free members with no sponsor, and who adopted whom</span></div>
|
||||
<div class="tablewrap"><table class="adm-table" id="tankWait"></table></div>
|
||||
<p class="small muted" style="margin:12px 0 6px">Adoptions (newest first)</p>
|
||||
<div class="tablewrap"><table class="adm-table" id="tankAdopt"></table></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Members</h3><span class="sub" id="memSub">newest first</span></div>
|
||||
<p style="margin:0 0 10px"><input id="memFilter" placeholder="Filter by email, username, member # or sponsor" style="width:100%"></p>
|
||||
<p class="small muted">Sponsor = the token the account joined under (username, share code, or member #). Editing it re-points free referrals and future purchases. On-chain sponsorship is permanent once activated.</p>
|
||||
<div class="tablewrap"><table class="adm-table" id="memTable"></table></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pane" id="pane-reports" hidden>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Counters audit</h3><span class="sub" id="audSub">views vs delivery logs, charges vs shows</span></div>
|
||||
<p class="small muted" style="margin:0 0 8px">Every format's recorded views are reconciled against the log that proves delivery, login days charged against days shown, and featured bookings checked for views. Runs daily and alerts you on Telegram; run it any time here.</p>
|
||||
<div style="margin:0 0 8px"><button type="button" class="btn small sec" id="audRun">Run now</button></div>
|
||||
<div class="tablewrap"><table class="adm-table" id="audTable"></table></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Ad reports</h3><span class="sub">members flagging ads</span></div>
|
||||
<div class="tablewrap"><table class="adm-table" id="repTable"></table></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Pending burns</h3><span class="sub">on-chain credit burns waiting to be executed</span></div>
|
||||
<div class="tablewrap"><table class="adm-table" id="burnTable"></table></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pane" id="pane-traffic" hidden>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Where visitors come from</h3><span class="sub" id="trfSub"></span></div>
|
||||
<div class="chips" id="trfRange" style="margin:0 0 10px"><button type="button" class="chip-t" data-days="7">7 days</button><button type="button" class="chip-t on" data-days="30">30 days</button><button type="button" class="chip-t" data-days="90">90 days</button><button type="button" class="chip-t" data-days="365">Year</button></div>
|
||||
<p class="muted small" style="margin:0 0 10px">Page views are public-page loads by referring domain (crawlers skipped; our own pages and no referrer count as direct). Join-page views are invite-link opens. Signups, registered and $20+ buyers are accounts whose first-touch source was that domain; legacy arrivals show as legacy:brand:domain.</p>
|
||||
<p style="margin:0 0 6px;display:flex;gap:8px;align-items:center"><input class="tfilter small" data-for="trfSources" placeholder="Filter sources" style="max-width:260px"><span class="tfilter-count muted small"></span></p><div class="tablewrap"><table class="adm-table" id="trfSources"></table></div>
|
||||
</div>
|
||||
<div class="grid c2">
|
||||
<div class="card"><div class="card-head"><h3>Landing pages</h3><span class="sub">page views by page</span></div><p style="margin:0 0 6px;display:flex;gap:8px;align-items:center"><input class="tfilter small" data-for="trfPaths" placeholder="Filter pages" style="max-width:260px"><span class="tfilter-count muted small"></span></p><div class="tablewrap"><table class="adm-table" id="trfPaths"></table></div></div>
|
||||
<div class="card"><div class="card-head"><h3>Angles</h3><span class="sub">join-page hook copy</span></div><p style="margin:0 0 6px;display:flex;gap:8px;align-items:center"><input class="tfilter small" data-for="trfAngles" placeholder="Filter angles" style="max-width:260px"><span class="tfilter-count muted small"></span></p><div class="tablewrap"><table class="adm-table" id="trfAngles"></table></div></div>
|
||||
</div>
|
||||
<div class="card"><div class="card-head"><h3>By day</h3><span class="sub">page views, signups</span></div><p style="margin:0 0 6px;display:flex;gap:8px;align-items:center"><input class="tfilter small" data-for="trfDaily" placeholder="Filter days" style="max-width:260px"><span class="tfilter-count muted small"></span></p><div class="tablewrap"><table class="adm-table" id="trfDaily"></table></div></div>
|
||||
<div class="card" id="promoAdmin">
|
||||
<div class="card-head"><h3>Partner promo codes</h3><span class="sub">free ad credits for members who redeem a partner's code</span></div>
|
||||
<p class="muted small" style="margin:0 0 10px">Give a site owner a code. Their members redeem it on a join link (<code>linkspin-test.saasy.top/join/martbost?promo=CODE</code>) or in the "Have a promo code?" box on the Overview. One use per account; uses and the last redemptions are listed below.</p>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:8px;align-items:end">
|
||||
<label class="small">Code<input id="pcCode" type="text" placeholder="TRAFFICWAVE" style="text-transform:uppercase"></label>
|
||||
<label class="small">Credits<input id="pcCredits" type="number" min="1" placeholder="250"></label>
|
||||
<label class="small">Partner<input id="pcPartner" type="text" placeholder="site or owner"></label>
|
||||
<label class="small">Max uses (0 = unlimited)<input id="pcMax" type="number" min="0" value="0"></label>
|
||||
<label class="small">Expires (optional)<input id="pcExpires" type="date"></label>
|
||||
<button type="button" class="btn small" id="pcSave">Save code</button>
|
||||
</div>
|
||||
<p class="small" id="pcMsg" hidden style="margin:8px 0 0"></p>
|
||||
<div class="tablewrap" style="margin-top:12px"><table class="adm-table" id="pcTable"></table></div>
|
||||
<div class="tablewrap" style="margin-top:12px"><table class="adm-table" id="pcRecent"></table></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pane" id="pane-blog" hidden>
|
||||
<div class="card" id="blogList">
|
||||
<div class="card-head"><h3>Articles</h3><span class="sub" id="blSub"></span></div>
|
||||
<p class="muted small" style="margin:0 0 10px">Coaching and teaching stories, published at <a href="/blog" target="_blank" rel="noopener">linkspin-test.saasy.top/blog</a>. Every published post gets its own page with a title tag, description, canonical link, social preview card, structured data, and a spot in the sitemap and RSS feed. Drafts are visible only to you (open one from its row to preview the real page).</p>
|
||||
<p class="small muted" id="blSyndNote" hidden style="margin:0 0 10px"></p>
|
||||
<p><button type="button" class="btn small" id="blNew">New article</button></p>
|
||||
<div class="tablewrap"><table class="adm-table" id="blTable"></table></div>
|
||||
</div>
|
||||
<div class="card" id="blogEditor" hidden>
|
||||
<div class="card-head"><h3 id="blEdTitle">New article</h3><span class="sub" id="blEdSub"></span></div>
|
||||
<div style="display:grid;gap:10px">
|
||||
<label class="small">Title (the headline and the browser title; under 60 characters shows whole in search results) <span id="blTitleCount" class="muted"></span><input id="blTitle" type="text" maxlength="140" placeholder="The thimble and the bucket"></label>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
|
||||
<label class="small">URL slug (letters, numbers, dashes)<input id="blSlug" type="text" maxlength="80" placeholder="auto from the title"></label>
|
||||
<label class="small">Tags (comma separated; the first one shows as the category)<input id="blTags" type="text" maxlength="200" placeholder="mindset, team building"></label>
|
||||
</div>
|
||||
<label class="small">Excerpt (the meta description and the card text; 120 to 160 characters is ideal) <span id="blExcCount" class="muted"></span><textarea id="blExcerpt" maxlength="300" rows="2" style="width:100%"></textarea></label>
|
||||
<div style="display:grid;grid-template-columns:1fr auto;gap:8px;align-items:end">
|
||||
<label class="small">Cover image (1200x630 works best; it becomes the social preview card)<input id="blCover" type="text" placeholder="/uploads/... or https://..."></label>
|
||||
<span><button type="button" class="btn small sec" id="blCoverBtn">Upload</button><input type="file" id="blCoverFile" accept="image/png,image/jpeg,image/webp" hidden></span>
|
||||
</div>
|
||||
<p class="small muted" id="blCoverInfo" style="margin:-4px 0 0"></p>
|
||||
<div>
|
||||
<div class="ed-bar" aria-label="Formatting">
|
||||
<button type="button" data-bl="bold" title="Bold"><b>B</b></button>
|
||||
<button type="button" data-bl="italic" title="Italic"><i>I</i></button>
|
||||
<button type="button" data-blblock="h2" title="Section heading">H2</button>
|
||||
<button type="button" data-blblock="h3" title="Sub heading">H3</button>
|
||||
<button type="button" data-blblock="p" title="Paragraph">P</button>
|
||||
<button type="button" data-blblock="blockquote" title="Quote">Quote</button>
|
||||
<button type="button" data-bl="insertUnorderedList" title="Bullet list">• List</button>
|
||||
<button type="button" data-bl="insertOrderedList" title="Numbered list">1. List</button>
|
||||
<button type="button" id="blLinkBtn" title="Insert link">🔗 Link</button>
|
||||
<button type="button" id="blImgBtn" title="Insert image">🖼 Image</button><input type="file" id="blImgFile" accept="image/png,image/jpeg,image/webp,image/gif" hidden>
|
||||
<button type="button" id="blHtmlBtn" title="Edit the HTML directly"></></button>
|
||||
</div>
|
||||
<div id="blBody" class="ed-body" contenteditable="true" data-ph="Write the article. Headings break it up; short paragraphs read better on phones." style="min-height:360px;line-height:1.6"></div>
|
||||
<textarea id="blHtml" class="ed-body" hidden style="min-height:360px;font-family:var(--mono);font-size:13px"></textarea>
|
||||
<p class="small muted" style="margin:6px 0 0"><span id="blWords">0 words</span> · 600 or more gives a page something to rank on; one idea per section.</p>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
|
||||
<button type="button" class="btn small" id="blSaveDraft">Save draft</button>
|
||||
<button type="button" class="btn small" id="blPublish">Publish</button>
|
||||
<button type="button" class="btn small sec" id="blUnpublish" hidden>Back to draft</button>
|
||||
<a class="btn small sec" id="blPreview" href="#" target="_blank" rel="noopener" hidden>Open page</a>
|
||||
<button type="button" class="btn small sec" id="blClose">Close</button>
|
||||
<button type="button" class="btn small sec" id="blDelete" hidden style="margin-left:auto">Delete</button>
|
||||
</div>
|
||||
<p class="small" id="blMsg" hidden></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pane" id="pane-releases" hidden>
|
||||
<div class="grid c2">
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Release note</h3><span class="sub">what shipped; shows on /whats-new and every Overview</span></div>
|
||||
<div style="display:grid;gap:8px">
|
||||
<input type="hidden" id="rnId">
|
||||
<label class="small">Title<input id="rnTitle" type="text" maxlength="120" placeholder="Achievement badges post to Telegram"></label>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
|
||||
<label class="small">Date<input id="rnDate" type="date"></label>
|
||||
<label class="small">Tags (new, improved, fixed)<input id="rnTags" type="text" placeholder="new, improved"></label>
|
||||
</div>
|
||||
<label class="small">Details (plain text; start a line with "- " for a bullet)<textarea id="rnBody" rows="6" style="width:100%"></textarea></label>
|
||||
<div style="display:flex;gap:8px"><button type="button" class="btn small" id="rnSave">Save note</button><button type="button" class="btn small sec" id="rnClear">Clear</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Roadmap item</h3><span class="sub">what is coming; "done" hides it from the public list</span></div>
|
||||
<div style="display:grid;gap:8px">
|
||||
<input type="hidden" id="rmId">
|
||||
<label class="small">Title<input id="rmTitle" type="text" maxlength="120" placeholder="Second daily ad set"></label>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px">
|
||||
<label class="small">Status<select id="rmStatus"><option value="planned">planned</option><option value="building">building</option><option value="done">done</option></select></label>
|
||||
<label class="small">ETA (free text)<input id="rmEta" type="text" placeholder="this week"></label>
|
||||
<label class="small">Order<input id="rmOrder" type="number" min="1" placeholder="1"></label>
|
||||
</div>
|
||||
<label class="small">One-line note<input id="rmNote" type="text" maxlength="600" placeholder="why it matters"></label>
|
||||
<div style="display:flex;gap:8px"><button type="button" class="btn small" id="rmSave">Save item</button><button type="button" class="btn small sec" id="rmClear">Clear</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid c2">
|
||||
<div class="card"><div class="card-head"><h3>Notes</h3><span class="sub" id="rnSub"></span></div><div class="tablewrap"><table class="adm-table" id="rnTable"></table></div></div>
|
||||
<div class="card"><div class="card-head"><h3>Roadmap</h3><span class="sub" id="rmSub"></span></div><div class="tablewrap"><table class="adm-table" id="rmTable"></table></div></div>
|
||||
</div>
|
||||
<div class="card" id="updCard">
|
||||
<div class="card-head"><h3>Email an update to members</h3><span class="sub" id="updSub"></span></div>
|
||||
<p class="small muted" style="margin:0 0 10px">Pick the notes to include, add a line or two in your own words, preview, send yourself a test, then send. Plain text from no-reply@linkspin-test.saasy.top, one member at a time. The default audience is members who ticked the newsletter box at sign-up (the Sendy list is the record); unsubscribes are skipped. Nothing goes out until you press Send.</p>
|
||||
<div class="grid c2">
|
||||
<div style="display:grid;gap:8px">
|
||||
<label class="small">Subject<input id="updSubject" type="text" maxlength="150" placeholder="What is new on LinkSpin this week"></label>
|
||||
<label class="small">Intro (optional, plain text)<textarea id="updIntro" rows="4" style="width:100%" placeholder="Quick one. Three things shipped since my last note, and the third one is the one to try today."></textarea></label>
|
||||
<label class="small">Closing (after the notes, e.g. your sign-off)<textarea id="updClosing" rows="2" style="width:100%" placeholder="See you Monday. Marty"></textarea></label>
|
||||
<label class="small">Audience<select id="updAudience"></select></label>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap"><button type="button" class="btn small sec" id="updSaveDraft">Save draft</button><button type="button" class="btn small sec" id="updPreview">Preview</button><button type="button" class="btn small sec" id="updTest">Send test to me</button><button type="button" class="btn small" id="updSend">Send</button></div>
|
||||
<pre id="updPre" class="small" hidden style="white-space:pre-wrap;background:rgba(255,255,255,.04);border:1px solid var(--line);border-radius:10px;padding:10px;max-height:320px;overflow:auto"></pre>
|
||||
</div>
|
||||
<div>
|
||||
<div class="small muted" style="margin:0 0 6px">Release notes to include (newest first; notes since your last send are pre-ticked)</div>
|
||||
<div id="updNotes" style="display:grid;gap:4px;max-height:260px;overflow:auto"></div>
|
||||
<div class="small muted" style="margin:12px 0 6px">Recent sends</div>
|
||||
<div class="tablewrap"><table class="adm-table" id="updLog"></table></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="small muted">Public page: <a href="/whats-new" target="_blank" rel="noopener">linkspin-test.saasy.top/whats-new</a></p>
|
||||
</div>
|
||||
<div class="pane" id="pane-pnl" hidden>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Profit and loss</h3><span class="sub">read from the chain index; periods are by block (about 43,200 Polygon blocks a day)</span></div>
|
||||
<div class="chips" id="pnlPeriods"><button type="button" class="chip-t" data-days="7">7 days</button><button type="button" class="chip-t on" data-days="30">30 days</button><button type="button" class="chip-t" data-days="90">90 days</button><button type="button" class="chip-t" data-days="0">All time</button></div>
|
||||
<div class="grid" id="pnlTiles" style="grid-template-columns:repeat(auto-fit,minmax(200px,1fr));margin-top:12px"></div>
|
||||
</div>
|
||||
<div class="grid c2">
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Where the money went</h3><span class="sub">per period</span></div>
|
||||
<div class="tablewrap"><table class="adm-table" id="pnlSplit"></table></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Wallets</h3><span class="sub">live balances</span></div>
|
||||
<div class="tablewrap"><table class="adm-table" id="pnlWallets"></table></div>
|
||||
<p class="small muted" style="margin:10px 0 0">Fixed monthly cost (USD) for the net line: <input type="number" id="pnlFixed" min="0" step="1" style="width:110px"> <button class="btn small sec" type="button" id="pnlFixedSave">Save</button></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Automatic credit burner</h3><span class="sub">settles pending campaign spend on-chain from the engine wallet</span></div>
|
||||
<p class="small" id="burnerLine">…</p>
|
||||
<p><button class="btn small sec" type="button" id="burnerRun">Run now</button></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pane" id="pane-settings" hidden>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Follow-up emails</h3><span class="sub" id="dripSub">sent to every new free member, hours after sign-up</span></div>
|
||||
<p class="small muted">One card per email. Placeholders fill in per reader: click a chip to drop it where your cursor is.
|
||||
<span class="ph-chips" id="phChips">
|
||||
<button type="button" class="chip-t" data-ph="{{link}}" title="the reader's own invite link">{{link}}</button>
|
||||
<button type="button" class="chip-t" data-ph="{{sponsor}}" title="their sponsor's name">{{sponsor}}</button>
|
||||
<button type="button" class="chip-t" data-ph="{{site}}" title="https://linkspin-test.saasy.top">{{site}}</button>
|
||||
<button type="button" class="chip-t" data-ph="{{paid:words if they bought|words if they have not}}" title="two versions: bought / not yet">{{paid:…|…}}</button>
|
||||
<button type="button" class="chip-t" data-ph="{{footer}}" title="unsubscribe line + disclaimer (keep it at the end)">{{footer}}</button>
|
||||
</span></p>
|
||||
<div id="dripSteps" class="drip-steps"></div>
|
||||
<p id="dripErr" class="small" style="color:var(--bad)" hidden></p>
|
||||
<p style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin:14px 0 0">
|
||||
<button class="btn small" id="dripSave" type="button">Save sequence</button>
|
||||
<button class="btn small sec" id="dripAdd" type="button">+ Add an email</button>
|
||||
<button class="btn small sec" id="dripReset" type="button">Reset to defaults</button>
|
||||
</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Rates</h3><span class="sub">credit prices, rewards and limits</span></div>
|
||||
<div id="ratesForm" class="rates-form"></div>
|
||||
<p id="ratesErr" class="small" style="color:var(--bad)" hidden></p>
|
||||
<p style="margin:14px 0 0"><button class="btn small" id="ratesSave" type="button">Save rates</button></p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Site settings</h3><span class="sub">public: everything here is served to the browser, never put a secret in it</span></div>
|
||||
<div id="siteForm" class="kv-form"></div>
|
||||
<p style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin:12px 0 0">
|
||||
<input id="siteNewKey" placeholder="new setting name" style="max-width:220px">
|
||||
<button class="btn small sec" id="siteAddKey" type="button">+ Add setting</button>
|
||||
</p>
|
||||
<p id="siteErr" class="small" style="color:var(--bad)" hidden></p>
|
||||
<p style="margin:14px 0 0"><button class="btn small" id="siteSave" type="button">Save site settings</button></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/assets/common.js?v=20260914a"></script>
|
||||
<script src="/assets/admin.js?v=20260915a"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,843 @@
|
||||
// Admin portal: email-code sign-in (allowlisted to ADMIN_EMAIL on the server),
|
||||
// house ads that cost nothing, every campaign, members, reports, settings.
|
||||
(function () {
|
||||
const $ = IAP.$;
|
||||
const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
async function api(path, body, method) {
|
||||
const opts = { method: method || (body === undefined ? 'GET' : 'POST'), headers: {} };
|
||||
if (body !== undefined) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); }
|
||||
const r = await (await fetch(path, opts)).json();
|
||||
if (r.error) throw new Error(r.error === 'auth' ? 'Session expired. Sign in again.' : r.error);
|
||||
return r;
|
||||
}
|
||||
function busy(btn, fn) {
|
||||
return async (...a) => {
|
||||
if (btn.disabled) return;
|
||||
btn.disabled = true;
|
||||
try { await fn(...a); } catch (e) { IAP.status(e.message || 'Something went wrong.', 'bad'); }
|
||||
finally { btn.disabled = false; }
|
||||
};
|
||||
}
|
||||
const when = ts => ts ? new Date(Number(ts)).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : '';
|
||||
let rates = {}, sizes = [], houseOwner = 'house@linkspin-test.saasy.top';
|
||||
|
||||
// ── sign-in ──
|
||||
$('adSend').addEventListener('click', busy($('adSend'), async () => {
|
||||
$('adErr').hidden = true;
|
||||
const r = await api('/api/admin/auth/start', { email: $('adEmail').value });
|
||||
$('adCodeRow').hidden = false; $('adVerify').hidden = false;
|
||||
if (r.devCode) $('adCode').value = r.devCode;
|
||||
IAP.status(r.sent ? 'Code sent. Check your inbox.' : 'Dev mode: code filled in.', 'ok');
|
||||
$('adCode').focus();
|
||||
}));
|
||||
$('adVerify').addEventListener('click', busy($('adVerify'), async () => {
|
||||
$('adErr').hidden = true;
|
||||
await api('/api/admin/auth/verify', { email: $('adEmail').value, code: $('adCode').value });
|
||||
await render();
|
||||
}));
|
||||
$('adCode').addEventListener('keydown', e => { if (e.key === 'Enter') $('adVerify').click(); });
|
||||
$('adEmail').addEventListener('keydown', e => { if (e.key === 'Enter') ($('adVerify').hidden ? $('adSend') : $('adVerify')).click(); });
|
||||
$('adLogout').addEventListener('click', async e => {
|
||||
e.preventDefault();
|
||||
try { await api('/api/admin/auth/logout', {}); } catch (err) {}
|
||||
location.reload();
|
||||
});
|
||||
|
||||
// ── panes ──
|
||||
const TITLES = { overview: 'Overview', house: 'House ads', campaigns: 'All campaigns', members: 'Members', reports: 'Reports', traffic: 'Traffic', blog: 'Blog', releases: 'Releases and roadmap', pnl: 'Profit and loss', settings: 'Settings' };
|
||||
const loaders = { overview: loadOverview, house: loadHouse, campaigns: loadCampaigns, members: loadMembers, reports: loadReports, traffic: loadTraffic, blog: loadBlog, releases: loadReleases, pnl: loadPnl, settings: loadSettings };
|
||||
function setPane(name) {
|
||||
if (!TITLES[name]) name = 'overview';
|
||||
document.querySelectorAll('.pane').forEach(p => { p.hidden = p.id !== 'pane-' + name; });
|
||||
document.querySelectorAll('.bo-menu [data-pane]').forEach(b => b.classList.toggle('on', b.dataset.pane === name));
|
||||
$('boTitle').textContent = TITLES[name];
|
||||
if (location.hash.slice(1) !== name) history.replaceState(null, '', '#' + name);
|
||||
$('adminArea').classList.remove('side-open');
|
||||
loaders[name]().catch(e => IAP.status(e.message, 'bad'));
|
||||
}
|
||||
document.querySelectorAll('.bo-menu [data-pane]').forEach(b => b.addEventListener('click', () => setPane(b.dataset.pane)));
|
||||
document.addEventListener('click', e => { const g = e.target.closest('[data-goto]'); if (g) setPane(g.dataset.goto); });
|
||||
window.addEventListener('hashchange', () => setPane(location.hash.slice(1)));
|
||||
$('boBurger').addEventListener('click', () => $('adminArea').classList.toggle('side-open'));
|
||||
|
||||
async function render() {
|
||||
let me = { admin: false };
|
||||
try { me = await api('/api/admin/me'); } catch (e) {}
|
||||
$('authArea').hidden = !!me.admin;
|
||||
$('adminArea').hidden = !me.admin;
|
||||
if (!me.admin) return;
|
||||
$('adWho').textContent = me.email || 'admin';
|
||||
try {
|
||||
const c = await IAP.getConfig();
|
||||
$('chainLine').textContent = c.chainName + (c.rehearsal ? ' · rehearsal' : '');
|
||||
} catch (e) {}
|
||||
setPane(location.hash.slice(1) || 'overview');
|
||||
}
|
||||
|
||||
// ── overview ──
|
||||
async function loadOverview() {
|
||||
const o = await api('/api/admin/overview');
|
||||
rates = o.rates || rates;
|
||||
$('ovAccounts').textContent = (o.accounts || 0).toLocaleString();
|
||||
$('ovMembers').textContent = o.memberCount == null ? '?' : Number(o.memberCount).toLocaleString();
|
||||
$('ovActive').textContent = (o.byStatus && o.byStatus.active) || 0;
|
||||
$('ovCampSub').textContent = o.campaigns + ' total · ' + o.house + ' house';
|
||||
$('ovReports').textContent = o.openReports || 0;
|
||||
$('ovBurnSub').textContent = (o.pendingBurns || 0) + ' pending burns';
|
||||
$('repBadge').hidden = !o.openReports; $('repBadge').textContent = o.openReports || '';
|
||||
const f = o.followups || {};
|
||||
$('ovDrips').textContent = f.active || 0;
|
||||
$('ovDripSub').textContent = (f.done || 0) + ' finished · ' + (f.unsubscribed || 0) + ' unsubscribed';
|
||||
const bt = Object.entries(o.byType || {}).sort((a, b) => b[1] - a[1]);
|
||||
$('ovByType').innerHTML = bt.length ? bt.map(([t, n]) => '<div style="display:flex;justify-content:space-between;padding:4px 0;border-bottom:1px solid var(--line)"><span>' + esc(t) + '</span><b>' + n + '</b></div>').join('') : 'No campaigns yet.';
|
||||
const ch = o.chain || {};
|
||||
$('ovChain').innerHTML = '<div>' + esc(ch.chainName) + ' (chain ' + esc(ch.chainId) + ')</div>'
|
||||
+ '<div class="mono" style="word-break:break-all;margin:6px 0">' + esc(ch.contract) + '</div>'
|
||||
+ (ch.explorer ? '<a href="' + esc(ch.explorer) + '/address/' + esc(ch.contract) + '" target="_blank" rel="noopener">Open in explorer →</a>' : '');
|
||||
}
|
||||
|
||||
// ── house ads ──
|
||||
const HROWS = { banner: ['hBannerRow'], text: ['hTextRow'], login: [], solo: ['hSoloRow'], video: ['hVideoRow'], featured: ['hFeatRow'], visits: ['hVisitsRow'] };
|
||||
function showHouseRows() {
|
||||
const t = $('hType').value;
|
||||
['hBannerRow', 'hTextRow', 'hSoloRow', 'hVideoRow', 'hFeatRow', 'hVisitsRow'].forEach(id => { $(id).hidden = !(HROWS[t] || []).includes(id); });
|
||||
$('hBudget').hidden = t === 'featured' || t === 'visits';
|
||||
houseHints();
|
||||
}
|
||||
function houseHints() {
|
||||
const r = rates || {};
|
||||
const soloCost = r.soloCostPerRecipient || 5, soloMin = r.soloMinRecipients || 10;
|
||||
const cap = Number($('hBudget').value) || 100000;
|
||||
$('hSoloHint').textContent = 'Delivers to one inbox per ' + soloCost + ' credits of cap (minimum ' + soloMin + ' recipients). A cap of ' + cap.toLocaleString() + ' reaches up to ' + Math.floor(cap / soloCost).toLocaleString() + ' members.';
|
||||
const days = Number($('hFeatDays').value) || 0;
|
||||
$('hFeatHint').textContent = days ? days + '-day run in the featured strip (' + (r.featuredPerDay || 40) + ' credits/day, free here). Book up to ' + (r.featuredWindowDays || 7) + ' days ahead.' : '';
|
||||
const n = Number($('hVisitCount').value) || 0;
|
||||
$('hVisitHint').textContent = 'Packs start at ' + (r.visitMinPack || 20) + ' visits.' + (n ? ' ' + n + ' verified visits, delivered one per member.' : '');
|
||||
}
|
||||
$('hType').addEventListener('change', showHouseRows);
|
||||
['hBudget', 'hFeatDays', 'hVisitCount'].forEach(id => $(id).addEventListener('input', houseHints));
|
||||
$('hFeatDays').addEventListener('change', houseHints);
|
||||
$('hImageUploadBtn').addEventListener('click', () => $('hImageFile').click());
|
||||
$('hVideoUploadBtn').addEventListener('click', () => $('hVideoFile').click());
|
||||
async function upload(fileInput, info, target, kind) {
|
||||
const f = fileInput.files[0]; if (!f) return;
|
||||
info.textContent = 'Uploading ' + f.name + '…';
|
||||
try {
|
||||
const r = await (await fetch('/api/admin/upload', { method: 'POST', headers: { 'Content-Type': f.type }, body: f })).json();
|
||||
if (r.error) { info.textContent = r.error; }
|
||||
else { target.value = r.url; info.textContent = f.name + ' uploaded'; }
|
||||
} catch (e) { info.textContent = 'Upload failed. Try again.'; }
|
||||
fileInput.value = '';
|
||||
}
|
||||
$('hImageFile').addEventListener('change', () => upload($('hImageFile'), $('hImageInfo'), $('hImage')));
|
||||
$('hVideoFile').addEventListener('change', () => upload($('hVideoFile'), $('hVideoInfo'), $('hVideoUrl')));
|
||||
let hVidDims = null;
|
||||
function probeVideoDims(url) {
|
||||
return new Promise(resolve => {
|
||||
const v = document.createElement('video'); v.preload = 'metadata'; v.muted = true;
|
||||
const done = d => { v.src = ''; resolve(d); };
|
||||
v.onloadedmetadata = () => done(v.videoWidth && v.videoHeight ? { w: v.videoWidth, h: v.videoHeight } : null);
|
||||
v.onerror = () => done(null);
|
||||
setTimeout(() => done(null), 12000);
|
||||
v.src = url;
|
||||
});
|
||||
}
|
||||
$('hCreate').addEventListener('click', busy($('hCreate'), async () => {
|
||||
$('hErr').hidden = true;
|
||||
const t = $('hType').value;
|
||||
if (t === 'video' && $('hVideoUrl').value) hVidDims = await probeVideoDims($('hVideoUrl').value);
|
||||
const days = Number($('hFeatDays').value), count = Number($('hVisitCount').value);
|
||||
const body = { type: t, name: $('hName').value, targetUrl: $('hTarget').value,
|
||||
imageUrl: $('hImage').value, size: $('hSize').value,
|
||||
title: t === 'video' ? $('hVideoTitle').value : t === 'featured' ? $('hFeatTitle').value : t === 'visits' ? $('hVisitTitle').value : t === 'solo' ? $('hSoloTitle').value : $('hTitle').value,
|
||||
body: t === 'solo' ? $('hSoloBody').value : $('hBody').value,
|
||||
ctaLabel: t === 'video' ? $('hVideoCta').value : $('hSoloCta').value,
|
||||
videoUrl: $('hVideoUrl').value, watchSecs: Number($('hWatchSecs').value),
|
||||
videoW: hVidDims ? hVidDims.w : null, videoH: hVidDims ? hVidDims.h : null,
|
||||
days, startDay: Number($('hFeatStart').value) || 0, count,
|
||||
budget: t === 'featured' ? days * (rates.featuredPerDay || 40)
|
||||
: t === 'visits' ? count * (rates.visitCostPerVisit || 3)
|
||||
: (Number($('hBudget').value) || 0) };
|
||||
try {
|
||||
await api('/api/admin/campaigns', body);
|
||||
} catch (e) { $('hErr').textContent = e.message; $('hErr').hidden = false; throw e; }
|
||||
IAP.status('House ad is live. It serves right away at no cost.', 'ok');
|
||||
['hName', 'hBudget', 'hTarget', 'hImage', 'hTitle', 'hBody', 'hSoloTitle', 'hSoloBody', 'hSoloCta',
|
||||
'hVideoUrl', 'hVideoTitle', 'hVideoCta', 'hFeatTitle', 'hVisitTitle', 'hVisitCount'].forEach(id => { $(id).value = ''; });
|
||||
$('hImageInfo').textContent = ''; $('hVideoInfo').textContent = ''; hVidDims = null;
|
||||
await loadHouse();
|
||||
}));
|
||||
function campRow(c, showOwner) {
|
||||
const left = Math.max(0, (c.budget || 0) - (c.spent || 0));
|
||||
const creative = c.type === 'banner' && c.imageUrl ? '<img src="' + esc(c.imageUrl) + '" alt="" style="max-height:34px;max-width:120px;border-radius:4px">' : esc(c.title || c.name);
|
||||
const act = c.status === 'active' ? '<button class="btn small sec" data-act="pause" data-id="' + c.id + '">Pause</button>'
|
||||
: c.status === 'paused' ? '<button class="btn small" data-act="resume" data-id="' + c.id + '">Resume</button>' : '';
|
||||
return '<tr><td>#' + c.id + (c.house ? '<span class="house-tag">HOUSE</span>' : '') + '</td>'
|
||||
+ (showOwner ? '<td><span class="trunc" title="' + esc(c.owner) + '">' + esc(c.house ? 'house' : c.owner) + '</span></td>' : '')
|
||||
+ '<td>' + esc(c.type) + '</td>'
|
||||
+ '<td>' + esc(c.name) + '<div class="small muted">' + creative + '</div><a class="small trunc" href="' + esc(c.targetUrl) + '" target="_blank" rel="noopener">' + esc(c.targetUrl) + '</a></td>'
|
||||
+ '<td><span class="st ' + esc(c.status) + '">' + esc(c.status) + '</span></td>'
|
||||
+ '<td class="mono small">' + (c.spent || 0).toLocaleString() + ' / ' + (c.budget || 0).toLocaleString() + '<div class="muted">' + left.toLocaleString() + ' left</div></td>'
|
||||
+ '<td class="mono small">' + (c.imps || 0).toLocaleString() + (c.impsNas ? ' +' + c.impsNas + ' nas' : '') + '<div class="muted">' + (c.clicks || 0) + ' clicks</div></td>'
|
||||
+ '<td class="small muted">' + when(c.created) + '</td>'
|
||||
+ '<td class="act">' + act + '</td></tr>';
|
||||
}
|
||||
function campHead(showOwner) {
|
||||
return '<tr><th>ID</th>' + (showOwner ? '<th>Owner</th>' : '') + '<th>Type</th><th>Campaign</th><th>Status</th><th>Spent / cap</th><th>Delivery</th><th>Created</th><th></th></tr>';
|
||||
}
|
||||
async function loadHouse() {
|
||||
const r = await api('/api/admin/campaigns');
|
||||
rates = r.rates || rates; sizes = r.bannerSizes || sizes; houseOwner = r.houseOwner || houseOwner;
|
||||
if (!$('hSize').options.length) $('hSize').innerHTML = sizes.map(s => '<option value="' + esc(s.id) + '">' + esc(s.label || s.id) + ' (' + s.w + '×' + s.h + ')</option>').join('');
|
||||
if (!$('hWatchSecs').options.length) $('hWatchSecs').innerHTML = (rates.videoTiers || []).map(t => '<option value="' + t.secs + '">Watch ' + t.secs + 's (viewer earns ' + t.reward + ')</option>').join('');
|
||||
if (!$('hFeatDays').options.length) $('hFeatDays').innerHTML = (rates.featuredDurations || [1, 2, 7]).map(d => '<option value="' + d + '">' + d + ' day' + (d > 1 ? 's' : '') + '</option>').join('');
|
||||
showHouseRows();
|
||||
loadWallAds();
|
||||
const house = (r.campaigns || []).filter(c => c.house);
|
||||
$('houseSub').textContent = house.filter(c => c.status === 'active').length + ' active · ' + house.length + ' total';
|
||||
$('houseTable').innerHTML = house.length ? campHead(false) + house.map(c => campRow(c, false)).join('') : '<tr><td class="muted">No house ads yet. Place one above.</td></tr>';
|
||||
}
|
||||
// wall fallback ads editor
|
||||
let wallAds = [];
|
||||
function drawWallAds() {
|
||||
const w = $('wallAdsList');
|
||||
w.innerHTML = wallAds.map((a, i) => '<div class="drip-step" data-i="' + i + '"><div class="ds-head"><span class="ds-n">WALL AD ' + (i + 1) + '</span>'
|
||||
+ '<span class="ds-tools"><button type="button" class="btn small sec" data-wact="up" ' + (i === 0 ? 'disabled' : '') + '>↑</button><button type="button" class="btn small sec" data-wact="down" ' + (i === wallAds.length - 1 ? 'disabled' : '') + '>↓</button><button type="button" class="btn small sec" data-wact="remove">Remove</button></span></div>'
|
||||
+ '<div class="grid c3"><p><input class="wa-name" maxlength="60" placeholder="Label shown under the ad" value="' + esc(a.name || '') + '"></p>'
|
||||
+ '<p><input class="wa-target" placeholder="Link (https://…)" value="' + esc(a.targetUrl || '') + '"></p>'
|
||||
+ '<p><input class="wa-banner" placeholder="Banner image URL or upload" value="' + esc(a.bannerUrl || '') + '"> <button type="button" class="btn small sec wa-upload">Upload</button><input type="file" class="wa-file" accept="image/png,image/jpeg,image/webp,image/gif" hidden></p></div>'
|
||||
+ (a.bannerUrl ? '<img src="' + esc(a.bannerUrl) + '" alt="" style="max-height:60px;border-radius:6px">' : '')
|
||||
+ '</div>').join('') || '<p class="muted small">No wall ads set. Walls fall back to a plain LinkSpin card.</p>';
|
||||
}
|
||||
function readWallAds() {
|
||||
return [...document.querySelectorAll('#wallAdsList .drip-step')].map(c => ({ name: c.querySelector('.wa-name').value.trim(), targetUrl: c.querySelector('.wa-target').value.trim(), bannerUrl: c.querySelector('.wa-banner').value.trim() }));
|
||||
}
|
||||
async function loadWallAds() {
|
||||
try { const r = await api('/api/admin/wall-ads'); wallAds = r.ads || []; $('wallAdsSub').textContent = r.usingDefaults ? 'none set: walls show the default LinkSpin card' : wallAds.length + ' in rotation'; drawWallAds(); } catch (e) {}
|
||||
}
|
||||
$('wallAdsList').addEventListener('click', async e => {
|
||||
const up = e.target.closest('.wa-upload');
|
||||
if (up) { up.parentElement.querySelector('.wa-file').click(); return; }
|
||||
const b = e.target.closest('[data-wact]'); if (!b) return;
|
||||
const i = Number(b.closest('.drip-step').dataset.i); wallAds = readWallAds();
|
||||
if (b.dataset.wact === 'remove') wallAds.splice(i, 1);
|
||||
if (b.dataset.wact === 'up' && i > 0) [wallAds[i - 1], wallAds[i]] = [wallAds[i], wallAds[i - 1]];
|
||||
if (b.dataset.wact === 'down' && i < wallAds.length - 1) [wallAds[i + 1], wallAds[i]] = [wallAds[i], wallAds[i + 1]];
|
||||
drawWallAds();
|
||||
});
|
||||
$('wallAdsList').addEventListener('change', async e => {
|
||||
const f = e.target.closest('.wa-file'); if (!f || !f.files[0]) return;
|
||||
const file = f.files[0]; const card = f.closest('.drip-step');
|
||||
try {
|
||||
const r = await (await fetch('/api/admin/upload', { method: 'POST', headers: { 'Content-Type': file.type }, body: file })).json();
|
||||
if (r.error) IAP.status(r.error, 'bad'); else { card.querySelector('.wa-banner').value = r.url; IAP.status('Uploaded.', 'ok'); }
|
||||
} catch (err) { IAP.status('Upload failed.', 'bad'); }
|
||||
f.value = '';
|
||||
});
|
||||
$('wallAdsAdd').addEventListener('click', () => { wallAds = readWallAds(); wallAds.push({ name: '', targetUrl: '', bannerUrl: '' }); drawWallAds(); });
|
||||
$('wallAdsSave').addEventListener('click', busy($('wallAdsSave'), async () => {
|
||||
$('wallAdsErr').hidden = true;
|
||||
try { const r = await api('/api/admin/wall-ads', { ads: readWallAds() }, 'PATCH'); wallAds = r.ads || []; drawWallAds(); IAP.status('Wall ads saved.', 'ok'); await loadWallAds(); }
|
||||
catch (e) { $('wallAdsErr').textContent = e.message; $('wallAdsErr').hidden = false; }
|
||||
}));
|
||||
document.addEventListener('click', async e => {
|
||||
const b = e.target.closest('[data-act][data-id]'); if (!b) return;
|
||||
b.disabled = true;
|
||||
try {
|
||||
await api('/api/admin/campaigns/' + b.dataset.id + '/' + b.dataset.act, {});
|
||||
IAP.status('Campaign #' + b.dataset.id + ' ' + (b.dataset.act === 'pause' ? 'paused' : 'resumed') + '.', 'ok');
|
||||
await Promise.all([loadHouse(), loadCampaigns()]);
|
||||
} catch (err) { IAP.status(err.message, 'bad'); b.disabled = false; }
|
||||
});
|
||||
|
||||
// ── all campaigns ──
|
||||
let allCamps = [];
|
||||
async function loadCampaigns() {
|
||||
const r = await api('/api/admin/campaigns');
|
||||
allCamps = r.campaigns || [];
|
||||
drawCamps();
|
||||
}
|
||||
function drawCamps() {
|
||||
const q = ($('campFilter').value || '').trim().toLowerCase();
|
||||
const list = allCamps.filter(c => !q || [c.owner, c.name, c.type, c.status, c.targetUrl, String(c.id)].join(' ').toLowerCase().includes(q));
|
||||
$('campSub').textContent = list.length + ' of ' + allCamps.length;
|
||||
$('campTable').innerHTML = list.length ? campHead(true) + list.map(c => campRow(c, true)).join('') : '<tr><td class="muted">Nothing matches.</td></tr>';
|
||||
}
|
||||
$('campFilter').addEventListener('input', drawCamps);
|
||||
|
||||
// ── members ──
|
||||
let allMembers = [];
|
||||
async function loadTank() {
|
||||
try {
|
||||
const r = await (await fetch('/api/admin/tank')).json(); if (r.error) return;
|
||||
$('tankAdmSub').textContent = r.waiting.length + ' waiting · cap ' + r.cap + ' open per adopter · ' + r.ttlDays + '-day window';
|
||||
$('tankWait').innerHTML = '<tr><th>Waiting</th><th>Email</th><th>Joined</th><th>Last sign-in</th></tr>' + (r.waiting.length ? r.waiting.map(w => '<tr><td>' + esc(w.name) + '</td><td>' + esc(w.email) + '</td><td class="when">' + when(w.joined) + '</td><td class="when">' + (w.lastSeen ? when(w.lastSeen) : '<span class="muted">never</span>') + '</td></tr>').join('') : '<tr><td colspan="4" class="muted">empty</td></tr>');
|
||||
$('tankAdopt').innerHTML = '<tr><th>Member</th><th>Adopted by</th><th>When</th><th>Window ends</th><th>Status</th></tr>' + (r.adoptions.length ? r.adoptions.map(a => '<tr><td>' + esc(a.adopteeName) + '</td><td>' + esc(a.adopterName) + '</td><td class="when">' + when(a.ts) + '</td><td class="when">' + (a.status === 'released' ? '' : when(a.expires)) + '</td><td>' + esc(a.status) + '</td></tr>').join('') : '<tr><td colspan="5" class="muted">none yet</td></tr>');
|
||||
} catch (e) {}
|
||||
}
|
||||
async function loadMembers() {
|
||||
loadTank();
|
||||
const r = await api('/api/admin/members');
|
||||
allMembers = r.members || [];
|
||||
drawMembers();
|
||||
}
|
||||
function drawMembers() {
|
||||
const q = ($('memFilter').value || '').trim().toLowerCase();
|
||||
const list = allMembers.filter(a => !q || [a.email, a.username, a.memberId, a.sponsorRef, a.address, a.code].join(' ').toLowerCase().includes(q));
|
||||
$('memSub').textContent = list.length + ' of ' + allMembers.length;
|
||||
$('memTable').innerHTML = '<tr><th>Email</th><th>Username</th><th>Member #</th><th>Wallet</th><th>Sponsor</th><th>Positions</th><th>Via</th><th>Code</th><th>Joined</th><th></th></tr>'
|
||||
+ list.map(a => '<tr><td>' + esc(a.email) + '</td><td>' + (a.username ? '@' + esc(a.username) : '<span class="muted">none</span>') + '</td>'
|
||||
+ '<td>' + (a.memberId ? '#' + a.memberId : '<span class="muted">free</span>') + '</td>'
|
||||
+ '<td class="mono small">' + (a.address ? esc(a.address.slice(0, 8) + '…' + a.address.slice(-6)) : '<span class="muted">none</span>') + '</td>'
|
||||
+ '<td>' + (a.sponsorName ? esc(a.sponsorName) + (a.sponsorVia === 'code' ? ' <span class="muted small" title="joined through this share code">via code ' + esc(a.sponsorRef) + '</span>' : a.sponsorVia === 'member #' ? ' <span class="muted small">via #' + esc(a.sponsorRef) + '</span>' : '') : a.sponsorRef ? '<span class="badge amber" title="this token points at nobody; the member will move to the holding tank">dead link: ' + esc(a.sponsorRef) + '</span>' : '<span class="muted">none</span>') + '</td><td class="small" title="linked Qualified Start positions' + (a.positionIds && a.positionIds.length ? ': #' + a.positionIds.join(', #') : '') + '">' + (a.positions ? a.positions : '<span class="muted">0</span>') + '</td><td class="small muted">' + esc(a.joinedVia || '') + '</td><td class="mono small">' + esc(a.code || '') + '</td>'
|
||||
+ '<td class="small muted when">' + when(a.created) + '</td>'
|
||||
+ '<td class="act"><button class="btn small sec" data-mcopen="' + esc(a.email) + '">Open</button> <button class="btn small sec" data-spon="' + esc(a.email) + '" data-cur="' + esc(a.sponsorRef || '') + '">Sponsor</button></td></tr>').join('');
|
||||
}
|
||||
$('memFilter').addEventListener('input', drawMembers);
|
||||
document.addEventListener('click', async e => {
|
||||
const b = e.target.closest('[data-spon]'); if (!b) return;
|
||||
const v = await IAP.ask({ title: 'Sponsor for ' + b.dataset.spon, text: 'Username, share code, or member #. Leave blank to clear.', value: b.dataset.cur, ok: 'Save' });
|
||||
if (v === null || v === undefined) return;
|
||||
try {
|
||||
await api('/api/admin/members', { email: b.dataset.spon, sponsorRef: v.trim() }, 'PATCH');
|
||||
IAP.status('Sponsor updated.', 'ok');
|
||||
await loadMembers();
|
||||
} catch (err) { IAP.status(err.message, 'bad'); }
|
||||
});
|
||||
|
||||
// ── member card: search, drill down, act (Marty, 2026-09-13) ──
|
||||
let mcCur = null;
|
||||
const polOf = w => { try { return (Number(BigInt(w || '0') / 10n ** 14n) / 10000).toLocaleString(undefined, { maximumFractionDigits: 2 }); } catch (e) { return '0'; } };
|
||||
const ago = ts => { if (!ts) return 'never'; const d = Date.now() - Number(ts); const h = Math.floor(d / 3600000); return h < 1 ? Math.max(1, Math.floor(d / 60000)) + ' min ago' : h < 48 ? h + ' h ago' : Math.floor(h / 24) + ' days ago'; };
|
||||
const memLink = (email, label) => '<a href="#" data-mcopen="' + esc(email) + '">' + esc(label) + '</a>';
|
||||
async function openMember(q) {
|
||||
const msg = $('memSearchMsg'); msg.hidden = true;
|
||||
let d;
|
||||
try { d = await api('/api/admin/member?q=' + encodeURIComponent(q)); } catch (e) { msg.textContent = e.message; msg.hidden = false; msg.className = 'small bad'; return; }
|
||||
renderMember(d);
|
||||
}
|
||||
function kv(rows) { return '<table class="adm-table kv">' + rows.map(r => '<tr><th style="width:170px">' + r[0] + '</th><td>' + r[1] + '</td></tr>').join('') + '</table>'; }
|
||||
function renderMember(d) {
|
||||
mcCur = d; const a = d.account; $('memHits').hidden = true;
|
||||
$('memCard').hidden = false; document.querySelectorAll('#pane-members > .card').forEach(c => { if (c.id !== 'memCard' && c.id !== 'memSearchCard') c.hidden = true; });
|
||||
$('mcName').textContent = (a.username ? '@' + a.username : a.email) + (a.memberId ? ' · member #' + a.memberId : ' · free member');
|
||||
$('mcSub').textContent = 'joined ' + when(a.created) + ' · last seen ' + ago(a.lastSeen);
|
||||
$('mcWall').hidden = !a.username; if (a.username) $('mcWall').href = '/wall/' + a.username;
|
||||
const ch = d.chain, cr = d.credits, t = d.totals;
|
||||
const level = ch && !ch.readError ? (ch.buyerCount >= 5 ? 'level 3 (5+ buyers)' : ch.buyerCount >= 2 ? 'level 2 (2 buyers)' : 'level 1') : '';
|
||||
let h = '<div class="grid c2">';
|
||||
h += '<div><h4 style="margin:0 0 6px">Identity</h4>' + kv([
|
||||
['Email', esc(a.email)], ['Username', a.username ? '@' + esc(a.username) : '<span class="muted">not set</span>'], ['Share code', esc(a.code || '')],
|
||||
['Main wallet', a.address ? '<span class="mono small">' + esc(a.address) + '</span>' : '<span class="muted">none linked</span>'],
|
||||
['Extra positions', d.positions.length ? d.positions.map(p => '<span class="mono small">' + esc(p.address.slice(0, 8) + '…' + p.address.slice(-6)) + '</span>' + (p.memberId ? ' = #' + p.memberId : ' (unregistered)')).join('<br>') : '<span class="muted">none</span>'],
|
||||
['Sponsor (site)', d.upline.length ? memLink(d.upline[0].email, d.upline[0].name) + ' <span class="muted small">token ' + esc(a.sponsorRef || '') + '</span>' : (a.sponsorRef ? '<span class="muted">unresolved: ' + esc(a.sponsorRef) + '</span>' : '<span class="muted">none (company)</span>')],
|
||||
['Upline chain', d.upline.length > 1 ? d.upline.map(u => memLink(u.email, u.name)).join(' → ') : '<span class="muted">-</span>'],
|
||||
['Joined via', esc(a.joinedVia || 'join page') + (a.joinedRef ? ' from ' + esc(a.joinedRef) : '')],
|
||||
['Line banner', a.lineTargetUrl ? '<a href="' + esc(a.lineTargetUrl) + '" target="_blank" rel="noopener">' + esc(a.lineTargetUrl.slice(0, 50)) + '</a>' : '<span class="muted">not set</span>'],
|
||||
['Chat', a.chatAvailable ? 'available' : 'switched off']]) + '</div>';
|
||||
h += '<div><h4 style="margin:0 0 6px">On-chain and money</h4>' + kv([
|
||||
['Registered', ch ? (ch.readError ? 'read error' : 'yes, #' + ch.memberId + ' under ' + (ch.sponsorId ? '#' + ch.sponsorId + (d.names[ch.sponsorId] ? ' @' + esc(d.names[ch.sponsorId]) : '') : 'nobody')) : '<span class="muted">no (payouts off)</span>'],
|
||||
['Qualifying buyers', ch && !ch.readError ? ch.buyerCount + ' · ' + level : '-'],
|
||||
['Packages bought', t.purchases + (t.purchases ? ' · $' + (t.spentCents / 100).toFixed(0) + ' · ' + polOf(t.spentWei) + ' POL' : '')],
|
||||
['Payouts received', t.payoutsIn + (t.payoutsIn ? ' · ' + polOf(t.receivedWei) + ' POL' : '')],
|
||||
['Credits', cr ? cr.available.toLocaleString() + ' available · ' + cr.inCampaigns.toLocaleString() + ' in campaigns · ' + cr.total.toLocaleString() + ' total' : '<span class="muted">-</span>'],
|
||||
['Earned pool', d.earnedSplit ? d.earnedSplit.total.toLocaleString() + ' (' + (d.earnedSplit.grade || 0).toLocaleString() + ' purchased-grade)' : '-'],
|
||||
['Old-site account', d.legacy ? 'had a ' + (d.legacy.brand === 'both' ? 'Faucet Wave and Tier One Ads' : d.legacy.brand === 'tier1ads' ? 'Tier One Ads' : 'Faucet Wave') + ' account (' + d.legacy.seg + ') · welcome-back credits ' + (d.legacy.grant ? d.legacy.grant.credits + ' issued ' + when(d.legacy.grant.at) : 'not issued (joined outside the legacy bridge)') : '<span class="muted">none on record</span>'],
|
||||
['Promo codes', d.promos.length ? d.promos.map(p => esc(p.code) + ' (' + p.credits + ', ' + when(p.ts) + ')').join('<br>') : '<span class="muted">none</span>'],
|
||||
['Drip', d.drip ? (d.drip.stopped ? 'stopped' : 'step ' + d.drip.step + ', next ' + when(d.drip.next_at)) + (d.drip.angle ? ' · ' + esc(d.drip.angle) : '') : '<span class="muted">-</span>'],
|
||||
['Holding tank', d.tank ? (d.tank.waiting ? '<b>waiting for a sponsor</b>' : 'not in tank') + (d.tank.adoptedBy.length ? ' · adopted by ' + d.tank.adoptedBy.map(x => memLink(x.email, x.name)).join(', ') : '') + (d.tank.adopted.length ? ' · adopted ' + d.tank.adopted.map(x => memLink(x.email, x.name)).join(', ') : '') : '-'],
|
||||
['Earning', d.earning ? 'today ' + d.earning.today + '/5' + (d.earning.claimed ? ' claimed' : '') + ' · streak day ' + d.earning.streakDay + (d.activeDays14 !== undefined ? ' · active ' + d.activeDays14 + ' of last 14 days, ' + d.claims14 + ' claims' : '') : '-'],
|
||||
['Visits / videos / chat', (d.visits || 0) + ' verified visits · ' + (d.videos || 0) + ' video watches · ' + (d.messageCount || 0) + ' messages']]) + '</div></div>';
|
||||
// line
|
||||
h += '<h4 style="margin:18px 0 6px">Line (' + d.lineCounts.join(' / ') + ')</h4>';
|
||||
if (!d.line.length) h += '<p class="muted small">Nobody in their line yet.</p>';
|
||||
for (const L of d.line) {
|
||||
h += '<p class="small muted" style="margin:8px 0 4px">Level ' + L.level + ' · ' + L.members.length + '</p><div class="tablewrap"><table class="adm-table"><tr><th>Member</th><th>Member #</th><th>Wallet</th><th>Bought</th><th>Qualified</th><th>Joined</th><th>Last seen</th></tr>'
|
||||
+ L.members.map(m => '<tr><td>' + memLink(m.email, m.name) + (L.level === 1 ? '<br><span class="muted small">' + esc(m.email) + '</span>' : '') + '</td><td>' + (m.memberId ? '#' + m.memberId : '<span class="muted">free</span>') + '</td><td>' + (m.wallet ? 'yes' : '<span class="muted">no</span>') + '</td><td>' + (m.bought ? 'yes' : '<span class="muted">no</span>') + '</td><td>' + (m.qualified ? '<span class="chip-t on">yes</span>' : '') + '</td><td class="small muted">' + when(m.joined) + '</td><td class="small muted">' + ago(m.lastSeen) + '</td></tr>').join('') + '</table></div>';
|
||||
}
|
||||
// purchases + payouts + campaigns
|
||||
h += '<div class="grid c2" style="margin-top:18px"><div><h4 style="margin:0 0 6px">Purchases</h4><div class="tablewrap"><table class="adm-table"><tr><th>When</th><th>Position</th><th>Package</th><th>Paid</th><th>Tx</th></tr>'
|
||||
+ (d.purchases.length ? d.purchases.map(p => '<tr><td class="small">' + when(p.ts) + '</td><td>#' + p.buyerId + '</td><td>$' + (p.priceCents / 100).toFixed(0) + ' · ' + Number(p.credits || 0).toLocaleString() + ' cr</td><td>' + polOf(p.paidWei) + ' POL</td><td><a href="/tx/' + esc(p.tx) + '" target="_blank" rel="noopener" class="mono small">' + esc(p.tx.slice(0, 10)) + '…</a></td></tr>').join('') : '<tr><td colspan="5" class="muted">No purchases.</td></tr>') + '</table></div></div>';
|
||||
h += '<div><h4 style="margin:0 0 6px">Payouts received</h4><div class="tablewrap"><table class="adm-table"><tr><th>When</th><th>From</th><th>Tier</th><th>Amount</th></tr>'
|
||||
+ (d.received.length ? d.received.map(r => '<tr><td class="small">' + when(r.ts) + '</td><td>#' + r.buyerId + (d.names[r.buyerId] ? ' @' + esc(d.names[r.buyerId]) : '') + '</td><td>' + r.tier + '</td><td>' + polOf(r.amountWei) + ' POL</td></tr>').join('') : '<tr><td colspan="4" class="muted">Nothing received yet.</td></tr>') + '</table></div></div></div>';
|
||||
h += '<h4 style="margin:18px 0 6px">Campaigns (' + d.campaigns.length + ')</h4><div class="tablewrap"><table class="adm-table"><tr><th>#</th><th>Type</th><th>Status</th><th>Budget</th><th>Spent</th><th>Views</th><th>Clicks</th><th>Created</th></tr>'
|
||||
+ (d.campaigns.length ? d.campaigns.map(c => '<tr><td>' + c.id + '</td><td>' + esc(c.type) + '</td><td>' + esc(c.status) + '</td><td>' + Number(c.budget || 0).toLocaleString() + '</td><td>' + Number(c.spent || 0).toLocaleString() + '</td><td>' + Number(c.views || 0).toLocaleString() + '</td><td>' + Number(c.clicks || 0).toLocaleString() + '</td><td class="small muted">' + when(c.created) + '</td></tr>').join('') : '<tr><td colspan="8" class="muted">No campaigns.</td></tr>') + '</table></div>';
|
||||
$('mcBody').innerHTML = h;
|
||||
if (location.hash !== '#members') history.replaceState(null, '', '#members');
|
||||
}
|
||||
function closeMember() { $('memCard').hidden = true; document.querySelectorAll('#pane-members > .card').forEach(c => { c.hidden = false; }); }
|
||||
// live matches while typing: any part of the username, email, member #, share code or wallet
|
||||
let memHitList = [];
|
||||
function memMatches(q) {
|
||||
q = q.toLowerCase();
|
||||
return allMembers.filter(a => [a.username, a.email, a.memberId ? '#' + a.memberId : '', a.memberId, a.code, a.address, a.sponsorName].filter(Boolean).join(' ').toLowerCase().includes(q)).slice(0, 12);
|
||||
}
|
||||
async function memTypeahead() {
|
||||
const q = $('memSearch').value.trim();
|
||||
if (!allMembers.length) { try { const r = await api('/api/admin/members'); allMembers = r.members || []; } catch (e) {} }
|
||||
if (q.length < 2) { $('memHits').hidden = true; memHitList = []; return; }
|
||||
memHitList = memMatches(q);
|
||||
$('memHits').innerHTML = memHitList.length ? memHitList.map(a => '<button type="button" data-mcopen="' + esc(a.email) + '" style="display:flex;gap:12px;width:100%;text-align:left;background:transparent;border:0;border-bottom:1px solid var(--line);padding:8px 12px;color:inherit;cursor:pointer;font:inherit"><b style="min-width:140px">' + (a.username ? '@' + esc(a.username) : '<span class="muted">no username</span>') + '</b><span>' + esc(a.email) + '</span><span class="muted">' + (a.memberId ? '#' + a.memberId : 'free') + (a.sponsorName ? ' · under ' + esc(a.sponsorName) : '') + '</span></button>').join('')
|
||||
: '<p class="muted small" style="margin:0;padding:8px 12px">No member matches that.</p>';
|
||||
$('memHits').hidden = false;
|
||||
}
|
||||
$('memSearch').addEventListener('input', memTypeahead);
|
||||
$('memSearch').addEventListener('focus', memTypeahead);
|
||||
$('memOpen').addEventListener('click', () => { const q = $('memSearch').value.trim(); if (!q) return; if (memHitList.length) openMember(memHitList[0].email); else openMember(q); });
|
||||
$('memSearch').addEventListener('keydown', e => { if (e.key === 'Enter') $('memOpen').click(); if (e.key === 'Escape') $('memHits').hidden = true; });
|
||||
document.addEventListener('click', e => { if (!e.target.closest('#memSearchCard')) $('memHits').hidden = true; });
|
||||
$('mcBack').addEventListener('click', closeMember);
|
||||
document.addEventListener('click', e => { const l = e.target.closest('[data-mcopen]'); if (l) { e.preventDefault(); openMember(l.dataset.mcopen); } });
|
||||
document.querySelectorAll('[data-mcact]').forEach(b => b.addEventListener('click', busy(b, async () => {
|
||||
if (!mcCur) return; const a = mcCur.account, act = b.dataset.mcact; let body = null;
|
||||
if (act === 'username') { const v = await IAP.ask({ title: 'Username for ' + a.email, text: '3-20 letters, numbers or underscore. Changing it breaks any invite links they already handed out.', value: a.username || '', ok: 'Save' }); if (v === null || v === undefined) return; body = { username: v }; }
|
||||
if (act === 'sponsor') { const v = await IAP.ask({ title: 'Sponsor for ' + (a.username ? '@' + a.username : a.email), text: 'Username, share code or member #. Blank = no sponsor (company). Re-points free referrals and future purchases; on-chain sponsorship never changes.', value: a.sponsorRef || '', ok: 'Save' }); if (v === null || v === undefined) return; body = { sponsorRef: v }; }
|
||||
if (act === 'wallet') { const v = await IAP.ask({ title: 'Main wallet for ' + a.email, text: 'Paste the 0x address that should be their main wallet (the one that paid, if a purchase came from an unlinked account). The member number is re-read from the chain. Blank = unlink.', value: a.address || '', ok: 'Swap' }); if (v === null || v === undefined) return; if (!await IAP.confirmBox('Swap the main wallet for ' + a.email + ' to ' + (v.trim() || 'nothing') + '?', { title: 'Sure?', ok: 'Swap it', cancel: 'Cancel' })) return; body = { address: v }; }
|
||||
if (act === 'credits') { const v = await IAP.ask({ title: 'Grant credits to ' + (a.username ? '@' + a.username : a.email), text: 'Whole number of earned-pool credits (1 credit = 1 cent of delivery). They can spend them on campaigns right away.', type: 'number', value: '', placeholder: '250', ok: 'Grant' }); if (!v) return; const note = await IAP.ask({ title: 'Reason (kept in the server log)', value: '', placeholder: 'e.g. refund for broken banner', ok: 'Grant' }); body = { grantCredits: v, note: note || '' }; }
|
||||
if (act === 'delete') {
|
||||
if (a.memberId) { IAP.status('Registered members cannot be deleted; their position is on-chain.', 'bad'); return; }
|
||||
if (!await IAP.confirmBox('Delete the free account ' + a.email + '? Their sign-in, referrals link and credits go away. There is no undo.', { title: 'Delete account', ok: 'Delete', cancel: 'Keep it' })) return;
|
||||
await api('/api/admin/member?email=' + encodeURIComponent(a.email), undefined, 'DELETE'); IAP.status('Account deleted.', 'ok'); closeMember(); loadMembers().catch(() => {}); return;
|
||||
}
|
||||
if (!body) return;
|
||||
const d = await api('/api/admin/member', Object.assign({ email: a.email }, body), 'PATCH');
|
||||
renderMember(d); IAP.status('Saved.', 'ok'); loadMembers().catch(() => {});
|
||||
})));
|
||||
|
||||
// ── every admin table: click a header to sort (numbers sort as numbers), inputs with
|
||||
// class "tfilter" filter the table named in data-for ──
|
||||
document.addEventListener('click', e => {
|
||||
const th = e.target.closest('.adm-table th'); if (!th || th.closest('table').classList.contains('kv')) return;
|
||||
const table = th.closest('table'), hdr = th.parentElement, idx = [...hdr.children].indexOf(th);
|
||||
const rows = [...table.querySelectorAll('tr')].filter(r => r !== hdr && r.children.length > 1);
|
||||
const num = s => { const t = String(s).replace(/[$,%\s]/g, '').replace(/…$/, ''); return t !== '' && !isNaN(t) ? Number(t) : null; };
|
||||
const dir = th.dataset.dir === 'asc' ? 'desc' : 'asc';
|
||||
hdr.querySelectorAll('th').forEach(x => { delete x.dataset.dir; x.classList.remove('sort-asc', 'sort-desc'); });
|
||||
th.dataset.dir = dir; th.classList.add('sort-' + dir);
|
||||
rows.sort((r1, r2) => { const a = (r1.children[idx] || {}).textContent || '', b = (r2.children[idx] || {}).textContent || ''; const na = num(a), nb = num(b); const c = na !== null && nb !== null ? na - nb : a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }); return dir === 'asc' ? c : -c; });
|
||||
rows.forEach(r => (hdr.parentElement).appendChild(r));
|
||||
});
|
||||
document.addEventListener('input', e => {
|
||||
const inp = e.target.closest('.tfilter'); if (!inp) return;
|
||||
const table = $(inp.dataset.for); if (!table) return;
|
||||
const q = inp.value.trim().toLowerCase(); let shown = 0;
|
||||
[...table.querySelectorAll('tr')].forEach((r, i) => { if (i === 0 || r.querySelector('th')) return; const hit = !q || r.textContent.toLowerCase().includes(q); r.hidden = !hit; if (hit) shown++; });
|
||||
const c = inp.parentElement.querySelector('.tfilter-count'); if (c) c.textContent = q ? shown + ' shown' : '';
|
||||
});
|
||||
|
||||
// ── release notes + roadmap (Marty, 2026-09-14) ──
|
||||
async function loadReleases() {
|
||||
loadUpdates();
|
||||
const d = await api('/api/admin/releases');
|
||||
$('rnSub').textContent = d.notes.length + ' notes'; $('rmSub').textContent = d.roadmap.length + ' items';
|
||||
$('rnTable').innerHTML = '<tr><th>Date</th><th>Title</th><th>Tags</th><th></th></tr>' + (d.notes.length ? d.notes.map(n => '<tr><td class="small">' + esc(n.date) + '</td><td><b>' + esc(n.title) + '</b></td><td class="small">' + esc(n.tags.join(', ')) + '</td><td class="act"><button type="button" class="btn small sec" data-rnedit="' + esc(n.id) + '">Edit</button> <button type="button" class="btn small sec" data-rndel="' + esc(n.id) + '">Delete</button></td></tr>').join('') : '<tr><td colspan="4" class="muted">No notes yet.</td></tr>');
|
||||
$('rmTable').innerHTML = '<tr><th>Status</th><th>Title</th><th>ETA</th><th>#</th><th></th></tr>' + (d.roadmap.length ? d.roadmap.map(r => '<tr><td class="small">' + esc(r.status) + '</td><td><b>' + esc(r.title) + '</b>' + (r.note ? '<br><span class="muted small">' + esc(r.note) + '</span>' : '') + '</td><td class="small">' + esc(r.eta || '') + '</td><td class="small">' + (r.order || '') + '</td><td class="act"><button type="button" class="btn small sec" data-rmedit="' + esc(r.id) + '">Edit</button> <button type="button" class="btn small sec" data-rmdel="' + esc(r.id) + '">Delete</button></td></tr>').join('') : '<tr><td colspan="5" class="muted">Nothing on the roadmap yet.</td></tr>');
|
||||
$('rnTable').querySelectorAll('[data-rnedit]').forEach(b => b.addEventListener('click', () => { const n = d.notes.find(x => x.id === b.dataset.rnedit); if (!n) return; $('rnId').value = n.id; $('rnTitle').value = n.title; $('rnDate').value = n.date; $('rnTags').value = n.tags.join(', '); $('rnBody').value = n.body; $('rnTitle').focus(); }));
|
||||
$('rmTable').querySelectorAll('[data-rmedit]').forEach(b => b.addEventListener('click', () => { const r = d.roadmap.find(x => x.id === b.dataset.rmedit); if (!r) return; $('rmId').value = r.id; $('rmTitle').value = r.title; $('rmStatus').value = r.status; $('rmEta').value = r.eta || ''; $('rmOrder').value = r.order || ''; $('rmNote').value = r.note || ''; $('rmTitle').focus(); }));
|
||||
$('rnTable').querySelectorAll('[data-rndel]').forEach(b => b.addEventListener('click', async () => { if (!await IAP.confirmBox('Delete this release note?', { ok: 'Delete', cancel: 'Keep' })) return; await api('/api/admin/releases?kind=notes&id=' + encodeURIComponent(b.dataset.rndel), undefined, 'DELETE'); loadReleases(); }));
|
||||
$('rmTable').querySelectorAll('[data-rmdel]').forEach(b => b.addEventListener('click', async () => { if (!await IAP.confirmBox('Delete this roadmap item?', { ok: 'Delete', cancel: 'Keep' })) return; await api('/api/admin/releases?kind=roadmap&id=' + encodeURIComponent(b.dataset.rmdel), undefined, 'DELETE'); loadReleases(); }));
|
||||
}
|
||||
// ── member update emails (Marty, 2026-09-14) ──
|
||||
async function loadUpdates() {
|
||||
if (!$('updCard')) return;
|
||||
try {
|
||||
const d = await api('/api/admin/updates');
|
||||
$('updSub').textContent = (d.mailer ? '' : 'mailer not configured · ') + (d.lastSentAt ? 'last send ' + new Date(d.lastSentAt).toLocaleString() : 'nothing sent yet') + (d.running ? ' · sending now' : '');
|
||||
$('updAudience').innerHTML = Object.entries(d.audiences).map(([k, v]) => '<option value="' + k + '">' + esc(v) + ' (' + (d.counts[k] || 0) + ')</option>').join('');
|
||||
$('updNotes').innerHTML = d.notes.length ? d.notes.map(n => '<label class="small" style="display:flex;gap:8px;align-items:flex-start"><input type="checkbox" value="' + esc(n.id) + '"' + (n.fresh ? ' checked' : '') + '><span>' + esc(n.title) + ' <span class="muted">' + esc(n.date) + '</span></span></label>').join('') : '<span class="muted small">No release notes yet.</span>';
|
||||
$('updLog').innerHTML = '<tr><th>When</th><th>Subject</th><th>Audience</th><th>Sent</th></tr>' + (d.sends.length ? d.sends.map(x => '<tr><td class="small">' + new Date(x.ts).toLocaleString() + '</td><td>' + esc(x.subject) + '</td><td class="small">' + esc(d.audiences[x.audience] || x.audience) + '</td><td class="small">' + x.sent + ' of ' + x.total + (x.skipped ? ' · ' + x.skipped + ' opted out' : '') + (x.failed ? ' · ' + x.failed + ' failed' : '') + (x.unknown ? ' · ' + x.unknown + ' unchecked (Sendy gave no answer)' : '') + (x.status === 'running' ? ' · running' : '') + '</td></tr>').join('') : '<tr><td colspan="4" class="muted small">None yet.</td></tr>');
|
||||
if (d.draft && !updDraftLoaded) { updDraftLoaded = true; $('updSubject').value = d.draft.subject || ''; $('updIntro').value = d.draft.intro || ''; $('updClosing').value = d.draft.closing || ''; if (d.draft.audience) $('updAudience').value = d.draft.audience; $('updNotes').querySelectorAll('input').forEach(i => { i.checked = (d.draft.noteIds || []).includes(i.value); }); $('updSub').textContent += ' · draft loaded (saved ' + new Date(d.draft.savedAt).toLocaleString() + ')'; }
|
||||
if (d.running) setTimeout(loadUpdates, 4000);
|
||||
} catch (e) { $('updSub').textContent = e.message; }
|
||||
}
|
||||
let updDraftLoaded = false;
|
||||
const updInput = () => ({ subject: $('updSubject').value, intro: $('updIntro').value, closing: $('updClosing').value, noteIds: [...$('updNotes').querySelectorAll('input:checked')].map(i => i.value), audience: $('updAudience').value });
|
||||
if ($('updCard')) {
|
||||
$('updSaveDraft').addEventListener('click', busy($('updSaveDraft'), async () => { await api('/api/admin/updates/draft', updInput()); IAP.status('Draft saved.', 'ok'); }));
|
||||
$('updPreview').addEventListener('click', busy($('updPreview'), async () => { const r = await api('/api/admin/updates/preview', updInput()); $('updPre').hidden = false; $('updPre').textContent = 'Subject: ' + r.subject + '\n\n' + r.text; }));
|
||||
$('updTest').addEventListener('click', busy($('updTest'), async () => { const r = await api('/api/admin/updates/send', Object.assign(updInput(), { test: true })); IAP.status('Test sent to ' + r.to + '.', 'ok'); }));
|
||||
$('updSend').addEventListener('click', busy($('updSend'), async () => {
|
||||
const inp = updInput(); if (!inp.noteIds.length) { IAP.status('Pick at least one note.', 'bad'); return; }
|
||||
const opt = $('updAudience').selectedOptions[0].textContent;
|
||||
if (!(await IAP.confirmBox('Send this update to ' + opt + '? One email per member, opt-outs skipped.', { title: 'Send member update', ok: 'Send now', cancel: 'Not yet' }))) return;
|
||||
const r = await api('/api/admin/updates/send', inp); IAP.status('Sending to ' + r.total + ' members in the background.', 'ok'); loadUpdates();
|
||||
}));
|
||||
}
|
||||
$('rnClear').addEventListener('click', () => { ['rnId', 'rnTitle', 'rnDate', 'rnTags', 'rnBody'].forEach(id => { $(id).value = ''; }); });
|
||||
$('rmClear').addEventListener('click', () => { ['rmId', 'rmTitle', 'rmEta', 'rmOrder', 'rmNote'].forEach(id => { $(id).value = ''; }); $('rmStatus').value = 'planned'; });
|
||||
$('rnSave').addEventListener('click', busy($('rnSave'), async () => { await api('/api/admin/releases', { kind: 'note', id: $('rnId').value || null, title: $('rnTitle').value, date: $('rnDate').value, tags: $('rnTags').value, body: $('rnBody').value }); IAP.status('Note saved.', 'ok'); $('rnClear').click(); loadReleases(); }));
|
||||
$('rmSave').addEventListener('click', busy($('rmSave'), async () => { await api('/api/admin/releases', { kind: 'roadmap', id: $('rmId').value || null, title: $('rmTitle').value, status: $('rmStatus').value, eta: $('rmEta').value, order: $('rmOrder').value, note: $('rmNote').value }); IAP.status('Roadmap item saved.', 'ok'); $('rmClear').click(); loadReleases(); }));
|
||||
|
||||
// ── reports + burns ──
|
||||
// ── profit and loss ──
|
||||
let pnlDays = 30;
|
||||
const pol = w => { try { return (Number(BigInt(w || '0') / 10n ** 14n) / 10000).toLocaleString(undefined, { maximumFractionDigits: 2 }); } catch (e) { return '0'; } };
|
||||
const usdOf = (w, px) => { try { return '$' + ((Number(BigInt(w || '0') / 10n ** 14n) / 10000) * px).toLocaleString(undefined, { maximumFractionDigits: 0 }); } catch (e) { return '$0'; } };
|
||||
// ── traffic: referring domains / sources, landing pages, angles, by day ──
|
||||
let trfDays = 30;
|
||||
document.querySelectorAll('#trfRange [data-days]').forEach(b => b.addEventListener('click', () => { trfDays = Number(b.dataset.days); document.querySelectorAll('#trfRange [data-days]').forEach(x => x.classList.toggle('on', x === b)); loadTraffic().catch(e => IAP.status(e.message, 'bad')); }));
|
||||
async function loadPromos() {
|
||||
const d = await (await fetch('/api/admin/promos')).json();
|
||||
if (d.error) throw new Error(d.error);
|
||||
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||
const when = t => t ? new Date(t).toLocaleDateString() : '';
|
||||
$('pcTable').innerHTML = '<tr><th>Code</th><th>Credits</th><th>Partner</th><th>Uses</th><th>Max</th><th>Expires</th><th>Status</th><th></th></tr>'
|
||||
+ (d.codes.length ? d.codes.map(c => '<tr><td><b>' + esc(c.code) + '</b></td><td>' + c.credits.toLocaleString() + '</td><td>' + esc(c.partner) + '</td><td>' + c.uses + '</td><td>' + (c.maxUses || '∞') + '</td><td>' + (c.expires ? when(c.expires) : '') + '</td><td>' + (c.active ? 'active' : 'off') + '</td><td class="act"><button type="button" class="btn small ghost" data-pctoggle="' + esc(c.code) + '" data-on="' + (c.active ? 0 : 1) + '">' + (c.active ? 'Switch off' : 'Switch on') + '</button></td></tr>').join('') : '<tr><td colspan="8" class="muted">No codes yet.</td></tr>');
|
||||
$('pcRecent').innerHTML = '<tr><th>When</th><th>Code</th><th>Email</th><th>Credits</th><th>Via</th></tr>'
|
||||
+ (d.recent.length ? d.recent.map(r => '<tr><td>' + new Date(r.ts).toLocaleString() + '</td><td>' + esc(r.code) + '</td><td>' + esc(r.email) + '</td><td>' + r.credits + '</td><td>' + esc(r.via) + '</td></tr>').join('') : '<tr><td colspan="5" class="muted">No redemptions yet.</td></tr>');
|
||||
document.querySelectorAll('[data-pctoggle]').forEach(b => b.addEventListener('click', async () => {
|
||||
try { await api('/api/admin/promos', { code: b.dataset.pctoggle, active: b.dataset.on === '1' }, 'PATCH'); loadPromos(); } catch (e) { IAP.status(e.message, 'bad'); }
|
||||
}));
|
||||
}
|
||||
if ($('pcSave')) $('pcSave').addEventListener('click', async () => {
|
||||
const msg = $('pcMsg'); msg.hidden = false;
|
||||
try {
|
||||
const r = await api('/api/admin/promos', { code: $('pcCode').value, credits: $('pcCredits').value, partner: $('pcPartner').value, maxUses: $('pcMax').value, expires: $('pcExpires').value || null, active: true });
|
||||
msg.textContent = 'Saved ' + r.code.code + ': ' + r.code.credits + ' credits.'; msg.style.color = 'var(--mint)';
|
||||
$('pcCode').value = ''; $('pcCredits').value = ''; $('pcPartner').value = ''; loadPromos();
|
||||
} catch (e) { msg.textContent = e.message; msg.style.color = '#ff8a8a'; }
|
||||
});
|
||||
async function loadTraffic() {
|
||||
loadPromos().catch(e => IAP.status(e.message, 'bad'));
|
||||
const d = await (await fetch('/api/admin/traffic?days=' + trfDays)).json();
|
||||
if (d.error) throw new Error(d.error);
|
||||
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||
const n = v => Number(v || 0).toLocaleString();
|
||||
$('trfSub').textContent = 'last ' + d.days + ' days · ' + n(d.totals.hits) + ' page views · ' + n(d.totals.joinViews) + ' join-page views · ' + n(d.totals.signups) + ' signups · ' + n(d.totals.buyers) + ' buyers';
|
||||
// conversion columns (Marty, 2026-09-13): visits = page views + join-page views; signup rate is per visit,
|
||||
// registered and buyer rates are per signup (what happened to the people who did sign up)
|
||||
const pct = (num, den) => den ? (100 * num / den).toFixed(num && 100 * num / den < 10 ? 1 : 0) + '%' : '<span class="muted">-</span>';
|
||||
$('trfSources').innerHTML = '<tr><th>Source</th><th>Page<br>views</th><th>Join-page<br>views</th><th>Signups</th><th>Visit →<br>signup</th><th>Registered</th><th>Signup →<br>registered</th><th>$20+<br>buyers</th><th>Signup →<br>buyer</th></tr>'
|
||||
+ (d.sources.length ? d.sources.map(s => '<tr><td>' + esc(s.source) + '</td><td>' + n(s.hits) + '</td><td>' + n(s.joinViews) + '</td><td>' + n(s.signups) + '</td><td>' + pct(s.signups, s.hits + s.joinViews) + '</td><td>' + n(s.registered) + '</td><td>' + pct(s.registered, s.signups) + '</td><td>' + n(s.buyers) + '</td><td>' + pct(s.buyers, s.signups) + '</td></tr>').join('') : '<tr><td colspan="9" class="muted">Nothing recorded in this range yet.</td></tr>');
|
||||
$('trfPaths').innerHTML = '<tr><th>Page</th><th>Views</th></tr>' + (d.paths.length ? d.paths.map(p => '<tr><td>' + esc(p.path) + '</td><td>' + n(p.hits) + '</td></tr>').join('') : '<tr><td colspan="2" class="muted">No page views yet.</td></tr>');
|
||||
$('trfAngles').innerHTML = '<tr><th>Angle</th><th>Join-page<br>views</th><th>Signups</th><th>View →<br>signup</th></tr>' + (d.angles.length ? d.angles.map(a => '<tr><td>' + esc(a.angle) + '</td><td>' + n(a.views) + '</td><td>' + n(a.signups) + '</td><td>' + pct(a.signups, a.views) + '</td></tr>').join('') : '<tr><td colspan="4" class="muted">No angle data yet.</td></tr>');
|
||||
$('trfDaily').innerHTML = '<tr><th>Day</th><th>Page views</th><th>Signups</th></tr>' + (d.daily.length ? d.daily.slice().reverse().map(x => '<tr><td>' + esc(x.day) + '</td><td>' + n(x.hits) + '</td><td>' + n(x.signups) + '</td></tr>').join('') : '<tr><td colspan="3" class="muted">Nothing yet.</td></tr>');
|
||||
}
|
||||
// ── blog: coaching articles, public at /blog (Marty, 2026-09-12) ──
|
||||
let blCur = null; // slug being edited, or null for a new one
|
||||
function blCount() {
|
||||
const t = $('blTitle').value.length, e = $('blExcerpt').value.length;
|
||||
$('blTitleCount').textContent = t + '/60' + (t > 60 ? ' (long)' : '');
|
||||
$('blExcCount').textContent = e + ' chars' + (e && (e < 120 || e > 160) ? ' (aim 120-160)' : '');
|
||||
const w = $('blBody').textContent.trim().split(/\s+/).filter(Boolean).length;
|
||||
$('blWords').textContent = w + ' words';
|
||||
}
|
||||
['blTitle', 'blExcerpt'].forEach(id => $(id).addEventListener('input', blCount));
|
||||
$('blBody').addEventListener('input', blCount);
|
||||
$('blTitle').addEventListener('input', () => { if (!blCur && !$('blSlug').dataset.touched) $('blSlug').value = $('blTitle').value.toLowerCase().replace(/['’]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80); });
|
||||
$('blSlug').addEventListener('input', () => { $('blSlug').dataset.touched = '1'; });
|
||||
document.querySelectorAll('.ed-bar [data-bl]').forEach(b => b.addEventListener('click', () => { $('blBody').focus(); document.execCommand(b.dataset.bl, false, null); }));
|
||||
document.querySelectorAll('.ed-bar [data-blblock]').forEach(b => b.addEventListener('click', () => { $('blBody').focus(); document.execCommand('formatBlock', false, b.dataset.blblock); }));
|
||||
$('blLinkBtn').addEventListener('click', async () => {
|
||||
const sel = window.getSelection(); const range = sel && sel.rangeCount ? sel.getRangeAt(0).cloneRange() : null;
|
||||
const u = await IAP.ask({ title: 'Link address', label: 'https://', placeholder: 'https://linkspin-test.saasy.top/join/martbost', ok: 'Insert' });
|
||||
if (u) { $('blBody').focus(); if (range) { sel.removeAllRanges(); sel.addRange(range); } document.execCommand('createLink', false, u); }
|
||||
});
|
||||
$('blImgBtn').addEventListener('click', () => $('blImgFile').click());
|
||||
$('blImgFile').addEventListener('change', async () => {
|
||||
const f = $('blImgFile').files[0]; if (!f) return;
|
||||
try {
|
||||
const r = await (await fetch('/api/admin/upload', { method: 'POST', headers: { 'Content-Type': f.type }, body: f })).json();
|
||||
if (r.error) throw new Error(r.error);
|
||||
$('blBody').focus();
|
||||
const html = '<img src="' + r.url + '" alt="">';
|
||||
if (!document.execCommand('insertHTML', false, html)) $('blBody').insertAdjacentHTML('beforeend', html);
|
||||
blCount();
|
||||
} catch (e) { IAP.status(e.message || 'Upload failed.', 'bad'); }
|
||||
$('blImgFile').value = '';
|
||||
});
|
||||
$('blCoverBtn').addEventListener('click', () => $('blCoverFile').click());
|
||||
$('blCoverFile').addEventListener('change', () => upload($('blCoverFile'), $('blCoverInfo'), $('blCover')));
|
||||
$('blHtmlBtn').addEventListener('click', () => {
|
||||
const raw = !$('blHtml').hidden;
|
||||
if (raw) { $('blBody').innerHTML = $('blHtml').value; $('blHtml').hidden = true; $('blBody').hidden = false; }
|
||||
else { $('blHtml').value = $('blBody').innerHTML; $('blBody').hidden = true; $('blHtml').hidden = false; }
|
||||
blCount();
|
||||
});
|
||||
function blBodyHtml() { return $('blHtml').hidden ? $('blBody').innerHTML : $('blHtml').value; }
|
||||
function blMsg(t, bad) { $('blMsg').textContent = t; $('blMsg').hidden = !t; $('blMsg').className = 'small ' + (bad ? 'bad' : 'ok'); }
|
||||
function blOpen(post) {
|
||||
blCur = post ? post.slug : null;
|
||||
$('blogList').hidden = true; $('blogEditor').hidden = false;
|
||||
$('blEdTitle').textContent = post ? 'Edit article' : 'New article';
|
||||
$('blEdSub').textContent = post ? (post.status === 'published' ? 'published ' + when(post.publishedAt) + ' · ' + (post.views || 0) + ' views' : 'draft') : '';
|
||||
$('blTitle').value = post ? post.title : ''; $('blSlug').value = post ? post.slug : ''; delete $('blSlug').dataset.touched;
|
||||
$('blTags').value = post ? post.tags.join(', ') : ''; $('blExcerpt').value = post ? post.excerpt : ''; $('blCover').value = post ? post.cover : ''; $('blCoverInfo').textContent = '';
|
||||
$('blHtml').hidden = true; $('blBody').hidden = false; $('blBody').innerHTML = post ? post.body : '';
|
||||
$('blUnpublish').hidden = !(post && post.status === 'published'); $('blDelete').hidden = !post;
|
||||
$('blPreview').hidden = !post; if (post) $('blPreview').href = '/blog/' + post.slug;
|
||||
$('blPublish').textContent = post && post.status === 'published' ? 'Save and publish' : 'Publish';
|
||||
blMsg(''); blCount(); $('blTitle').focus();
|
||||
}
|
||||
async function blSave(status) {
|
||||
const body = { existingSlug: blCur, title: $('blTitle').value, slug: $('blSlug').value, tags: $('blTags').value, excerpt: $('blExcerpt').value, cover: $('blCover').value, body: blBodyHtml(), status };
|
||||
const r = await api('/api/admin/blog', body);
|
||||
blCur = r.post.slug;
|
||||
$('blSlug').value = r.post.slug; $('blPreview').hidden = false; $('blPreview').href = '/blog/' + r.post.slug; $('blDelete').hidden = false;
|
||||
$('blUnpublish').hidden = r.post.status !== 'published'; $('blPublish').textContent = r.post.status === 'published' ? 'Save and publish' : 'Publish';
|
||||
$('blEdTitle').textContent = 'Edit article';
|
||||
blMsg(r.post.status === 'published' ? 'Published. Live at linkspin-test.saasy.top/blog/' + r.post.slug + (r.syndicating ? ' · posting to X and Instagram now (see the Social column in the list).' : '') : 'Draft saved.');
|
||||
IAP.status(r.post.status === 'published' ? 'Published.' : 'Draft saved.', 'ok');
|
||||
}
|
||||
$('blSaveDraft').addEventListener('click', busy($('blSaveDraft'), () => blSave('draft')));
|
||||
$('blPublish').addEventListener('click', busy($('blPublish'), () => blSave('published')));
|
||||
$('blUnpublish').addEventListener('click', busy($('blUnpublish'), () => blSave('draft')));
|
||||
$('blClose').addEventListener('click', () => { $('blogEditor').hidden = true; $('blogList').hidden = false; loadBlog().catch(e => IAP.status(e.message, 'bad')); });
|
||||
$('blDelete').addEventListener('click', busy($('blDelete'), async () => {
|
||||
if (!blCur) return;
|
||||
if (!await IAP.confirmBox('The page at /blog/' + blCur + ' stops existing. There is no undo.', { title: 'Delete this article?', ok: 'Delete', cancel: 'Keep it' })) return;
|
||||
await api('/api/admin/blog?slug=' + encodeURIComponent(blCur), undefined, 'DELETE');
|
||||
$('blClose').click();
|
||||
}));
|
||||
$('blNew').addEventListener('click', () => blOpen(null));
|
||||
async function loadBlog() {
|
||||
const d = await api('/api/admin/blog');
|
||||
const pub = d.posts.filter(p => p.status === 'published').length;
|
||||
$('blSub').textContent = pub + ' published · ' + (d.posts.length - pub) + ' drafts';
|
||||
const synd = p => { const s = p.syndicated; if (!s) return p.status === 'published' ? '<span class="muted small">not posted</span>' : ''; const r = s.results || {}; const part = ['x', 'instagram'].map(k => r[k] ? (r[k].ok ? k + ' ✓' : k + ' ✗') : k + ' –').join(' · '); return '<span class="small' + (s.done ? '' : ' bad') + '" title="' + esc(Object.values(r).map(v => v.error || '').filter(Boolean).join(' | ') || (s.error || '')) + '">' + part + '</span>'; };
|
||||
$('blSyndNote').hidden = false; $('blSyndNote').textContent = d.syndication ? 'Publishing an article posts it to X (@cryptoteambuild) and Instagram (marketingwithmarty) through Blotato, once per article, with the cover image.' : 'Social syndication is off: no Blotato key on the server.';
|
||||
$('blTable').innerHTML = '<tr><th>Title</th><th>Status</th><th>Social</th><th>Tags</th><th>Views</th><th>Updated</th><th></th></tr>'
|
||||
+ (d.posts.length ? d.posts.map(p => '<tr><td><b>' + esc(p.title) + '</b><br><span class="muted small">/blog/' + esc(p.slug) + '</span></td><td>' + (p.status === 'published' ? '<span class="chip-t on">published</span>' : '<span class="chip-t">draft</span>') + '</td><td>' + synd(p) + '</td><td>' + esc(p.tags.join(', ')) + '</td><td>' + (p.views || 0) + '</td><td>' + when(p.updated) + '</td><td class="act"><button type="button" class="btn small sec" data-bledit="' + esc(p.slug) + '">Edit</button> <a class="btn small sec" href="/blog/' + esc(p.slug) + '" target="_blank" rel="noopener">View</a>' + (p.status === 'published' && d.syndication && !(p.syndicated && p.syndicated.done) ? ' <button type="button" class="btn small sec" data-blsynd="' + esc(p.slug) + '">Post to X + IG</button>' : '') + '</td></tr>').join('')
|
||||
: '<tr><td colspan="7" class="muted">No articles yet. Start with "New article".</td></tr>');
|
||||
$('blTable').querySelectorAll('[data-blsynd]').forEach(b => b.addEventListener('click', busy(b, async () => {
|
||||
const r = await api('/api/admin/blog/syndicate', { slug: b.dataset.blsynd });
|
||||
const res = r.syndicated && r.syndicated.results || {}; const bad = Object.entries(res).filter(([, v]) => !v.ok).map(([k, v]) => k + ': ' + v.error);
|
||||
IAP.status(bad.length ? 'Posted with problems: ' + bad.join(' | ') : 'Posted to X and Instagram.', bad.length ? 'bad' : 'ok'); loadBlog().catch(() => {});
|
||||
})));
|
||||
$('blTable').querySelectorAll('[data-bledit]').forEach(b => b.addEventListener('click', async () => {
|
||||
try { const r = await api('/api/admin/blog?slug=' + encodeURIComponent(b.dataset.bledit)); blOpen(r.post); } catch (e) { IAP.status(e.message, 'bad'); }
|
||||
}));
|
||||
}
|
||||
|
||||
async function loadPnl() {
|
||||
const r = await api('/api/admin/pnl?days=' + pnlDays);
|
||||
const px = r.polUsd || 0;
|
||||
const platUsd = (Number(BigInt(r.platformWei || '0') / 10n ** 14n) / 10000) * px;
|
||||
const months = pnlDays ? pnlDays / 30 : Math.max(1, (r.latest - r.fromBlock) / 43200 / 30);
|
||||
const fixed = (r.fixedMonthlyUsd || 0) * months;
|
||||
$('pnlTiles').innerHTML = [
|
||||
['Packages sold', r.purchases.count, Object.entries(r.purchases.byPackage || {}).map(([k, v]) => v + '×' + k).join(' · ') || '—'],
|
||||
['Gross volume', pol(r.purchases.volumeWei) + ' POL', usdOf(r.purchases.volumeWei, px) + ' at today\'s rate · $' + (r.purchases.usdCents / 100).toLocaleString() + ' at sale'],
|
||||
['Platform (fees + dust + unclaimed)', pol(r.platformWei) + ' POL', usdOf(r.platformWei, px)],
|
||||
['Paid to members', pol(r.memberPayoutsWei) + ' POL', usdOf(r.memberPayoutsWei, px)],
|
||||
['Net after fixed costs', '$' + Math.round(platUsd - fixed).toLocaleString(), 'fixed ' + Math.round(fixed).toLocaleString() + ' over ' + months.toFixed(1) + ' month(s)'],
|
||||
['Pass-ups', r.passedUp.count, r.passedUp.unqualified + ' unqualified · ' + r.passedUp.sendFailed + ' send-failed']
|
||||
].map(t => '<div class="statx"><div><div class="nv" style="font-size:22px">' + esc(String(t[1])) + '</div><div class="lb">' + esc(t[0]) + '</div><span class="chip flat">' + esc(t[2]) + '</span></div></div>').join('');
|
||||
$('pnlSplit').innerHTML = '<tr><th>Line</th><th>POL</th><th>USD now</th></tr>'
|
||||
+ [['Level 1 (50%)', r.byTier[1]], ['Level 2 (20%)', r.byTier[2]], ['Level 3 (10%)', r.byTier[3]], ['Platform (20% + pass-ups)', r.platformWei]].map(x => '<tr><td>' + x[0] + '</td><td class="mono">' + pol(x[1]) + '</td><td class="mono">' + usdOf(x[1], px) + '</td></tr>').join('');
|
||||
const W = r.wallets || {}, B = r.balances || {};
|
||||
$('pnlWallets').innerHTML = '<tr><th>Wallet</th><th>Address</th><th>Balance</th></tr>'
|
||||
+ [['Owner / fee A (Tangem)', W.feeA, B.feeA], ['Fee B', W.feeB, B.feeB], ['Engine (gas)', W.engine, B.engine]].filter(x => x[1]).map(x => '<tr><td>' + x[0] + '</td><td class="mono small">' + esc(x[1]) + '</td><td class="mono">' + (x[2] == null ? '?' : pol(x[2]) + ' POL') + '</td></tr>').join('');
|
||||
$('pnlFixed').value = r.fixedMonthlyUsd || 0;
|
||||
const b = r.burner || {};
|
||||
$('burnerLine').textContent = !b.hasEthers ? 'ethers is not installed in this build.' : !b.keyPresent ? 'No engine key configured (ENGINE_KEY). Burns stay pending until it is set.' : b.mismatch ? 'ENGINE_KEY does not match the contract engine signer. Disabled.' : 'Engine wallet ' + b.address + ' holds ' + pol(b.balanceWei) + ' POL. That is its gas fund, not a cost: one burn uses about 0.003 POL (roughly 48,000 gas), paid by this wallet, never by the member. ' + b.burned + ' burn' + (b.burned === 1 ? '' : 's') + ' since boot' + (b.lastRun ? ' · last check ' + when(b.lastRun) : '') + (b.lastError ? ' · last error: ' + b.lastError : '') + (b.skipped && Object.keys(b.skipped).length ? ' · skipped (needs review): ' + Object.entries(b.skipped).map(([k, v]) => k + ' (' + v + ')').join(', ') : '');
|
||||
}
|
||||
document.querySelectorAll('#pnlPeriods [data-days]').forEach(b => b.addEventListener('click', () => { pnlDays = Number(b.dataset.days); document.querySelectorAll('#pnlPeriods [data-days]').forEach(x => x.classList.toggle('on', x === b)); loadPnl().catch(e => IAP.status(e.message, 'bad')); }));
|
||||
if ($('pnlFixedSave')) $('pnlFixedSave').addEventListener('click', async () => { try { await api('/api/admin/site', { pnlFixedMonthlyUsd: Number($('pnlFixed').value) || 0 }, 'PATCH'); IAP.status('Saved.', 'ok'); loadPnl(); } catch (e) { IAP.status(e.message, 'bad'); } });
|
||||
if ($('burnerRun')) $('burnerRun').addEventListener('click', async () => { try { const r = await api('/api/admin/burner/run', {}); IAP.status('Burner ran: ' + (r.burned || 0) + ' burned.', 'ok'); loadPnl(); } catch (e) { IAP.status(e.message, 'bad'); } });
|
||||
async function loadAudit() {
|
||||
if (!$('audTable')) return;
|
||||
try {
|
||||
$('audSub').textContent = 'checking…';
|
||||
const a = await api('/api/admin/audit');
|
||||
const bad = a.checks.filter(c => !c.ok).length;
|
||||
$('audSub').textContent = (bad ? bad + ' issue' + (bad === 1 ? '' : 's') : 'all counters reconcile') + ' · checked ' + new Date(a.checkedAt).toLocaleTimeString();
|
||||
$('audTable').innerHTML = '<tr><th>Check</th><th>Status</th><th>Detail</th></tr>' + a.checks.map(c => '<tr><td>' + esc(c.name) + '</td><td>' + (c.ok ? '<span class="badge">ok</span>' : '<span class="badge amber">' + c.issues.length + ' issue' + (c.issues.length === 1 ? '' : 's') + '</span>') + '</td><td class="small">' + esc(c.detail) + (c.issues.length ? '<br>' + c.issues.map(esc).join('<br>') : '') + '</td></tr>').join('');
|
||||
} catch (e) { $('audSub').textContent = e.message; }
|
||||
}
|
||||
if ($('audRun')) $('audRun').addEventListener('click', busy($('audRun'), loadAudit));
|
||||
async function loadReports() {
|
||||
loadAudit();
|
||||
const [r, b] = await Promise.all([api('/api/admin/reports'), api('/api/admin/burns')]);
|
||||
const reps = r.reports || [];
|
||||
$('repTable').innerHTML = reps.length ? '<tr><th>When</th><th>Campaign</th><th>Reason</th><th>Note</th><th>By</th><th></th></tr>'
|
||||
+ reps.map(x => '<tr' + (x.resolved ? ' style="opacity:.5"' : '') + '><td class="small muted">' + when(x.ts) + '</td><td>#' + x.campaignId + '</td><td>' + esc(x.reason) + '</td><td>' + esc(x.note || '') + '</td><td class="small">' + esc(x.reporter || 'anon') + '</td>'
|
||||
+ '<td class="act">' + (x.resolved ? 'resolved' : '<button class="btn small sec" data-act="pause" data-id="' + x.campaignId + '">Pause ad</button><button class="btn small" data-resolve="' + x.id + '">Resolve</button>') + '</td></tr>').join('')
|
||||
: '<tr><td class="muted">No reports.</td></tr>';
|
||||
const burns = b.pending || [];
|
||||
$('burnTable').innerHTML = burns.length ? '<tr><th>When</th><th>Member</th><th>Credits</th><th>Ref</th><th>Burn id</th></tr>'
|
||||
+ burns.map(x => '<tr><td class="small muted">' + when(x.ts) + '</td><td>#' + x.memberId + '</td><td class="mono">' + x.amount + '</td><td>' + esc(x.ref) + '</td><td class="mono small">' + esc(x.id) + '</td></tr>').join('')
|
||||
: '<tr><td class="muted">Nothing pending.</td></tr>';
|
||||
}
|
||||
document.addEventListener('click', async e => {
|
||||
const b = e.target.closest('[data-resolve]'); if (!b) return;
|
||||
b.disabled = true;
|
||||
try { await api('/api/admin/reports/' + b.dataset.resolve + '/resolve', {}); IAP.status('Report resolved.', 'ok'); await Promise.all([loadReports(), loadOverview()]); }
|
||||
catch (err) { IAP.status(err.message, 'bad'); b.disabled = false; }
|
||||
});
|
||||
|
||||
// ── settings: graphical editors (follow-up emails, rates, site settings) ──
|
||||
let dripSeq = [], ratesObj = {}, siteObj = {};
|
||||
let lastFocusedField = null;
|
||||
document.addEventListener('focusin', e => { if (e.target && (e.target.matches('textarea.ds-body') || e.target.matches('input.ds-subject'))) lastFocusedField = e.target; });
|
||||
document.addEventListener('click', e => {
|
||||
const c = e.target.closest('[data-ph]'); if (!c) return;
|
||||
const el = lastFocusedField; if (!el) { IAP.status('Click into a subject or body first, then the chip.', 'bad'); return; }
|
||||
const ph = c.dataset.ph, st = el.selectionStart || 0, en = el.selectionEnd || st;
|
||||
el.value = el.value.slice(0, st) + ph + el.value.slice(en);
|
||||
el.focus(); el.selectionStart = el.selectionEnd = st + ph.length;
|
||||
el.dispatchEvent(new Event('input'));
|
||||
});
|
||||
const whenLabel = h => { h = Number(h) || 0; if (h < 24) return h + ' hour' + (h === 1 ? '' : 's') + ' after sign-up'; const d = h / 24; return (Number.isInteger(d) ? d : d.toFixed(1)) + ' day' + (d === 1 ? '' : 's') + ' after sign-up'; };
|
||||
function drawDrip() {
|
||||
const wrap = $('dripSteps');
|
||||
wrap.innerHTML = dripSeq.map((st, i) => '<div class="drip-step" data-i="' + i + '">'
|
||||
+ '<div class="ds-head"><span class="ds-n">EMAIL ' + (i + 1) + '</span>'
|
||||
+ '<span class="ds-when">send <input type="number" min="1" class="ds-hours" value="' + esc(st.hours) + '"> hours after sign-up <b class="ds-whenlbl">(' + esc(whenLabel(st.hours)) + ')</b></span>'
|
||||
+ '<span class="ds-tools"><button type="button" class="btn small sec" data-act="up" ' + (i === 0 ? 'disabled' : '') + '>↑</button><button type="button" class="btn small sec" data-act="down" ' + (i === dripSeq.length - 1 ? 'disabled' : '') + '>↓</button>'
|
||||
+ '<button type="button" class="btn small sec" data-act="test">Send to me</button><button type="button" class="btn small sec" data-act="remove">Remove</button></span></div>'
|
||||
+ '<input class="ds-subject" placeholder="Subject line" maxlength="150" value="' + esc(st.subject) + '">'
|
||||
+ '<textarea class="ds-body" placeholder="Plain-text email body">' + esc(st.body) + '</textarea>'
|
||||
+ '</div>').join('') || '<p class="muted small">No emails yet. Add one below.</p>';
|
||||
}
|
||||
function readDrip() {
|
||||
return [...document.querySelectorAll('#dripSteps .drip-step')].map(card => ({
|
||||
hours: Number(card.querySelector('.ds-hours').value) || 0,
|
||||
subject: card.querySelector('.ds-subject').value.trim(),
|
||||
body: card.querySelector('.ds-body').value.trim()
|
||||
}));
|
||||
}
|
||||
$('dripSteps').addEventListener('input', e => {
|
||||
if (e.target.classList.contains('ds-hours')) { const l = e.target.closest('.ds-when').querySelector('.ds-whenlbl'); if (l) l.textContent = '(' + whenLabel(e.target.value) + ')'; }
|
||||
});
|
||||
$('dripSteps').addEventListener('click', async e => {
|
||||
const b = e.target.closest('[data-act]'); if (!b) return;
|
||||
const card = b.closest('.drip-step'), i = Number(card.dataset.i);
|
||||
dripSeq = readDrip();
|
||||
if (b.dataset.act === 'remove') { if (!confirm('Remove email ' + (i + 1) + '?')) return; dripSeq.splice(i, 1); drawDrip(); return; }
|
||||
if (b.dataset.act === 'up' && i > 0) { [dripSeq[i - 1], dripSeq[i]] = [dripSeq[i], dripSeq[i - 1]]; drawDrip(); return; }
|
||||
if (b.dataset.act === 'down' && i < dripSeq.length - 1) { [dripSeq[i + 1], dripSeq[i]] = [dripSeq[i], dripSeq[i + 1]]; drawDrip(); return; }
|
||||
if (b.dataset.act === 'test') {
|
||||
b.disabled = true;
|
||||
try { await saveDrip(); await api('/api/admin/drip/test', { step: i }); IAP.status('Email ' + (i + 1) + ' sent to your inbox.', 'ok'); }
|
||||
catch (err) { IAP.status(err.message, 'bad'); }
|
||||
b.disabled = false;
|
||||
}
|
||||
});
|
||||
async function saveDrip() {
|
||||
$('dripErr').hidden = true;
|
||||
const seq = readDrip();
|
||||
const r = await api('/api/admin/drip', { sequence: seq }, 'PATCH').catch(err => { $('dripErr').textContent = err.message; $('dripErr').hidden = false; throw err; });
|
||||
dripSeq = r.sequence; drawDrip();
|
||||
return r;
|
||||
}
|
||||
$('dripSave').addEventListener('click', busy($('dripSave'), async () => { await saveDrip(); IAP.status('Sequence saved.', 'ok'); await loadSettings(); }));
|
||||
$('dripAdd').addEventListener('click', () => {
|
||||
dripSeq = readDrip();
|
||||
const last = dripSeq[dripSeq.length - 1];
|
||||
dripSeq.push({ hours: last ? Number(last.hours) + 48 : 24, subject: '', body: '\n\nMarty\n\n{{footer}}' });
|
||||
drawDrip();
|
||||
const cards = document.querySelectorAll('#dripSteps .drip-step'); const c = cards[cards.length - 1]; if (c) { c.scrollIntoView({ behavior: 'smooth', block: 'center' }); c.querySelector('.ds-subject').focus(); }
|
||||
});
|
||||
$('dripReset').addEventListener('click', busy($('dripReset'), async () => {
|
||||
if (!confirm('Replace the saved sequence with the built-in defaults?')) return;
|
||||
const r = await api('/api/admin/drip', { reset: true }, 'PATCH');
|
||||
dripSeq = r.sequence; drawDrip(); IAP.status('Defaults restored.', 'ok'); await loadSettings();
|
||||
}));
|
||||
|
||||
// rates: labels + hints for the known keys; anything unknown still gets a plain field
|
||||
const RATE_META = {
|
||||
bannerBatch: ['Banner: views per batch', 'impressions counted before a banner campaign is charged'],
|
||||
bannerCreditsPerBatch: ['Banner: credits per batch', 'charged to the advertiser per batch'],
|
||||
textBatch: ['Text ad: views per batch', ''], textCreditsPerBatch: ['Text ad: credits per batch', ''],
|
||||
loginCreditsPerDay: ['Login ad: credits per day', 'flat daily charge while active'],
|
||||
loginDwellSeconds: ['Login ad: seconds shown', 'full-screen interstitial after sign-in'],
|
||||
burnBatchMin: ['On-chain burn batch (credits)', 'accrued spend is burned once it reaches this'],
|
||||
welcomeCredits: ['Welcome credits', 'granted after the welcome tour'],
|
||||
dailyViewTarget: ['Daily view set (ads)', 'ads a member views for the daily claim'],
|
||||
dailyClaimCredits: ['Daily claim (credits)', 'paid when the set is complete'],
|
||||
viewDwellSeconds: ['Ad view: seconds per ad', 'the countdown; server-enforced'],
|
||||
soloCostPerRecipient: ['Solo ad: credits per recipient', ''], soloMinRecipients: ['Solo ad: minimum recipients', ''],
|
||||
soloReadCredits: ['Solo ad: reader reward (credits)', ''], soloReadCapPerDay: ['Solo ad: rewarded reads per day', ''], soloReadDwellSeconds: ['Solo ad: seconds to read', ''],
|
||||
videoWatchCapPerDay: ['Video: rewarded watches per day', ''],
|
||||
featuredPerDay: ['Featured link: credits per day', ''], featuredSlotsPerDay: ['Featured link: slots per day', ''], featuredWindowDays: ['Featured link: booking window (days)', ''],
|
||||
featuredDurations: ['Featured link: durations offered (days)', 'comma-separated'],
|
||||
visitCostPerVisit: ['Verified visit: credits per visit', ''], visitMinPack: ['Verified visit: smallest pack', ''], visitReward: ['Verified visit: viewer reward (credits)', ''], visitDwellSeconds: ['Verified visit: seconds on site', ''], visitCapPerDay: ['Verified visit: rewarded visits per day', ''],
|
||||
videoTiers: ['Video ad tiers', 'watch length → advertiser cost → viewer reward'],
|
||||
milestoneBonus: ['Milestone bonuses (credits)', 'one-time, when a member reaches each step']
|
||||
};
|
||||
const humanize = k => k.replace(/([A-Z])/g, ' $1').replace(/^./, c => c.toUpperCase());
|
||||
function drawRates() {
|
||||
const wrap = $('ratesForm'); const html = [];
|
||||
for (const [k, v] of Object.entries(ratesObj)) {
|
||||
const [label, hint] = RATE_META[k] || [humanize(k), ''];
|
||||
if (typeof v === 'number') html.push('<div class="rf"><label>' + esc(label) + '</label><input type="number" step="any" data-rk="' + esc(k) + '" value="' + esc(v) + '">' + (hint ? '<span class="hint">' + esc(hint) + '</span>' : '') + '</div>');
|
||||
else if (typeof v === 'boolean') html.push('<div class="rf"><label>' + esc(label) + '</label><label class="small"><input type="checkbox" data-rk="' + esc(k) + '"' + (v ? ' checked' : '') + ' style="width:auto"> on</label></div>');
|
||||
else if (Array.isArray(v) && v.every(x => typeof x === 'number')) html.push('<div class="rf"><label>' + esc(label) + '</label><input data-rk="' + esc(k) + '" data-kind="numlist" value="' + esc(v.join(', ')) + '">' + (hint ? '<span class="hint">' + esc(hint) + '</span>' : '') + '</div>');
|
||||
else if (Array.isArray(v) && v.every(x => x && typeof x === 'object')) {
|
||||
const cols = [...new Set(v.flatMap(x => Object.keys(x)))];
|
||||
html.push('<div class="rf wide"><label>' + esc(label) + '</label>' + (hint ? '<span class="hint">' + esc(hint) + '</span>' : '')
|
||||
+ '<table class="tiers" data-rk="' + esc(k) + '" data-kind="table"><tr>' + cols.map(c => '<th>' + esc(c) + '</th>').join('') + '</tr>'
|
||||
+ v.map((row, i) => '<tr>' + cols.map(c => '<td><input type="number" step="any" data-col="' + esc(c) + '" value="' + esc(row[c] == null ? '' : row[c]) + '"></td>').join('') + '</tr>').join('') + '</table></div>');
|
||||
} else if (v && typeof v === 'object') {
|
||||
html.push('<div class="rf wide"><label>' + esc(label) + '</label>' + (hint ? '<span class="hint">' + esc(hint) + '</span>' : '') + '<div class="sub-grid" data-rk="' + esc(k) + '" data-kind="object">'
|
||||
+ Object.entries(v).map(([sk, sv]) => '<label>' + esc(humanize(sk)) + '<input type="number" step="any" data-sub="' + esc(sk) + '" value="' + esc(sv) + '"></label>').join('') + '</div></div>');
|
||||
} else html.push('<div class="rf"><label>' + esc(label) + '</label><input data-rk="' + esc(k) + '" value="' + esc(v == null ? '' : v) + '"></div>');
|
||||
}
|
||||
wrap.innerHTML = html.join('');
|
||||
}
|
||||
function readRates() {
|
||||
const out = {};
|
||||
document.querySelectorAll('#ratesForm [data-rk]').forEach(el => {
|
||||
const k = el.dataset.rk, kind = el.dataset.kind;
|
||||
if (kind === 'numlist') out[k] = el.value.split(/[\s,]+/).filter(Boolean).map(Number).filter(n => !isNaN(n));
|
||||
else if (kind === 'table') out[k] = [...el.querySelectorAll('tr')].slice(1).map(tr => { const o = {}; tr.querySelectorAll('input[data-col]').forEach(i => { o[i.dataset.col] = Number(i.value); }); return o; });
|
||||
else if (kind === 'object') { const o = {}; el.querySelectorAll('input[data-sub]').forEach(i => { o[i.dataset.sub] = Number(i.value); }); out[k] = o; }
|
||||
else if (el.type === 'checkbox') out[k] = !!el.checked;
|
||||
else if (el.type === 'number') out[k] = Number(el.value);
|
||||
else out[k] = el.value;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
$('ratesSave').addEventListener('click', busy($('ratesSave'), async () => {
|
||||
$('ratesErr').hidden = true;
|
||||
try { const r = await api('/api/admin/rates', readRates(), 'PATCH'); ratesObj = r.rates || readRates(); drawRates(); IAP.status('Rates saved.', 'ok'); }
|
||||
catch (e) { $('ratesErr').textContent = e.message; $('ratesErr').hidden = false; }
|
||||
}));
|
||||
|
||||
// site settings: key / value rows; booleans as checkboxes, numbers stay numbers
|
||||
const SITE_META = { noPayoutIds: 'No-payout positions (member #s, comma): linkage only, no buys from them, no joins routed under them', siteName: 'Site name', tagline: 'Tagline', rehearsal: 'Testnet rehearsal banner', defaultSponsorId: 'Fallback sponsor (member #)', walletConnectProjectId: 'WalletConnect project id', moonpayPublicKey: 'MoonPay public key', telegramBotToken: 'Telegram proof feed: bot token', telegramChatId: 'Telegram proof feed: chat id', telegramTopicId: 'Telegram proof feed: topic id (optional)', telegramEvents: 'Telegram proof feed: events (payouts | payouts+purchases | all)', telegramCtaUrl: 'Telegram proof feed: join link under each post', aiCreditsPerGen: 'AI Copy Engine: credits per generation after the free allowance', aiFreeSurge: 'AI Copy Engine: free generations a month at Surge', aiFreeCircuit: 'AI Copy Engine: free generations a month at Circuit', aiFreeNexus: 'AI Copy Engine: free generations a month at Nexus', snapshotEnabled: 'Daily growth snapshot to Telegram (1/0)', snapshotHourUtc: 'Daily growth snapshot: hour (UTC; 14 = 9 AM Central)', snapshotTargets: 'Daily growth snapshot: targets (feed = proof channel, echo = shared payments topic; comma list)', pipelineMode: 'Pipeline board: off (coming soon card) | preview (admin account only) | on (everyone)', pipelineEta: 'Pipeline: opening date shown on the coming-soon card (e.g. Sep 28)', memberWeeklyEmail: 'Weekly member email to everyone active (1) or only sponsors with a line (0)', leaderboardWeeklyPrize: 'Leaderboard: weekly prize text (optional; blank shows the credit ladder)', leaderboardMonthlyPrize: 'Leaderboard: monthly prize text (optional)', leaderboardWeeklyCredits: 'Leaderboard: weekly credits for 1st,2nd,3rd… (e.g. 1000,500,250; blank = none)', leaderboardMonthlyCredits: 'Leaderboard: monthly credits for 1st,2nd,3rd… (e.g. 5000,2500,1000)', leaderboardAnnounceGeneral: 'Leaderboard: announce winners in the main group too (1/0)', telegramEchoChatId: 'Telegram echo (shared payments topic): chat id', telegramEchoTopicId: 'Telegram echo: topic id', telegramEchoEvents: 'Telegram echo: events (payouts | payouts+purchases | all)', legacyCreditsAdvertiser: 'Legacy welcome credits: former advertisers', legacyCreditsEarner: 'Legacy welcome credits: former earners', pnlFixedMonthlyUsd: 'P&L: fixed monthly cost (USD)' };
|
||||
function drawSite() {
|
||||
const wrap = $('siteForm');
|
||||
wrap.innerHTML = Object.entries(siteObj).map(([k, v]) => '<div class="kv-row"><span class="k" title="' + esc(k) + '">' + esc(SITE_META[k] || humanize(k)) + '</span>'
|
||||
+ (typeof v === 'boolean' ? '<input type="checkbox" data-sk="' + esc(k) + '"' + (v ? ' checked' : '') + '>'
|
||||
: typeof v === 'number' ? '<input type="number" step="any" data-sk="' + esc(k) + '" value="' + esc(v) + '">'
|
||||
: '<input data-sk="' + esc(k) + '" value="' + esc(typeof v === 'object' ? JSON.stringify(v) : (v == null ? '' : v)) + '">')
|
||||
+ '<button type="button" class="btn small sec" data-sdel="' + esc(k) + '">Clear</button></div>').join('') || '<p class="muted small">No settings saved yet.</p>';
|
||||
}
|
||||
function readSite() {
|
||||
const out = {};
|
||||
document.querySelectorAll('#siteForm [data-sk]').forEach(el => {
|
||||
const k = el.dataset.sk;
|
||||
if (el.type === 'checkbox') out[k] = !!el.checked;
|
||||
else if (el.type === 'number') out[k] = Number(el.value);
|
||||
else { const v = el.value; if (/^[\[{]/.test(v)) { try { out[k] = JSON.parse(v); return; } catch (e) {} } out[k] = v; }
|
||||
});
|
||||
return out;
|
||||
}
|
||||
$('siteForm').addEventListener('click', e => {
|
||||
const b = e.target.closest('[data-sdel]'); if (!b) return;
|
||||
siteObj = readSite(); siteObj[b.dataset.sdel] = ''; drawSite();
|
||||
});
|
||||
$('siteAddKey').addEventListener('click', () => {
|
||||
const k = $('siteNewKey').value.trim(); if (!/^[A-Za-z][A-Za-z0-9_]{0,40}$/.test(k)) { IAP.status('Setting names are letters and numbers, no spaces.', 'bad'); return; }
|
||||
siteObj = readSite(); if (!(k in siteObj)) siteObj[k] = ''; $('siteNewKey').value = ''; drawSite();
|
||||
const el = document.querySelector('#siteForm [data-sk="' + k + '"]'); if (el) el.focus();
|
||||
});
|
||||
$('siteSave').addEventListener('click', busy($('siteSave'), async () => {
|
||||
$('siteErr').hidden = true;
|
||||
try { const r = await api('/api/admin/site', readSite(), 'PATCH'); siteObj = r.site || readSite(); drawSite(); IAP.status('Site settings saved.', 'ok'); }
|
||||
catch (e) { $('siteErr').textContent = e.message; $('siteErr').hidden = false; }
|
||||
}));
|
||||
|
||||
async function loadSettings() {
|
||||
const [r, s, d] = await Promise.all([api('/api/admin/rates'), api('/api/admin/site'), api('/api/admin/drip')]);
|
||||
ratesObj = r.rates || {}; drawRates();
|
||||
siteObj = s.site || {}; drawSite();
|
||||
dripSeq = d.sequence || []; drawDrip();
|
||||
const st = d.stats || {};
|
||||
$('dripSub').textContent = (st.active || 0) + ' in flight · ' + (st.done || 0) + ' finished · ' + (st.unsubscribed || 0) + ' unsubscribed' + (d.mailReady ? '' : ' · NO MAIL KEY: nothing sends');
|
||||
}
|
||||
|
||||
render();
|
||||
})();
|
||||
@@ -0,0 +1,2 @@
|
||||
// public blog pages: the shared nav (with wallet status) and footer, nothing else
|
||||
(function () { try { IAP.renderNav(location.pathname.indexOf('/leaderboard') === 0 ? 'leaderboard' : 'blog'); } catch (e) {} })();
|
||||
@@ -0,0 +1,58 @@
|
||||
// 24/7 assistant widget: floating bubble, slide-up panel, /api/chat.
|
||||
(function () {
|
||||
const root = document.createElement('div');
|
||||
root.id = 'iapChat';
|
||||
root.innerHTML = '<button id="iapChatBtn" aria-label="Chat with us" type="button">💬</button>'
|
||||
+ '<div id="iapChatPanel" hidden>'
|
||||
+ '<div class="ch-head"><b>Ask anything</b><span class="ch-sub">Real answers, around the clock</span>'
|
||||
+ '<button id="iapChatClose" aria-label="Close chat" type="button">×</button></div>'
|
||||
+ '<div class="ch-msgs" id="iapChatMsgs">'
|
||||
+ '<div class="ch-m bot">Hey. Ask me how the payments work, what the packages buy, or anything else. Straight answers only, no income hype.</div>'
|
||||
+ '</div>'
|
||||
+ '<div class="ch-input"><input id="iapChatIn" placeholder="Type your question…" maxlength="600">'
|
||||
+ '<button id="iapChatSend" type="button">Send</button></div>'
|
||||
+ '</div>';
|
||||
document.body.appendChild(root);
|
||||
|
||||
const $ = id => document.getElementById(id);
|
||||
const msgs = $('iapChatMsgs');
|
||||
const input = $('iapChatIn');
|
||||
let history = [];
|
||||
|
||||
const add = (text, who) => {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'ch-m ' + who;
|
||||
// linkify plain URLs
|
||||
d.innerHTML = String(text).replace(/[&<>]/g, c => ({ '&': '&', '<': '<', '>': '>' }[c]))
|
||||
.replace(/(https?:\/\/[^\s]+)/g, '<a href="$1" target="_blank" rel="noopener">$1</a>');
|
||||
msgs.appendChild(d);
|
||||
msgs.scrollTop = msgs.scrollHeight;
|
||||
return d;
|
||||
};
|
||||
async function send() {
|
||||
const q = input.value.trim();
|
||||
if (!q) return;
|
||||
input.value = '';
|
||||
add(q, 'me');
|
||||
const wait = add('…', 'bot');
|
||||
try {
|
||||
const r = await (await fetch('/api/chat', { method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message: q, history: history.slice(-4).join(' | ') }) })).json();
|
||||
wait.remove();
|
||||
add(r.reply || r.error || 'No answer came back. Try again.', 'bot');
|
||||
history.push('Q: ' + q, 'A: ' + (r.reply || ''));
|
||||
} catch (e) {
|
||||
wait.remove();
|
||||
add('Connection hiccup. Try that again.', 'bot');
|
||||
}
|
||||
}
|
||||
$('iapChatBtn').addEventListener('click', () => {
|
||||
const p = $('iapChatPanel');
|
||||
p.hidden = !p.hidden;
|
||||
if (!p.hidden) input.focus();
|
||||
});
|
||||
$('iapChatClose').addEventListener('click', () => { $('iapChatPanel').hidden = true; });
|
||||
$('iapChatSend').addEventListener('click', send);
|
||||
input.addEventListener('keydown', e => { if (e.key === 'Enter') send(); });
|
||||
})();
|
||||
@@ -0,0 +1,248 @@
|
||||
// Shared page runtime: site config, nav, formatting. Zero dependencies.
|
||||
window.IAP = (function () {
|
||||
let config = null;
|
||||
const $ = id => document.getElementById(id);
|
||||
|
||||
async function getConfig() {
|
||||
if (!config) config = await (await fetch('/api/config')).json();
|
||||
return config;
|
||||
}
|
||||
// POL amounts display with two decimals (rounded half-up), e.g. 523.39
|
||||
function fmtPol(wei) {
|
||||
const cents = (BigInt(wei) + 5000000000000000n) / 10000000000000000n; // wei -> hundredths of a POL
|
||||
const s = cents.toString().padStart(3, '0');
|
||||
return s.slice(0, -2) + '.' + s.slice(-2);
|
||||
}
|
||||
const fmtUsd = cents => '$' + (cents / 100).toFixed(2);
|
||||
|
||||
function status(msg, cls) {
|
||||
let el = $('status');
|
||||
if (!el) { el = document.createElement('div'); el.id = 'status'; document.body.appendChild(el); }
|
||||
el.textContent = msg; el.className = cls || ''; el.hidden = false;
|
||||
clearTimeout(status._t);
|
||||
if (cls === 'ok') status._t = setTimeout(() => { el.hidden = true; }, 6000);
|
||||
}
|
||||
|
||||
async function renderNav(active) {
|
||||
const c = await getConfig();
|
||||
const nav = document.createElement('nav');
|
||||
nav.innerHTML = '<div class="wrap">'
|
||||
+ '<span class="logo-wrap"><a class="logo" href="/"><img src="/logo.png" alt="LinkSpin" style="height:30px;display:block"></a><span class="byline">Brought to you by the <b>Crypto Team Build Network</b></span></span>'
|
||||
+ '<span class="links">'
|
||||
+ '<a href="/#how" data-p="home">How it works</a>'
|
||||
+ '<a href="/#packages" data-p="pricing">Ad packages</a>'
|
||||
+ '<a href="/ledger" data-p="ledger">Live ledger</a>'
|
||||
+ '<a href="/leaderboard" data-p="leaderboard">Leaderboard</a>'
|
||||
+ '<a href="/contract" data-p="contract">The contract</a>'
|
||||
+ '<a href="/my" data-p="my">Members</a>'
|
||||
+ '</span><span id="navWallet" class="muted">…</span></div>';
|
||||
document.body.prepend(nav);
|
||||
if (c.rehearsal) {
|
||||
const b = document.createElement('div');
|
||||
b.className = 'rehearsal';
|
||||
b.innerHTML = '<b>Testnet rehearsal</b>: running on ' + c.chainName + '. Purchases use valueless test POL while we prove every payout in public.';
|
||||
document.body.prepend(b);
|
||||
}
|
||||
const a = nav.querySelector('[data-p="' + active + '"]');
|
||||
if (a) a.className = 'active';
|
||||
refreshNavWallet();
|
||||
renderFooter();
|
||||
}
|
||||
function renderFooter() {
|
||||
if (document.getElementById('iapFooter')) return;
|
||||
const f = document.createElement('footer'); f.id = 'iapFooter';
|
||||
f.style.cssText = 'border-top:1px solid var(--line);margin-top:48px;padding:26px 22px;text-align:center;color:var(--muted);font-size:13px';
|
||||
f.innerHTML = '<div>© ' + new Date().getFullYear() + ' LinkSpin</div>'
|
||||
+ '<div style="margin-top:8px;display:flex;gap:16px;justify-content:center;flex-wrap:wrap">'
|
||||
+ '<a href="/">How it works</a><a href="/ledger">Live ledger</a><a href="/contract">The contract</a><a href="/blog">Blog</a><a href="/leaderboard">Leaderboard</a><a href="/whats-new">What\'s new</a>'
|
||||
+ '<a href="/terms">Terms</a><a href="/privacy">Privacy</a><a href="/disclaimer">Disclaimer</a></div>';
|
||||
document.body.appendChild(f);
|
||||
}
|
||||
async function refreshNavWallet() {
|
||||
try {
|
||||
const me = await (await fetch('/api/me')).json();
|
||||
const el = $('navWallet');
|
||||
if (!el) return;
|
||||
if (me.signedIn) {
|
||||
// identity order: username, then email, then wallet
|
||||
const who = me.username
|
||||
? '<b>@' + String(me.username).replace(/[&<>]/g, '') + '</b>'
|
||||
: (me.email ? String(me.email).replace(/[&<>]/g, '')
|
||||
: (me.address ? '<span class="mono">' + me.address.slice(0, 6) + '…' + me.address.slice(-4) + '</span>' : 'signed in'));
|
||||
el.innerHTML = (me.memberId ? '<span class="badge">member #' + me.memberId + '</span> ' : '') + who;
|
||||
} else {
|
||||
el.innerHTML = '<a href="/my">Sign in</a>';
|
||||
}
|
||||
return me;
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
function describeEvent(ev, c) {
|
||||
const pol = w => fmtPol(w) + ' POL';
|
||||
// real people, not numbers: use usernames when the site knows them
|
||||
const nm = id => (ev.names && ev.names[id])
|
||||
? String(ev.names[id]).replace(/[&<>]/g, '')
|
||||
: 'member #' + id;
|
||||
switch (ev.type) {
|
||||
case 'Purchase': return '🧾 ' + nm(ev.buyerId) + ' bought package #' + ev.productId
|
||||
+ ' (' + fmtUsd(ev.priceCents) + ') for ' + pol(ev.paidWei) + ' → +' + ev.creditAmount.toLocaleString() + ' credits';
|
||||
case 'TierPaid': return '💸 level ' + ev.tier + ' payout → ' + nm(ev.recipientId) + ': ' + pol(ev.amountWei)
|
||||
+ (ev.hops ? ' (passed up ' + ev.hops + ')' : '');
|
||||
case 'PassedUp': return '↷ level ' + ev.tier + ' passed over ' + nm(ev.skippedId) + ' (' + ev.reason + ')';
|
||||
case 'AdminPaid': return '🏛 platform fee settled: ' + pol(ev.amountWei);
|
||||
case 'BuyerCounted': return '⭐ ' + nm(ev.sponsorId) + ' now has ' + ev.newCount + ' qualifying buyer(s)';
|
||||
case 'MemberActivated': return '👤 ' + nm(ev.id) + ' activated a payout wallet';
|
||||
case 'AwardPaid': return '🎁 award: ' + pol(ev.amountWei) + ' → ' + nm(ev.toId);
|
||||
case 'CreditsConsumed': return '📣 ' + nm(ev.memberId) + ' ran ads: −' + ev.amount.toLocaleString() + ' credits';
|
||||
case 'PriceCached': return '🔮 oracle price refreshed';
|
||||
case 'FallbackPriceUsed': return '🔮 cached price bridged an oracle gap';
|
||||
default: return '· ' + ev.type;
|
||||
}
|
||||
}
|
||||
function feedRow(ev, c) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'row t-' + ev.type;
|
||||
const when = ev.ts ? new Date(ev.ts).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : '';
|
||||
div.innerHTML = (when ? '<span class="when" title="' + new Date(ev.ts).toLocaleString() + '">' + when + '</span>' : '') + '<span>' + describeEvent(ev, c) + '</span>'
|
||||
+ (c.explorer
|
||||
? '<span class="tx"><a target="_blank" rel="noopener" href="' + c.explorer + '/tx/' + ev.tx + '">verify ↗</a></span>'
|
||||
: '<span class="tx"><a href="/tx/' + ev.tx + '">verify ↗</a></span>'); // built-in viewer when the chain has no public explorer
|
||||
return div;
|
||||
}
|
||||
// Render one served ad into #<elId>. Silent if no inventory.
|
||||
// report an ad (auto-approved ads need a member-facing flag → admin notified)
|
||||
function reportAd(campaignId) {
|
||||
if (!campaignId) return;
|
||||
const reason = (prompt('Report this ad. Reason: broken, inappropriate, spam, scam, or other', 'broken') || '').trim().toLowerCase();
|
||||
if (!reason) return;
|
||||
const note = prompt('Anything to add? (optional)') || '';
|
||||
fetch('/api/report-ad', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ campaignId, reason, note }) })
|
||||
.then(() => status('Thanks — this ad was reported to the admin for review.', 'ok'))
|
||||
.catch(() => status('Could not send the report. Try again.', 'bad'));
|
||||
}
|
||||
const reportTag = ad => ' <a class="ad-report small muted" href="#" data-cid="' + ad.id + '" style="margin-left:8px">⚠ report</a>';
|
||||
function wireReport(el) {
|
||||
const rl = el.querySelector('.ad-report');
|
||||
if (rl) rl.addEventListener('click', e => { e.preventDefault(); reportAd(Number(rl.dataset.cid)); });
|
||||
}
|
||||
async function adSlot(type, elId, opts) {
|
||||
try {
|
||||
const q = '/api/ads/slot?type=' + type + (opts && opts.width ? '&w=' + opts.width + '&h=' + opts.height : '');
|
||||
const { ad } = await (await fetch(q)).json();
|
||||
const el = $(elId);
|
||||
if (!ad || !el) return;
|
||||
el.hidden = false;
|
||||
if (ad.imageUrl) {
|
||||
el.style.textAlign = 'center';
|
||||
el.innerHTML = '<a href="' + ad.targetUrl + '" target="_blank" rel="noopener nofollow">'
|
||||
+ '<img src="' + ad.imageUrl + '" alt="advertisement" style="max-width:min(100%,728px);height:auto;display:block;margin:0 auto;border-radius:8px"></a>'
|
||||
+ '<div class="small muted">member ad' + reportTag(ad) + '</div>';
|
||||
} else {
|
||||
el.innerHTML = '<a href="' + ad.targetUrl + '" target="_blank" rel="noopener nofollow"><b>' + ad.title + '</b>'
|
||||
+ (ad.body ? ' · ' + ad.body : '') + '</a> <span class="small muted">member ad' + reportTag(ad) + '</span>';
|
||||
}
|
||||
wireReport(el);
|
||||
el.hidden = false;
|
||||
} catch (e) {}
|
||||
}
|
||||
// ── sign-up code request with the invisible guard fields (form age + honeypot)
|
||||
// and the icon check the server asks for only after an IP trips a limit ──
|
||||
const FORM_TS = Date.now();
|
||||
function iconCheck(host, ch, note) {
|
||||
return new Promise(resolve => {
|
||||
host.hidden = false;
|
||||
host.innerHTML = '<div class="small" style="margin:0 0 8px">' + (note ? esc(note) + ' ' : '') + 'Tap the <b>' + esc(ch.prompt) + '</b>.</div>'
|
||||
+ '<div class="icon-check">' + ch.options.map(o => '<button type="button" class="ic-btn">' + esc(o) + '</button>').join('') + '</div>';
|
||||
host.querySelectorAll('.ic-btn').forEach(b => b.addEventListener('click', () => { host.innerHTML = ''; host.hidden = true; resolve(b.textContent); }, { once: true }));
|
||||
});
|
||||
}
|
||||
// honeypot fields (join + sign-in): read-only until a trusted focus, so browser autofill and
|
||||
// password managers leave them alone; a value that appeared without a trusted event is ignored
|
||||
function armHoneypots() {
|
||||
document.querySelectorAll('.hp-field').forEach(el => {
|
||||
if (el.dataset.armed) return; el.dataset.armed = '1'; el.readOnly = true;
|
||||
const touch = e => { if (e.isTrusted) { el.readOnly = false; el.dataset.touched = '1'; } };
|
||||
el.addEventListener('focus', touch); el.addEventListener('input', touch); el.addEventListener('keydown', touch);
|
||||
});
|
||||
}
|
||||
armHoneypots(); document.addEventListener('DOMContentLoaded', armHoneypots);
|
||||
const hpValue = el => (el && el.dataset.touched === '1') ? (el.value || '') : '';
|
||||
async function requestCode(email, opts) {
|
||||
const o = opts || {};
|
||||
let pick = null;
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const r = await (await fetch('/api/auth/email/start', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, fts: FORM_TS, hp_field_x9: hpValue(o.honeypot), pick }) })).json();
|
||||
if (r.challenge && o.host) { pick = await iconCheck(o.host, r.challenge, r.error); continue; }
|
||||
if (r.error) throw new Error(r.error);
|
||||
return r;
|
||||
}
|
||||
throw new Error('Could not verify. Refresh the page and try again.');
|
||||
}
|
||||
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||
// ── founding-week checklist, read from the live account. Shared by /launch and
|
||||
// the dashboard mark. Two items are the member's own call and persist locally.
|
||||
const LAUNCH_KEY = 'iap.launch.manual';
|
||||
const manualSet = () => { try { return new Set(JSON.parse(localStorage.getItem(LAUNCH_KEY) || '[]')); } catch (e) { return new Set(); } };
|
||||
function launchToggle(key) { const s = manualSet(); if (s.has(key)) s.delete(key); else s.add(key); try { localStorage.setItem(LAUNCH_KEY, JSON.stringify([...s])); } catch (e) {} }
|
||||
function launchChecks(me) {
|
||||
const m = me || {}, man = manualSet();
|
||||
const bc = Number(m.buyerCount || 0), refs = (m.referrals || []).length;
|
||||
return [
|
||||
{ key: 'username', title: 'Pick your username', done: !!m.username, href: '/my#profile', cta: 'Profile',
|
||||
how: 'Profile tab. It becomes your invite link and your public page, and it is permanent.',
|
||||
why: 'Every link, banner and video you hand out this week carries it. Change it later and the links you already sent die.' },
|
||||
{ key: 'wallet', title: 'Link your wallet', done: !!m.address, href: '/my#wallet', cta: 'Wallet',
|
||||
how: 'Wallet tab, Connect, sign the free message. MetaMask recommended. Never held crypto? The wallet guide in Training walks through buying POL with a card.',
|
||||
why: 'Payouts go to this address. No wallet, nowhere to pay you.' },
|
||||
{ key: 'payouts', title: 'Switch on payouts', done: !!m.memberId, href: '/my#wallet', cta: 'Wallet',
|
||||
how: 'Wallet tab, one small transaction. It registers your address with the contract.',
|
||||
why: 'The contract binds each buyer to their sponsor at their first purchase. If payouts are off when your first person buys, that commission is not yours.' },
|
||||
{ key: 'level2', title: 'Qualify: open level 2', done: bc >= 2, href: '/my#buy', cta: 'Buy packages',
|
||||
how: 'Two of your people buy a $20 or more package. Or use Qualified Start: add two positions from extra wallets in your own MetaMask and buy a $20 package from each (about $23 of POL in each wallet).',
|
||||
why: 'Until you have two qualifying buyers, every level 2 payment from your team climbs past you.',
|
||||
note: 'Qualifying buyers so far: <b>' + bc + '</b> of 2.' },
|
||||
{ key: 'level3', title: 'The leader play: open all three levels', done: bc >= 5, href: '/my#buy', cta: 'Qualified Start',
|
||||
how: 'Five qualifying buyers, real or Qualified Start, up to five linked positions. Once qualified, buy from your main wallet so your sponsor is paid in full.',
|
||||
why: 'A leader whose team goes three deep this week collects level 3 from day one instead of watching those 10% payments pass upward. Optional for members, the play for leaders.',
|
||||
note: 'Qualifying buyers so far: <b>' + bc + '</b> of 5.' },
|
||||
{ key: 'banner', title: 'Upload your line banner', done: !!m.lineBannerUrl, href: '/my#profile', cta: 'Profile',
|
||||
how: 'Profile tab, line banner. It shows on the welcome tour to everyone in your next three levels.',
|
||||
why: 'Your first advertising to your own team, free, and it is live the moment they join.' },
|
||||
{ key: 'links', title: 'Copy your links and pick a play', done: man.has('links'), manual: true,
|
||||
how: 'Promo tools, Your links: the invite link and the five angle links, plus the matching hook videos. Then read the plays page and choose one.',
|
||||
why: 'On launch day you send links, not explanations. Having them ready is the whole difference between a launch and a scramble.' },
|
||||
{ key: 'two', title: 'Place your first two', done: refs >= 2, href: '/my#line', cta: 'My line',
|
||||
how: 'Two people you have actually talked to, joined through your link, walked through items 1 to 3 on their own accounts.',
|
||||
why: 'Your first two are the shape of your whole line. Choose them, do not wait for them.',
|
||||
note: 'Joined through you so far: <b>' + refs + '</b>.' }
|
||||
];
|
||||
}
|
||||
// In-page dialogs instead of window.prompt / confirm. Mobile Safari shows a red "Suppress dialogs"
|
||||
// option on the second native pop-up in a row and, once tapped, swallows every later prompt on the
|
||||
// site until reload. ask() resolves the typed value (null on cancel); confirmBox() resolves true/false.
|
||||
function dialog(o) {
|
||||
return new Promise(resolve => {
|
||||
const esc = t => String(t == null ? '' : t).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||
const back = document.createElement('div'); back.className = 'modal-back'; back.style.zIndex = '200';
|
||||
const field = o.type === 'none' ? '' : o.type === 'textarea'
|
||||
? '<textarea id="dlgInput" rows="5" style="width:100%;margin-top:12px">' + esc(o.value) + '</textarea>'
|
||||
: '<input id="dlgInput" type="' + (o.type === 'number' ? 'number' : 'text') + '" ' + (o.type === 'number' ? 'inputmode="decimal" min="0" step="any" ' : '') + 'value="' + esc(o.value) + '" placeholder="' + esc(o.placeholder) + '" style="width:100%;margin-top:12px" autocomplete="off">';
|
||||
back.innerHTML = '<div class="modal-card" role="dialog" aria-modal="true">' + (o.title ? '<h3 style="margin:0 0 8px">' + esc(o.title) + '</h3>' : '')
|
||||
+ (o.text ? '<p class="muted small" style="margin:0;white-space:pre-line">' + esc(o.text) + '</p>' : '') + field
|
||||
+ '<div style="display:flex;gap:10px;justify-content:flex-end;margin-top:16px;flex-wrap:wrap"><button type="button" class="btn sec small" id="dlgCancel">' + esc(o.cancel || 'Cancel') + '</button><button type="button" class="btn small" id="dlgOk">' + esc(o.ok || 'OK') + '</button></div></div>';
|
||||
document.body.appendChild(back);
|
||||
const inp = back.querySelector('#dlgInput');
|
||||
const done = v => { document.removeEventListener('keydown', onKey); back.remove(); resolve(v); };
|
||||
const okv = () => done(o.type === 'none' ? true : (inp ? inp.value : ''));
|
||||
const onKey = e => { if (e.key === 'Escape') { e.preventDefault(); done(o.type === 'none' ? false : null); } else if (e.key === 'Enter' && o.type !== 'textarea') { e.preventDefault(); okv(); } };
|
||||
document.addEventListener('keydown', onKey);
|
||||
back.querySelector('#dlgOk').addEventListener('click', okv);
|
||||
back.querySelector('#dlgCancel').addEventListener('click', () => done(o.type === 'none' ? false : null));
|
||||
back.addEventListener('click', e => { if (e.target === back) done(o.type === 'none' ? false : null); });
|
||||
setTimeout(() => { if (inp) { inp.focus(); if (inp.select && o.type !== 'textarea') inp.select(); } else back.querySelector('#dlgOk').focus(); }, 30);
|
||||
});
|
||||
}
|
||||
function ask(o) { return dialog(Object.assign({ type: 'text', value: '', placeholder: '' }, o || {})); }
|
||||
function confirmBox(text, o) { return dialog(Object.assign({ type: 'none', text, ok: 'Yes', cancel: 'No' }, o || {})); }
|
||||
return { getConfig, fmtPol, fmtUsd, status, renderNav, refreshNavWallet, describeEvent, feedRow, adSlot, reportAd, requestCode, launchChecks, launchToggle, ask, confirmBox, $ };
|
||||
})();
|
||||
@@ -0,0 +1,21 @@
|
||||
// Contract page: inject live address, chain, explorer + verified-source links.
|
||||
(async function () {
|
||||
await IAP.renderNav('contract');
|
||||
const c = await IAP.getConfig();
|
||||
const $ = IAP.$;
|
||||
$('cAddr').textContent = c.contract.slice(0, 10) + '…' + c.contract.slice(-6);
|
||||
$('cChain').textContent = c.chainName;
|
||||
$('mockAddr').textContent = c.contract.slice(0, 18) + '…';
|
||||
if (c.explorer) {
|
||||
$('lnkExplorer').href = c.explorer + '/address/' + c.contract + '#code';
|
||||
const src = 'https://repo.sourcify.dev/contracts/full_match/' + c.chainId + '/' + c.contract + '/';
|
||||
$('lnkSource').href = src;
|
||||
$('lnkSource2').href = src;
|
||||
} else {
|
||||
// fallback if explorer unset: point straight at the Sourcify-verified source
|
||||
const src = 'https://repo.sourcify.dev/contracts/full_match/137/0xBE1ECA72AFF47d13D8E907e523F55eB9e2d365E0/';
|
||||
$('lnkExplorer').href = src;
|
||||
$('lnkSource').href = src;
|
||||
$('lnkSource2').href = src;
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,149 @@
|
||||
// Landing page: live ladder, buy buttons, sponsor attribution line.
|
||||
(async function () {
|
||||
await IAP.renderNav('home');
|
||||
const c = await IAP.getConfig();
|
||||
IAP.$('contractLink').href = c.explorer + '/address/' + c.contract;
|
||||
|
||||
const sp = await (await fetch('/api/sponsor')).json();
|
||||
if (sp.invited) {
|
||||
const el = IAP.$('sponsorLine');
|
||||
el.hidden = false;
|
||||
el.textContent = (sp.sponsorId ? 'You were invited by member #' + sp.sponsorId + '.' : 'You arrived through a member’s invite.')
|
||||
+ ' Your purchases pay their team, and your own link will do the same for you.';
|
||||
}
|
||||
|
||||
async function loadLadder() {
|
||||
const { products } = await (await fetch('/api/catalog')).json();
|
||||
const wrap = document.getElementById('tiles');
|
||||
wrap.innerHTML = '';
|
||||
const NAMES = { 1: 'Micro', 2: 'Activation', 3: 'Builder', 4: 'Growth', 5: 'Leader' };
|
||||
for (const p of products) {
|
||||
const bonus = p.creditAmount - p.priceCents; // credits above 1cr/cent = bulk bonus
|
||||
const div = document.createElement('div');
|
||||
div.className = 'tile' + (p.priceCents === 5000 ? ' hot' : '');
|
||||
div.innerHTML = '<div class="name">' + (NAMES[p.id] || 'Package ' + p.id) + '</div>'
|
||||
+ '<div class="price">$' + Math.round(p.priceCents / 100) + '</div>'
|
||||
+ '<div class="cr">' + p.creditAmount.toLocaleString() + ' credits</div>'
|
||||
+ '<div class="bonus">' + (bonus > 0 ? '+' + bonus.toLocaleString() + ' bonus credits' : ' ') + '</div>'
|
||||
+ '<div class="pol">' + (p.costWei ? IAP.fmtPol(p.costWei) + ' POL right now' : 'paused') + '</div>'
|
||||
+ '<button class="btn small" data-id="' + p.id + '" data-cost="' + (p.costWei || '') + '"'
|
||||
+ (p.costWei ? '' : ' disabled') + '>Buy</button>';
|
||||
wrap.appendChild(div);
|
||||
}
|
||||
wrap.querySelectorAll('button[data-id]').forEach(b => b.addEventListener('click', () => buyPack(b)));
|
||||
}
|
||||
async function buyPack(btn) {
|
||||
try {
|
||||
btn.disabled = true;
|
||||
// email members get their wallet linked to the account at buy time
|
||||
const me = await (await fetch('/api/me')).json();
|
||||
if (me.signedIn && me.email && !me.address) {
|
||||
IAP.status('First, a free signature links your wallet to your account…');
|
||||
await IAPWallet.signIn();
|
||||
}
|
||||
IAP.status('Confirm the purchase in your wallet…');
|
||||
// resolve the sponsor at buy time: a code referrer who activated since
|
||||
// page load still gets locked in
|
||||
const spNow = await (await fetch('/api/sponsor')).json();
|
||||
const r = await IAPWallet.buy(Number(btn.dataset.id), spNow.sponsorId || 0, btn.dataset.cost);
|
||||
if (r.receipt.status !== '0x1') throw new Error('Transaction reverted. Check the explorer.');
|
||||
IAP.status('Purchase settled on-chain. Credits are yours, payouts delivered. Watch it on the ledger.', 'ok');
|
||||
IAP.refreshNavWallet();
|
||||
} catch (e) {
|
||||
IAP.status('Purchase failed: ' + (e.message || e), 'bad');
|
||||
} finally { btn.disabled = false; }
|
||||
}
|
||||
async function loadStats() {
|
||||
try {
|
||||
const s = await (await fetch('/api/stats')).json();
|
||||
IAP.$('stMembers').textContent = (s.onchainMembers || 0).toLocaleString();
|
||||
IAP.$('stPurchases').textContent = (s.purchases || 0).toLocaleString();
|
||||
IAP.$('stPaid').textContent = IAP.fmtPol(s.paidInWei || '0');
|
||||
IAP.$('stPayouts').textContent = (s.payouts || 0).toLocaleString();
|
||||
} catch (e) {}
|
||||
}
|
||||
async function loadTicker() {
|
||||
try {
|
||||
const { events } = await (await fetch('/api/feed?n=30')).json();
|
||||
if (!events.length) return;
|
||||
const inner = IAP.$('tickerInner');
|
||||
inner.innerHTML = events.map(ev => '<span>' + IAP.describeEvent(ev, c) + '</span>').join('');
|
||||
IAP.$('ticker').hidden = false;
|
||||
// readable pace (Marty, 2026-09-12): about 70 px per second no matter how much text is loaded,
|
||||
// instead of a fixed 42 s for the whole strip; pause while a finger or pointer rests on it
|
||||
const wrap = IAP.$('ticker');
|
||||
const secs = Math.max(30, Math.round((inner.scrollWidth + wrap.clientWidth) / 70));
|
||||
inner.style.animationDuration = secs + 's';
|
||||
const pause = on => { inner.style.animationPlayState = on ? 'paused' : 'running'; };
|
||||
wrap.addEventListener('mouseenter', () => pause(true)); wrap.addEventListener('mouseleave', () => pause(false));
|
||||
wrap.addEventListener('touchstart', () => pause(true), { passive: true }); wrap.addEventListener('touchend', () => pause(false), { passive: true });
|
||||
} catch (e) {}
|
||||
}
|
||||
// level cycler: chips + generation highlighting + auto-advance
|
||||
const LVL = {
|
||||
1: { pct: '50%', desc: 'Activate with the $20 starter package and switch on payouts from your wallet. From then on your direct referrals each pay you 50 percent of every package they ever buy, in POL, straight to your wallet. Until you activate, you earn ad credits, not POL.' },
|
||||
2: { pct: '20%', desc: 'Bring 2 buyers of $20 or more and level 2 unlocks: 20 percent of every package your referrals’ referrals buy, on every purchase, forever.' },
|
||||
3: { pct: '10%', desc: 'At 5 qualifying buyers, level 3 opens the third generation: 10 percent of everything they buy. Eight positions deep in this picture, and it keeps growing.' }
|
||||
};
|
||||
const viz = document.getElementById('genViz');
|
||||
if (viz) {
|
||||
const chips = [...document.querySelectorAll('.chips [data-lvl]')];
|
||||
const setLvl = n => {
|
||||
viz.dataset.lvl = n;
|
||||
document.getElementById('vizPct').textContent = LVL[n].pct;
|
||||
document.getElementById('lvlDesc').textContent = LVL[n].desc;
|
||||
chips.forEach(ch => ch.classList.toggle('on', ch.dataset.lvl === String(n)));
|
||||
};
|
||||
let cur = 1;
|
||||
let auto = null;
|
||||
if (!matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
auto = setInterval(() => { cur = cur % 3 + 1; setLvl(cur); }, 4200);
|
||||
}
|
||||
chips.forEach(ch => ch.addEventListener('click', () => {
|
||||
if (auto) { clearInterval(auto); auto = null; } // a click takes the wheel
|
||||
cur = Number(ch.dataset.lvl);
|
||||
setLvl(cur);
|
||||
}));
|
||||
}
|
||||
|
||||
// what-if calculator: pure arithmetic on the locked constants
|
||||
const dc = document.getElementById('dcDirects');
|
||||
if (dc) {
|
||||
const $id = x => document.getElementById(x);
|
||||
const usd = n => '$' + n.toLocaleString(undefined, { maximumFractionDigits: 2 });
|
||||
const recalc = () => {
|
||||
const d = Number($id('dcDirects').value);
|
||||
const p = Number($id('dcPkg').value);
|
||||
const r = Number($id('dcSpread').value);
|
||||
$id('dcDirectsV').textContent = d;
|
||||
$id('dcSpreadV').textContent = r;
|
||||
const qualifies = p >= 20; // sub-$20 packages never count toward qualification
|
||||
const l2open = qualifies && d >= 2;
|
||||
const l3open = qualifies && d >= 5;
|
||||
const g2 = d * r, g3 = g2 * r;
|
||||
const e1 = d * p * 0.5;
|
||||
const e2 = l2open ? g2 * p * 0.2 : 0;
|
||||
const e3 = l3open ? g3 * p * 0.1 : 0;
|
||||
$id('dcN1').textContent = d; $id('dcN2').textContent = g2; $id('dcN3').textContent = g3;
|
||||
$id('dcE1').textContent = usd(e1);
|
||||
$id('dcE2').textContent = l2open ? usd(e2) : 'passes up';
|
||||
$id('dcE3').textContent = l3open ? usd(e3) : 'passes up';
|
||||
$id('dcTotal').textContent = usd(e1 + e2 + e3);
|
||||
const setB = (el, open, need) => { el.textContent = open ? 'open' : 'locked: ' + need; el.className = 'badge' + (open ? '' : ' amber'); };
|
||||
setB($id('dcB1'), true, '');
|
||||
setB($id('dcB2'), l2open, qualifies ? (2 - d) + ' more buyer(s)' : 'needs $20+ buyers');
|
||||
setB($id('dcB3'), l3open, qualifies ? (5 - d) + ' more buyer(s)' : 'needs $20+ buyers');
|
||||
$id('dcQualNote').textContent = qualifies
|
||||
? 'Buyers of $20 or more count toward your qualification. 2 unlock level 2, 5 unlock level 3.'
|
||||
: 'Heads up: $5 packages pay your level 1 but do not qualify buyers, so levels 2 and 3 stay locked in this scenario.';
|
||||
};
|
||||
['dcDirects', 'dcPkg', 'dcSpread'].forEach(x => $id(x).addEventListener('input', recalc));
|
||||
recalc();
|
||||
}
|
||||
|
||||
IAP.adSlot('banner', 'adSlotHome');
|
||||
loadLadder();
|
||||
loadStats();
|
||||
loadTicker();
|
||||
setInterval(loadStats, 60000);
|
||||
})();
|
||||
@@ -0,0 +1,112 @@
|
||||
// Invite / lead-capture page: /join/<token>[?v=<angle>]
|
||||
// Email first (code sign-in creates the account), wallet later inside the
|
||||
// member area. The angle only changes the hook copy; the sponsor cookie was
|
||||
// set by the server when this page was served.
|
||||
(function () {
|
||||
const $ = id => document.getElementById(id);
|
||||
const LEG = (brand, seg) => seg === 'adv'
|
||||
? { eyebrow: 'For former ' + brand + ' advertisers', h: 'Your next ad budget <em>pays you back.</em>',
|
||||
lead: brand + ' is closed. The people who bought ads there are exactly who LinkSpin was built for: real ad packages from $5, seven formats, and every package in your line paid out by a verified contract on Polygon in the same transaction.',
|
||||
points: ['Welcome-back credits land the moment your account exists: enough to run a real banner or text campaign today, on us.', 'Seven formats: banners, text ads, login ads, solo ads to member inboxes, video, featured links and verified visits. Views are timed on the server, so a real person saw your ad.', 'When anyone in your line buys ads, the contract pays you in that same transaction. Public on Polygonscan, nothing held, nothing to withdraw.'],
|
||||
cta: 'Claim your welcome-back credits', sub: 'Free account by email. No password, no wallet today. Use the email you had on ' + brand + ': the credits are tied to it.', video: false }
|
||||
: { eyebrow: 'For former ' + brand + ' members', h: 'Same daily habit. <em>Real payouts on-chain.</em>',
|
||||
lead: 'You viewed ads on ' + brand + '. Here you view ads to earn credits, run your own campaign with them for free, and when anyone in your line buys ads you are paid in POL to your own wallet in the same transaction.',
|
||||
points: ['Welcome-back credits on day one, so your first campaign runs before you have viewed a single ad.', 'Join with just an email. No password, no wallet today. Link a wallet later, only when you want payouts switched on.', 'Every payout is a public transaction on Polygon. Nothing is held, so there is nothing to withdraw and nothing to wait for.'],
|
||||
cta: 'Claim your welcome-back credits', sub: 'Free account by email. Use the email you had on ' + brand + ': the credits are tied to it.', video: false };
|
||||
const ANGLES = {
|
||||
'fw-adv': LEG('Faucet Wave', 'adv'), 'fw-earn': LEG('Faucet Wave', 'earn'), 't1-adv': LEG('Tier One Ads', 'adv'), 't1-earn': LEG('Tier One Ads', 'earn'),
|
||||
instant: { eyebrow: 'Same-transaction payouts', h: 'Paid before the page <em>reloads.</em>', lead: 'What if your commission landed before the thank-you page finished loading? On LinkSpin that is not a metaphor. A smart contract on Polygon splits every ad package the moment it sells.',
|
||||
points: ['A verified contract splits every package in the same transaction it sells: 50 percent to the sponsor, 20 and 10 up the line, 20 to the platform.', 'It lands in your own wallet in seconds. There is no balance to withdraw because nothing is ever held.', 'Every payment is public on Polygonscan, so you can check the claim before you spend a dollar.'],
|
||||
cta: 'See a payout land in seconds', sub: 'Free account by email. No password, no wallet today. Your invite link is live the moment you are in.' },
|
||||
adspend: { eyebrow: 'For marketers who buy traffic', h: 'You were buying <em>ads anyway.</em>', lead: 'Every ad dollar you ever spent went one direction: out. Here the ad spend in your line pays you, in the same transaction, on a public ledger. Seven formats, dwell-timed views, packages from $5.',
|
||||
points: ['Seven formats: banners, text ads, login ads, solo ads to member inboxes, video, featured links and verified visits.', 'Views are timed on the server, so a real person saw your ad. Banners and text also run across a partner ad network.', 'When anyone in your line buys ads, the contract pays you in that same transaction.'],
|
||||
cta: 'Put your next ad dollar where it pays you back', sub: 'Free account by email. See every format and the live rates before you buy anything.' },
|
||||
free: { eyebrow: 'Costs nothing to try', h: 'Watch first. <em>Spend never.</em>', lead: 'Join free, view a few ads, earn credits, and run your first campaign for zero dollars. Upgrade only if you want more reach.',
|
||||
points: ['Join with just an email. No password, no wallet today.', 'View a few ads each day and earn credits you can spend on your own banner or text campaign.', 'Buy a package only if you want more reach. They start at $5, and every payout on them is public.'],
|
||||
cta: 'Start for free. No card, no wallet.', sub: 'Type your email and we send a 6-digit code. That is the whole signup.' },
|
||||
ledger: { eyebrow: 'No back office', h: 'No back office. <em>No payday.</em>', lead: 'Your last program paid you on the 15th, if it paid you. Here every payout is a public transaction on Polygon you can read yourself, and nothing is ever held.',
|
||||
points: ['Every payout is a public transaction on Polygon. Click it, read it, verify it yourself.', 'The contract holds zero balance. It splits and sends in the same transaction, with no pause switch and no upgrade path.', 'No back office means nobody can delay, reverse or review your commission.'],
|
||||
cta: 'Check the ledger yourself, then decide', sub: 'Free account by email. The public ledger and the verified contract are one click from your dashboard.' },
|
||||
two: { eyebrow: 'The referral side, exactly as coded', h: 'Two buyers open <em>level two.</em>', lead: 'Every direct buyer pays you 50 percent from their first package. Two qualifying buyers open level two, five open level three. Written as constants in a verified contract.',
|
||||
points: ['Every direct buyer pays you 50 percent from their very first package.', 'Two qualifying buyers open level two at 20 percent. Five open level three at 10 percent. Constants in a verified contract.', 'Until a level opens, its share climbs to the next qualified member above, so the plan rewards the people who build.'],
|
||||
cta: 'Start your line. Two buyers is the target.', sub: 'Free account by email. Your invite link and the team-building plays are waiting inside.' }
|
||||
};
|
||||
// ?name=First personalises the page (site-owner links, Marty 2026-09-12): letters, spaces, hyphens,
|
||||
// apostrophes only, 30 chars. On the company placement links it says the top spot is reserved.
|
||||
const who = String(new URLSearchParams(location.search).get('name') || '').replace(/[^A-Za-z\u00C0-\u024F' -]/g, '').trim().slice(0, 30);
|
||||
if (who) {
|
||||
const top = /^\/join\/(company|top)$/i.test(location.pathname);
|
||||
const h = $('jnHello');
|
||||
if (h) { h.textContent = who + (top ? ', your spot directly under the company at the top is reserved for you. Activate with the $20 package to claim it.' : ', this invitation is for you.'); h.hidden = false; }
|
||||
}
|
||||
const v = new URLSearchParams(location.search).get('v') || (document.cookie.match(/(?:^|; )iap\.angle=([^;]+)/) || [])[1] || '';
|
||||
const a = v && ANGLES[v];
|
||||
if (a) {
|
||||
document.body.classList.add('squeeze'); // server sets it too; this covers cached HTML
|
||||
$('jnEyebrow').textContent = '· ' + a.eyebrow; $('jnHead').innerHTML = a.h; $('jnLead').textContent = a.lead;
|
||||
if (a.cta) { $('jnCapH').textContent = a.cta; $('jnCapSub').textContent = a.sub || ''; }
|
||||
// the matching hook video + this angle's three points replace the worked example
|
||||
const VID = 'https://coolify-saasytop.nyc3.digitaloceanspaces.com/promo/';
|
||||
const vid = $('jnVideo');
|
||||
if (a.video === false) vid.hidden = true; else { vid.src = VID + v + '.mp4'; vid.poster = VID + v + '.jpg'; }
|
||||
$('jnPoints').innerHTML = (a.points || []).map(t => '<li>' + t.replace(/[&<>]/g, c => ({ '&': '&', '<': '<', '>': '>' }[c])) + '</li>').join('');
|
||||
$('jnMock').hidden = true; $('jnAngle').hidden = false; $('jnPoints').hidden = false;
|
||||
}
|
||||
|
||||
async function api(path, body) {
|
||||
const r = await (await fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) })).json();
|
||||
if (r.error) throw new Error(r.error);
|
||||
return r;
|
||||
}
|
||||
const err = m => { const e = $('jnErr'); e.textContent = m || ''; e.hidden = !m; };
|
||||
function busy(btn, fn) {
|
||||
return async () => { if (btn.disabled) return; btn.disabled = true; err(''); try { await fn(); } catch (e) { err(e.message || 'Something went wrong.'); } finally { btn.disabled = false; } };
|
||||
}
|
||||
const codeOpts = () => ({ honeypot: $('jnWebsite'), host: $('jnCheck') });
|
||||
const send = busy($('jnSend'), async () => {
|
||||
const r = await IAP.requestCode($('jnEmail').value, codeOpts());
|
||||
$('jnCodeRow').hidden = false; $('jnVerify').hidden = false; $('jnSend').hidden = true; $('jnResend').hidden = false;
|
||||
if (r.devCode) $('jnCode').value = r.devCode;
|
||||
// the last thing they see before the account is created: who they are joining under
|
||||
if (sponsorName) { $('jnUnder').textContent = 'Joining under ' + sponsorName + '. Not who invited you? Open their invite link first, then come back for the code.'; $('jnUnder').hidden = false; }
|
||||
IAP.status(r.sent ? 'Code sent. Check your inbox (and spam, the first time).' : 'Dev mode: code filled in.', 'ok');
|
||||
$('jnCode').focus();
|
||||
});
|
||||
$('jnSend').addEventListener('click', send);
|
||||
$('jnResend').addEventListener('click', busy($('jnResend'), async () => {
|
||||
const r = await IAP.requestCode($('jnEmail').value, codeOpts());
|
||||
if (r.devCode) $('jnCode').value = r.devCode;
|
||||
IAP.status('Fresh code sent.', 'ok');
|
||||
}));
|
||||
$('jnVerify').addEventListener('click', busy($('jnVerify'), async () => {
|
||||
await api('/api/auth/email/verify', { email: $('jnEmail').value, code: $('jnCode').value, newsletter: !!$('jnNews').checked, followups: !!$('jnNews').checked });
|
||||
IAP.status('You are in. Taking you to your dashboard…', 'ok');
|
||||
location.href = '/my?welcome=1'; // dashboard runs the username step + welcome tour on arrival
|
||||
}));
|
||||
$('jnEmail').addEventListener('keydown', e => { if (e.key === 'Enter') ($('jnVerify').hidden ? $('jnSend') : $('jnVerify')).click(); });
|
||||
$('jnCode').addEventListener('keydown', e => { if (e.key === 'Enter') $('jnVerify').click(); });
|
||||
|
||||
// sponsor line (the link opened most recently sets the sponsor; it locks at account creation)
|
||||
let sponsorName = '';
|
||||
const linkTok = (location.pathname.match(/^\/join\/([^/?#]+)/) || [])[1] || '';
|
||||
fetch('/api/sponsor' + (linkTok ? '?ref=' + encodeURIComponent(linkTok) : '')).then(r => r.json()).then(sp => {
|
||||
if (sp && sp.invited && sp.name) {
|
||||
sponsorName = sp.name;
|
||||
$('jnSponName').textContent = sp.name + (sp.own ? ' (this is your own invite page; visitors see your name here)' : '');
|
||||
if (sp.avatarUrl) { $('jnSponImg').src = sp.avatarUrl; $('jnSponImg').hidden = false; }
|
||||
if (sp.cobrand && sp.bio && $('jnSponBio')) { $('jnSponBio').textContent = sp.bio; $('jnSponBio').hidden = false; $('jnSpon').classList.add('cobrand'); }
|
||||
$('jnSpon').hidden = false;
|
||||
}
|
||||
}).catch(() => {});
|
||||
// package ladder (dollar constants + live POL quote)
|
||||
const NAMES = { 1: 'Micro', 2: 'Activation', 3: 'Builder', 4: 'Growth', 5: 'Leader' };
|
||||
fetch('/api/catalog').then(r => r.json()).then(c => {
|
||||
const wrap = $('jnLadder'); if (!wrap || !c.products) return;
|
||||
wrap.innerHTML = c.products.filter(p => p.active !== false).map(p =>
|
||||
'<div class="jn-pk"><div class="n">' + (NAMES[p.id] || 'Package ' + p.id) + '</div><div class="p">' + IAP.fmtUsd(p.priceCents) + '</div><div class="c">' + Number(p.creditAmount).toLocaleString() + ' credits</div>'
|
||||
+ (p.costWei ? '<div class="small muted">' + IAP.fmtPol(p.costWei) + ' POL now</div>' : '') + '</div>').join('');
|
||||
}).catch(() => {});
|
||||
fetch('/api/stats').then(r => r.json()).then(s => {
|
||||
if (s && s.onchainMembers) $('jnStats').textContent = s.onchainMembers.toLocaleString() + (s.onchainMembers === 1 ? ' member' : ' members') + ' on-chain so far.';
|
||||
}).catch(() => {});
|
||||
})();
|
||||
@@ -0,0 +1,114 @@
|
||||
// Founding week checklist: members only; every item reads from the live account
|
||||
// (IAP.launchChecks in common.js is shared with the dashboard's "launch ready" mark).
|
||||
(async function () {
|
||||
const $ = id => document.getElementById(id);
|
||||
try { await IAP.renderNav('training'); } catch (e) {}
|
||||
let me = null, cfg = {};
|
||||
try { me = await (await fetch('/api/me')).json(); } catch (e) {}
|
||||
// buyerCount and the referral list live on the dashboard endpoint; merge it in
|
||||
if (me && me.signedIn) { try { const d = await (await fetch('/api/my/dashboard')).json(); if (d && !d.error) me = Object.assign({}, me, d, { signedIn: true, email: me.email }); } catch (e) {} }
|
||||
try { cfg = await IAP.getConfig(); } catch (e) {}
|
||||
const signedIn = !!(me && me.signedIn && me.email);
|
||||
$('gate').style.display = signedIn ? 'none' : 'block';
|
||||
$('body').style.display = signedIn ? 'block' : 'none';
|
||||
if (!signedIn) return;
|
||||
const pb = $('printBtn'); if (pb) pb.addEventListener('click', () => window.print());
|
||||
const tok = me.username || me.refCode || me.memberId;
|
||||
if (tok) document.querySelectorAll('[data-link]').forEach(el => { el.textContent = location.origin + '/join/' + tok; });
|
||||
// launch week swipes: four promoter emails, the member's link + FOUNDER code filled in (Marty, 2026-09-15)
|
||||
const LINK = location.origin + '/join/' + (tok || '') + '?promo=FOUNDER';
|
||||
const SW = [
|
||||
{ when: 'Day 1 · six days out', subject: 'Something opens Monday. I am bringing a few people in early.', body: `Quick one, because I want you positioned before this goes public.
|
||||
|
||||
I have been inside a new advertising platform for a week. It pays sponsors in the same transaction a package is bought, wallet to wallet, on the Polygon ledger where anyone can check it. No back office. No payday. I have watched the payouts land.
|
||||
|
||||
It opens to everyone Monday, September 21 at 9 AM Central. Founding members are in now, and the people they bring in before then are the ones the launch traffic lands under.
|
||||
|
||||
Joining is free with an email. This week the code FOUNDER gives you 500 ad credits to run a campaign on day one:
|
||||
|
||||
{link}
|
||||
|
||||
No income is promised. It is an ad platform with a referral program. But the payment mechanics are real and public, and that is why I am in.
|
||||
|
||||
Look before Monday. I will walk you through the first three steps.`},
|
||||
{ when: 'Day 2 · five days out', subject: 'Five hundred free credits, and what you can do with them', body: `Yesterday I sent you a link. Here is what is on the other side of it.
|
||||
|
||||
You join free with your email. Every day you view a short set of ads and earn credits. Credits run your own ads: banners, text ads, video ads, solo mailings, verified visits, across the whole network.
|
||||
|
||||
This week the FOUNDER code adds 500 credits on top the moment you join, enough to launch a real campaign pointed at whatever you are promoting. Watch it deliver before Monday, when everyone else arrives.
|
||||
|
||||
The other half is simple. When someone you invite buys an ad package, the contract pays you 50 percent instantly. Level two gets 20 percent, level three 10 percent. Same transaction, on the public ledger.
|
||||
|
||||
{link}
|
||||
|
||||
Use the code before it expires Monday morning. Then tell me you are in and I will show you the checklist.`},
|
||||
{ when: 'Day 4 · the weekend', subject: 'The two people you bring in this weekend', body: `The doors open Monday morning and I am spending the weekend getting my first people set up. I want you to be one of them.
|
||||
|
||||
Here is why the weekend matters. Everyone who joins now is a founding member. The traffic that arrives Monday lands under whoever is already in and set up. Two people under you before Monday, and your line is built when the wave hits.
|
||||
|
||||
Setup takes ten minutes: username, link a wallet, switch on payouts. Then share your link. The site gives you posts, swipes, banners and a leaderboard with weekly credit prizes for the top sponsors.
|
||||
|
||||
Join with the FOUNDER code and the 500 credits are yours:
|
||||
|
||||
{link}
|
||||
|
||||
Message me when you are in and I will go through the checklist with you before Monday.`},
|
||||
{ when: 'Day 6 · Sunday evening', subject: 'Doors open tomorrow at 9 AM Central', body: `Last note before the launch.
|
||||
|
||||
LinkSpin opens to the public tomorrow, Monday, at 9 AM Central. After that the FOUNDER code is gone. Tonight it still gives you 500 free ad credits.
|
||||
|
||||
If you join tonight you are in as a founding member with a campaign already funded and your link ready when the first wave arrives. If you wait, you join the wave.
|
||||
|
||||
Free to join, real advertising, and every payment lands in your own wallet the second it happens, on a ledger anyone can read. No income promises. Just mechanics you can verify.
|
||||
|
||||
{link}
|
||||
|
||||
I will be online from 8 AM tomorrow walking people through the first three steps. Get in tonight and you are ahead of them.`}
|
||||
];
|
||||
// short posts and a DM for the same days, one per day, link + code included
|
||||
const POSTS = [
|
||||
{ when: 'Day 1 · six days out', text: `Something I have been inside for a week opens to the public Monday, September 21 at 9 AM Central. An ad platform that pays sponsors in the same transaction a package is bought, on the Polygon ledger where anyone can check it. Free to join. Code FOUNDER gives you 500 ad credits before then. No income promises, just mechanics you can verify. {link}` },
|
||||
{ when: 'Day 2 · five days out', text: `500 free ad credits for joining before Monday. View a short daily set of ads, earn more, run banners, text ads, video ads and solo mailings across the network. When someone you invite buys a package, the contract pays you 50 percent instantly, wallet to wallet. Code FOUNDER. {link}` },
|
||||
{ when: 'Day 4 · the weekend', text: `The doors open Monday morning. Everyone joining this weekend is a founding member, and the launch traffic lands under whoever is already set up. Ten minutes to set up, 500 credits with code FOUNDER, and a leaderboard that pays weekly credit prizes to the top sponsors. {link}` },
|
||||
{ when: 'Day 6 · Sunday evening', text: `Doors open tomorrow, 9 AM Central. Tonight the FOUNDER code still gives you 500 free ad credits; tomorrow it is gone. Free to join, real advertising, every payment on a public ledger. Get in tonight and you are ahead of the wave. {link}` },
|
||||
{ when: 'Text or DM · any day', text: `Hey, I am in something that opens publicly Monday and I am bringing a few people in early. Free to join, real advertising, every payment lands in your own wallet the second it happens. Use code FOUNDER for 500 free credits. Ten minutes to set up and I will walk you through it: {link}` }
|
||||
];
|
||||
const pl = $('postList');
|
||||
if (pl) {
|
||||
pl.innerHTML = POSTS.map((s, i) => '<div class="swipe"><div class="cap"><span>' + s.when + '</span><button type="button" class="btn sec" data-post="' + i + '">Copy post</button></div><pre>' + s.text.replace('{link}', LINK).replace(/&/g, '&').replace(/</g, '<') + '</pre></div>').join('');
|
||||
pl.querySelectorAll('[data-post]').forEach(b => b.addEventListener('click', async () => {
|
||||
const t = POSTS[Number(b.dataset.post)].text.replace('{link}', LINK);
|
||||
try { await navigator.clipboard.writeText(t); b.textContent = 'Copied'; setTimeout(() => { b.textContent = 'Copy post'; }, 1500); } catch (e) { IAP.status('Copy failed; select the text by hand.', 'bad'); }
|
||||
}));
|
||||
}
|
||||
const wrap = $('swipeList');
|
||||
if (wrap) {
|
||||
wrap.innerHTML = SW.map((s, i) => '<div class="swipe"><div class="cap"><span>Email ' + (i + 1) + ' · ' + s.when + '</span><button type="button" class="btn sec" data-swipe="' + i + '">Copy email</button></div><div class="subj">Subject: ' + s.subject + '</div><pre>' + s.body.replace('{link}', LINK).replace(/&/g, '&').replace(/</g, '<') + '</pre></div>').join('');
|
||||
wrap.querySelectorAll('[data-swipe]').forEach(b => b.addEventListener('click', async () => {
|
||||
const s = SW[Number(b.dataset.swipe)]; const text = 'Subject: ' + s.subject + '\n\n' + s.body.replace('{link}', LINK);
|
||||
try { await navigator.clipboard.writeText(text); b.textContent = 'Copied'; setTimeout(() => { b.textContent = 'Copy email'; }, 1500); } catch (e) { IAP.status('Copy failed; select the text by hand.', 'bad'); }
|
||||
}));
|
||||
}
|
||||
|
||||
// launch moment (admin: Settings > launchAt, ISO 8601 with offset)
|
||||
const at = cfg.launchAt ? new Date(cfg.launchAt) : null;
|
||||
if (at && !isNaN(at)) {
|
||||
$('lwWhen').hidden = false;
|
||||
$('lwWhenAt').textContent = at.toLocaleString([], { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
|
||||
const tick = () => { const ms = at - Date.now(); if (ms <= 0) { $('lwWhenIn').textContent = 'doors are open'; return; }
|
||||
const d = Math.floor(ms / 86400000), h = Math.floor(ms % 86400000 / 3600000), m = Math.floor(ms % 3600000 / 60000);
|
||||
$('lwWhenIn').textContent = (d ? d + 'd ' : '') + h + 'h ' + m + 'm to go'; };
|
||||
tick(); setInterval(tick, 30000);
|
||||
}
|
||||
|
||||
function render() {
|
||||
const items = IAP.launchChecks(me);
|
||||
const done = items.filter(i => i.done).length;
|
||||
$('lwDone').textContent = done; $('lwBar').style.width = Math.round(done / items.length * 100) + '%';
|
||||
$('chk').innerHTML = items.map((i, n) => '<li class="' + (i.done ? 'done' : '') + '"><div class="box">' + (i.done ? '✓' : (n + 1)) + '</div>'
|
||||
+ '<div><h3>' + i.title + '</h3><p>' + i.how + '</p><div class="why">' + i.why + '</div>' + (i.note ? '<p class="small" style="margin-top:4px">' + i.note + '</p>' : '') + '</div>'
|
||||
+ '<div class="act">' + (i.manual ? '<button class="btn small ' + (i.done ? 'sec' : '') + '" type="button" data-manual="' + i.key + '">' + (i.done ? 'Undo' : 'Mark done') + '</button>' : '<a class="btn small sec" href="' + i.href + '">' + i.cta + '</a>') + '</div></li>').join('');
|
||||
$('chk').querySelectorAll('[data-manual]').forEach(b => b.addEventListener('click', () => { IAP.launchToggle(b.dataset.manual); render(); }));
|
||||
}
|
||||
render();
|
||||
})();
|
||||
@@ -0,0 +1,31 @@
|
||||
// Live ledger: recent history + SSE stream of new chain events.
|
||||
(async function () {
|
||||
await IAP.renderNav('ledger');
|
||||
const c = await IAP.getConfig();
|
||||
IAP.$('contractLink').href = c.explorer ? c.explorer + '/address/' + c.contract : '/contract';
|
||||
const feed = IAP.$('feed');
|
||||
|
||||
const { events } = await (await fetch('/api/feed?n=150')).json();
|
||||
feed.innerHTML = '';
|
||||
if (!events.length) feed.innerHTML = '<div class="row muted">No activity yet. The first purchase will appear here the moment it lands.</div>';
|
||||
for (const ev of events) feed.appendChild(IAP.feedRow(ev, c));
|
||||
|
||||
try {
|
||||
const stats = await (await fetch('/api/stats')).json();
|
||||
IAP.$('statLine').textContent = stats.onchainMembers + ' on-chain member(s)';
|
||||
} catch (e) {}
|
||||
|
||||
IAP.adSlot('banner', 'adSlotBanner');
|
||||
IAP.adSlot('text', 'adSlotText');
|
||||
|
||||
const es = new EventSource('/api/feed/live');
|
||||
es.onopen = () => { const b = IAP.$('liveBadge'); b.textContent = '● live'; };
|
||||
es.onerror = () => { const b = IAP.$('liveBadge'); b.textContent = 'reconnecting…'; };
|
||||
es.onmessage = m => {
|
||||
try {
|
||||
const ev = JSON.parse(m.data);
|
||||
feed.prepend(IAP.feedRow(ev, c));
|
||||
while (feed.children.length > 200) feed.removeChild(feed.lastChild);
|
||||
} catch (e) {}
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,2 @@
|
||||
// legal/static content pages: render the shared nav + footer
|
||||
(async function () { try { await IAP.renderNav(''); } catch (e) {} })();
|
||||
@@ -0,0 +1,21 @@
|
||||
// Site-owner partner page: ?name=First greets the owner, ?promo=CODE rides along into the
|
||||
// company placement link and the example builder link. Display only; placement and credits
|
||||
// come from the join route and the promo code.
|
||||
(function () {
|
||||
const q = new URLSearchParams(location.search);
|
||||
const name = String(q.get('name') || '').replace(/[^A-Za-zÀ-ɏ' -]/g, '').trim().slice(0, 30);
|
||||
const promo = String(q.get('promo') || '').toUpperCase().replace(/[^A-Z0-9_-]/g, '').slice(0, 24);
|
||||
const ref = String(q.get('ref') || '').toLowerCase().replace(/[^a-z0-9_]/g, '').slice(0, 20); // a member's own partner kit (Nexus): the deal lands under them
|
||||
const hello = document.getElementById('pkHello');
|
||||
if (hello && name) { hello.textContent = name + ', this page is for you. Your spot directly under the company at the top is reserved; activate with the $20 package to claim it.'; hello.style.display = 'block'; }
|
||||
const cta = document.getElementById('pkCta');
|
||||
if (cta) {
|
||||
const p = new URLSearchParams(); if (name) p.set('name', name); if (promo) p.set('promo', promo);
|
||||
cta.href = (ref ? '/join/' + ref : '/join/company') + (p.toString() ? '?' + p.toString() : '');
|
||||
if (name) cta.textContent = 'Claim my spot, ' + name;
|
||||
}
|
||||
const ex = document.getElementById('pkExample');
|
||||
if (ex && promo) ex.textContent = 'https://linkspin-test.saasy.top/join/yourname?promo=' + promo;
|
||||
const sub = document.getElementById('pkCtaSub');
|
||||
if (sub && promo) sub.textContent = 'Free account by email. No password, no wallet today. The link below places you directly under the company and applies your code ' + promo + ' as well.';
|
||||
})();
|
||||
@@ -0,0 +1,14 @@
|
||||
// Team-building plays page: members only, and the scripts carry the member's own link.
|
||||
(async function () {
|
||||
try { await IAP.renderNav('training'); } catch (e) {}
|
||||
let me = null;
|
||||
try { me = await (await fetch('/api/me')).json(); } catch (e) {}
|
||||
const signedIn = !!(me && me.signedIn && me.email);
|
||||
document.getElementById('gate').style.display = signedIn ? 'none' : 'block';
|
||||
document.getElementById('body').style.display = signedIn ? 'block' : 'none';
|
||||
if (!signedIn) return;
|
||||
try { IAP.adSlot('banner', 'adSlotPlays'); } catch (e) {}
|
||||
const pb = document.getElementById('printBtn'); if (pb) pb.addEventListener('click', () => window.print());
|
||||
const tok = me.username || me.refCode || me.memberId;
|
||||
if (tok) document.querySelectorAll('[data-link]').forEach(el => { el.textContent = location.origin + '/join/' + tok; });
|
||||
})();
|
||||
@@ -0,0 +1,217 @@
|
||||
// Promo tools content + rendering for the member area (Promo tools pane).
|
||||
// Everything is personalized from the member's join link. Angle links carry
|
||||
// ?v=<angle> so they keep working when the matched squeeze pages ship.
|
||||
// Copy rules: no income promises, package prices in dollars are fine, never a
|
||||
// POL figure, no em dashes.
|
||||
window.IAPPromo = (function () {
|
||||
const $ = id => document.getElementById(id);
|
||||
const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
const status = (m, c) => (window.IAP && IAP.status) ? IAP.status(m, c) : console.log(m);
|
||||
const angleLink = (link, angle) => angle ? link + '?v=' + angle : link;
|
||||
|
||||
// ── social posts ──
|
||||
const POSTS = [
|
||||
{ net: 'X', label: 'X · instant payout', text: 'No withdraw button. A smart contract on Polygon splits every ad package the second it sells: 50% to the sponsor, 20% to level 2, 10% to level 3. Lands in your own wallet. Watch the ledger move: {{LINK:instant}}' },
|
||||
{ net: 'X', label: 'X · advertiser', text: 'Marketers: you were buying traffic anyway. Here the ad spend in your line pays you, in the same transaction, on a public ledger. Packages from $5. Join free: {{LINK:adspend}}' },
|
||||
{ net: 'X', label: 'X · free join', text: 'Join free. View a few ads, earn credits, run your first campaign for zero dollars. Seven ad formats, real dwell-timed attention, every payout public on Polygon. {{LINK:free}}' },
|
||||
{ net: 'Facebook', label: 'Facebook · story post', text: 'I got tired of "your commission is pending." So I joined a platform where there is no pending. A smart contract on the Polygon blockchain splits every ad package the moment it sells: half to the sponsor, then levels two and three, then the platform. It lands in your own wallet in seconds. No approval queue, no withdrawal button, no 15th of the month.\n\nYou are buying real advertising: banners, text ads, login ads, solo ads to member inboxes, video ads, featured links and verified visits. Members earn credits for their attention, so the ads actually get seen.\n\nJoin free, look around, and check the public ledger before you spend a dollar: {{LINK:instant}}\n\nNo income promises. It is advertising, not investing, and crypto carries risk.' },
|
||||
{ net: 'Facebook', label: 'Facebook · skeptic angle', text: 'Every online program I ever joined asked me to trust a back office. This one does not. Every payout is a public transaction on Polygon you can look up yourself, with the wallet addresses and amounts right there. The split is written in a verified smart contract nobody can quietly edit.\n\nFree to join with just an email. The wallet comes out only if you buy a package or switch on payouts. Packages run $5 to $250, and every one of them is ad delivery you can watch running.\n\nTake the tour: {{LINK:ledger}}' },
|
||||
{ net: 'LinkedIn', label: 'LinkedIn · professional', text: 'An experiment in transparent affiliate payouts: LinkSpin sells advertising packages ($5 to $250) whose sponsor commissions are settled by a verified smart contract on Polygon in the same transaction as the purchase. 50 / 20 / 10 across three levels, 20 percent to the platform, every payment public on the chain.\n\nWhat I find interesting is not the commission. It is that "pending payout" stops being a concept. If you buy traffic for a living and want to see how same-transaction settlement works in practice, the tour is free: {{LINK:adspend}}' },
|
||||
{ net: 'Telegram', label: 'Telegram or WhatsApp group', text: 'Quick one for the group. Ad platform on Polygon where every package splits to sponsor wallets the second it sells. No withdrawals, no pending. Free to join by email, packages from $5, seven ad formats, and you earn credits for viewing. Tour here: {{LINK:instant}}' },
|
||||
{ net: 'Telegram', label: 'Telegram or WhatsApp group · free angle', text: 'If you want to test an ad network without spending anything: join free, view a handful of ads, earn credits, run your first campaign on the house. Payouts are on the public Polygon ledger. {{LINK:free}}' }
|
||||
];
|
||||
|
||||
// ── text a friend (SMS-sized) ──
|
||||
const TEXTS = [
|
||||
{ angle: '', title: '"Thought of you"', text: 'Hey, found an ad platform where the commission lands in your wallet the second someone buys. No pending, no withdraw button. Free to look: {{LINK}}' },
|
||||
{ angle: 'instant', title: '"Before the page reloads"', text: 'Random thought. What if your commission showed up before the thank-you page finished loading? That is literally how this works: {{LINK:instant}}' },
|
||||
{ angle: 'adspend', title: '"You buy ads anyway"', text: 'You already buy traffic. This one pays your line every time someone in it buys ads, same transaction, on Polygon. Two-minute look: {{LINK:adspend}}' },
|
||||
{ angle: 'free', title: '"Costs nothing to try"', text: 'Try this without spending a dollar: join free, view a few ads, earn credits, run your first campaign. {{LINK:free}}' },
|
||||
{ angle: 'ledger', title: '"No back office"', text: 'Remember waiting on payouts that never came? This one has no back office. Every payment is public on the blockchain. Check it yourself: {{LINK:ledger}}' }
|
||||
];
|
||||
|
||||
// ── email swipes ──
|
||||
const SWIPES = [
|
||||
{ tier: 'Short', subject: 'Paid the second it sells', body: 'Quick one.\n\nI joined an ad platform where every package splits to sponsor wallets in the same transaction as the sale. No pending payouts, no withdraw button. It is on the Polygon blockchain, and every payment is public.\n\nJoin free, look at the ledger, decide later: {{LINK:instant}}\n\nNo income promises. Advertising, not investing.' },
|
||||
{ tier: 'Standard', subject: 'Your wallet gets paid instantly', body: 'You know the usual drill. Someone buys on your link, you wait for a payout. Maybe days. Maybe an approval hold. Maybe a "your account is under review."\n\nLinkSpin does not work like that.\n\nA smart contract on the Polygon blockchain handles every purchase the second it happens. 50% to the sponsor. 20% to the next level. 10% to the one after that. 20% to the platform. Each split lands directly in your own wallet. No withdrawal button. No "request payout." No approval queue.\n\nThe money just shows up.\n\nYou can watch every transaction on the public ledger. Real time. Anyone can verify it.\n\nFree to join. Packages from $5 to $250. No income promises. It is advertising, not investing.\n\n{{LINK}}' },
|
||||
{ tier: 'Long', subject: 'The ad network with no pending payouts', body: 'Let me tell you what you are actually looking at, because "crypto ad platform" can mean anything.\n\nLinkSpin sells advertising. Five packages, $5 to $250. A package mints ad credits, and one credit is one cent of delivery across seven formats: display banners, text ads, full-screen login ads, solo ads delivered into member inboxes, video ads, featured links, and verified visits. Members earn credits for their attention, with a timer that pauses when they look away, so your ad is seen by a person, not a script.\n\nNow the part that made me join.\n\nWhen anyone in your line buys a package, a verified smart contract on Polygon splits the payment in that same transaction: 50 percent to their direct sponsor, 20 percent to level two, 10 percent to level three, 20 percent to the platform. It lands in real wallets in seconds. There is no balance to withdraw because nothing is ever held.\n\nEvery one of those payments is public. Open the ledger, click a transaction, read it on Polygonscan.\n\nJoining is free and only needs an email. Your wallet comes out when you buy a package or switch on payouts. If you never spend a dollar, you can still view ads, earn credits, and run a small campaign on those.\n\nHave a look: {{LINK:instant}}\n\nOne honest line: nobody is promising you an income. Results depend on your effort, and crypto carries risk of loss.' },
|
||||
{ tier: 'Follow-up', subject: 'Did you see the ledger?', body: 'Following up on the ad platform I sent over.\n\nIf you only look at one thing, look at the live ledger. Every purchase and every payout, with real wallet addresses, in the order they happened. That is the whole pitch: nothing to take on faith.\n\n{{LINK:ledger}}\n\nIf it is not for you, no worries at all.' }
|
||||
];
|
||||
|
||||
// ── objection bank (truth + say this) ──
|
||||
const OBJECTIONS = [
|
||||
{ q: 'Which crypto do I get paid in?', truth: 'One coin, one network: POL, the native coin of Polygon. Packages are dollar-priced and settled in POL at the live Chainlink rate; every payout is sent as POL to the recipient\'s own Polygon wallet in the same transaction. No tokens, no other chains, no stablecoins.', say: 'You get paid in POL, which is Polygon\'s own coin, straight into your wallet the moment a package in your line sells. Any Polygon wallet works, MetaMask, SafePal, Phantom, and if you have never held crypto you can buy POL with a card inside the member area. {{LINK}}' },
|
||||
{ q: 'Is this a pyramid scheme?', truth: 'You are buying advertising that actually runs: a credit is one cent of delivery across seven live formats, and members earn credits for dwell-timed attention. Sponsor payments are referral commissions written as constants in a verified contract, paid only when a real package sells. Nobody has to recruit to use the ads, and joining costs nothing.', say: 'Fair question. Here is the test I use: is there a real product that people would buy with no referral involved? Here the product is ad delivery, seven formats, one cent per credit, and you can watch your campaign serving. The sponsor split is a referral commission on those sales, fixed in a public contract. Join free and run the ads with zero referrals if you like. {{LINK:free}}' },
|
||||
{ q: 'I do not have a crypto wallet. I am not technical.', truth: 'Joining needs only an email and a 6-digit code. The wallet comes out only when someone buys a package or switches on payouts, and the site walks them through it. A card on-ramp (MoonPay) delivers POL straight to their own wallet. Email first, wallet second.', say: 'You do not need one to join. It is email only, no password even. The wallet shows up later, only if you decide to buy a package or want payouts, and the site walks you through it step by step. Start here and look around first: {{LINK}}' },
|
||||
{ q: 'What am I actually buying?', truth: 'Ad credits minted on-chain the moment a package is bought: $5 = 500, $20 = 2,000, $50 = 5,500, $100 = 12,000, $250 = 32,500. Credits spend on banners, text ads, login ads, solo ads to member inboxes, video ads, featured links and verified visits, with live stats per campaign. Only the buyer\'s campaigns can spend them.', say: 'Advertising. A package mints credits, one credit is one cent of delivery, and you spend them on seven ad formats from your own dashboard with live stats. The $5 package is 500 credits, the $250 package is 32,500. You can see the formats before you spend anything: {{LINK:adspend}}' },
|
||||
{ q: 'Who holds my money?', truth: 'Nobody. A purchase is one Polygon transaction that splits to the sponsor line and the platform wallets immediately. The site never holds balances, and the contract source is verified on Polygonscan and Sourcify. Overpayment refunds itself in the same transaction.', say: 'That is the part I like most: nobody holds it. The purchase is a single blockchain transaction that pays the sponsor line and the platform in the same moment. There is no balance sitting anywhere waiting for a withdrawal. The contract code is public and verified, and every payment is on the ledger: {{LINK:ledger}}' },
|
||||
{ q: 'What if POL drops?', truth: 'Packages are priced in dollars and settled in POL at the live Chainlink rate at the moment of purchase. Payouts arrive as POL in the recipient\'s own wallet immediately, so what they do with it is their call. This is advertising, not an investment, and POL is volatile like any crypto asset.', say: 'Packages are dollar priced, so $20 is $20 worth of POL at that moment. Payouts land in your wallet right away, and it is your money from that second. Crypto does move, so treat it as advertising you bought, not an investment. {{LINK}}' },
|
||||
{ q: 'Why would anyone buy ads here?', truth: 'The members are marketers who already buy traffic. Viewers earn credits only after a server-timed dwell, so impressions are real people. Solo ads reach member inboxes with a guaranteed delivery count, verified visits are one unique member per visit, and banner and text ads also syndicate to a partner ad network.', say: 'Because the people on it are marketers, and they get paid to actually look. Every view is dwell-timed on the server, solo ads land in real inboxes with a guaranteed count, and verified visits are one real person each. You can watch your own campaign serve: {{LINK:adspend}}' },
|
||||
{ q: 'I do not know anyone to refer.', truth: 'Referrals are optional. Members can view ads, earn credits and advertise with no line at all. When they do refer, every direct buyer pays them 50 percent from the very first package, and their line banner is shown to their next three levels during welcome tours.', say: 'You do not have to refer anyone. Join free, earn credits by viewing, run ads. If one person ever joins through you and buys a $5 package, half of it hits your wallet in that transaction. That is the whole referral side, and it is optional. {{LINK:free}}' },
|
||||
{ q: 'How much can I make?', truth: 'No income is guaranteed or implied. Sponsor commissions are 50 / 20 / 10 across three levels on packages that actually sell; level 2 needs 2 qualifying buyers ($20 or more) and level 3 needs 5. Results depend on the member\'s effort, and crypto carries risk of loss. Never quote a figure.', say: 'Nobody can tell you that, and I would not trust anyone who did. What I can tell you is exactly how it is split: 50 percent to the direct sponsor, 20 and 10 to the next two levels, paid the moment a package sells, all public. What that adds up to depends entirely on what you build. {{LINK:instant}}' }
|
||||
];
|
||||
|
||||
function fillLink(text, link) {
|
||||
return text.replace(/\{\{LINK(?::([a-z]+))?\}\}/g, (m, a) => angleLink(link, a));
|
||||
}
|
||||
function copyBtn(text, label) {
|
||||
const b = document.createElement('button');
|
||||
b.className = 'btn small sec'; b.type = 'button'; b.textContent = label || 'Copy';
|
||||
b.addEventListener('click', async () => {
|
||||
try { await navigator.clipboard.writeText(text); status('Copied. Paste it anywhere.', 'ok'); }
|
||||
catch (e) { status('Copy failed. Select the text instead.', 'bad'); }
|
||||
});
|
||||
return b;
|
||||
}
|
||||
function block(text, head, extraBtns) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'promo-block';
|
||||
if (head) { const h = document.createElement('div'); h.className = 'pb-head'; h.innerHTML = head; div.appendChild(h); }
|
||||
const pre = document.createElement('div'); pre.className = 'pb-text'; pre.textContent = text; div.appendChild(pre);
|
||||
const row = document.createElement('div'); row.className = 'pb-actions';
|
||||
row.appendChild(copyBtn(text));
|
||||
(extraBtns || []).forEach(b => row.appendChild(b));
|
||||
div.appendChild(row);
|
||||
return div;
|
||||
}
|
||||
function linkBtn(href, label, cls) {
|
||||
const a = document.createElement('a');
|
||||
a.className = 'btn small ' + (cls || 'sec'); a.href = href; a.textContent = label;
|
||||
if (!/^sms:/.test(href)) { a.target = '_blank'; a.rel = 'noopener'; }
|
||||
return a;
|
||||
}
|
||||
|
||||
// hook videos: one per angle; the matched link continues the same hook on the join page
|
||||
const VID_BASE = 'https://coolify-saasytop.nyc3.digitaloceanspaces.com/promo/';
|
||||
const VIDEOS = [
|
||||
{ angle: 'instant', title: 'Paid before the page reloads', hook: 'What if your commission landed before the thank-you page finished loading?', caption: 'What if your commission landed before the thank-you page finished loading? On LinkSpin a smart contract splits every ad package the second it sells, straight to wallets, on a public ledger. Free to join by email: {{LINK:instant}}' },
|
||||
{ angle: 'adspend', title: 'You were buying traffic anyway', hook: 'Every ad dollar you have ever spent went one direction. Out.', caption: 'Every ad dollar you have ever spent went one direction. Out. Here the ad spend in your line pays you back in the same transaction. Seven formats, packages from $5, free to join: {{LINK:adspend}}' },
|
||||
{ angle: 'free', title: 'Watch first, spend never', hook: 'You can run your first ad campaign here for exactly zero dollars.', caption: 'Run your first ad campaign for exactly zero dollars. Join free by email, earn credits by viewing ads, launch a real campaign, and watch real payouts land on a public ledger before you spend a cent: {{LINK:free}}' },
|
||||
{ angle: 'ledger', title: 'No back office. No payday.', hook: 'Your last affiliate program paid you on the fifteenth. If it paid you.', caption: 'Your last affiliate program paid you on the fifteenth. If it paid you. Here the payroll is the blockchain: every payout is a public transaction, nothing is held, nobody can change the split. Open the ledger, then join free: {{LINK:ledger}}' },
|
||||
{ angle: 'two', title: 'Two buyers open level two', hook: 'Two buyers. Then five. That is the whole ladder.', caption: 'Two buyers. Then five. That is the whole ladder. Level 1 pays from your first buyer, two qualifying buyers open level 2, five open level 3, all in the same transaction, straight to your wallet. Free to join: {{LINK:two}}' }
|
||||
];
|
||||
// the six front doors: the plain invite link plus one page per hook angle
|
||||
const ANGLES = [
|
||||
{ angle: '', name: 'General', hook: 'The whole picture', use: 'What LinkSpin is, the payment split, join free. Use it when you have not said anything specific yet.' },
|
||||
{ angle: 'instant', name: 'Instant', hook: 'Paid before the page reloads', use: 'For anyone burned by pending commissions. The page opens on the on-chain payout that lands in seconds.' },
|
||||
{ angle: 'adspend', name: 'Ad spend', hook: 'You buy ads anyway', use: 'For marketers who already pay for traffic. Frames it as advertising that also pays your line when they buy.' },
|
||||
{ angle: 'free', name: 'Free', hook: 'Costs nothing to try', use: 'For skeptics and beginners. Join free, earn credits by viewing, run a first campaign without spending.' },
|
||||
{ angle: 'ledger', name: 'Ledger', hook: 'No back office, no payday', use: 'For people who have waited on a payout. Every payment is public on Polygonscan and nothing is ever held.' },
|
||||
{ angle: 'two', name: 'Two', hook: 'Two buyers open level two', use: 'For team builders. The 2-then-5 qualification ladder and how a line stacks under you.' }
|
||||
];
|
||||
function fill(link, me) {
|
||||
const token = (me && (me.username || me.refCode || me.memberId)) || '';
|
||||
// invite strip
|
||||
if ($('promoLink')) $('promoLink').textContent = link;
|
||||
if ($('promoLinkCopy')) $('promoLinkCopy').onclick = async () => {
|
||||
try { await navigator.clipboard.writeText(link); status('Link copied.', 'ok'); } catch (e) { status('Copy failed.', 'bad'); }
|
||||
};
|
||||
// angle links: one row per front door, copy + share
|
||||
const al = $('promoAngles');
|
||||
if (al && al.dataset.filled !== link) {
|
||||
al.dataset.filled = link; al.innerHTML = '';
|
||||
for (const a of ANGLES) {
|
||||
const url = angleLink(link, a.angle);
|
||||
const hasVideo = VIDEOS.some(v => v.angle === a.angle);
|
||||
// one collapsed band per angle, like the banner kit: open the one you want
|
||||
const row = document.createElement('details');
|
||||
row.className = 'angle-row pb-acc';
|
||||
row.innerHTML = '<summary><span><span class="angle-name">' + esc(a.name) + '</span> <span class="angle-hook">' + esc(a.hook) + '</span></span>'
|
||||
+ (hasVideo ? '<span class="angle-tag">video</span>' : '') + '</summary>'
|
||||
+ '<div class="angle-body"><div class="angle-txt">'
|
||||
+ '<div class="muted small">' + esc(a.use) + '</div>'
|
||||
+ '<a class="mono small angle-url" href="' + esc(url) + '" target="_blank" rel="noopener">' + esc(url) + '</a></div>'
|
||||
+ '<div class="angle-btns"><a class="btn small sec" href="' + esc(url) + '" target="_blank" rel="noopener">Open</a><button class="btn small" type="button">Copy</button>'
|
||||
+ (navigator.share ? '<button class="btn small sec" type="button">Share</button>' : '') + '</div></div>'; // Share = the phone's share sheet; desktops have none, so no button
|
||||
const [copyBtn, shareBtn] = row.querySelectorAll('button');
|
||||
copyBtn.onclick = async () => { try { await navigator.clipboard.writeText(url); status((a.name === 'General' ? 'Invite' : a.name) + ' link copied.', 'ok'); } catch (e) { status('Copy failed.', 'bad'); } };
|
||||
if (shareBtn) shareBtn.onclick = async () => { try { await navigator.share({ title: 'LinkSpin', text: a.hook, url }); } catch (e) { if (!(e && e.name === 'AbortError')) status('Could not open the share sheet.', 'bad'); } };
|
||||
al.appendChild(row);
|
||||
}
|
||||
}
|
||||
// posts
|
||||
const posts = $('promoPosts');
|
||||
if (posts && posts.dataset.filled !== link) {
|
||||
posts.dataset.filled = link; posts.innerHTML = '';
|
||||
for (const p of POSTS) {
|
||||
const text = fillLink(p.text, link);
|
||||
const extras = [];
|
||||
if (p.net === 'X') extras.push(linkBtn('https://twitter.com/intent/tweet?text=' + encodeURIComponent(text), 'Post on X'));
|
||||
if (p.net === 'Facebook') extras.push(linkBtn('https://www.facebook.com/sharer/sharer.php?u=' + encodeURIComponent(angleLink(link, (/\{\{LINK:([a-z]+)\}\}/.exec(p.text) || [])[1] || '')), 'Share on Facebook'));
|
||||
if (p.net === 'LinkedIn') extras.push(linkBtn('https://www.linkedin.com/sharing/share-offsite/?url=' + encodeURIComponent(angleLink(link, 'adspend')), 'Share on LinkedIn'));
|
||||
if (p.net === 'Telegram') extras.push(linkBtn('https://t.me/share/url?url=' + encodeURIComponent(angleLink(link, (/\{\{LINK:([a-z]+)\}\}/.exec(p.text) || [])[1] || '')) + '&text=' + encodeURIComponent(text.replace(/\s*\{\{LINK[^}]*\}\}\s*$/, '').replace(/https?:\/\/\S+$/, '').trim()), 'Share on Telegram'));
|
||||
posts.appendChild(block(text, '<span class="pb-net">' + esc(p.label) + '</span>', extras));
|
||||
}
|
||||
}
|
||||
// text a friend
|
||||
const texts = $('promoTexts');
|
||||
if (texts && texts.dataset.filled !== link) {
|
||||
texts.dataset.filled = link; texts.innerHTML = '';
|
||||
TEXTS.forEach((t, i) => {
|
||||
const text = fillLink(t.text, link);
|
||||
const enc = encodeURIComponent(text);
|
||||
const tgUrl = angleLink(link, t.angle);
|
||||
const tgLead = text.replace(/https?:\/\/\S+/, '').trim();
|
||||
const extras = [
|
||||
linkBtn('sms:?&body=' + enc, 'Text it', ''),
|
||||
linkBtn('https://wa.me/?text=' + enc, 'WhatsApp'),
|
||||
linkBtn('https://t.me/share/url?url=' + encodeURIComponent(tgUrl) + '&text=' + encodeURIComponent(tgLead), 'Telegram')
|
||||
];
|
||||
texts.appendChild(block(text, '<span class="pb-net">Text ' + (i + 1) + (t.angle ? ' · ' + esc(t.angle) + ' angle' : ' · general') + '</span> <b>' + esc(t.title) + '</b>', extras));
|
||||
});
|
||||
}
|
||||
// swipes
|
||||
const sw = $('promoSwipeWrap');
|
||||
if (sw && sw.dataset.filled !== link) {
|
||||
sw.dataset.filled = link; sw.innerHTML = '';
|
||||
for (const s of SWIPES) {
|
||||
const text = 'Subject: ' + s.subject + '\n\n' + fillLink(s.body, link);
|
||||
sw.appendChild(block(text, '<span class="pb-net">' + esc(s.tier) + '</span> <b>' + esc(s.subject) + '</b>'));
|
||||
}
|
||||
}
|
||||
// hook videos
|
||||
const vw = $('promoVideos');
|
||||
if (vw && vw.dataset.filled !== link) {
|
||||
vw.dataset.filled = link; vw.innerHTML = '';
|
||||
for (const v of VIDEOS) {
|
||||
const mlink = angleLink(link, v.angle);
|
||||
const card = document.createElement('div'); card.className = 'pv';
|
||||
card.innerHTML = '<div class="pv-head"><span class="pb-net">' + esc(v.angle) + ' angle</span> <b>' + esc(v.title) + '</b><div class="muted small">' + esc(v.hook) + '</div></div>'
|
||||
+ '<video src="' + VID_BASE + v.angle + '.mp4" poster="' + VID_BASE + v.angle + '.jpg" preload="none" controls playsinline style="width:100%;max-width:640px;border-radius:12px;background:#000;margin:10px 0"></video>'
|
||||
+ '<p class="small"><span class="muted">Matched link:</span> <span class="mono" style="overflow-wrap:anywhere">' + esc(mlink) + '</span></p>';
|
||||
const row = document.createElement('p'); row.className = 'pb-actions';
|
||||
row.appendChild(copyBtn(mlink, 'Copy matched link'));
|
||||
row.appendChild(linkBtn(VID_BASE + v.angle + '.mp4', 'Download 16:9'));
|
||||
row.appendChild(linkBtn(VID_BASE + v.angle + '-portrait.mp4', 'Download 9:16'));
|
||||
card.appendChild(row);
|
||||
const cap = fillLink(v.caption, link);
|
||||
const extras = [
|
||||
linkBtn('https://twitter.com/intent/tweet?text=' + encodeURIComponent(cap), 'Post on X'),
|
||||
linkBtn('https://www.facebook.com/sharer/sharer.php?u=' + encodeURIComponent(mlink), 'Share on Facebook'),
|
||||
linkBtn('https://t.me/share/url?url=' + encodeURIComponent(mlink) + '&text=' + encodeURIComponent(cap.replace(/https?:\/\/\S+$/, '').trim()), 'Telegram'),
|
||||
linkBtn('https://wa.me/?text=' + encodeURIComponent(cap), 'WhatsApp')
|
||||
];
|
||||
card.appendChild(block(cap, '<span class="lab">Caption</span>', extras));
|
||||
vw.appendChild(card);
|
||||
}
|
||||
}
|
||||
// objections
|
||||
const ob = $('promoObjections');
|
||||
if (ob && ob.dataset.filled !== link) {
|
||||
ob.dataset.filled = link; ob.innerHTML = '';
|
||||
for (const o of OBJECTIONS) {
|
||||
const d = document.createElement('details'); d.className = 'obj';
|
||||
const s = document.createElement('summary'); s.textContent = o.q; d.appendChild(s);
|
||||
const body = document.createElement('div'); body.className = 'obj-body';
|
||||
const t = document.createElement('div'); t.className = 'obj-truth';
|
||||
t.innerHTML = '<span class="lab">The truth</span><p>' + esc(o.truth) + '</p>'; body.appendChild(t);
|
||||
const say = fillLink(o.say, link);
|
||||
const sayEl = block(say, '<span class="lab lab-say">Say this</span>');
|
||||
sayEl.classList.add('obj-say'); body.appendChild(sayEl);
|
||||
d.appendChild(body); ob.appendChild(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { fill, POSTS, TEXTS, SWIPES, OBJECTIONS, VIDEOS };
|
||||
})();
|
||||
@@ -0,0 +1,75 @@
|
||||
// Paid shorts: a full-screen vertical feed over the video-ad inventory. You must
|
||||
// watch each short's required time (server-clock enforced, no seek) to earn,
|
||||
// then advance to the next. Reuses /api/my/videos + /api/my/videowatch.
|
||||
(function () {
|
||||
const $ = id => document.getElementById(id);
|
||||
const st = { token: null, secs: 0, maxSeen: 0, credited: false, done: false, skips: 0 };
|
||||
function j(url, body) {
|
||||
return fetch(url, body
|
||||
? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }
|
||||
: undefined).then(r => r.json());
|
||||
}
|
||||
async function load() {
|
||||
st.token = null; st.maxSeen = 0; st.credited = false; st.done = false;
|
||||
$('shOver').hidden = true; $('shCta').hidden = true; $('shNext').hidden = true;
|
||||
let r = null;
|
||||
// signed-out visitors get the sign-in note without a 401 round trip
|
||||
let me = null; try { me = await j('/api/me'); } catch (e) {}
|
||||
if (me && me.signedIn && me.email) { try { r = await j('/api/my/videos?orientation=portrait'); } catch (e) {} }
|
||||
if (!r || r.error) { $('shVideo').hidden = true; $('shMsg').hidden = false; $('shMsg').textContent = 'Sign in on the dashboard to watch shorts and earn.'; return; }
|
||||
$('shStat').textContent = 'today: ' + (r.status.count || 0) + ' / ' + r.status.cap;
|
||||
if (!r.ad) {
|
||||
$('shVideo').hidden = true; $('shMsg').hidden = false;
|
||||
$('shMsg').textContent = r.status.left <= 0 ? 'That\'s today\'s shorts. Come back tomorrow.' : 'No shorts in rotation right now. Check back soon.';
|
||||
return;
|
||||
}
|
||||
st.token = r.token; st.secs = r.ad.watchSecs; st.adId = r.ad.id;
|
||||
const v = $('shVideo');
|
||||
$('shMsg').hidden = true; v.hidden = false;
|
||||
v.src = r.ad.videoUrl; v.currentTime = 0;
|
||||
// safety net: this reel is portrait-only. If a landscape video slips through, skip to the next.
|
||||
v.onloadedmetadata = () => {
|
||||
if (v.videoWidth && v.videoHeight && v.videoWidth > v.videoHeight) {
|
||||
if (st.skips++ < 4) { load(); return; }
|
||||
v.hidden = true; $('shMsg').hidden = false; $('shMsg').textContent = 'No shorts in rotation right now. Check back soon.';
|
||||
} else { st.skips = 0; }
|
||||
};
|
||||
$('shTitle').textContent = r.ad.title || '';
|
||||
$('shCta').href = r.ad.ctaUrl; $('shCta').textContent = r.ad.ctaLabel || 'Learn more';
|
||||
$('shOver').hidden = false; $('shCta').hidden = false;
|
||||
v.onseeking = () => { if (v.currentTime > st.maxSeen + 0.5) v.currentTime = st.maxSeen; };
|
||||
v.ontimeupdate = () => {
|
||||
if (v.currentTime > st.maxSeen) st.maxSeen = v.currentTime;
|
||||
const left = Math.max(0, Math.ceil(st.secs - st.maxSeen));
|
||||
$('shTimer').textContent = left > 0 ? 'Watch ' + left + 's more to earn' : 'Earned — swipe to the next';
|
||||
if (!st.done && st.maxSeen >= st.secs) { st.done = true; credit(); }
|
||||
};
|
||||
v.play().catch(() => {});
|
||||
}
|
||||
async function credit() {
|
||||
if (st.credited) return; st.credited = true;
|
||||
try {
|
||||
const r = await j('/api/my/videowatch', { token: st.token });
|
||||
if (r.credited) { $('shStat').textContent = 'today: ' + (r.status.count || 0) + ' / ' + (r.status.cap || 0) + ' · +' + r.credited; }
|
||||
} catch (e) {}
|
||||
$('shNext').hidden = false;
|
||||
}
|
||||
$('shNext').addEventListener('click', load);
|
||||
$('shReport').addEventListener('click', e => {
|
||||
e.preventDefault(); if (!st.adId) return;
|
||||
const reason = (prompt('Report this short: broken, inappropriate, spam, scam, or other', 'inappropriate') || '').trim().toLowerCase();
|
||||
if (!reason) return;
|
||||
fetch('/api/report-ad', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ campaignId: st.adId, reason }) })
|
||||
.then(() => { $('shReport').textContent = '✓ reported — thanks'; }).catch(() => {});
|
||||
});
|
||||
// presence enforcement: pause when the tab/window loses focus, resume on return
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
const v = $('shVideo'); if (!v || !v.src) return;
|
||||
if (document.hidden) v.pause(); else if (!st.done) v.play().catch(() => {});
|
||||
});
|
||||
window.addEventListener('blur', () => { const v = $('shVideo'); if (v && v.src) v.pause(); });
|
||||
window.addEventListener('focus', () => { const v = $('shVideo'); if (v && v.src && !st.done) v.play().catch(() => {}); });
|
||||
// tap the video to pause/resume
|
||||
$('shVideo').addEventListener('click', () => { const v = $('shVideo'); if (v.paused) v.play(); else v.pause(); });
|
||||
load();
|
||||
})();
|
||||
@@ -0,0 +1,750 @@
|
||||
/* LinkSpin visual system v4 — built to Marty's picked reference
|
||||
(Behance 243562211 "ChainLock"): near-black ground, ONE neon-mint accent,
|
||||
sweeping light-trail hero, glowing icon plates, mockup-anchored sections,
|
||||
ghost wordmark footer. Discipline over decoration. */
|
||||
:root{
|
||||
--ground:#040807; --ground2:#071009;
|
||||
--panel:rgba(16,28,24,.55); --panel-solid:#0b1512;
|
||||
--line:rgba(84,150,128,.16); --line-strong:rgba(84,150,128,.36);
|
||||
--ink:#eef7f3; --muted:#8ba69c;
|
||||
--mint:#43e8c3; --mint-hi:#8ffbe3; --mint-ink:#03211a;
|
||||
--mint-soft:rgba(67,232,195,.09); --bad:#ff8f7d;
|
||||
--cyan:#54ccff; --violet:#9d7dff; --amber:#ffb238;
|
||||
--mono:"Consolas","JetBrains Mono",monospace;
|
||||
--disp:"Sora","Segoe UI",system-ui,sans-serif;
|
||||
--radius:18px;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
[hidden]{display:none!important} /* beats any display: set by a class (chat panel bug) */
|
||||
html{scroll-behavior:smooth}
|
||||
body{margin:0;background:var(--ground);color:var(--ink);font:16px/1.65 "Segoe UI",system-ui,sans-serif;overflow-x:hidden}
|
||||
/* topo-contour ground texture, very faint */
|
||||
body::before{content:"";position:fixed;inset:0;z-index:-1;pointer-events:none;opacity:.5;
|
||||
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='560' height='560' viewBox='0 0 560 560'%3E%3Cg fill='none' stroke='%2343e8c3' stroke-opacity='.05'%3E%3Cpath d='M60 280c40-90 160-130 220-90s60 150 160 150 120-90 120-90'/%3E%3Cpath d='M40 340c60-110 190-160 260-110s60 170 180 170'/%3E%3Cpath d='M20 400c80-130 220-190 300-130s60 190 200 190'/%3E%3Cpath d='M80 220c30-70 130-100 180-70s50 120 130 120 100-70 100-70'/%3E%3C/g%3E%3C/svg%3E")}
|
||||
a{color:var(--mint);text-decoration:none}
|
||||
a:hover{text-decoration:underline}
|
||||
.wrap{max-width:1100px;margin:0 auto;padding:0 22px}
|
||||
h1,h2,h3{font-family:var(--disp);letter-spacing:-.015em}
|
||||
/* ── nav: capsule pill ─────────────────────────────── */
|
||||
nav{position:sticky;top:0;z-index:10;background:rgba(4,8,7,.7);backdrop-filter:blur(16px);
|
||||
-webkit-backdrop-filter:blur(16px);border-bottom:1px solid var(--line)}
|
||||
nav .wrap{display:flex;align-items:center;gap:20px;min-height:66px;flex-wrap:wrap;padding-top:8px;padding-bottom:8px}
|
||||
.logo{font-family:var(--disp);font-weight:800;font-size:20px;color:var(--ink)}
|
||||
.logo b{color:var(--mint)}
|
||||
.logo-wrap{display:flex;flex-direction:column;gap:3px;min-width:0}
|
||||
.byline{display:block;font-size:10.5px;line-height:1.2;letter-spacing:.05em;color:var(--muted);white-space:nowrap}
|
||||
.byline b{color:var(--ink);font-weight:600}
|
||||
.bo-side .byline{padding:4px 8px 10px;white-space:normal}
|
||||
@media (max-width:560px){nav .byline{font-size:9.5px}}
|
||||
nav .links{display:flex;gap:4px;flex:1;flex-wrap:wrap;background:rgba(16,28,24,.7);border:1px solid var(--line);
|
||||
border-radius:999px;padding:5px 8px;width:fit-content;flex:0 1 auto;margin:0 auto}
|
||||
nav .links a{color:var(--muted);font-size:13.5px;font-weight:600;padding:7px 14px;border-radius:999px}
|
||||
nav .links a:hover{color:var(--ink);text-decoration:none}
|
||||
nav .links a.active{color:var(--mint);background:rgba(67,232,195,.1)}
|
||||
#navWallet{font-size:13px;margin-left:auto}
|
||||
.rehearsal{background:rgba(67,232,195,.07);border-bottom:1px solid var(--line);color:var(--muted);
|
||||
text-align:center;font-size:12.5px;padding:7px 12px}
|
||||
.rehearsal b{color:var(--mint)}
|
||||
/* ── buttons ───────────────────────────────────────── */
|
||||
.btn{display:inline-block;background:var(--mint);color:var(--mint-ink);border:0;border-radius:999px;
|
||||
padding:13px 26px;font-weight:700;font-size:15px;cursor:pointer;font-family:var(--disp);
|
||||
box-shadow:0 4px 30px rgba(67,232,195,.35);transition:transform .16s ease,box-shadow .16s ease,background .16s ease}
|
||||
.btn:hover{transform:translateY(-2px);background:var(--mint-hi);box-shadow:0 8px 44px rgba(67,232,195,.5);text-decoration:none}
|
||||
.btn.sec{background:transparent;color:var(--mint);border:1px solid rgba(67,232,195,.6);box-shadow:none}
|
||||
.btn.sec:hover{background:rgba(67,232,195,.08);box-shadow:0 4px 26px rgba(67,232,195,.2)}
|
||||
.btn.small{padding:8px 17px;font-size:13px}
|
||||
.btn:disabled{opacity:.45;cursor:default;transform:none;box-shadow:none}
|
||||
/* ── hero: centered, light-trail set-piece ─────────── */
|
||||
.hero{position:relative;text-align:center;padding:120px 0 84px;overflow:visible}
|
||||
.hero h1{font-size:clamp(36px,5.4vw,62px);font-weight:700;line-height:1.08;margin:0 auto 18px;max-width:820px;text-wrap:balance}
|
||||
.hero h1 em{font-style:normal;color:var(--mint)}
|
||||
.hero p.lead{color:var(--muted);font-size:17.5px;max-width:600px;margin:0 auto 32px}
|
||||
.hero p.lead b{color:var(--ink)}
|
||||
.hero .ctas{display:flex;gap:14px;justify-content:center;flex-wrap:wrap}
|
||||
/* neon arcs behind the hero text */
|
||||
.arcs{position:absolute;inset:-40px 0 0 0;z-index:-1;pointer-events:none}
|
||||
.arcs svg{width:100%;height:100%;overflow:visible}
|
||||
.arc{fill:none;stroke:url(#arcGrad);stroke-width:5;stroke-linecap:round;filter:url(#arcGlow)}
|
||||
.arc2{fill:none;stroke:url(#arcGrad);stroke-width:3;stroke-linecap:round;filter:url(#arcGlow);opacity:.8}
|
||||
/* falling glow streaks */
|
||||
.streak{position:absolute;top:-140px;width:1.5px;height:110px;pointer-events:none;
|
||||
background:linear-gradient(180deg,transparent,rgba(143,251,227,.8));border-radius:2px;opacity:0}
|
||||
@media(prefers-reduced-motion:no-preference){
|
||||
.streak{animation:fall 7s linear infinite}
|
||||
@keyframes fall{0%{transform:translateY(0);opacity:0}12%{opacity:.7}55%{opacity:.5}75%{transform:translateY(72vh);opacity:0}100%{transform:translateY(72vh);opacity:0}}
|
||||
}
|
||||
/* ── counters ──────────────────────────────────────── */
|
||||
.stats{display:grid;grid-template-columns:repeat(2,1fr);gap:14px;margin:60px auto 0;max-width:900px}
|
||||
@media(min-width:760px){.stats{grid-template-columns:repeat(4,1fr)}}
|
||||
.stat{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);padding:18px 12px;text-align:center}
|
||||
.stat .n{font-family:var(--disp);font-size:30px;font-weight:700;font-variant-numeric:tabular-nums;color:var(--mint)}
|
||||
.stat .l{font-size:11.5px;color:var(--muted);text-transform:uppercase;letter-spacing:.1em;margin-top:3px}
|
||||
/* ── sections ──────────────────────────────────────── */
|
||||
section{padding:64px 0 8px}
|
||||
.sectionhead{text-align:center;max-width:640px;margin:0 auto 40px}
|
||||
.cmp{width:100%;border-collapse:separate;border-spacing:0;font-size:15px;background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);overflow:hidden}
|
||||
.cmp th,.cmp td{padding:13px 16px;border-bottom:1px solid var(--line);vertical-align:top;text-align:left}
|
||||
.cmp th{font-family:var(--disp);font-size:13px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted)}
|
||||
.cmp th:last-child,.cmp td:last-child{color:var(--ink);background:rgba(67,232,195,.06)}
|
||||
.cmp td:first-child{font-weight:700;white-space:nowrap}
|
||||
.cmp td:nth-child(2){color:var(--muted)}
|
||||
.cmp tr:last-child td{border-bottom:0}
|
||||
@media (max-width:640px){.cmp td:first-child{white-space:normal}.cmp th,.cmp td{padding:10px 10px;font-size:14px}}
|
||||
h2{font-size:clamp(26px,3.2vw,36px);font-weight:700;margin:0 0 12px;text-wrap:balance}
|
||||
.sectionhead p{color:var(--muted);font-size:15.5px;margin:0}
|
||||
h3{font-size:16.5px;margin:0 0 8px;font-weight:700}
|
||||
.muted{color:var(--muted)}
|
||||
.small{font-size:13.5px}
|
||||
.mono{font-family:var(--mono)}
|
||||
/* icon-plate feature cards */
|
||||
.plates{display:grid;gap:16px;grid-template-columns:1fr}
|
||||
@media(min-width:760px){.plates{grid-template-columns:1fr 1fr 1fr}}
|
||||
.platecard{background:linear-gradient(170deg,rgba(22,38,32,.6),rgba(10,18,15,.7));border:1px solid var(--line);
|
||||
border-radius:var(--radius);padding:26px 22px;text-align:center;transition:border-color .2s ease,transform .2s ease}
|
||||
.platecard:hover{border-color:var(--line-strong);transform:translateY(-4px)}
|
||||
.plate{width:88px;height:88px;margin:0 auto 18px;border-radius:22px;display:grid;place-items:center;
|
||||
background:linear-gradient(160deg,#12241e,#0a1411);border:1px solid var(--line-strong);
|
||||
box-shadow:0 0 34px rgba(67,232,195,.15),inset 0 1px 0 rgba(143,251,227,.15)}
|
||||
.plate svg{width:38px;height:38px;stroke:var(--mint);fill:none;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}
|
||||
.platecard p{color:var(--muted);font-size:14px;margin:0}
|
||||
/* two-col flank layout (icons around a central mockup) */
|
||||
.flank{display:grid;gap:26px;align-items:center;grid-template-columns:1fr}
|
||||
@media(min-width:920px){.flank{grid-template-columns:1fr 1.15fr 1fr}}
|
||||
.flankitem{margin:0 0 26px}
|
||||
.flankitem .plate{width:52px;height:52px;border-radius:14px;margin:0 0 12px}
|
||||
.flankitem .plate svg{width:24px;height:24px}
|
||||
.flankitem p{color:var(--muted);font-size:13.5px;margin:0}
|
||||
/* ledger mockup frame */
|
||||
.mockup{background:linear-gradient(170deg,#0e1c17,#080f0c);border:1px solid var(--line-strong);border-radius:20px;
|
||||
box-shadow:0 30px 80px rgba(0,0,0,.6),0 0 60px rgba(67,232,195,.08);overflow:hidden}
|
||||
.mockup .bar{display:flex;gap:6px;align-items:center;padding:11px 14px;border-bottom:1px solid var(--line)}
|
||||
.mockup .bar i{width:9px;height:9px;border-radius:50%;background:#1e3a31;display:inline-block}
|
||||
.mockup .bar .addr{font-family:var(--mono);font-size:11px;color:var(--muted);margin-left:8px;background:rgba(4,8,7,.6);
|
||||
border-radius:999px;padding:3px 12px}
|
||||
.mockup .body{padding:6px 0}
|
||||
.mockup .mrow{display:flex;justify-content:space-between;gap:10px;padding:9px 16px;font-family:var(--mono);
|
||||
font-size:12px;border-bottom:1px solid rgba(84,150,128,.08)}
|
||||
.mockup .mrow span:last-child{color:var(--mint)}
|
||||
.mockup .mrow.dim span:last-child{color:var(--muted)}
|
||||
/* 2-col story block: iso illustration + checklist */
|
||||
.story{display:grid;gap:36px;align-items:center;grid-template-columns:1fr}
|
||||
@media(min-width:880px){.story{grid-template-columns:1fr 1fr}}
|
||||
.checks{list-style:none;padding:0;margin:14px 0 0}
|
||||
.checks li{padding:7px 0 7px 30px;position:relative;color:var(--muted);font-size:14.5px}
|
||||
.checks li::before{content:"✓";position:absolute;left:0;top:6px;width:20px;height:20px;border-radius:50%;
|
||||
background:var(--mint-soft);color:var(--mint);font-size:12px;font-weight:700;display:grid;place-items:center;
|
||||
border:1px solid rgba(67,232,195,.35)}
|
||||
.chips{display:flex;gap:8px;margin:14px 0 4px;flex-wrap:wrap}
|
||||
.chip-t{border:1px solid var(--line-strong);border-radius:999px;padding:5px 16px;font-size:12.5px;color:var(--muted);font-family:var(--disp);font-weight:600}
|
||||
.chip-t.on{background:var(--mint);color:var(--mint-ink);border-color:var(--mint)}
|
||||
.iso{filter:drop-shadow(0 24px 50px rgba(0,0,0,.5))}
|
||||
/* level cycler: light the paying generation */
|
||||
#genViz .edge path{stroke:#22523f;transition:stroke .5s ease}
|
||||
#genViz .node-o{fill:#122e25;stroke:#2f6b55;stroke-width:1.3;transition:stroke .5s ease,fill .5s ease}
|
||||
#genViz .node-d{fill:#3f8f74;transition:fill .5s ease}
|
||||
#genViz[data-lvl="1"] .e1 path,#genViz[data-lvl="2"] .e2 path,#genViz[data-lvl="3"] .e3 path{stroke:#43e8c3}
|
||||
#genViz[data-lvl="1"] .g1 .node-o,#genViz[data-lvl="2"] .g2 .node-o,#genViz[data-lvl="3"] .g3 .node-o{stroke:#43e8c3;fill:#11362b}
|
||||
#genViz[data-lvl="1"] .g1 .node-d,#genViz[data-lvl="2"] .g2 .node-d,#genViz[data-lvl="3"] .g3 .node-d{fill:#43e8c3}
|
||||
.chips button{cursor:pointer;background:transparent;font:inherit}
|
||||
.chips button.chip-t{border:1px solid var(--line-strong);color:var(--muted)}
|
||||
.chips button.chip-t.on{background:var(--mint);color:var(--mint-ink);border-color:var(--mint)}
|
||||
/* pricing tiles: mint discipline */
|
||||
.tiles{display:grid;gap:16px;grid-template-columns:repeat(auto-fit,minmax(185px,1fr));align-items:stretch}
|
||||
.tile{position:relative;background:linear-gradient(170deg,rgba(22,38,32,.55),rgba(10,18,15,.65));border:1px solid var(--line);
|
||||
border-radius:var(--radius);padding:26px 18px;text-align:center;display:flex;flex-direction:column;gap:6px;
|
||||
transition:transform .2s ease,border-color .2s ease}
|
||||
.tile:hover{transform:translateY(-5px);border-color:var(--line-strong)}
|
||||
.tile .name{font-family:var(--disp);font-weight:700;font-size:13px;color:var(--muted);text-transform:uppercase;letter-spacing:.12em}
|
||||
.tile .price{font-family:var(--disp);font-size:36px;font-weight:700}
|
||||
.tile .cr{color:var(--mint);font-weight:700;font-size:14.5px}
|
||||
.tile .bonus{font-size:12px;color:var(--mint-hi)}
|
||||
.tile .pol{font-family:var(--mono);font-size:12px;color:var(--muted);flex:1}
|
||||
.tile.hot{border:1px solid rgba(67,232,195,.65);box-shadow:0 0 44px rgba(67,232,195,.14)}
|
||||
.tile.hot::before{content:"MOST POPULAR";position:absolute;top:-11px;left:50%;transform:translateX(-50%);
|
||||
background:var(--mint);color:var(--mint-ink);font-family:var(--disp);font-size:10px;font-weight:800;
|
||||
letter-spacing:.12em;border-radius:999px;padding:4px 14px}
|
||||
/* split strip: mint opacities */
|
||||
.split{display:flex;gap:8px;margin:18px 0}
|
||||
.split div{border-radius:12px;padding:16px 6px;text-align:center;font-size:13px;font-weight:700;font-family:var(--disp);
|
||||
border:1px solid var(--line-strong);color:var(--mint)}
|
||||
.split .s50{flex:5;background:rgba(67,232,195,.16);box-shadow:0 0 30px rgba(67,232,195,.12)}
|
||||
.split .s20{flex:2;background:rgba(67,232,195,.09)}
|
||||
.split .s10{flex:1;background:rgba(67,232,195,.05)}
|
||||
.split .sa{flex:2;background:rgba(139,166,156,.08);color:var(--muted)}
|
||||
section[id]{scroll-margin-top:84px} /* anchored menu targets clear the sticky nav */
|
||||
/* ticker */
|
||||
.ticker{overflow:hidden;contain:layout paint;border-top:1px solid var(--line);border-bottom:1px solid var(--line);
|
||||
background:rgba(67,232,195,.04);white-space:nowrap;padding:10px 0;
|
||||
-webkit-mask-image:linear-gradient(90deg,transparent,#000 6%,#000 94%,transparent);
|
||||
mask-image:linear-gradient(90deg,transparent,#000 6%,#000 94%,transparent)}
|
||||
.ticker .inner{display:inline-block;font-family:var(--mono);font-size:12.5px;padding-left:100%;animation:tick 120s linear infinite}
|
||||
.ticker .inner span{margin-right:48px;color:var(--muted)}
|
||||
.ticker .inner span b{color:var(--mint);font-weight:600}
|
||||
@keyframes tick{to{transform:translateX(-100%)}}
|
||||
@media(prefers-reduced-motion:reduce){.ticker .inner{animation:none;padding-left:0}}
|
||||
/* cards / tables / feed (inner pages) */
|
||||
.card{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);padding:24px;margin:0 0 16px}
|
||||
.card:hover{border-color:var(--line-strong)}
|
||||
.grid{display:grid;gap:16px}
|
||||
.grid>*,.flank>*,.story>*{min-width:0} /* let grid children shrink so inner tablewraps scroll instead of widening the page */
|
||||
@media(min-width:760px){.grid.c3{grid-template-columns:1fr 1fr 1fr}.grid.c2{grid-template-columns:1fr 1fr}}
|
||||
table{width:100%;border-collapse:collapse;font-size:15px}
|
||||
th,td{text-align:left;padding:12px 14px;border-bottom:1px solid var(--line);vertical-align:middle}
|
||||
th{color:var(--muted);font-size:11.5px;text-transform:uppercase;letter-spacing:.1em;font-family:var(--disp)}
|
||||
td.num,th.num{font-variant-numeric:tabular-nums}
|
||||
tr:last-child td{border-bottom:0}
|
||||
.tablewrap{overflow-x:auto}
|
||||
.feed{font-family:var(--mono);font-size:13.5px;line-height:1.7}
|
||||
.feed .row{padding:9px 14px;border-bottom:1px solid var(--line);display:flex;gap:10px;align-items:baseline;flex-wrap:wrap}
|
||||
.feed .row:first-child{background:var(--mint-soft)}
|
||||
.feed .row .when{font-family:var(--mono);font-size:11px;color:var(--muted);white-space:nowrap;min-width:92px}
|
||||
.feed .t-Purchase,.feed .t-AwardPaid{color:var(--mint-hi)}
|
||||
.feed .t-TierPaid{color:var(--mint)}
|
||||
.feed .t-AdminPaid{color:var(--muted)}
|
||||
.feed .tx a{color:var(--muted);font-size:12px}
|
||||
.badge{display:inline-block;background:var(--mint-soft);color:var(--mint);border:1px solid rgba(67,232,195,.35);
|
||||
border-radius:999px;padding:3px 13px;font-size:12px;font-weight:700}
|
||||
.spend-banner{background:linear-gradient(160deg,rgba(242,201,76,.14),rgba(242,201,76,.04));border-color:rgba(242,201,76,.55);text-align:center;padding:22px 20px}
|
||||
.spend-banner .lbl{font-family:var(--disp);font-size:13px;letter-spacing:.14em;text-transform:uppercase;color:#f2c94c}
|
||||
.spend-banner .big{font-family:var(--disp);font-size:clamp(40px,6vw,64px);font-weight:800;color:#f2c94c;line-height:1.05;margin:6px 0 4px;font-variant-numeric:tabular-nums}
|
||||
.spend-banner .big .unit{font-size:20px;font-weight:700;color:var(--ink)}
|
||||
.spend-banner .sub{color:var(--ink);font-size:15px}
|
||||
.spend-banner .rates{display:flex;gap:8px 18px;flex-wrap:wrap;justify-content:center;margin-top:12px;font-size:12.5px;color:var(--muted)}
|
||||
.spend-banner .rates b{color:var(--ink)}
|
||||
.boot-spin{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px;min-height:60vh}
|
||||
.boot-spin .ring{width:46px;height:46px;border-radius:50%;border:4px solid rgba(67,232,195,.18);border-top-color:var(--mint);animation:bootspin .9s linear infinite}
|
||||
@keyframes bootspin{to{transform:rotate(360deg)}}
|
||||
.bo-top{flex-wrap:wrap}
|
||||
.ad-strip{flex:1 1 100%;font-size:13.5px;padding:8px 0 2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.ad-strip a{color:var(--ink)}
|
||||
.ad-strip b{color:var(--mint)}
|
||||
.ad-foot{text-align:center}
|
||||
.pv{border:1px solid var(--line);border-radius:14px;padding:16px 18px;margin:0 0 16px;background:rgba(4,8,7,.35)}
|
||||
.pv-head b{display:block;font-size:18px;margin:4px 0 2px}
|
||||
.done-big{display:flex;flex-direction:column;align-items:center;gap:6px;padding:26px 16px;text-align:center}
|
||||
.done-big .tick{width:84px;height:84px;border-radius:50%;display:grid;place-items:center;font-size:48px;font-weight:800;color:var(--mint-ink);background:var(--mint);box-shadow:0 0 0 10px rgba(67,232,195,.15),0 10px 30px rgba(67,232,195,.35)}
|
||||
.done-big b{font-family:var(--disp);font-size:22px;margin-top:8px}
|
||||
.badge.amber{background:rgba(139,166,156,.1);border-color:rgba(139,166,156,.4);color:var(--muted)}
|
||||
/* faq */
|
||||
details{border:1px solid var(--line);border-radius:14px;margin:0 0 10px;background:var(--panel)}
|
||||
details[open]{border-color:rgba(67,232,195,.45)}
|
||||
summary{cursor:pointer;padding:16px 20px;font-weight:700;font-family:var(--disp);font-size:15px;list-style:none}
|
||||
summary::before{content:"+";color:var(--mint);font-weight:800;margin-right:12px}
|
||||
details[open] summary::before{content:"−"}
|
||||
details p{margin:0;padding:0 20px 18px 42px;color:var(--muted);font-size:14.5px}
|
||||
/* closing cta card */
|
||||
.closer{position:relative;display:grid;gap:26px;align-items:center;grid-template-columns:1fr;
|
||||
padding:44px 36px;border-radius:24px;overflow:hidden;margin:80px 0 0;
|
||||
background:linear-gradient(160deg,#0d1d17,#060c0a);border:1px solid var(--line-strong)}
|
||||
@media(min-width:880px){.closer{grid-template-columns:1.2fr .8fr}}
|
||||
.closer::before{content:"";position:absolute;width:460px;height:460px;right:-140px;top:-260px;border-radius:50%;
|
||||
background:radial-gradient(circle,rgba(67,232,195,.2),transparent 65%);pointer-events:none}
|
||||
.closer h2{margin:0 0 12px}
|
||||
/* footer + ghost wordmark */
|
||||
footer{border-top:1px solid var(--line);margin-top:90px;padding:34px 0 0;color:var(--muted);font-size:13.5px;overflow:hidden}
|
||||
.ghost{font-family:var(--disp);font-weight:800;font-size:clamp(64px,13vw,170px);line-height:.9;text-align:center;
|
||||
margin:34px 0 -20px;letter-spacing:-.02em;user-select:none;
|
||||
background:linear-gradient(180deg,rgba(67,232,195,.14),transparent 78%);
|
||||
-webkit-background-clip:text;background-clip:text;color:transparent;white-space:nowrap}
|
||||
/* misc */
|
||||
#status{position:fixed;left:50%;transform:translateX(-50%);bottom:22px;background:var(--panel-solid);
|
||||
border:1px solid var(--line-strong);border-radius:14px;padding:13px 22px;font-size:14px;max-width:90vw;z-index:30;
|
||||
box-shadow:0 12px 40px rgba(0,0,0,.55)}
|
||||
#status.ok{border-color:var(--mint)}
|
||||
#status.bad{border-color:var(--bad)}
|
||||
input,select,textarea{background:rgba(4,8,7,.65);border:1px solid var(--line-strong);color:var(--ink);border-radius:11px;
|
||||
padding:11px 14px;font-size:14.5px;font-family:inherit}
|
||||
input[type=range]{padding:0;border:0;background:transparent;accent-color:var(--mint);height:28px;vertical-align:middle}
|
||||
input:focus,select:focus,textarea:focus{border-color:var(--mint)}
|
||||
textarea{resize:vertical;font:inherit}
|
||||
:focus-visible{outline:2px solid var(--mint);outline-offset:2px}
|
||||
.hero-note{font-family:var(--mono);font-size:12px;color:var(--muted);margin-top:26px}
|
||||
/* ── member back-office shell ─────────────────────────── */
|
||||
.bo-body{background:var(--ground)}
|
||||
.bo{display:grid;grid-template-columns:236px 1fr;min-height:100vh}
|
||||
.bo-side{position:sticky;top:0;height:100vh;overflow-y:auto;scrollbar-width:none;-ms-overflow-style:none;display:flex;flex-direction:column;gap:22px;
|
||||
padding:22px 16px;background:rgba(10,18,15,.92);border-right:1px solid var(--line)}
|
||||
.bo-side .logo{font-size:19px;padding:0 8px}
|
||||
.bo-menu{display:flex;flex-direction:column;gap:4px}
|
||||
.bo-menu button{display:flex;align-items:center;gap:11px;background:transparent;border:0;color:var(--muted);
|
||||
font:600 14px var(--disp);padding:11px 12px;border-radius:11px;cursor:pointer;text-align:left;
|
||||
border-left:3px solid transparent}
|
||||
.bo-menu button svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;flex:0 0 auto}
|
||||
.bo-menu button:hover{color:var(--ink);background:rgba(67,232,195,.05)}
|
||||
.bo-menu button.on{color:var(--mint);background:rgba(67,232,195,.09);border-left-color:var(--mint)}
|
||||
.bo-links{display:flex;flex-direction:column;gap:2px;padding:14px 12px;border-top:1px solid var(--line)}
|
||||
.bo-cap{font-family:var(--mono);font-size:10.5px;letter-spacing:.18em;text-transform:uppercase;color:var(--muted);margin-bottom:6px}
|
||||
.bo-links a{color:var(--muted);font-size:13.5px;padding:5px 0}
|
||||
.bo-links a:hover{color:var(--ink);text-decoration:none}
|
||||
.bo-foot{margin-top:auto;padding:14px 12px 0;border-top:1px solid var(--line);display:flex;flex-direction:column;gap:6px;overflow-wrap:anywhere}
|
||||
.bo-main{min-width:0;display:flex;flex-direction:column}
|
||||
.bo-side::-webkit-scrollbar{width:0;height:0;display:none}
|
||||
.bo-pagefoot{margin-top:auto;padding:22px 26px;border-top:1px solid var(--line);display:flex;gap:20px;flex-wrap:wrap;justify-content:center;font-size:13px}
|
||||
.bo-pagefoot a{color:var(--muted)}
|
||||
.bo-pagefoot a:hover{color:var(--ink);text-decoration:none}
|
||||
.bo-top{display:flex;align-items:center;gap:16px;padding:16px 26px;border-bottom:1px solid var(--line);
|
||||
background:rgba(6,10,8,.75);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);position:sticky;top:0;z-index:5}
|
||||
.bo-top h2{font-size:20px}
|
||||
#boBurger{display:none;background:transparent;border:1px solid var(--line-strong);color:var(--ink);
|
||||
border-radius:9px;font-size:17px;padding:5px 11px;cursor:pointer}
|
||||
.bo-rehearsal{margin-left:auto;color:var(--muted)}
|
||||
.bo-rehearsal b{color:var(--mint)}
|
||||
.bo-content{padding:26px;max-width:1060px;width:100%}
|
||||
@media(max-width:959px){
|
||||
.bo{grid-template-columns:1fr}
|
||||
.bo-side{position:fixed;left:0;top:0;bottom:0;width:250px;z-index:20;transform:translateX(-100%);
|
||||
transition:transform .22s ease;height:100dvh}
|
||||
.bo.side-open .bo-side{transform:none;box-shadow:0 0 60px rgba(0,0,0,.6)}
|
||||
#boBurger{display:block}
|
||||
.bo-content{padding:18px}
|
||||
}
|
||||
/* ── promo banners ── */
|
||||
.promo-banners{display:flex;flex-direction:column;gap:10px;margin-top:12px}
|
||||
.pb-acc{margin:0}
|
||||
.pb-acc>summary{display:flex;justify-content:space-between;align-items:center;gap:10px;padding:13px 16px;font-size:14.5px}
|
||||
.pb-acc>summary::before{content:none}
|
||||
.pb-acc>summary>span:first-child::before{content:"+";color:var(--mint);font-weight:800;margin-right:10px}
|
||||
.pb-acc[open]>summary>span:first-child::before{content:"−"}
|
||||
.pb-acc .pb-count{font-family:var(--mono);font-size:11px;color:var(--muted);white-space:nowrap}
|
||||
.pb-acc .pb-grid{display:flex;flex-direction:column;gap:14px;padding:0 16px 16px}
|
||||
.pb-item img{max-width:100%;border-radius:8px;border:1px solid var(--line-strong);display:block}
|
||||
.pb-item .pb-row{display:flex;gap:10px;align-items:center;margin-top:6px;flex-wrap:wrap}
|
||||
.pb-item .pb-size{font-family:var(--mono);font-size:11px;color:var(--muted)}
|
||||
/* ── featured day-slot picker ── */
|
||||
.feat-days{display:flex;gap:8px;flex-wrap:wrap}
|
||||
.feat-day{flex:0 0 auto;min-width:82px;padding:8px 10px;border:1px solid var(--line-strong);border-radius:10px;
|
||||
background:var(--panel);cursor:pointer;text-align:center}
|
||||
.feat-day.on{border-color:var(--mint);box-shadow:0 0 0 1px var(--mint)}
|
||||
.feat-day.full{opacity:.45;cursor:not-allowed}
|
||||
.feat-day .fd-day{font-weight:700;font-size:12px}
|
||||
.feat-day .fd-occ{font-family:var(--mono);font-size:11px;color:var(--muted)}
|
||||
.feat-day.open2 .fd-occ{color:var(--mint)}
|
||||
/* ── featured rotation strip ── */
|
||||
.feat-top{max-width:760px;margin:16px auto;border:1px solid rgba(255,177,56,.5)}
|
||||
.feat-strip{display:flex;justify-content:center;margin-top:10px}
|
||||
.feat-strip a{max-width:728px;width:100%;display:flex;justify-content:space-between;gap:10px;padding:11px 14px;border-radius:10px;
|
||||
background:var(--panel);border:1px solid #ffb238;color:var(--ink);text-decoration:none;font-weight:600}
|
||||
.feat-strip a:hover{border-color:#ffd15c;box-shadow:0 0 0 1px rgba(255,177,56,.4)}
|
||||
.feat-strip .by{font-size:12px;color:var(--muted);font-weight:500;white-space:nowrap}
|
||||
.feat-strip .by{font-size:12px;color:var(--muted);font-weight:500;white-space:nowrap}
|
||||
/* ── achievement badges ── */
|
||||
.badges{display:flex;gap:18px;flex-wrap:wrap;margin-top:12px}
|
||||
.badge-a{display:flex;flex-direction:column;align-items:center;gap:6px;width:150px;text-align:center}
|
||||
.badge-a .badge-img{width:150px;height:150px;border-radius:14px;object-fit:cover;border:1px solid var(--line-strong)}
|
||||
.badge-a.locked .badge-img{filter:grayscale(.85) brightness(.5);opacity:.7}
|
||||
.badge-a .medal{width:76px;height:76px;border-radius:50%;display:grid;place-items:center;position:relative;
|
||||
background:radial-gradient(circle at 50% 35%,rgba(67,232,195,.28),rgba(9,26,19,.9));
|
||||
border:2px solid var(--mint);box-shadow:0 0 18px rgba(67,232,195,.3)}
|
||||
.badge-a.locked .medal{background:rgba(139,166,156,.08);border-color:var(--line-strong);box-shadow:none;filter:grayscale(1);opacity:.5}
|
||||
.badge-a .medal svg{width:38px;height:38px;stroke:var(--mint);fill:none;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}
|
||||
.badge-a.locked .medal svg{stroke:var(--muted)}
|
||||
.badge-a .bl{font-size:12px;font-weight:700;line-height:1.2}
|
||||
.badge-a .bs{font-size:10.5px;color:var(--muted)}
|
||||
.badge-a .share{font-size:11px;color:var(--mint);cursor:pointer;background:none;border:0;padding:0;text-decoration:underline}
|
||||
/* ── "Your next move": milestone stepper card ── */
|
||||
.next-card{position:relative;overflow:hidden;border-color:rgba(67,232,195,.28)}
|
||||
.next-card::before{content:"";position:absolute;inset:0;pointer-events:none;background:
|
||||
radial-gradient(420px 150px at 10% 0%,rgba(67,232,195,.12),transparent 70%),
|
||||
radial-gradient(360px 130px at 90% 100%,rgba(157,125,255,.09),transparent 70%)}
|
||||
.nc-head{display:flex;gap:14px;align-items:flex-start;position:relative}
|
||||
.nc-head .pl{flex:0 0 auto;width:44px;height:44px;border-radius:12px;display:grid;place-items:center;
|
||||
background:rgba(67,232,195,.12);border:1px solid rgba(67,232,195,.35)}
|
||||
.nc-head .pl svg{width:20px;height:20px;stroke:var(--mint);fill:none;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
|
||||
.nc-steps{display:flex;margin-top:20px;position:relative;flex-wrap:wrap;row-gap:14px}
|
||||
.nc-step{flex:1;min-width:88px;display:flex;flex-direction:column;align-items:center;gap:7px;
|
||||
position:relative;text-align:center;padding:0 4px}
|
||||
.nc-step::before{content:"";position:absolute;top:13px;left:-50%;width:100%;height:2px;background:var(--line-strong)}
|
||||
.nc-step:first-child::before{display:none}
|
||||
.nc-step.hit::before{background:linear-gradient(90deg,var(--mint),var(--mint-hi))}
|
||||
.nc-step .dot{width:27px;height:27px;border-radius:50%;display:grid;place-items:center;font-size:12px;
|
||||
font-weight:800;background:var(--panel-solid);border:2px solid var(--line-strong);color:var(--muted);
|
||||
position:relative;z-index:1}
|
||||
.nc-step.hit .dot{background:var(--mint);border-color:var(--mint);color:var(--mint-ink);
|
||||
box-shadow:0 0 14px rgba(67,232,195,.35)}
|
||||
.nc-step.cur .dot{border-color:var(--amber);color:var(--amber);box-shadow:0 0 12px rgba(255,178,56,.4)}
|
||||
.nc-step .lb{font-size:12px;font-weight:700;line-height:1.25}
|
||||
.nc-step .lb i{display:block;font-style:normal;font-weight:500;font-size:10.5px;color:var(--muted);margin-top:2px}
|
||||
.nc-step.cur .lb{color:var(--amber)}
|
||||
/* ── overview v3: stat cards w/ plates+chips, hand-rolled charts ── */
|
||||
.statx{display:flex;gap:14px;align-items:flex-start;background:var(--panel);border:1px solid var(--line);
|
||||
border-radius:var(--radius);padding:18px;position:relative;overflow:hidden}
|
||||
.statx .pl{flex:0 0 auto;width:46px;height:46px;border-radius:13px;display:grid;place-items:center;
|
||||
background:rgba(67,232,195,.1);border:1px solid rgba(67,232,195,.3)}
|
||||
.statx .pl svg{width:22px;height:22px;stroke:var(--mint);fill:none;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
|
||||
.statx.c-cyan .pl{background:rgba(84,204,255,.1);border-color:rgba(84,204,255,.3)}
|
||||
.statx.c-cyan .pl svg{stroke:var(--cyan)}
|
||||
.statx.c-violet .pl{background:rgba(157,125,255,.12);border-color:rgba(157,125,255,.32)}
|
||||
.statx.c-violet .pl svg{stroke:var(--violet)}
|
||||
.statx.c-amber .pl{background:rgba(255,178,56,.1);border-color:rgba(255,178,56,.3)}
|
||||
.statx.c-amber .pl svg{stroke:var(--amber)}
|
||||
.statx .nv{font-family:var(--disp);font-size:26px;font-weight:800;font-variant-numeric:tabular-nums;line-height:1.1}
|
||||
.statx .lb{font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.08em;margin-top:2px}
|
||||
.chip{display:inline-block;margin-top:7px;font-size:11.5px;font-weight:700;border-radius:999px;padding:2px 10px;
|
||||
background:rgba(67,232,195,.1);color:var(--mint)}
|
||||
.chip.flat{background:rgba(139,166,156,.12);color:var(--muted)}
|
||||
.chip.warm{background:rgba(255,178,56,.12);color:var(--amber)}
|
||||
.card-head{display:flex;align-items:baseline;justify-content:space-between;gap:10px}
|
||||
.card-head .sub{font-size:12px;color:var(--muted)}
|
||||
/* bar chart (divs, real data) */
|
||||
.barchart{display:flex;align-items:flex-end;gap:6px;height:120px;margin-top:14px}
|
||||
.barchart .bar{flex:1;min-width:6px;background:linear-gradient(180deg,var(--mint),rgba(67,232,195,.25));
|
||||
border-radius:5px 5px 2px 2px;position:relative;transition:height .5s ease}
|
||||
.barchart .bar.alt{background:linear-gradient(180deg,var(--violet),rgba(157,125,255,.25))}
|
||||
.barchart .bar:hover::after{content:attr(data-v);position:absolute;bottom:calc(100% + 4px);left:50%;
|
||||
transform:translateX(-50%);background:var(--panel-solid);border:1px solid var(--line-strong);border-radius:7px;
|
||||
padding:2px 8px;font-family:var(--mono);font-size:11px;white-space:nowrap;z-index:3}
|
||||
.barchart .bar.empty{background:rgba(139,166,156,.15);height:4px!important}
|
||||
.chart-x{display:flex;gap:6px;margin-top:6px}
|
||||
.chart-x span{flex:1;text-align:center;font-family:var(--mono);font-size:10px;color:var(--muted);overflow:hidden;white-space:nowrap}
|
||||
/* donut + ring (SVG) */
|
||||
.donut-wrap{display:flex;gap:22px;align-items:center;margin-top:10px;flex-wrap:wrap}
|
||||
.donut-legend{display:flex;flex-direction:column;gap:8px;font-size:13px}
|
||||
.donut-legend i{display:inline-block;width:10px;height:10px;border-radius:3px;margin-right:8px}
|
||||
.donut-center{font-family:var(--disp);font-weight:800}
|
||||
/* ── login ad interstitial: sponsor opens in a new tab, timer runs here ── */
|
||||
.lgate{position:fixed;inset:0;z-index:90;display:flex;flex-direction:column;background:var(--ground)}
|
||||
.lgate-bar{display:flex;align-items:center;gap:14px;padding:10px 16px;background:var(--panel-solid);
|
||||
border-bottom:1px solid var(--line);flex-wrap:wrap}
|
||||
.lg-brand{font-family:var(--disp);font-weight:800;font-size:15px;white-space:nowrap}
|
||||
.lg-brand em{color:var(--mint);font-style:normal}
|
||||
.lgate-note{flex:1;min-width:140px}
|
||||
.lg-timer{font-weight:700;font-size:14px;border:1px solid var(--line-strong);border-radius:999px;
|
||||
padding:5px 14px;min-width:150px;text-align:center}
|
||||
.lg-timer.done{color:var(--mint);border-color:var(--mint)}
|
||||
.lgate-body{flex:1;display:grid;place-items:center;padding:24px;overflow:auto}
|
||||
.lgate-card{max-width:560px;text-align:center}
|
||||
.lgate-card img{max-width:100%;max-height:50vh;border-radius:12px;border:1px solid var(--line-strong)}
|
||||
#lgCreative .lg-linkcard{display:inline-block;background:var(--panel);border:1px solid var(--line-strong);
|
||||
border-radius:14px;padding:22px 28px;font-family:var(--disp);font-weight:700;font-size:18px;overflow-wrap:anywhere}
|
||||
/* ── welcome tour (gauntlet): framed line sites + countdown ── */
|
||||
.lgate-frame{flex:1;border:0;width:100%;background:#fff;min-height:0}
|
||||
.ggmeta{padding:8px 16px;background:var(--panel-solid);border-bottom:1px solid var(--line)}
|
||||
/* ── bio header (wall/profile page) ── */
|
||||
.bio-head{display:flex;gap:22px;align-items:center;flex-wrap:wrap;padding:8px 0 4px}
|
||||
.bio-avatar{width:96px;height:96px;border-radius:50%;object-fit:cover;border:2px solid var(--mint);flex:0 0 auto}
|
||||
.bio-badge{width:74px;height:74px;border-radius:12px;object-fit:cover;flex:0 0 auto;border:1px solid var(--line-strong)}
|
||||
.btn.disabled{opacity:.45;pointer-events:none;filter:grayscale(.3)}
|
||||
.bio-meta{flex:1;min-width:220px}
|
||||
.bio-socials{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px}
|
||||
.bio-video{margin:14px auto 8px;max-width:760px}
|
||||
@media (max-width:720px){.bio-head{flex-direction:column;align-items:center;text-align:center;gap:14px}.bio-head .bio-avatar,.bio-head .bio-badge{margin:0 auto}.bio-meta{min-width:0;width:100%}.bio-socials{justify-content:center}.bio-qr{margin:4px auto 0}.bio-video .eyebrow{text-align:center}}
|
||||
.bio-video iframe,.bio-video video{width:100%;aspect-ratio:16/9;border:1px solid var(--line-strong);border-radius:16px;background:#000;display:block}
|
||||
.bio-socials a{display:inline-flex;align-items:center;gap:6px;font-size:12px;font-weight:700;color:var(--mint);border:1px solid var(--line-strong);
|
||||
border-radius:999px;padding:4px 12px 4px 9px;text-decoration:none}
|
||||
.bio-socials a svg{flex:0 0 auto}
|
||||
.bio-socials a:hover{border-color:var(--mint)}
|
||||
.bio-qr{flex:0 0 auto;background:var(--panel);border:1px solid var(--line-strong);border-radius:14px;padding:10px}
|
||||
.bio-qr img{display:block;border-radius:8px;background:#eef7f3}
|
||||
/* ── wall page ── */
|
||||
.wall-card{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);padding:16px;text-align:center}
|
||||
.wall-card img{max-width:100%;border-radius:10px;border:1px solid var(--line-strong)}
|
||||
.wall-pos{font-family:var(--mono);font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.08em;margin-bottom:8px}
|
||||
.wc-creative img{max-width:100%;border-radius:10px;border:1px solid var(--line-strong)}
|
||||
.wc-action{margin-top:10px;min-height:32px;display:flex;justify-content:center;align-items:center}
|
||||
.wc-check{color:var(--mint);font-weight:800;border:1px solid var(--mint);border-radius:999px;padding:4px 14px;
|
||||
box-shadow:0 0 14px rgba(67,232,195,.3)}
|
||||
.wc-timer{color:var(--amber);font-weight:700;font-family:var(--mono);font-size:13px}
|
||||
/* ── modal (sponsor message) ── */
|
||||
.modal-back{position:fixed;inset:0;z-index:100;background:rgba(2,6,5,.72);display:flex;align-items:center;justify-content:center;padding:20px}
|
||||
.modal-card{background:var(--panel-solid);border:1px solid var(--line-strong);border-radius:var(--radius);
|
||||
padding:24px;max-width:520px;width:100%;box-shadow:0 20px 60px rgba(0,0,0,.5)}
|
||||
/* ── downline lineage list ── */
|
||||
.lin-lvl{margin:10px 0}
|
||||
.lin-lvl>.cap{display:inline-block;font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;
|
||||
color:var(--mint);background:var(--mint-soft);border:1px solid var(--line-strong);border-radius:8px;padding:4px 10px;margin-bottom:10px}
|
||||
.lin-row{display:grid;grid-template-columns:minmax(110px,1.2fr) minmax(0,2fr) auto auto auto auto;gap:6px 14px;
|
||||
align-items:center;padding:9px 0;border-bottom:1px solid var(--line)}
|
||||
.lin-row .nm{font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.lin-row .em{font-size:12px;color:var(--mint);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.lin-row .id{font-family:var(--mono);font-size:11px;color:var(--muted)}
|
||||
.lin-row .sp{display:block;font-size:11.5px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .lin-row .em{min-width:0}
|
||||
.lin-row .dt{font-size:12px;color:var(--muted);text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}
|
||||
.lin-row .chat-msg-btn{justify-self:end}
|
||||
.lin-row .lin-act-btn{justify-self:end;white-space:nowrap}
|
||||
.lin-act{display:flex;flex-wrap:wrap;gap:6px;align-items:center;padding:8px 10px 10px;margin:-1px 0 6px;border-bottom:1px solid var(--line);background:rgba(255,255,255,.025);border-radius:0 0 10px 10px}
|
||||
.lin-chip{font-size:12px;color:var(--muted);border:1px solid var(--line);border-radius:999px;padding:3px 10px;white-space:nowrap} .lin-chip b{font-weight:600;color:var(--ink);margin-right:5px} .lin-chip.on{border-color:rgba(67,232,195,.45);color:var(--ink)} .lin-chip.warn{border-color:rgba(255,209,92,.5)}
|
||||
.lin-verdict{margin-left:auto;font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:#ffd15c} .lin-verdict.on{color:var(--mint)}
|
||||
@media (max-width:560px){.lin-chip{white-space:normal}.lin-act{padding:8px 4px 10px}}
|
||||
@media (max-width:560px){.lin-row{grid-template-columns:1fr auto;gap:2px 10px}
|
||||
.lin-row .em{grid-column:1/2}.lin-row .dt{grid-column:1/2;text-align:left}.lin-row .chat-msg-btn{grid-row:1/3;grid-column:2}
|
||||
.lin-row .lin-act-btn{grid-column:2;grid-row:3/5}.lin-row:not(:has(.chat-msg-btn)) .lin-act-btn{grid-row:1/3}}
|
||||
/* ── solo composer: toolbar + contenteditable editor ── */
|
||||
.ed-bar{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px}
|
||||
.ed-bar button{background:var(--panel);color:var(--ink);border:1px solid var(--line-strong);border-radius:8px;
|
||||
padding:5px 11px;font-size:12.5px;cursor:pointer}
|
||||
.ed-bar button:hover,.ed-bar button:focus-visible{border-color:var(--mint);outline:none}
|
||||
.ed-body{background:rgba(4,8,7,.65);border:1px solid var(--line-strong);border-radius:11px;
|
||||
min-height:180px;padding:12px 14px;outline:none;overflow-wrap:anywhere}
|
||||
.ed-body:focus{border-color:var(--mint)}
|
||||
.ed-body:empty::before{content:attr(data-ph);color:var(--muted)}
|
||||
.ed-body img,.ed-body video,.ib-rich img,.ib-rich video{max-width:100%;border-radius:10px;margin:6px 0;display:block}
|
||||
.ed-sep{width:1px;align-self:stretch;background:var(--line-strong);margin:2px 3px}
|
||||
.ed-bar sub{font-size:9px}
|
||||
textarea.ed-body{width:100%;min-height:180px;resize:vertical}
|
||||
.ed-body a,.ib-rich a{color:var(--mint)}
|
||||
.ed-body h3,.ib-rich h3,.ed-body h4,.ib-rich h4{margin:.5em 0 .3em}
|
||||
.ed-media{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin:10px 0 0}
|
||||
/* ── earn sub-tabs (Watch ads | Inbox) ── */
|
||||
.subtabs{display:flex;gap:6px;margin:0 0 16px;border-bottom:1px solid var(--line);padding-bottom:0}
|
||||
.subtab{background:none;border:0;border-bottom:2px solid transparent;color:var(--muted);
|
||||
font:inherit;font-weight:700;font-size:14px;padding:8px 14px;cursor:pointer;margin-bottom:-1px;
|
||||
display:inline-flex;align-items:center;gap:7px}
|
||||
.subtab:hover{color:var(--ink)}
|
||||
.subtab.on{color:var(--mint);border-bottom-color:var(--mint)}
|
||||
.subtab .pill{position:static}
|
||||
/* ── solo-ads inbox ── */
|
||||
.bo-menu .pill{margin-left:auto;background:var(--amber);color:#1a1206;font-size:11px;font-weight:800;
|
||||
border-radius:999px;padding:1px 8px;line-height:1.5}
|
||||
.ib-row{display:flex;gap:10px;align-items:baseline;padding:11px 6px;border-bottom:1px solid var(--line);
|
||||
cursor:pointer;flex-wrap:wrap}
|
||||
.ib-row:hover{background:rgba(67,232,195,.05)}
|
||||
.ib-row .sub{font-weight:700;flex:1;min-width:160px;overflow-wrap:anywhere}
|
||||
.ib-row.unread .sub{color:var(--mint)}
|
||||
.ib-row .from,.ib-row .when{font-size:12px;color:var(--muted);white-space:nowrap}
|
||||
.cta-need{box-shadow:0 0 0 2px var(--amber),0 0 16px rgba(255,178,56,.4)!important;animation:ctapulse 1.6s ease-in-out infinite}
|
||||
@keyframes ctapulse{50%{box-shadow:0 0 0 2px var(--amber),0 0 24px rgba(255,178,56,.65)!important}}
|
||||
@media (prefers-reduced-motion:reduce){.cta-need{animation:none}}
|
||||
/* ── back-office accent family: green leads, cyan/violet/amber season the cards ── */
|
||||
.bo .stats .stat:nth-child(2) .n{color:var(--cyan)}
|
||||
.bo .stats .stat:nth-child(2)::before{background:linear-gradient(90deg,transparent,var(--cyan),transparent)}
|
||||
.bo .stats .stat:nth-child(3) .n{color:var(--violet)}
|
||||
.bo .stats .stat:nth-child(3)::before{background:linear-gradient(90deg,transparent,var(--violet),transparent)}
|
||||
.bo .stats .stat:nth-child(4) .n{color:var(--amber)}
|
||||
.bo .stats .stat:nth-child(4)::before{background:linear-gradient(90deg,transparent,var(--amber),transparent)}
|
||||
.bo .card h3::before{content:"";display:inline-block;width:9px;height:9px;border-radius:2.5px;
|
||||
background:var(--mint);margin-right:10px;transform:rotate(45deg);vertical-align:1px}
|
||||
#pane-line .card h3::before{background:var(--cyan)}
|
||||
#pane-buy .card h3::before,#pane-campaigns .card h3::before{background:var(--amber)}
|
||||
#pane-earn .card h3::before,#pane-earnings .card h3::before{background:var(--violet)}
|
||||
#pane-promo .card h3::before{background:var(--cyan)}
|
||||
#pane-wallet .card h3::before,#pane-profile .card h3::before{background:var(--mint)}
|
||||
.bo .card{background:linear-gradient(165deg,rgba(24,36,31,.6),rgba(13,19,17,.66))}
|
||||
#pane-line .qualbar,#nextCard{border-left:3px solid rgba(67,232,195,.4)}
|
||||
.promo-block{background:rgba(4,8,7,.55);border:1px solid var(--line);border-radius:12px;
|
||||
padding:14px 16px;margin:0 0 12px;font-size:13.5px;line-height:1.6;white-space:pre-wrap}
|
||||
.promo-block .btn{margin-top:10px}
|
||||
/* back-office polish: pane transitions, quick actions */
|
||||
@media(prefers-reduced-motion:no-preference){
|
||||
.pane:not([hidden]){animation:panein .25s ease}
|
||||
@keyframes panein{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
|
||||
}
|
||||
.qa{display:flex;flex-direction:column;gap:8px}
|
||||
.qa button{display:flex;align-items:center;gap:10px;background:rgba(67,232,195,.05);border:1px solid var(--line);
|
||||
color:var(--ink);font:600 14px var(--disp);padding:11px 14px;border-radius:11px;cursor:pointer;text-align:left;
|
||||
transition:border-color .15s ease,background .15s ease}
|
||||
.qa button:hover{border-color:var(--mint);background:rgba(67,232,195,.1)}
|
||||
/* member sub-navigation */
|
||||
.subnav{display:flex;gap:6px;flex-wrap:wrap;background:rgba(16,28,24,.7);border:1px solid var(--line);
|
||||
border-radius:999px;padding:6px 8px;width:fit-content;margin:0 0 22px}
|
||||
.subnav button{background:transparent;border:0;color:var(--muted);font:600 13.5px var(--disp);
|
||||
padding:8px 16px;border-radius:999px;cursor:pointer}
|
||||
.subnav button:hover{color:var(--ink)}
|
||||
.subnav button.on{color:var(--mint-ink);background:var(--mint)}
|
||||
/* member dashboard: qualification progress + roster */
|
||||
.qualbar{position:relative;height:12px;border-radius:999px;background:rgba(4,8,7,.7);border:1px solid var(--line-strong);
|
||||
margin:26px 0 30px;max-width:520px}
|
||||
.qualbar #qualFill{height:100%;border-radius:999px;background:linear-gradient(90deg,var(--mint),var(--mint-hi));
|
||||
box-shadow:0 0 18px rgba(67,232,195,.4);width:0;transition:width .6s ease}
|
||||
.qb-mark{position:absolute;top:16px;transform:translateX(-50%);font-family:var(--mono);font-size:11px;color:var(--muted);white-space:nowrap}
|
||||
.qb-mark.end{left:auto!important;right:0;transform:none} /* keeps the last label inside the card on mobile */
|
||||
#inviteLine,#pitchPreview{overflow-wrap:anywhere}
|
||||
img{max-width:100%}
|
||||
.roster{width:100%;border-collapse:collapse;font-size:13.5px}
|
||||
.roster td{padding:8px 10px;border-bottom:1px solid var(--line)}
|
||||
.roster td:first-child{font-family:var(--mono)}
|
||||
.roster tr:last-child td{border-bottom:0}
|
||||
/* chat widget */
|
||||
#iapChatBtn{position:fixed;right:20px;bottom:20px;z-index:40;width:56px;height:56px;border-radius:50%;
|
||||
border:0;background:var(--mint);color:var(--mint-ink);font-size:24px;cursor:pointer;
|
||||
box-shadow:0 8px 30px rgba(67,232,195,.4)}
|
||||
#iapChatBtn:hover{transform:scale(1.06)}
|
||||
#iapChatPanel{position:fixed;right:20px;bottom:88px;z-index:40;width:min(360px,calc(100vw - 40px));
|
||||
background:var(--panel-solid);border:1px solid var(--line-strong);border-radius:18px;overflow:hidden;
|
||||
box-shadow:0 24px 70px rgba(0,0,0,.65);display:flex;flex-direction:column}
|
||||
.ch-head{padding:14px 16px;border-bottom:1px solid var(--line);font-family:var(--disp);position:relative}
|
||||
.ch-head .ch-sub{display:block;font-size:12px;color:var(--muted);font-family:"Segoe UI",system-ui,sans-serif}
|
||||
#iapChatClose{position:absolute;right:10px;top:10px;background:transparent;border:0;color:var(--muted);
|
||||
font-size:22px;cursor:pointer;line-height:1}
|
||||
.ch-msgs{padding:14px;overflow-y:auto;max-height:340px;display:flex;flex-direction:column;gap:10px}
|
||||
.ch-m{border-radius:12px;padding:9px 13px;font-size:13.5px;line-height:1.5;max-width:86%}
|
||||
.ch-m.bot{background:rgba(67,232,195,.08);border:1px solid rgba(67,232,195,.2);align-self:flex-start}
|
||||
.ch-m.me{background:rgba(130,148,196,.12);border:1px solid var(--line);align-self:flex-end}
|
||||
.ch-m a{word-break:break-all}
|
||||
.ch-input{display:flex;gap:8px;padding:11px;border-top:1px solid var(--line)}
|
||||
.ch-input input{flex:1;min-width:0}
|
||||
.ch-input button{background:var(--mint);color:var(--mint-ink);border:0;border-radius:10px;padding:0 16px;
|
||||
font-weight:700;cursor:pointer;font-family:var(--disp)}
|
||||
@media(prefers-reduced-motion:no-preference){
|
||||
.feed .row:first-child{animation:landed .9s ease}
|
||||
@keyframes landed{from{background:rgba(67,232,195,.3)}to{background:var(--mint-soft)}}
|
||||
}
|
||||
|
||||
/* ── Sponsor chat: FAB, slide-in drawer, threads, bubbles, presence ── */
|
||||
.bo-chat{display:flex;align-items:center}
|
||||
.bo-chat .pill{margin-left:auto;min-width:20px;height:20px;padding:0 6px;border-radius:10px;background:var(--mint);
|
||||
color:var(--mint-ink);font:700 12px/20px var(--disp);text-align:center}
|
||||
.chat-drawer{position:fixed;right:0;top:0;bottom:0;width:380px;max-width:92vw;z-index:70;display:flex;flex-direction:column;
|
||||
background:var(--panel-solid);border-left:1px solid var(--line-strong);box-shadow:-16px 0 40px rgba(0,0,0,.45);
|
||||
transform:translateX(0);animation:chatIn .16s ease}
|
||||
@keyframes chatIn{from{transform:translateX(24px);opacity:.4}to{transform:translateX(0);opacity:1}}
|
||||
@media (prefers-reduced-motion:reduce){.chat-drawer{animation:none}.chat-fab{transition:none}}
|
||||
.chat-head{display:flex;align-items:center;gap:8px;padding:12px 12px;border-bottom:1px solid var(--line)}
|
||||
.chat-who{display:flex;flex-direction:column;line-height:1.15;min-width:0;flex:1}
|
||||
.chat-who b{font:700 15px var(--disp);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.chat-icon{background:none;border:0;color:var(--muted);font-size:26px;line-height:1;cursor:pointer;padding:0 6px}
|
||||
.chat-icon:hover{color:var(--ink)}
|
||||
.pres-dot{width:9px;height:9px;border-radius:50%;background:var(--muted);flex:0 0 auto}
|
||||
.pres-dot.on{background:var(--mint);box-shadow:0 0 8px var(--mint)}
|
||||
.chat-threads{flex:1;overflow:auto}
|
||||
.chat-thread{display:flex;align-items:center;gap:10px;padding:12px 14px;border-bottom:1px solid var(--line);cursor:pointer}
|
||||
.chat-thread:hover{background:var(--mint-soft)}
|
||||
.chat-thread .ct-main{flex:1;min-width:0}
|
||||
.chat-thread .ct-name{font:700 14px var(--disp)}
|
||||
.chat-thread .ct-last{color:var(--muted);font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.chat-thread .ct-un{min-width:20px;height:20px;padding:0 5px;border-radius:10px;background:var(--mint);color:var(--mint-ink);
|
||||
font:700 12px/20px var(--disp);text-align:center}
|
||||
.chat-empty{padding:22px 16px;color:var(--muted);font-size:14px}
|
||||
.chat-convo{flex:1;display:flex;flex-direction:column;min-height:0}
|
||||
.chat-msgs{flex:1;overflow:auto;padding:14px;display:flex;flex-direction:column;gap:8px}
|
||||
.cbub{max-width:78%;padding:9px 12px;border-radius:14px;font-size:14px;line-height:1.4;white-space:pre-wrap;word-wrap:break-word}
|
||||
.cbub.them{align-self:flex-start;background:var(--panel);border:1px solid var(--line);border-bottom-left-radius:4px}
|
||||
.cbub.me{align-self:flex-end;background:var(--mint);color:var(--mint-ink);border-bottom-right-radius:4px}
|
||||
.cbub .ct-time{display:block;margin-top:4px;font-size:10.5px;opacity:.6}
|
||||
.chat-day{align-self:center;color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.05em;margin:6px 0}
|
||||
.chat-banner{padding:8px 14px;border-top:1px solid var(--line);color:var(--muted);font-size:12.5px;background:var(--ground2)}
|
||||
.chat-compose{display:flex;gap:8px;padding:10px;border-top:1px solid var(--line);align-items:flex-end}
|
||||
.chat-compose textarea{flex:1;resize:vertical;min-height:44px;max-height:45vh;line-height:1.45;background:var(--ground2);border:1px solid var(--line);
|
||||
border-radius:12px;color:var(--ink);padding:9px 11px;font:400 14px var(--disp)}
|
||||
.chat-compose .btn{padding:9px 16px}
|
||||
.chat-msg-btn{margin-left:auto;font-size:12px;padding:3px 10px}
|
||||
.switch{display:flex;align-items:center;gap:10px;cursor:pointer;font-size:14px}
|
||||
.switch input{width:18px;height:18px;accent-color:var(--mint)}
|
||||
|
||||
/* ── Overview line tree ── */
|
||||
.line-tree{margin-top:6px}
|
||||
.lt-top{margin-left:26px}
|
||||
.lt-you{display:inline-block;background:var(--mint);color:var(--mint-ink);font-weight:800;padding:6px 18px;border-radius:999px;font-size:13px}
|
||||
.lt-stem{height:12px;border-left:2px solid var(--line-strong);width:0;margin:0 0 6px 30px}
|
||||
.lt-row{display:flex;align-items:center;gap:10px;margin:7px 0}
|
||||
.lt-cap{font:700 11px var(--mono);color:var(--muted);width:22px;flex:0 0 auto}
|
||||
.lt-nodes{display:flex;gap:8px;flex-wrap:wrap}
|
||||
.lt-node{background:var(--panel);border:1px solid var(--line-strong);border-radius:10px;padding:6px 12px;font-size:13px;font-weight:600;white-space:nowrap}
|
||||
.lt-node.deep{color:var(--muted)}
|
||||
.lt-node.qualified{border-color:#ffb238;color:#ffd15c;box-shadow:0 0 0 1px rgba(255,177,56,.35)}
|
||||
.lt-node.open{border-style:dashed;color:var(--muted);background:transparent}
|
||||
.lt-node .lt-n{display:inline-block;font-style:normal;font-size:10px;line-height:16px;min-width:16px;text-align:center;border-radius:8px;background:rgba(255,177,56,.18);color:#ffd15c;margin-left:6px;padding:0 4px;font-variant-numeric:tabular-nums}
|
||||
|
||||
.sh-report{display:block;text-align:center;margin-top:10px;color:rgba(255,255,255,.45);font-size:12px}
|
||||
.sh-report:hover{color:rgba(255,255,255,.8)}
|
||||
|
||||
/* ── promo tools (2026-09-09) ── */
|
||||
.promo-strip{display:flex;justify-content:space-between;align-items:center;gap:14px;flex-wrap:wrap;padding:16px 20px}
|
||||
.angle-list{display:flex;flex-direction:column;gap:8px}
|
||||
.hp-field{position:absolute;left:-9999px;top:auto;width:1px;height:1px;opacity:0;overflow:hidden}
|
||||
.hbars{display:flex;align-items:flex-end;gap:2px;height:20px;margin-top:6px;width:120px}
|
||||
.hbars i{flex:1 1 0;background:var(--mint);opacity:.75;border-radius:1px 1px 0 0;min-width:2px}
|
||||
.hbars i:hover{opacity:1}
|
||||
.icon-check{display:flex;gap:8px;flex-wrap:wrap;margin:0 0 12px}
|
||||
.icon-check .ic-btn{font-size:26px;line-height:1;padding:10px 14px;border-radius:12px;border:1px solid var(--line-strong);background:var(--panel);cursor:pointer}
|
||||
.icon-check .ic-btn:hover{border-color:var(--mint)}
|
||||
.angle-row.pb-acc>summary{padding:12px 14px;font-size:14.5px}
|
||||
.angle-row .angle-body{display:flex;justify-content:space-between;align-items:center;gap:14px;flex-wrap:wrap;padding:0 14px 14px}
|
||||
.angle-row .angle-txt{flex:1 1 320px;min-width:0}
|
||||
.angle-name{font-family:var(--disp);font-weight:700;font-size:15px}
|
||||
.angle-hook{color:var(--mint);font-size:13px;font-weight:500}
|
||||
.angle-tag{font-family:var(--mono);font-size:10px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);border:1px solid var(--line-strong);border-radius:999px;padding:1px 7px}
|
||||
.angle-url{overflow-wrap:anywhere;margin-top:4px;display:block;color:var(--mint);text-decoration:none}
|
||||
.angle-url:hover{text-decoration:underline}
|
||||
.angle-btns{display:flex;gap:8px;flex-wrap:wrap}
|
||||
.angle-btns .btn{margin-top:0}
|
||||
.promo-pills{margin:4px 0 14px}
|
||||
.promo-block .pb-head{display:flex;gap:8px;align-items:center;margin-bottom:8px;font-size:13.5px}
|
||||
.promo-block .pb-net{font-family:var(--mono);font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--mint)}
|
||||
.promo-block .pb-text{white-space:pre-wrap;font-size:14.5px;line-height:1.5}
|
||||
.promo-block .pb-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}
|
||||
.promo-block .pb-actions .btn{margin-top:0}
|
||||
.promo-bv{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-top:16px;padding:14px 16px;border:1px dashed var(--line-strong);border-radius:12px}
|
||||
.promo-bv b{font-family:var(--disp)}
|
||||
.obj-wrap{display:flex;flex-direction:column;gap:8px}
|
||||
.obj{border:1px solid var(--line);border-radius:12px;background:rgba(4,8,7,.4)}
|
||||
.obj summary{cursor:pointer;padding:12px 16px;font-family:var(--disp);font-weight:700;font-size:15px;list-style:none;display:flex;justify-content:space-between;gap:10px}
|
||||
.obj summary::after{content:"+";color:var(--mint);font-weight:800}
|
||||
.obj[open] summary::after{content:"–"}
|
||||
.obj summary::-webkit-details-marker{display:none}
|
||||
.obj summary::marker{content:""}
|
||||
.obj summary{text-align:left}
|
||||
.obj-body{padding:0 16px 14px;display:grid;gap:12px}
|
||||
.obj .lab{display:inline-block;font-family:var(--mono);font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:var(--mint);margin-bottom:4px}
|
||||
.obj .lab-say{color:var(--amber)}
|
||||
.obj-truth p{margin:0;font-size:14.5px;color:var(--ink)}
|
||||
.obj-say{margin:0}
|
||||
|
||||
.lin-row .earn{font-family:var(--mono);font-size:12px;color:var(--muted);white-space:nowrap;font-variant-numeric:tabular-nums}
|
||||
.lin-row .earn.on{color:var(--mint);font-weight:700}
|
||||
|
||||
.wo-slot{border:1px solid var(--line);border-radius:12px;padding:12px 14px}
|
||||
.wo-slot.locked{opacity:.6}
|
||||
.wo-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px}
|
||||
.wo-slot img{max-width:100%;border-radius:8px}
|
||||
|
||||
/* getting-started stepper on the Overview (Jim could not find the wallet step, 2026-09-14) */
|
||||
.gs{border-color:rgba(67,232,195,.45)}
|
||||
.gs-steps{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:8px;margin:4px 0 12px}
|
||||
.gs-step{display:flex;gap:10px;align-items:center;border:1px solid var(--line);border-radius:12px;padding:10px 12px;color:var(--muted);font-size:14px}
|
||||
.gs-step .n{flex:0 0 26px;width:26px;height:26px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;font-weight:700;border:1px solid var(--line);font-size:13px}
|
||||
.gs-step.done{color:var(--ink)} .gs-step.done .n{background:var(--mint);color:var(--mint-ink);border-color:var(--mint)}
|
||||
.gs-step.now{color:var(--ink);border-color:var(--mint);background:rgba(67,232,195,.07)} .gs-step.now .n{border-color:var(--mint);color:var(--mint)}
|
||||
.gs-now{display:flex;gap:14px;align-items:center;flex-wrap:wrap;border-top:1px solid var(--line);padding-top:12px}
|
||||
.gs-now p{margin:0;flex:1;min-width:240px;max-width:60ch} .gs-now p b{display:block;font-family:var(--disp);font-size:16px;margin-bottom:2px}
|
||||
@keyframes gsPulse{0%{box-shadow:0 0 0 0 rgba(67,232,195,.7)}70%{box-shadow:0 0 0 14px rgba(67,232,195,0)}100%{box-shadow:0 0 0 0 rgba(67,232,195,0)}}
|
||||
.pulse{animation:gsPulse 1.2s ease-out 4}
|
||||
|
||||
/* promo toolkit tiers */
|
||||
.tk-tiers{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:10px}
|
||||
.tk-tier{border:1px solid var(--line);border-radius:12px;padding:12px 14px;font-size:13.5px;color:var(--muted)}
|
||||
.tk-tier.reached{border-color:rgba(67,232,195,.5);color:var(--ink)} .tk-tier.current{background:rgba(67,232,195,.07)}
|
||||
.tk-tier b{display:block;font-family:var(--disp);font-size:15px;color:var(--ink)} .tk-tier .need{font-size:12px;margin:2px 0 8px;color:var(--muted)}
|
||||
.tk-tier ul{margin:0;padding-left:16px} .tk-tier li{margin:3px 0} .tk-tier li.soon{opacity:.65} .tk-tier li.soon::after{content:' (coming)';font-size:11px;color:var(--muted)}
|
||||
.tk-tier .lock{font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:#ffd15c}
|
||||
.tk-hist{border-top:1px solid var(--line);padding:8px 0;font-size:13px} .tk-hist .muted{font-size:12px}
|
||||
.tk-sec{margin-top:18px;padding-top:14px;border-top:1px solid var(--line)} .tk-grid{display:flex;gap:8px;flex-wrap:wrap}
|
||||
.tk-vids{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:8px} .tk-vid{border:1px solid var(--line);border-radius:10px;padding:10px 12px;font-size:13px} .tk-vid b{display:block;font-weight:600;margin-bottom:4px} .tk-vid .st{font-size:12px;color:var(--muted)}
|
||||
.tk-bar{height:8px;border-radius:99px;background:rgba(255,255,255,.08);overflow:hidden;margin:6px 0 4px} .tk-bar i{display:block;height:100%;border-radius:99px;background:linear-gradient(90deg,#1fb894,#43e8c3);transition:width .6s ease;min-width:4px}
|
||||
.tk-table{width:100%;border-collapse:collapse;font-size:13px} .tk-table th,.tk-table td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--line)} .tk-table th{font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted)} .tk-table td.n{font-variant-numeric:tabular-nums;text-align:right} .tk-table th.n{text-align:right} .tk-table tr.best td{color:var(--mint)}
|
||||
.tk-team{border:1px solid var(--line);border-radius:10px;padding:8px 12px;margin:6px 0;font-size:13px;display:flex;gap:10px;flex-wrap:wrap;align-items:center;justify-content:space-between} .tk-team .who b{font-weight:600} .tk-team .who span{color:var(--muted);font-size:12px} .tk-team.stalled{border-color:rgba(255,209,92,.45)}
|
||||
.tk-nudge{width:100%;margin:8px 0 0;padding:8px 12px;border:1px dashed var(--line);border-radius:10px;font-size:13px}
|
||||
/* click sources stack one per line so the Clicks column stays narrow (Marty, 2026-09-15) */
|
||||
.clk-src span{display:block;white-space:nowrap} .camp-table td{white-space:normal} .camp-table td:first-child{max-width:260px}
|
||||
/* campaigns list on phones and tablets: each row becomes a card, buttons at the bottom, no sideways scroll (Jim, 2026-09-15) */
|
||||
@media (max-width:900px){
|
||||
.tablewrap:has(.camp-table){overflow:visible}
|
||||
.camp-table{display:block;width:100%;border-collapse:separate}
|
||||
.camp-table thead{display:none}
|
||||
.camp-table tbody{display:block}
|
||||
.camp-table tr{display:block;border:1px solid var(--line);border-radius:12px;padding:10px 12px 12px;margin:0 0 10px;background:var(--panel)}
|
||||
.camp-table td{display:flex;justify-content:space-between;align-items:baseline;gap:12px;padding:5px 0;border:0;text-align:left;max-width:100%;overflow:hidden}
|
||||
.camp-table td.num{text-align:right;font-variant-numeric:tabular-nums}
|
||||
.camp-table td::before{content:attr(data-l);color:var(--muted);font-size:11px;letter-spacing:.06em;text-transform:uppercase;flex:0 0 auto}
|
||||
.camp-table td:first-child{display:block;padding:0 0 6px;font-size:16px} .camp-table td:first-child::before{display:none}
|
||||
.camp-table td:first-child > *{max-width:100%}
|
||||
.camp-table td.act{display:flex;flex-wrap:wrap;justify-content:flex-start;gap:8px;margin-top:8px;padding-top:10px;border-top:1px solid var(--line)} .camp-table td.act::before{display:none}
|
||||
.camp-table td.act .btn{flex:1 1 auto;text-align:center}
|
||||
}
|
||||
|
||||
/* Pipeline board (Marty, 2026-09-15): columns scroll sideways inside their own box, never the page */
|
||||
.pipe-board{display:flex;gap:10px;overflow-x:auto;padding:4px 2px 10px;scroll-snap-type:x proximity}
|
||||
.pipe-col{flex:0 0 220px;scroll-snap-align:start;background:rgba(4,8,7,.45);border:1px solid var(--line);border-radius:12px;padding:10px;min-height:120px}
|
||||
.pipe-col h4{margin:0 0 2px;font-size:13px;letter-spacing:.06em;text-transform:uppercase;color:var(--mint);display:flex;justify-content:space-between;gap:8px}
|
||||
.pipe-col h4 span{color:var(--muted);font-variant-numeric:tabular-nums}
|
||||
.pipe-col .hint{font-size:11.5px;color:var(--muted);margin:0 0 8px;line-height:1.35}
|
||||
.pipe-card{background:rgba(255,255,255,.04);border:1px solid var(--line);border-radius:10px;padding:8px 10px;margin:0 0 8px;cursor:pointer;transition:border-color .15s}
|
||||
.pipe-card:hover,.pipe-card:focus-visible{border-color:var(--mint);outline:none}
|
||||
.pipe-card.on{border-color:var(--gold,#e6c15a)}
|
||||
.pipe-card .nm{font-weight:700;font-size:14px;display:flex;justify-content:space-between;gap:6px;align-items:baseline}
|
||||
.pipe-card .nm small{font-weight:400;font-size:11px;color:var(--muted);white-space:nowrap}
|
||||
.pipe-card .sub{font-size:12px;color:var(--muted);margin-top:3px;line-height:1.35}
|
||||
.pipe-card .note{font-size:12px;color:var(--ink);margin-top:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.pipe-card .chip{margin-top:5px;margin-right:4px}
|
||||
.pipe-due{display:grid;gap:6px}
|
||||
.pipe-due .pipe-card{margin:0}
|
||||
@media (max-width:640px){.pipe-form{grid-template-columns:1fr !important}.pipe-col{flex-basis:200px}}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Transaction viewer: renders one tx from /api/tx/<hash> (the server-side RPC
|
||||
// relay, so the browser never talks to the chain directly and CSP stays 'self').
|
||||
// This is the "verify it yourself" page for chains without a public explorer;
|
||||
// when the config carries an explorer URL, feed links point there instead.
|
||||
(async function () {
|
||||
await IAP.renderNav('ledger');
|
||||
const c = await IAP.getConfig();
|
||||
const hash = location.pathname.split('/').pop();
|
||||
IAP.$('txHash').textContent = hash;
|
||||
IAP.$('txChain').textContent = 'on ' + (c.chainName || 'the settlement chain');
|
||||
let r = null;
|
||||
try { r = await (await fetch('/api/tx/' + hash)).json(); } catch (e) {}
|
||||
const stat = IAP.$('txStatus');
|
||||
if (!r || !r.found) {
|
||||
stat.textContent = 'not found';
|
||||
stat.className = 'badge amber';
|
||||
IAP.$('txHint').hidden = false;
|
||||
return;
|
||||
}
|
||||
const ok = r.status === '0x1';
|
||||
stat.textContent = ok ? '✓ confirmed' : '✗ reverted';
|
||||
stat.className = 'badge' + (ok ? '' : ' amber');
|
||||
const hx = v => { try { return parseInt(v, 16); } catch (e) { return 0; } };
|
||||
const rows = [
|
||||
['Block', '#' + hx(r.blockNumber).toLocaleString()],
|
||||
['Time', r.ts ? new Date(hx(r.ts) * 1000).toLocaleString() : 'pending'],
|
||||
['From', r.from || ''],
|
||||
['To (contract)', r.to || ''],
|
||||
['Value sent', IAP.fmtPol(r.valueWei || '0') + ' POL'],
|
||||
['Gas used', hx(r.gasUsed).toLocaleString()]
|
||||
];
|
||||
const tb = IAP.$('txTable');
|
||||
tb.hidden = false;
|
||||
tb.querySelector('tbody').innerHTML = rows.map(x =>
|
||||
'<tr><td class="muted small" style="white-space:nowrap">' + x[0] + '</td>'
|
||||
+ '<td class="mono small" style="overflow-wrap:anywhere">' + String(x[1]).replace(/[&<>]/g, '') + '</td></tr>').join('');
|
||||
if (r.events && r.events.length) {
|
||||
IAP.$('evCard').hidden = false;
|
||||
const feed = IAP.$('evFeed');
|
||||
for (const ev of r.events) feed.appendChild(IAP.feedRow(ev, c));
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,122 @@
|
||||
// Full-screen ad viewer: the advertiser URL fills the tab, a countdown runs in
|
||||
// the top bar (paused whenever this tab loses focus), and once the dwell is
|
||||
// done the server hands out a human check. Solve it and the view credits.
|
||||
// The dwell floor is enforced on the SERVER clock; this UI cannot cheat it.
|
||||
(() => {
|
||||
const $ = id => document.getElementById(id);
|
||||
const token = location.pathname.split('/').pop();
|
||||
const framed = window.self !== window.top; // shown inside the dashboard's in-page ad overlay
|
||||
let left = 5, timer = null, credited = false, asking = false;
|
||||
const setMsg = t => { $('vMsg').textContent = t; };
|
||||
async function j(url, body) {
|
||||
const r = await fetch(url, body
|
||||
? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }
|
||||
: undefined);
|
||||
return r.json();
|
||||
}
|
||||
function tick() {
|
||||
if (credited || asking) return;
|
||||
// In the in-page overlay the iframe often doesn't hold focus even though it's
|
||||
// fully on screen, so gate on tab visibility only when framed.
|
||||
if (document.visibilityState !== 'visible' || (!framed && !document.hasFocus())) {
|
||||
$('vTimer').classList.add('paused');
|
||||
$('vTimer').textContent = 'paused';
|
||||
setMsg(framed ? 'Keep this ad on screen to finish the countdown.' : 'Come back to this tab to keep the countdown moving.');
|
||||
return;
|
||||
}
|
||||
$('vTimer').classList.remove('paused');
|
||||
left = Math.max(0, left - 0.25);
|
||||
$('vTimer').textContent = Math.ceil(left) + 's left';
|
||||
if (left <= 0) { clearInterval(timer); askChallenge(); }
|
||||
}
|
||||
async function askChallenge() {
|
||||
asking = true;
|
||||
$('vTimer').textContent = 'check';
|
||||
$('vTimer').classList.add('done');
|
||||
setMsg('One quick check to count the view:');
|
||||
let c = await j('/api/my/viewchallenge?token=' + token);
|
||||
if (c.early) { // server clock says not quite yet: wait it out and re-ask
|
||||
await new Promise(r => setTimeout(r, (c.wait || 1) * 1000 + 300));
|
||||
c = await j('/api/my/viewchallenge?token=' + token);
|
||||
}
|
||||
if (c.error) return fail(c.error);
|
||||
renderChallenge(c);
|
||||
}
|
||||
function renderChallenge(c) {
|
||||
$('vPrompt').textContent = 'Click the ' + c.prompt + ':';
|
||||
const w = $('vOpts');
|
||||
w.innerHTML = '';
|
||||
c.options.forEach((em, i) => {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.textContent = em;
|
||||
b.addEventListener('click', () => answer(i));
|
||||
w.appendChild(b);
|
||||
});
|
||||
$('vCheck').classList.add('on');
|
||||
}
|
||||
async function answer(i) {
|
||||
const r = await j('/api/my/adview', { token, answer: i });
|
||||
if (r.error) {
|
||||
if (r.retry) {
|
||||
setMsg('Not that one — try again.');
|
||||
const c = await j('/api/my/viewchallenge?token=' + token);
|
||||
if (!c.error) return renderChallenge(c);
|
||||
return fail(c.error);
|
||||
}
|
||||
return fail(r.error);
|
||||
}
|
||||
credited = true;
|
||||
$('vCheck').classList.remove('on');
|
||||
$('vTimer').textContent = '✓ credited';
|
||||
const st = r.status || r;
|
||||
setMsg('View ' + st.views + ' of ' + st.target + ' counted for today.'
|
||||
+ (st.views >= st.target && !st.claimed ? ' Head back and claim your credits.' : ''));
|
||||
$('vDone').classList.add('on');
|
||||
try { localStorage.setItem('iap-view-done', String(Date.now())); } catch (e) {}
|
||||
try { if (framed) window.parent.postMessage({ t: 'iap-view-done' }, location.origin); } catch (e) {}
|
||||
}
|
||||
function fail(msg) {
|
||||
credited = true; // stop the loop; this view is over either way
|
||||
if (timer) clearInterval(timer);
|
||||
$('vCheck').classList.remove('on');
|
||||
$('vTimer').textContent = '—';
|
||||
setMsg(msg);
|
||||
$('vDone').classList.add('on');
|
||||
}
|
||||
$('vClose').addEventListener('click', () => {
|
||||
window.close();
|
||||
// window.close() is blocked in mobile and in-app (wallet dApp) browsers. If
|
||||
// the tab is still here a moment later, take them back to the dashboard so
|
||||
// they are never stuck on a tab they can't close.
|
||||
setTimeout(() => {
|
||||
if (!window.closed) { setMsg('Taking you back to your dashboard…'); location.href = '/my#earn'; }
|
||||
}, 400);
|
||||
});
|
||||
// When shown inside the dashboard's in-page overlay there is no tab to close:
|
||||
// hide "Close tab" and turn "Back to dashboard" into a close-the-overlay signal.
|
||||
if (framed) {
|
||||
const cb = $('vClose'); if (cb) cb.style.display = 'none';
|
||||
const back = document.querySelector('#vDone a[href="/my#earn"]');
|
||||
if (back) back.addEventListener('click', e => { e.preventDefault(); try { window.parent.postMessage({ t: 'iap-view-close' }, location.origin); } catch (x) {} });
|
||||
}
|
||||
(async () => {
|
||||
const info = await j('/api/my/viewinfo?token=' + token);
|
||||
if (info.error) {
|
||||
$('vFrame').remove();
|
||||
const d = document.createElement('div');
|
||||
d.className = 'vfail';
|
||||
d.textContent = info.error;
|
||||
document.querySelector('.vw').appendChild(d);
|
||||
$('vDone').classList.add('on');
|
||||
$('vTimer').textContent = '—';
|
||||
setMsg('');
|
||||
return;
|
||||
}
|
||||
left = info.dwell || 5;
|
||||
$('vFrame').src = info.targetUrl;
|
||||
setMsg('Watching: ' + (info.adName || 'member ad') + ' — stay on this tab.');
|
||||
$('vTimer').textContent = left + 's left';
|
||||
timer = setInterval(tick, 250);
|
||||
})();
|
||||
})();
|
||||
@@ -0,0 +1,104 @@
|
||||
// Public banner wall: a member's line banner plus their upline ladder, with
|
||||
// their join link. The viral surface: members send traffic here, every visit
|
||||
// puts eyes on the whole line.
|
||||
(async function () {
|
||||
await IAP.renderNav('');
|
||||
const name = location.pathname.split('/').pop();
|
||||
let w = null;
|
||||
try { w = await (await fetch('/api/wall/' + encodeURIComponent(name))).json(); } catch (e) {}
|
||||
const title = IAP.$('wallTitle');
|
||||
const hostEl = IAP.$('wallHost');
|
||||
if (!w || w.error) {
|
||||
title.textContent = 'No wall under that name.';
|
||||
IAP.$('wallJoin').href = '/my';
|
||||
return;
|
||||
}
|
||||
const safeName = String(w.name).replace(/[&<>]/g, '');
|
||||
title.innerHTML = safeName;
|
||||
if (hostEl) hostEl.innerHTML = safeName;
|
||||
IAP.$('bioHead').hidden = false;
|
||||
if (w.avatarUrl) { const av = IAP.$('bioAvatar'); av.src = w.avatarUrl; av.hidden = false; }
|
||||
if (w.badge) { const bb = IAP.$('bioBadge'); if (bb) { bb.src = w.badge.img; bb.title = w.badge.label + ' badge'; bb.hidden = false; } }
|
||||
IAP.$('bioText').textContent = w.bio || 'Building a team on LinkSpin — join through this page and you\'re in my line.';
|
||||
if (w.qrUrl) IAP.$('bioQr').src = w.qrUrl;
|
||||
// social links
|
||||
const SOC = { facebook: 'Facebook', twitter: 'X', youtube: 'YouTube', instagram: 'Instagram', tiktok: 'TikTok', telegram: 'Telegram', linkedin: 'LinkedIn', website: 'Website' };
|
||||
// small line icons, one per platform (currentColor so they follow the pill color)
|
||||
const svg = inner => '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + inner + '</svg>';
|
||||
const ICON = {
|
||||
facebook: svg('<path d="M14 8h3V4h-3a4 4 0 0 0-4 4v2H7v4h3v7h4v-7h3l1-4h-4V8.5c0-.3.2-.5.5-.5z" fill="currentColor" stroke="none"/>'),
|
||||
twitter: svg('<path d="M4 4l16 16M20 4L4 20"/>'),
|
||||
youtube: svg('<rect x="2.5" y="6" width="19" height="12" rx="4"/><path d="M10 9.5v5l4.5-2.5z" fill="currentColor" stroke="none"/>'),
|
||||
instagram: svg('<rect x="3" y="3" width="18" height="18" rx="5"/><circle cx="12" cy="12" r="4"/><circle cx="17.3" cy="6.7" r="1" fill="currentColor" stroke="none"/>'),
|
||||
tiktok: svg('<path d="M14 4c.3 2.3 1.9 3.8 4.3 4v3.1c-1.6 0-3.1-.5-4.3-1.4v5.8a5 5 0 1 1-5-5v3.2a1.9 1.9 0 1 0 1.9 1.9V4z" fill="currentColor" stroke="none"/>'),
|
||||
telegram: svg('<path d="M21 4L3 11.5l5.5 2L11 20l3-4.5L19 19z" fill="currentColor" stroke="none"/>'),
|
||||
linkedin: svg('<circle cx="6" cy="5.5" r="1.6" fill="currentColor" stroke="none"/><path d="M4.5 9.5h3V20h-3z" fill="currentColor" stroke="none"/><path d="M11 9.5h3v1.6c.7-1.1 1.9-1.9 3.5-1.9 2.8 0 3.5 1.9 3.5 4.5V20h-3v-5.6c0-1.3-.3-2.3-1.6-2.3s-2.4 1-2.4 2.4V20h-3z" fill="currentColor" stroke="none"/>'),
|
||||
website: svg('<circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c3 3.5 3 14.5 0 18M12 3c-3 3.5-3 14.5 0 18"/>')
|
||||
};
|
||||
const sc = IAP.$('bioSocials');
|
||||
if (sc && w.socials && typeof w.socials === 'object') {
|
||||
const links = Object.keys(SOC).filter(k => w.socials[k]).map(k =>
|
||||
'<a href="' + String(w.socials[k]).replace(/"/g, '%22') + '" target="_blank" rel="noopener nofollow me">' + (ICON[k] || '') + '<span>' + SOC[k] + '</span></a>');
|
||||
sc.innerHTML = links.join('');
|
||||
}
|
||||
// intro video under the bio: YouTube / Vimeo embed, or a direct file
|
||||
const bv = IAP.$('bioVideo');
|
||||
const vurl = w.socials && typeof w.socials === 'object' ? String(w.socials.video || '') : '';
|
||||
if (bv && vurl) {
|
||||
let yt = /(?:youtube\.com\/(?:watch\?(?:.*&)?v=|shorts\/|embed\/)|youtu\.be\/)([A-Za-z0-9_-]{6,})/.exec(vurl);
|
||||
let vm = /vimeo\.com\/(?:video\/)?(\d+)/.exec(vurl);
|
||||
let inner = '';
|
||||
if (yt) inner = '<iframe src="https://www.youtube-nocookie.com/embed/' + yt[1] + '?rel=0" title="Intro video" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen loading="lazy"></iframe>';
|
||||
else if (vm) inner = '<iframe src="https://player.vimeo.com/video/' + vm[1] + '" title="Intro video" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen loading="lazy"></iframe>';
|
||||
else if (/\.(mp4|webm)(\?|$)/i.test(vurl)) inner = '<video src="' + vurl.replace(/"/g, '%22') + '" controls playsinline preload="metadata"></video>';
|
||||
if (inner) { bv.innerHTML = '<p class="eyebrow" style="margin:0 0 8px">A word from ' + String(w.name).replace(/[&<>]/g, '') + '</p>' + inner; bv.hidden = false; }
|
||||
}
|
||||
IAP.$('wallCtaHead').textContent = 'Join ' + w.name + '’s line';
|
||||
// per-wall viewed-position memory (survives revisits; anonymous-friendly)
|
||||
const VKEY = 'iap-wall-' + name;
|
||||
let viewed = {};
|
||||
try { viewed = JSON.parse(localStorage.getItem(VKEY) || '{}'); } catch (e) {}
|
||||
const DWELL = 10;
|
||||
const grid = IAP.$('wallGrid');
|
||||
IAP.adSlot('text', 'adSlotWallText'); IAP.adSlot('banner', 'adSlotWallBanner');
|
||||
grid.innerHTML = '';
|
||||
// join is gated: you must view every ad on the wall before you can join
|
||||
const joinBtn = IAP.$('wallJoin'), gate = IAP.$('wallGate');
|
||||
function updateGate() {
|
||||
const viewable = w.ladder.filter(m => m.targetUrl).length;
|
||||
const seen = w.ladder.filter((m, i) => m.targetUrl && viewed[i]).length;
|
||||
if (viewable > 0 && seen < viewable) {
|
||||
joinBtn.classList.add('disabled'); joinBtn.removeAttribute('href');
|
||||
if (gate) { gate.hidden = false; gate.textContent = 'View all ' + viewable + ' ads above to unlock joining (' + seen + '/' + viewable + ' viewed).'; }
|
||||
} else { joinBtn.href = w.joinUrl; joinBtn.classList.remove('disabled'); if (gate) gate.hidden = true; }
|
||||
}
|
||||
w.ladder.forEach((m, i) => {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'wall-card';
|
||||
const safe = String(m.name || 'member').replace(/[&<>]/g, '');
|
||||
const creative = m.bannerUrl
|
||||
? '<img src="' + m.bannerUrl + '" alt="' + safe + ' banner">'
|
||||
: '<b>' + safe + '</b><br><span class="muted small">' + (m.targetUrl ? 'visit their site' : 'banner slot open') + '</span>';
|
||||
d.innerHTML = '<div class="wall-pos">Position ' + (i + 1) + (m.own ? ' · this wall' : m.admin ? ' · LinkSpin' : ' · their line') + '</div>'
|
||||
+ '<div class="wc-creative">' + creative + '</div>'
|
||||
+ '<div class="wc-action"></div>'
|
||||
+ '<div class="small muted" style="margin-top:8px">' + safe + '</div>';
|
||||
const act = d.querySelector('.wc-action');
|
||||
const done = () => { act.innerHTML = '<span class="wc-check">✓ viewed</span>'; };
|
||||
if (!m.targetUrl) { act.innerHTML = '<span class="muted small">no ad yet</span>'; }
|
||||
else if (viewed[i]) { done(); }
|
||||
else {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'btn small'; btn.textContent = 'View this ad';
|
||||
btn.addEventListener('click', () => {
|
||||
window.open(m.targetUrl, '_blank'); // real visit to the advertiser (no countdown)
|
||||
viewed[i] = 1;
|
||||
try { localStorage.setItem(VKEY, JSON.stringify(viewed)); } catch (e) {}
|
||||
done(); updateGate();
|
||||
});
|
||||
act.appendChild(btn);
|
||||
}
|
||||
grid.appendChild(d);
|
||||
});
|
||||
updateGate();
|
||||
})();
|
||||
@@ -0,0 +1,284 @@
|
||||
// Wallet plumbing via Reown AppKit — the universal connector every wallet is
|
||||
// built for (all wallets, QR + mobile deep-links, working icons). AppKit is
|
||||
// lazy-loaded from the CDN on first use; once connected we drive the raw
|
||||
// EIP-1193 provider for chain switch, SIWE sign-in, and contract transactions.
|
||||
window.IAPWallet = (function () {
|
||||
const SEL_BUY = '0xfd095e97'; // buy(uint32,uint32)
|
||||
const SEL_ACTIVATE = '0x1a93ec95'; // activate(uint32)
|
||||
const pad = v => BigInt(v).toString(16).padStart(64, '0');
|
||||
// Normalize a chainId to a decimal number. eth_chainId is meant to return a
|
||||
// hex string, but some wallets return a number or a decimal string — compare
|
||||
// numerically so a wallet's shape never crashes the flow.
|
||||
const chainNum = v => {
|
||||
if (v == null) return NaN;
|
||||
if (typeof v === 'number') return v;
|
||||
const s = String(v).trim();
|
||||
return /^0x/i.test(s) ? parseInt(s, 16) : parseInt(s, 10);
|
||||
};
|
||||
const APPKIT_URL = 'https://cdn.jsdelivr.net/npm/@reown/appkit-cdn@1.8.23/dist/appkit.js';
|
||||
|
||||
let modal = null, akPromise = null, provider = null;
|
||||
|
||||
async function initAppKit(c) {
|
||||
if (modal) return modal;
|
||||
if (akPromise) return akPromise;
|
||||
akPromise = (async () => {
|
||||
const mod = await import(APPKIT_URL);
|
||||
const { createAppKit, WagmiAdapter, networks } = mod;
|
||||
const netMap = { 80002: networks.polygonAmoy, 137: networks.polygon };
|
||||
const base = netMap[Number(c.chainId)] || networks.polygonAmoy;
|
||||
// Override the chain's RPC with our clean public endpoint. AppKit's built-in
|
||||
// networks advertise the WalletConnect RPC proxy
|
||||
// (rpc.walletconnect.org/v1/?chainId=…&projectId=…) as the chain RPC, and
|
||||
// wallets reject that query-string URL as "Invalid URL" when adding/switching
|
||||
// the network — the cause of Trust's "Invalid URL", MetaMask's switch loop,
|
||||
// and the failed mobile buy (the chain switch never completed).
|
||||
const rpc = String(c.rpc || '').trim();
|
||||
const net = rpc ? Object.assign({}, base, { rpcUrls: { default: { http: [rpc] }, public: { http: [rpc] } } }) : base;
|
||||
// Accept the wallet's usual networks too, so AppKit doesn't trap the user in
|
||||
// its own "Switch Network" modal — that modal loops on a testnet the wallet
|
||||
// can't auto-add. We switch to `net` ourselves (wallet_addEthereumChain adds
|
||||
// + switches in one step) and sendTx hard-guards the chain before signing.
|
||||
const allNets = [net];
|
||||
for (const k of ['polygon', 'mainnet']) {
|
||||
try { const n = networks[k]; if (n && n.id !== net.id) allNets.push(n); } catch (e) {}
|
||||
}
|
||||
const projectId = String(c.walletConnectProjectId || '').trim();
|
||||
const wagmiAdapter = new WagmiAdapter({ networks: allNets, projectId });
|
||||
modal = createAppKit({
|
||||
adapters: [wagmiAdapter], networks: allNets, projectId, defaultNetwork: net,
|
||||
metadata: { name: c.siteName || 'LinkSpin', description: c.tagline || 'Advertise and earn, paid on-chain.',
|
||||
url: location.origin, icons: [location.origin + '/logo-icon.png'] },
|
||||
features: { analytics: false, email: false, socials: [] },
|
||||
// Picker order: wallets without Trust's balance-proportion block go first.
|
||||
// Trust Wallet is NOT excluded; it just drops out of the featured row into
|
||||
// "All wallets" (Marty, 2026-09-09: move Trust to the bottom, not off).
|
||||
featuredWalletIds: [
|
||||
'c57ca95b47569778a828d19178114f4db188b89b763c899ba0be274e97267d96', // MetaMask
|
||||
'a797aa35c0fadbfc1a53e7f675162ed5226968b44a19ee3d24385c64d1d3c393', // Phantom
|
||||
'0b415a746fb9ee99cce155c2ceca0c6f6061b1dbca2d722b3ba16381d0562150', // SafePal
|
||||
'fd20dc426fb37566d803205b19bbc1d4096b248ac04548e3cfb6b3a38bd033aa' // Coinbase Wallet
|
||||
]
|
||||
});
|
||||
return modal;
|
||||
})();
|
||||
return akPromise;
|
||||
}
|
||||
|
||||
function currentAddress() { try { return (modal && modal.getAddress && modal.getAddress()) || null; } catch (e) { return null; } }
|
||||
|
||||
function waitForConnection(timeoutMs) {
|
||||
if (currentAddress()) return Promise.resolve(currentAddress());
|
||||
return new Promise((resolve, reject) => {
|
||||
let done = false, unsub = null;
|
||||
const finish = (addr, err) => { if (done) return; done = true; try { unsub && unsub(); } catch (e) {} err ? reject(err) : resolve(addr); };
|
||||
try { unsub = modal.subscribeAccount(acc => { if (acc && acc.isConnected && acc.address) finish(acc.address); }); } catch (e) {}
|
||||
const t0 = Date.now();
|
||||
(function poll() {
|
||||
if (done) return;
|
||||
const a = currentAddress();
|
||||
if (a) return finish(a);
|
||||
if (Date.now() - t0 > (timeoutMs || 180000)) return finish(null, new Error('Wallet connection timed out. Tap Connect and try again.'));
|
||||
setTimeout(poll, 400);
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveProvider() {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
try { const p = modal.getWalletProvider ? await Promise.resolve(modal.getWalletProvider()) : null; if (p && p.request) return p; } catch (e) {}
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
}
|
||||
throw new Error('Could not reach your wallet. Try connecting again.');
|
||||
}
|
||||
|
||||
function eth() { if (!provider) throw new Error('Connect your wallet first.'); return provider; }
|
||||
const isInjected = () => !!(provider && window.ethereum && (provider === window.ethereum || provider.isMetaMask));
|
||||
// the account the wallet will actually sign with: for an injected wallet (MetaMask
|
||||
// extension) that is its active account, which can differ from AppKit's cached one
|
||||
async function activeAddress(fallback) {
|
||||
if (isInjected()) { try { const a = await provider.request({ method: 'eth_accounts' }); if (a && a[0]) return a[0]; } catch (e) {} }
|
||||
return fallback || currentAddress();
|
||||
}
|
||||
// Force the wallet's own account picker. Injected wallets stay connected to the
|
||||
// site, so a plain disconnect/reconnect never shows one: asking for permissions
|
||||
// again makes MetaMask open its account-selection prompt, and whatever the user
|
||||
// ticks becomes the active account. WalletConnect wallets fall back to a fresh
|
||||
// session (the picker + the wallet app's own account choice).
|
||||
async function pickAccount() {
|
||||
const c = await IAP.getConfig();
|
||||
await initAppKit(c);
|
||||
const inj = window.ethereum;
|
||||
if (inj && inj.request) {
|
||||
try {
|
||||
await inj.request({ method: 'wallet_requestPermissions', params: [{ eth_accounts: {} }] });
|
||||
const accs = await inj.request({ method: 'eth_accounts' });
|
||||
if (accs && accs[0]) { provider = inj; await ensureChain(c).catch(() => {}); return accs[0]; }
|
||||
} catch (e) {
|
||||
if (e && (e.code === 4001 || /reject|denied/i.test(String(e.message || '')))) throw new Error('You closed the account picker. Pick the account you want and try again.');
|
||||
}
|
||||
}
|
||||
return freshConnect(c);
|
||||
}
|
||||
|
||||
// A WalletConnect session can die underneath AppKit's cached "connected"
|
||||
// state: the wallet app rejects or kills it (Trust does this after its own
|
||||
// security stop), the phone sleeps, the relay drops. AppKit still reports an
|
||||
// address, so the next request fails with a "disconnected" style error.
|
||||
// Detect that, wipe the stale session, and re-open the picker for a fresh one.
|
||||
const DEAD_RE = /disconnect|not connected|no matching key|session (topic|expired|deleted|not found)|call connect|please call connect|missing or invalid|relay/i;
|
||||
const isDead = e => DEAD_RE.test(String((e && e.message) || e || ''));
|
||||
async function freshConnect(c) {
|
||||
try { IAP.status('Your wallet session dropped. Reconnect in the picker…'); } catch (e) {}
|
||||
await disconnect();
|
||||
try { await modal.open(); } catch (e) {}
|
||||
const addr = await waitForConnection(180000);
|
||||
try { if (modal && modal.close) await modal.close(); } catch (e) {}
|
||||
provider = await resolveProvider();
|
||||
await ensureChain(c).catch(() => {});
|
||||
return addr;
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
const c = await IAP.getConfig();
|
||||
await initAppKit(c);
|
||||
let addr = currentAddress();
|
||||
if (!addr) {
|
||||
// give AppKit a moment to rehydrate an existing session before popping the
|
||||
// picker — otherwise an already-connected wallet still gets the modal
|
||||
for (let i = 0; i < 8 && !addr; i++) { await new Promise(r => setTimeout(r, 150)); addr = currentAddress(); }
|
||||
}
|
||||
if (!addr) { try { await modal.open(); } catch (e) {} addr = await waitForConnection(180000); }
|
||||
try { if (modal && modal.close) await modal.close(); } catch (e) {} // dismiss the picker once we're connected
|
||||
provider = await resolveProvider();
|
||||
// probe the session: a dead WalletConnect session answers with a disconnect error
|
||||
try { await provider.request({ method: 'eth_chainId' }); }
|
||||
catch (e) { if (isDead(e)) return freshConnect(c); }
|
||||
await ensureChain(c).catch(() => {}); // AppKit already connects on the right network; switch is best-effort
|
||||
return addr;
|
||||
}
|
||||
|
||||
async function ensureChain(c) {
|
||||
const want = '0x' + Number(c.chainId).toString(16);
|
||||
let cur; try { cur = await eth().request({ method: 'eth_chainId' }); } catch (e) { return; }
|
||||
if (chainNum(cur) === Number(c.chainId)) return;
|
||||
try {
|
||||
await eth().request({ method: 'wallet_switchEthereumChain', params: [{ chainId: want }] });
|
||||
} catch (e) {
|
||||
// any failure (not just 4902): try to add the chain — wallet_addEthereumChain
|
||||
// adds AND switches in one step, which is what unblocks wallets that can't
|
||||
// otherwise reach a chain they don't already have (e.g. a testnet).
|
||||
const addParams = { chainId: want, chainName: c.chainName, nativeCurrency: { name: 'POL', symbol: 'POL', decimals: 18 }, rpcUrls: [c.rpc] };
|
||||
if (c.explorer && /^https?:\/\//i.test(c.explorer)) addParams.blockExplorerUrls = [c.explorer];
|
||||
try { await eth().request({ method: 'wallet_addEthereumChain', params: [addParams] }); } catch (e2) {}
|
||||
}
|
||||
}
|
||||
|
||||
// SIWE: challenge -> personal_sign -> verify (server sets the session cookie)
|
||||
async function signIn(opts) {
|
||||
const addr = (opts && opts.pick) ? await pickAccount() : await activeAddress(await connect());
|
||||
const ch = await (await fetch('/api/auth/challenge', { method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ address: addr }) })).json();
|
||||
if (ch.error) throw new Error(ch.error);
|
||||
// hex-encode the message (Trust and others require hex for personal_sign)
|
||||
const hexMsg = '0x' + Array.from(new TextEncoder().encode(ch.message)).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
const sig = await eth().request({ method: 'personal_sign', params: [hexMsg, addr] });
|
||||
const r = await (await fetch('/api/auth/verify', { method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ address: addr, signature: sig, asPosition: !!(opts && opts.asPosition) }) })).json();
|
||||
if (r.error) throw new Error(r.error);
|
||||
return r;
|
||||
}
|
||||
|
||||
async function sendTx(data, valueWei) {
|
||||
const c = await IAP.getConfig();
|
||||
const addr = await connect();
|
||||
// Hard chain guard: connect()'s switch is best-effort and some wallets (or
|
||||
// AppKit's own modal) don't complete it. Never sign on the wrong chain —
|
||||
// a value tx to a contract that doesn't exist on that chain would look like
|
||||
// it "succeeded" while doing nothing.
|
||||
const wantNum = Number(c.chainId);
|
||||
let cur; try { cur = await eth().request({ method: 'eth_chainId' }); } catch (e) {}
|
||||
if (!isNaN(chainNum(cur)) && chainNum(cur) !== wantNum) {
|
||||
await ensureChain(c);
|
||||
try { cur = await eth().request({ method: 'eth_chainId' }); } catch (e) {}
|
||||
if (!isNaN(chainNum(cur)) && chainNum(cur) !== wantNum)
|
||||
throw new Error('Your wallet is on the wrong network. Switch it to ' + (c.chainName || 'the correct network') + ', then try again.');
|
||||
}
|
||||
const tx = { from: await activeAddress(addr), to: c.contract, data };
|
||||
if (valueWei) tx.value = '0x' + BigInt(valueWei).toString(16);
|
||||
// Amoy's Bor nodes enforce a ~25 gwei minimum priority fee that MetaMask's
|
||||
// own estimate misses ("gas tip below minimum"). Pull the network's correct
|
||||
// fees from the server and set them so the tx clears the floor.
|
||||
try {
|
||||
const g = await (await fetch('/api/gas')).json();
|
||||
if (g && g.maxPriorityFeePerGas && g.maxFeePerGas) {
|
||||
tx.maxPriorityFeePerGas = g.maxPriorityFeePerGas;
|
||||
tx.maxFeePerGas = g.maxFeePerGas;
|
||||
}
|
||||
} catch (e) {}
|
||||
try {
|
||||
return await eth().request({ method: 'eth_sendTransaction', params: [tx] });
|
||||
} catch (e) {
|
||||
if (!isDead(e)) throw e;
|
||||
// session died between connect and send: reconnect once and resend
|
||||
tx.from = await freshConnect(c);
|
||||
return eth().request({ method: 'eth_sendTransaction', params: [tx] });
|
||||
}
|
||||
}
|
||||
|
||||
// pay it forward: send POL straight from the sponsor's wallet to a downline member's
|
||||
// linked address. A native transfer, no contract, no site custody: the wallet app
|
||||
// shows the prefilled recipient and amount and the sponsor confirms there.
|
||||
async function sendPol(toAddress, valueWei) {
|
||||
if (!/^0x[0-9a-fA-F]{40}$/.test(String(toAddress || ''))) throw new Error('That member has no wallet address on file yet.');
|
||||
const c = await IAP.getConfig();
|
||||
const addr = await connect();
|
||||
const wantNum = chainNum(c.chainId);
|
||||
let cur; try { cur = await eth().request({ method: 'eth_chainId' }); } catch (e) {}
|
||||
if (!isNaN(chainNum(cur)) && chainNum(cur) !== wantNum) { await ensureChain(c); }
|
||||
const tx = { from: await activeAddress(addr), to: toAddress, value: '0x' + BigInt(valueWei).toString(16) };
|
||||
try { const g = await (await fetch('/api/gas')).json(); if (g && g.maxPriorityFeePerGas && g.maxFeePerGas) { tx.maxPriorityFeePerGas = g.maxPriorityFeePerGas; tx.maxFeePerGas = g.maxFeePerGas; } } catch (e) {}
|
||||
try { return await eth().request({ method: 'eth_sendTransaction', params: [tx] }); }
|
||||
catch (e) { if (!isDead(e)) throw e; tx.from = await freshConnect(c); return eth().request({ method: 'eth_sendTransaction', params: [tx] }); }
|
||||
}
|
||||
|
||||
async function waitTx(hash) {
|
||||
for (let i = 0; i < 90; i++) {
|
||||
try {
|
||||
const r = await (await fetch('/api/tx/' + hash)).json();
|
||||
if (r.found) return { status: r.status, blockNumber: r.blockNumber };
|
||||
} catch (e) { /* transient fetch failure (e.g. mobile app-switch) — keep polling */ }
|
||||
await new Promise(res => setTimeout(res, 2500));
|
||||
}
|
||||
throw new Error('Timed out waiting for the transaction. Check the explorer.');
|
||||
}
|
||||
|
||||
async function buy(productId, sponsorId, costWei) {
|
||||
const value = BigInt(costWei) + BigInt(costWei) / 50n; // 2% oracle-drift pad; contract refunds excess
|
||||
const data = SEL_BUY + pad(productId) + pad(sponsorId || 0);
|
||||
const hash = await sendTx(data, value);
|
||||
return { hash, receipt: await waitTx(hash) };
|
||||
}
|
||||
async function activate(sponsorId) {
|
||||
const data = SEL_ACTIVATE + pad(sponsorId || 0);
|
||||
const hash = await sendTx(data, null);
|
||||
return { hash, receipt: await waitTx(hash) };
|
||||
}
|
||||
|
||||
async function disconnect() {
|
||||
// AppKit's disconnect can hang on the WalletConnect relay (esp. mobile) —
|
||||
// never block on it, so the UI can't get stuck "disconnecting".
|
||||
try { if (modal && modal.disconnect) await Promise.race([modal.disconnect(), new Promise(r => setTimeout(r, 1200))]); } catch (e) {}
|
||||
provider = null;
|
||||
try { Object.keys(localStorage).forEach(k => { if (/wc@2|walletconnect|w3m|wcm|reown|wagmi|appkit/i.test(k)) localStorage.removeItem(k); }); } catch (e) {}
|
||||
}
|
||||
|
||||
// native balance of the connected wallet (pre-flight check before a buy)
|
||||
async function balance(addr) {
|
||||
await connect();
|
||||
const h = await eth().request({ method: 'eth_getBalance', params: [addr || currentAddress(), 'latest'] });
|
||||
return BigInt(h);
|
||||
}
|
||||
function walletName() { try { const w = modal && modal.getWalletInfo && modal.getWalletInfo(); return (w && w.name) || ''; } catch (e) { return ''; } }
|
||||
return { connect, signIn, buy, activate, sendPol, waitTx, disconnect, balance, address: currentAddress, activeAddress, pickAccount, walletName };
|
||||
})();
|
||||
@@ -0,0 +1,23 @@
|
||||
// Wallets + buying POL guide: members only.
|
||||
(async function () {
|
||||
try { await IAP.renderNav('training'); } catch (e) {}
|
||||
let me = null;
|
||||
try { me = await (await fetch('/api/me')).json(); } catch (e) {}
|
||||
const signedIn = !!(me && me.signedIn && me.email);
|
||||
document.getElementById('gate').style.display = signedIn ? 'none' : 'block';
|
||||
document.getElementById('body').style.display = signedIn ? 'block' : 'none';
|
||||
if (!signedIn) return;
|
||||
// MoonPay: same signed link the Buy pane uses, prefilled with this member's wallet when one is linked
|
||||
const mb = document.getElementById('wlMoonpay'), note = document.getElementById('wlMoonpayNote');
|
||||
if (note && !me.address) note.textContent = 'Link your wallet first (Wallet tab) and MoonPay opens with your address already filled in. Without it you paste the address yourself.';
|
||||
if (mb) mb.addEventListener('click', async () => {
|
||||
let pol = 30;
|
||||
try { const { products } = await (await fetch('/api/catalog')).json(); const p20 = (products || []).find(p => p.priceCents === 2000); if (p20 && p20.costWei) pol = Math.max(30, Math.ceil(Number(p20.costWei) / 1e18) + 3); } catch (e) {}
|
||||
try {
|
||||
const r = await (await fetch('/api/moonpay-url?pol=' + pol + (me.address ? '&address=' + encodeURIComponent(me.address) : ''))).json();
|
||||
if (!r.url) throw new Error('no url');
|
||||
window.open(r.url, '_blank', 'noopener');
|
||||
IAP.status(r.signed ? 'MoonPay opened with your wallet address pre-filled. Choose the amount, pay, and the POL lands in your wallet.' : 'MoonPay opened. Choose POL on the Polygon network and paste your own wallet address as the destination.', 'ok');
|
||||
} catch (e) { IAP.status('Could not open MoonPay. Try again in a minute.', 'bad'); }
|
||||
});
|
||||
})();
|
||||
|
After Width: | Height: | Size: 320 KiB |
|
After Width: | Height: | Size: 275 KiB |
|
After Width: | Height: | Size: 273 KiB |
|
After Width: | Height: | Size: 321 KiB |
|
After Width: | Height: | Size: 408 KiB |
|
After Width: | Height: | Size: 626 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 328 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 92 KiB |
@@ -0,0 +1,25 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="320" height="50" viewBox="0 0 320 50" role="img" aria-label="InstantAdPay — Earn Instantly">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#041109"/>
|
||||
<stop offset="1" stop-color="#0b2018"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="node" cx="0.5" cy="0.5" r="0.5">
|
||||
<stop offset="0" stop-color="#8ffbe3"/>
|
||||
<stop offset="0.5" stop-color="#43e8c3"/>
|
||||
<stop offset="1" stop-color="#43e8c3" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<rect width="320" height="50" rx="6" fill="url(#bg)"/>
|
||||
<!-- circuit accents on the right -->
|
||||
<g stroke="#43e8c3" stroke-opacity="0.5" stroke-width="1" fill="none">
|
||||
<path d="M300 12 H274 M300 25 H262 M300 38 H278"/>
|
||||
<circle cx="274" cy="12" r="1.6" fill="#43e8c3" stroke="none"/>
|
||||
<circle cx="278" cy="38" r="1.6" fill="#43e8c3" stroke="none"/>
|
||||
</g>
|
||||
<circle cx="300" cy="25" r="16" fill="url(#node)"/>
|
||||
<text x="14" y="32" font-family="Sora, 'Segoe UI', system-ui, sans-serif" font-size="20" font-weight="800" fill="#eef7f3">Earn <tspan fill="#43e8c3">Instantly</tspan></text>
|
||||
<!-- Join Free pill -->
|
||||
<rect x="212" y="15" width="60" height="20" rx="10" fill="#43e8c3"/>
|
||||
<text x="242" y="29" text-anchor="middle" font-family="Sora, 'Segoe UI', system-ui, sans-serif" font-size="11" font-weight="800" fill="#03211a">Join Free</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 224 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 782 KiB |
|
After Width: | Height: | Size: 215 KiB |
|
After Width: | Height: | Size: 214 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 214 KiB |
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 240 KiB |
|
After Width: | Height: | Size: 216 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 180 KiB |
@@ -0,0 +1,148 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>The contract | LinkSpin</title>
|
||||
<meta name="description" content="Plain-language review of the LinkSpin settlement contract: what it does, what nobody can change, what the operator can and cannot touch, and how to verify all of it yourself.">
|
||||
<link rel="canonical" href="https://linkspin-test.saasy.top/contract">
|
||||
<meta property="og:type" content="website"><meta property="og:site_name" content="LinkSpin">
|
||||
<meta property="og:url" content="https://linkspin-test.saasy.top/contract">
|
||||
<meta property="og:title" content="LinkSpin — the contract, in plain language">
|
||||
<meta property="og:description" content="What the settlement contract does, what nobody can change, and how to verify all of it yourself.">
|
||||
<meta property="og:image" content="https://linkspin-test.saasy.top/banners/iap-hero-1200x630.png">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="LinkSpin — the contract, in plain language">
|
||||
<meta name="twitter:description" content="Immutable 50-20-10 splits, no withdrawal button, verify it yourself.">
|
||||
<meta name="twitter:image" content="https://linkspin-test.saasy.top/banners/iap-hero-1200x630.png">
|
||||
<meta name="theme-color" content="#043b2f">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
|
||||
<link rel="icon" type="image/png" href="/logo-icon.png">
|
||||
<link rel="stylesheet" href="/assets/site.css?v=20260910h">
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<section class="hero" style="padding-bottom:24px">
|
||||
<h1>The contract, <em>in plain language</em>.</h1>
|
||||
<p class="lead">Every dollar on this platform moves through one smart contract. This page explains
|
||||
what it does, what nobody can change, and exactly what powers we kept. Verify every claim
|
||||
yourself. That is the point.</p>
|
||||
<p>
|
||||
<span class="badge">address: <span class="mono" id="cAddr">…</span></span>
|
||||
<span class="badge" id="cChain">…</span>
|
||||
</p>
|
||||
<p style="margin-top:16px">
|
||||
<a class="btn sec small" id="lnkExplorer" target="_blank" rel="noopener">Raw contract on the explorer ↗</a>
|
||||
<a class="btn sec small" id="lnkSource" target="_blank" rel="noopener">Verified source code ↗</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div class="card">
|
||||
<h3>The six laws the code enforces</h3>
|
||||
<ul class="checks">
|
||||
<li><b>It never holds funds.</b> Every purchase is fully paid out in the same transaction. The contract balance is zero after every sale. There is nothing to freeze, drain, or run away with.</li>
|
||||
<li><b>The compensation rules are constants.</b> 50 percent, 20 percent, 10 percent, 20 percent platform. They are compiled into the bytecode. No function exists to change them.</li>
|
||||
<li><b>No upgrade path, no pause switch, no self-destruct.</b> The deployed bytecode is the program forever.</li>
|
||||
<li><b>Purchased credits only ever go down by delivering your ads.</b> No function reduces them for any other reason, and nothing can mint them except a purchase.</li>
|
||||
<li><b>Qualification is earned, never bought.</b> Deeper levels unlock only by referring real buyers of $20 or more. No spend-based shortcuts exist.</li>
|
||||
<li><b>Everything is observable.</b> Every state change emits a public event. The website is a mirror of the chain, never the source of truth for money.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="grid c2">
|
||||
<div class="card">
|
||||
<h3>What the operator CAN do</h3>
|
||||
<ul class="checks">
|
||||
<li>Add ad packages to the catalog (price floor $1, ceiling $500)</li>
|
||||
<li>Queue a price change, which waits behind a public 24-hour timelock before anyone can apply it</li>
|
||||
<li>Retire a package from sale, and reactivate it later</li>
|
||||
<li>Rotate the fee-receiver, ad-engine, and owner addresses (key-loss insurance)</li>
|
||||
<li>The ad engine can burn credits, but only as your campaigns consume delivery</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>What the operator CANNOT do</h3>
|
||||
<ul class="checks">
|
||||
<li>Change any split percentage or qualification threshold</li>
|
||||
<li>Pause, upgrade, or replace the contract</li>
|
||||
<li>Hold, redirect, or claw back anyone's payout</li>
|
||||
<li>Mint credits, take credits, or touch anyone's membership record</li>
|
||||
<li>Move a price outside the $1 to $500 bounds, or skip the 24-hour notice</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Where every purchase goes</h3>
|
||||
<div class="split">
|
||||
<div class="s50">50%<br>direct sponsor</div>
|
||||
<div class="s20">20%<br>level 2</div>
|
||||
<div class="s10">10%<br>level 3</div>
|
||||
<div class="sa">20%<br>platform</div>
|
||||
</div>
|
||||
<p class="muted small">On a $20 package: $10.00 to the direct sponsor, $4.00 to level 2, $2.00 to level 3,
|
||||
$4.00 to the platform. Rounding dust of a few billionths of a cent goes to the platform wallet so the
|
||||
books always balance to zero. When a level has no qualified recipient, that share visibly passes up
|
||||
to the next qualified person; if none exists within 25 candidates, it goes to the platform. Every one
|
||||
of these movements is an event on the <a href="/ledger">live ledger</a>.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid c2">
|
||||
<div class="card">
|
||||
<h3>Dollar prices, POL settlement</h3>
|
||||
<p class="muted small">Packages are priced in dollars and settled in POL using the Chainlink POL/USD
|
||||
oracle at the moment of purchase. If you send slightly too much because the rate moved, the excess
|
||||
refunds to you in the same transaction. If the oracle ever goes quiet, the contract keeps selling
|
||||
at its last fresh price for up to 24 hours, then new purchases pause until the feed returns.
|
||||
Settled money, credits, and memberships are never affected by an oracle outage.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Nobody can stall it</h3>
|
||||
<p class="muted small">Payouts are pushed with a strict gas allowance. A wallet that refuses to
|
||||
accept payment is simply treated as unqualified and its share passes up. No escrow forms, no
|
||||
purchase reverts, nobody waits on anybody. One practical note: use a normal wallet address for
|
||||
payouts. Some exotic smart-contract wallets cost more gas to receive than the allowance and would
|
||||
be passed over.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>How it was tested</h3>
|
||||
<p class="muted small">Before deployment the contract passed a suite of 24 tests covering every split
|
||||
scenario, the pass-up walk to its exact 25-candidate boundary, oracle outages and price swings,
|
||||
refunds, hostile recipient wallets, and catalog rules. On top of that, an invariant fuzzer ran
|
||||
128,000 randomized transactions and confirmed after every single one: the contract balance stayed
|
||||
zero, every wei in equaled every wei out, credits equaled purchases minus delivery, and nobody was
|
||||
qualified without earning it. The full specification was then audited line by line against the code.
|
||||
The source you see at the verified-source link is byte-for-byte what runs on chain.</p>
|
||||
</div>
|
||||
|
||||
<div class="closer">
|
||||
<div>
|
||||
<h2>Do not trust this page. Check it.</h2>
|
||||
<p class="muted" style="margin:0 0 22px">The whole reason this platform exists is that you should
|
||||
not have to take anyone's word, including ours. Open the source, open the ledger, click a
|
||||
transaction.</p>
|
||||
<a class="btn" id="lnkSource2" target="_blank" rel="noopener">Read the verified source</a>
|
||||
<a class="btn sec" href="/ledger">Open the live ledger</a>
|
||||
</div>
|
||||
<div class="mockup">
|
||||
<div class="bar"><i></i><i></i><i></i><span class="addr" id="mockAddr">contract</span></div>
|
||||
<div class="body">
|
||||
<div class="mrow"><span>compiler check</span><span>exact match</span></div>
|
||||
<div class="mrow"><span>bytecode check</span><span>exact match</span></div>
|
||||
<div class="mrow dim"><span>upgrade path</span><span>none</span></div>
|
||||
<div class="mrow dim"><span>pause switch</span><span>none</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<div>LinkSpin · <a href="/">how it works</a> · <a href="/ledger">live ledger</a></div>
|
||||
<div class="small">Advertising services with a performance referral program. Not an investment product; no income guarantees. Crypto transactions are irreversible. Never spend what you cannot afford.</div>
|
||||
</footer>
|
||||
</div>
|
||||
<script src="/assets/common.js?v=20260914a"></script>
|
||||
<script src="/assets/contract.js?v=20260908p"></script>
|
||||
<script src="/assets/chat.js?v=20260906m"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,32 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Disclaimer | LinkSpin</title>
|
||||
<link rel="icon" type="image/png" href="/logo-icon.png">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
|
||||
<link rel="stylesheet" href="/assets/site.css?v=20260910h">
|
||||
</head>
|
||||
<body>
|
||||
<div id="nav"></div>
|
||||
<section><div class="wrap" style="max-width:820px">
|
||||
<div class="sectionhead" style="text-align:left"><h2>Risk & Earnings Disclaimer</h2><p class="muted small">Last updated: September 2026</p></div>
|
||||
<div class="card">
|
||||
<h3>No income guarantee</h3>
|
||||
<p class="muted small">LinkSpin is an advertising service with a referral program. It is not an investment, a security, or a passive-income scheme. We do not promise, project, or guarantee any earnings. Any figures shown in examples or sample creatives are illustrations only, not typical or expected results. Most participants should expect to earn little or nothing unless real advertising is purchased in their line.</p>
|
||||
<h3>Not financial or legal advice</h3>
|
||||
<p class="muted small">Nothing on the Platform is investment, financial, tax, or legal advice. Do your own research and consult a professional before spending money.</p>
|
||||
<h3>Crypto risk</h3>
|
||||
<p class="muted small">Purchases settle on a public blockchain using a volatile network token. On-chain transactions are final and irreversible: there are no refunds, chargebacks, or reversals. You are responsible for your wallet, your keys, network fees, and the tax treatment of your activity.</p>
|
||||
<h3>Live on Polygon</h3>
|
||||
<p class="muted small">The Platform is live on the Polygon mainnet. Purchases and payouts use POL, a real cryptocurrency with real monetary value. Every transaction is real and final — this is not a simulation or test environment.</p>
|
||||
<h3>Advertising</h3>
|
||||
<p class="muted small">Ads are member-created and auto-approved for speed. LinkSpin does not endorse and is not responsible for advertised products, sites, or claims. Use your judgment, and report anything broken or inappropriate.</p>
|
||||
<h3>Your responsibility</h3>
|
||||
<p class="muted small">You decide whether, and how much, to spend. Never spend more than you can afford to lose.</p>
|
||||
</div>
|
||||
</div></section>
|
||||
<script src="/assets/common.js?v=20260914a"></script>
|
||||
<script src="/assets/legal.js?v=20260908a"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,73 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>How earning works | LinkSpin</title>
|
||||
<meta name="description" content="How members earn ad credits on LinkSpin: the daily set, the claim streak, verified visits, videos, inbox ads, the login bonus, and what to do with the credits.">
|
||||
<meta name="robots" content="noindex">
|
||||
<meta name="theme-color" content="#043b2f">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
|
||||
<link rel="icon" type="image/png" href="/logo-icon.png">
|
||||
<link rel="stylesheet" href="/assets/site.css?v=20260912b">
|
||||
<style>
|
||||
.eg{max-width:820px}
|
||||
.eg h2{font-size:23px;margin:40px 0 10px;padding-top:18px;border-top:1px solid var(--line)}
|
||||
.eg p,.eg li{max-width:68ch}
|
||||
.eg ul,.eg ol{margin:0 0 14px 20px;padding:0} .eg li{margin:7px 0}
|
||||
table{border-collapse:collapse;width:100%;max-width:560px;margin:10px 0 16px;font-variant-numeric:tabular-nums;font-size:15px}
|
||||
th,td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--line)} th{color:var(--muted);font-family:var(--mono);font-size:11px;letter-spacing:.08em;text-transform:uppercase;font-weight:500}
|
||||
td:last-child,th:last-child{text-align:right}
|
||||
.rule{border-left:4px solid var(--mint);background:rgba(67,232,195,.07);padding:14px 18px;border-radius:0 12px 12px 0;margin:0 0 20px}
|
||||
.rule b{font-family:var(--disp);display:block;margin-bottom:4px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap eg">
|
||||
<section class="hero" style="padding:60px 0 14px">
|
||||
<p class="eyebrow">Member guide</p>
|
||||
<h1>How earning works, <em>and what to do with it</em>.</h1>
|
||||
<p class="lead">Every free way to earn ad credits, the claim streak, and the one thing that makes the credits worth anything: spending them on a campaign.</p>
|
||||
</section>
|
||||
|
||||
<div class="rule"><b>This page is about credits, which every member earns.</b> POL, the crypto the contract pays to your wallet when your referrals buy packages, is separate: activate with the $20 starter package and switch on payouts, and level 1 pays 50 percent of every package your directs buy.</div>
|
||||
<div class="rule"><b>One credit is one cent of ad delivery.</b> Credits are not money and cannot be withdrawn. They buy impressions, clicks and visits for your own campaigns across LinkSpin and the partner network. Payouts in POL come only from packages people in your line buy.</div>
|
||||
|
||||
<h2>1. The daily set</h2>
|
||||
<p>Earn credits › Watch ads. Five ads a day. Each one opens full screen, a 10-second countdown runs while you look, you pass a quick click-the-icon check, and you earn 1 credit. Finish all five and the Claim button appears.</p>
|
||||
|
||||
<h2>2. The claim streak</h2>
|
||||
<p>The claim pays more the more days in a row you claim it. Miss a day and it restarts at day 1.</p>
|
||||
<table>
|
||||
<tr><th>Consecutive day</th><th>Claim pays</th></tr>
|
||||
<tr><td>Day 1</td><td>5 credits</td></tr>
|
||||
<tr><td>Day 2</td><td>7 credits</td></tr>
|
||||
<tr><td>Day 3 and on</td><td>10 credits</td></tr>
|
||||
<tr><td>Every 7th day in a row</td><td>25 credits</td></tr>
|
||||
</table>
|
||||
<p>The hint under the set always tells you which day you are on and what tomorrow's claim pays. Days are counted in UTC, so the set resets at 7 PM Central.</p>
|
||||
|
||||
<h2>3. After the set: verified visits</h2>
|
||||
<p>When the set is claimed, the done screen offers verified visits: up to 20 a day, 1 credit each. You open a member's site in a new tab, stay for the dwell, and the visit counts. Each visit is unique per site per day, so it is 20 different sites, not one site 20 times.</p>
|
||||
|
||||
<h2>4. Videos and inbox ads</h2>
|
||||
<ul>
|
||||
<li><b>Videos.</b> Earn credits › Watch videos. You are credited only when the video plays to the end. Up to 6 a day, and the reward depends on the video's length.</li>
|
||||
<li><b>Inbox solo ads.</b> Earn credits › Inbox Ads. Open the message, visit the advertiser's link, then claim 2 credits. Once per message.</li>
|
||||
</ul>
|
||||
|
||||
<h2>5. The sign-in bonus and milestones</h2>
|
||||
<p>Signing in pays 5 credits a day, rising by one for each consecutive day up to 10. Milestone badges pay too: Spark when payouts are on, Surge at your first qualifying buyer, Circuit at two, Nexus at five.</p>
|
||||
|
||||
<h2>6. Now spend it</h2>
|
||||
<p>Credits sitting in your balance do nothing. Campaigns › New campaign: pick a banner or a text ad, give it a name and a budget in credits, point it at your own invite link or wall, and launch. Banner and text ads also push out to the partner network, so a 100-credit campaign is a hundred cents of real delivery. The done screen has a one-tap button for this after every claim.</p>
|
||||
<p>Two things to know: a campaign reserves its whole budget the moment you start it, so your available balance drops right away and never surprises you later, and login ads are the one format that needs purchased credits.</p>
|
||||
|
||||
<p class="muted small" style="margin-top:30px">No income is guaranteed. Results depend on your effort. Cryptocurrency involves risk of loss. LinkSpin sells advertising; it is not an investment.</p>
|
||||
<footer>
|
||||
<div>LinkSpin · <a href="/my#earn">Earn credits</a> · <a href="/my#campaigns">Campaigns</a> · <a href="/my#training">back to Training</a></div>
|
||||
<div class="small">Advertising services with a performance referral program. Not an investment product; no income guarantees.</div>
|
||||
</footer>
|
||||
</div>
|
||||
<script src="/assets/common.js?v=20260914a"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,489 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>LinkSpin: advertise and earn, locked in code</title>
|
||||
<meta name="description" content="Advertise and earn instantly. Real ad packages with instant on-chain settlement — every purchase pays the sponsor line in the same Polygon transaction, verifiable by anyone on the live ledger. Seven ad formats, earn credits back for your attention.">
|
||||
<link rel="canonical" href="https://linkspin-test.saasy.top/">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:site_name" content="LinkSpin">
|
||||
<meta property="og:url" content="https://linkspin-test.saasy.top/">
|
||||
<meta property="og:title" content="LinkSpin — Advertise and Earn Instantly">
|
||||
<meta property="og:description" content="Real ad packages with instant on-chain settlement. Every purchase pays the sponsor line in the same transaction — verifiable on the live ledger. Seven ad formats, earn credits back for your attention.">
|
||||
<meta property="og:image" content="https://linkspin-test.saasy.top/banners/iap-hero-1200x630.png">
|
||||
<meta property="og:image:width" content="1200">
|
||||
<meta property="og:image:height" content="630">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="LinkSpin — Advertise and Earn Instantly">
|
||||
<meta name="twitter:description" content="Instant on-chain ad payouts, verifiable on a public ledger. Seven ad formats. Earn credits back for your attention.">
|
||||
<meta name="twitter:image" content="https://linkspin-test.saasy.top/banners/iap-hero-1200x630.png">
|
||||
<meta name="theme-color" content="#043b2f">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
|
||||
<link rel="icon" type="image/png" href="/logo-icon.png">
|
||||
<link rel="stylesheet" href="/assets/site.css?v=20260912b">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<section class="hero">
|
||||
<div class="arcs">
|
||||
<svg viewBox="0 0 1200 620" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="arcGrad" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0" stop-color="#43e8c3" stop-opacity="0"/>
|
||||
<stop offset=".45" stop-color="#eafffa"/>
|
||||
<stop offset=".75" stop-color="#43e8c3"/>
|
||||
<stop offset="1" stop-color="#43e8c3" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<filter id="arcGlow" x="-40%" y="-40%" width="180%" height="180%">
|
||||
<feGaussianBlur stdDeviation="7" result="b"/>
|
||||
<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<path class="arc" d="M 700 150 C 880 90, 1080 130, 1220 260"/>
|
||||
<path class="arc2" d="M -30 430 C 160 520, 420 520, 560 400"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="streak" style="left:12%;animation-delay:0s"></span>
|
||||
<span class="streak" style="left:24%;animation-delay:2.1s"></span>
|
||||
<span class="streak" style="left:41%;animation-delay:4.4s"></span>
|
||||
<span class="streak" style="left:58%;animation-delay:1.2s"></span>
|
||||
<span class="streak" style="left:72%;animation-delay:3.4s"></span>
|
||||
<span class="streak" style="left:86%;animation-delay:5.6s"></span>
|
||||
<div class="wrap">
|
||||
<h1>Advertise and earn instantly.<br><em>Locked in code, not promises.</em></h1>
|
||||
<p class="lead">Buy real ad packages from 5 to 250 dollars. Every purchase settles through
|
||||
a smart contract you can read yourself, and your earnings land in
|
||||
<b>your own wallet</b> before the page even refreshes.</p>
|
||||
<p class="small muted" style="margin:-14px auto 26px;max-width:620px">One crypto, one network: packages are paid in <b style="color:var(--ink)">POL on Polygon</b> and every payout arrives as POL in your wallet. Buy POL with a card inside if you have never held any.</p>
|
||||
<div class="ctas">
|
||||
<a class="btn" href="/my">Join free</a>
|
||||
<a class="btn sec" href="/ledger">Watch payments land live</a>
|
||||
</div>
|
||||
<p class="small muted" id="sponsorLine" hidden style="margin-top:20px"></p>
|
||||
<div class="stats">
|
||||
<div class="stat"><div class="n" id="stMembers">–</div><div class="l">On-chain members</div></div>
|
||||
<div class="stat"><div class="n" id="stPurchases">–</div><div class="l">Packages bought</div></div>
|
||||
<div class="stat"><div class="n" id="stPaid">–</div><div class="l">POL settled</div></div>
|
||||
<div class="stat"><div class="n" id="stPayouts">–</div><div class="l">Instant payouts</div></div>
|
||||
</div>
|
||||
<p class="hero-note">every number above is read from the blockchain, not a marketing database</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="ticker" hidden id="ticker"><div class="inner" id="tickerInner"></div></div>
|
||||
|
||||
<section id="how">
|
||||
<div class="wrap">
|
||||
<div class="sectionhead">
|
||||
<h2>Instant payments without compromise</h2>
|
||||
<p>Most referral programs run on a database someone can change after you promote.
|
||||
This one runs on Polygon, split by a contract nobody can touch. Not even us.</p>
|
||||
</div>
|
||||
<div class="plates">
|
||||
<div class="platecard">
|
||||
<div class="plate"><svg viewBox="0 0 24 24"><path d="M13 2 4 14h6l-1 8 9-12h-6l1-8z"/></svg></div>
|
||||
<h3>Paid in the same transaction</h3>
|
||||
<p>The purchase and every payout are one blockchain event. No balances held, no withdrawal
|
||||
button, no company touching the money.</p>
|
||||
</div>
|
||||
<div class="platecard">
|
||||
<div class="plate"><svg viewBox="0 0 24 24"><path d="M12 3 5 6v5c0 4.5 3 8 7 10 4-2 7-5.5 7-10V6l-7-3z"/><path d="m9 12 2 2 4-4"/></svg></div>
|
||||
<h3>Rules that cannot move</h3>
|
||||
<p>50-20-10 across three levels plus a 20 percent platform fee, written as constants in an
|
||||
immutable contract. There is no function to change them.</p>
|
||||
</div>
|
||||
<div class="platecard">
|
||||
<div class="plate"><svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="6"/><path d="m20 20-4.5-4.5"/><path d="M11 8v6M8 11h6"/></svg></div>
|
||||
<h3>Verifiable by anyone</h3>
|
||||
<p>Every payment streams to a public ledger with a verify link straight to the block explorer.
|
||||
If it is not there, it did not happen.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="different">
|
||||
<div class="wrap">
|
||||
<div class="sectionhead">
|
||||
<h2>Why this is not another ad site</h2>
|
||||
<p>You have seen traffic exchanges, click-to-earn sites and solo-ad sellers. Here is the honest side by side.</p>
|
||||
</div>
|
||||
<div class="tablewrap"><table class="cmp">
|
||||
<tr><th></th><th>Typical advertising site</th><th>LinkSpin</th></tr>
|
||||
<tr><td>Referral commission</td><td>5 to 15 percent, often only after a minimum balance</td><td><b>50 percent</b> to the direct sponsor, then 20 and 10 on the next two levels</td></tr>
|
||||
<tr><td>When you get paid</td><td>Request a withdrawal, wait for approval, hope</td><td>In the <b>same transaction</b> the package sells</td></tr>
|
||||
<tr><td>Who holds the money</td><td>The site's balance, at the owner's discretion</td><td><b>Nobody.</b> The contract splits it to real wallets. There is no balance to hold</td></tr>
|
||||
<tr><td>Can the rules change</td><td>Whenever the owner edits a setting</td><td><b>Never.</b> 50 / 20 / 10 / 20 are constants in a verified, immutable contract</td></tr>
|
||||
<tr><td>Proof</td><td>A number on a dashboard</td><td>Every payout is a <b>public transaction</b> you can open yourself</td></tr>
|
||||
<tr><td>Ad views</td><td>Timers that run while nobody looks</td><td>A 10-second dwell, a human check, and a <b>server-side clock</b></td></tr>
|
||||
<tr><td>Credits</td><td>Points that expire or get devalued</td><td><b>1 credit = 1 cent</b> of delivery, recorded on-chain, spent only by your campaigns</td></tr>
|
||||
<tr><td>Joining</td><td>Install an app, connect a wallet, then maybe read</td><td><b>Email first.</b> The wallet comes when you are ready to be paid</td></tr>
|
||||
<tr><td>Advertising</td><td>Pay to be seen by people who are paid to click</td><td>Seven formats delivered to <b>members who buy ads themselves</b>, with verified visits and finished video views</td></tr>
|
||||
</table></div>
|
||||
<p class="small muted" style="text-align:center;margin-top:14px">No income is guaranteed. Results depend on your effort. Cryptocurrency involves risk of loss. LinkSpin sells advertising; it is not an investment.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="wrap">
|
||||
<div class="sectionhead">
|
||||
<h2>Watch the money move</h2>
|
||||
<p>This is a live view, not a brochure. Real purchases split into real payouts,
|
||||
each one a click away from the raw transaction.</p>
|
||||
</div>
|
||||
<div class="flank">
|
||||
<div>
|
||||
<div class="flankitem">
|
||||
<div class="plate"><svg viewBox="0 0 24 24"><rect x="3" y="6" width="18" height="13" rx="2"/><path d="M3 10h18M7 15h4"/></svg></div>
|
||||
<h3>Buy a package</h3>
|
||||
<p>Priced in dollars, settled in POL at the live oracle rate. Overpayment refunds itself in the same transaction.</p>
|
||||
</div>
|
||||
<div class="flankitem">
|
||||
<div class="plate"><svg viewBox="0 0 24 24"><path d="M12 3v13m0 0 5-5m-5 5-5-5"/><path d="M4 21h16"/></svg></div>
|
||||
<h3>The contract splits it</h3>
|
||||
<p>Half to the direct sponsor, then levels 2 and 3, then the platform. Automatically, immediately, every time.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mockup" aria-label="live ledger preview">
|
||||
<div class="bar"><i></i><i></i><i></i><span class="addr">linkspin-test.saasy.top/ledger</span></div>
|
||||
<div class="body" id="mockBody">
|
||||
<div class="mrow"><span>🧾 member #7 bought package #2 ($20.00)</span><span>−213 POL</span></div>
|
||||
<div class="mrow"><span>💸 level 1 payout → member #3</span><span>+106.5 POL</span></div>
|
||||
<div class="mrow"><span>💸 level 2 payout → member #2</span><span>+42.6 POL</span></div>
|
||||
<div class="mrow"><span>💸 level 3 payout → member #1</span><span>+21.3 POL</span></div>
|
||||
<div class="mrow dim"><span>🏛 platform fee settled</span><span>42.6 POL</span></div>
|
||||
<div class="mrow dim"><span>⭐ member #3 now has 2 qualifying buyers</span><span>level 2 ✓</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flankitem">
|
||||
<div class="plate"><svg viewBox="0 0 24 24"><rect x="4" y="8" width="16" height="12" rx="2"/><path d="M8 8V6a4 4 0 0 1 8 0v2"/><circle cx="12" cy="14" r="1.6"/></svg></div>
|
||||
<h3>Straight to their wallets</h3>
|
||||
<p>Recipients get POL in their own wallets within seconds. Nothing to claim, nothing to request.</p>
|
||||
</div>
|
||||
<div class="flankitem">
|
||||
<div class="plate"><svg viewBox="0 0 24 24"><path d="M4 17l5-5 4 3 7-8"/><path d="M14 7h6v6"/></svg></div>
|
||||
<h3>Credits mint on-chain</h3>
|
||||
<p>One credit is one cent of ad delivery across the network. Only your campaigns can ever spend them.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="small muted" style="text-align:center;margin-top:22px">Amounts shown are the $20 worked example at the current oracle rate. <a href="/ledger">Open the real ledger →</a></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="wrap">
|
||||
<div class="story">
|
||||
<div>
|
||||
<svg class="iso" id="genViz" data-lvl="1" viewBox="0 0 360 320" role="img" aria-label="Your position at the top of a three-generation network; each level lights up the generation that pays you">
|
||||
<defs>
|
||||
<linearGradient id="cubeTop" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#43e8c3"/><stop offset="1" stop-color="#1c7c65"/>
|
||||
</linearGradient>
|
||||
<filter id="softGlow" x="-60%" y="-60%" width="220%" height="220%">
|
||||
<feGaussianBlur stdDeviation="6" result="b"/>
|
||||
<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<g class="edge e1"><path d="M180 92 L120 138 M180 92 L240 138" fill="none" stroke-width="1.6"/></g>
|
||||
<g class="edge e2"><path d="M120 152 L75 205 M120 152 L150 205 M240 152 L210 205 M240 152 L285 205" fill="none" stroke-width="1.4"/></g>
|
||||
<g class="edge e3"><path d="M75 219 L45 272 M75 219 L95 272 M150 219 L130 272 M150 219 L170 272 M210 219 L195 272 M210 219 L232 272 M285 219 L268 272 M285 219 L315 272" fill="none" stroke-width="1.2"/></g>
|
||||
<g filter="url(#softGlow)">
|
||||
<path d="M180 38 L212 56 L180 74 L148 56 Z" fill="url(#cubeTop)"/>
|
||||
<path d="M148 56 L180 74 L180 110 L148 90 Z" fill="#0f4437"/>
|
||||
<path d="M212 56 L180 74 L180 110 L212 90 Z" fill="#16604c"/>
|
||||
</g>
|
||||
<text id="vizPct" x="236" y="66" font-size="26" font-weight="700" fill="#43e8c3" font-family="Sora,sans-serif">50%</text>
|
||||
<g class="gen g1">
|
||||
<circle class="node-o" cx="120" cy="145" r="11"/><circle class="node-d" cx="120" cy="145" r="3.4"/>
|
||||
<circle class="node-o" cx="240" cy="145" r="11"/><circle class="node-d" cx="240" cy="145" r="3.4"/>
|
||||
</g>
|
||||
<g class="gen g2">
|
||||
<circle class="node-o" cx="75" cy="212" r="9"/><circle class="node-d" cx="75" cy="212" r="2.8"/>
|
||||
<circle class="node-o" cx="150" cy="212" r="9"/><circle class="node-d" cx="150" cy="212" r="2.8"/>
|
||||
<circle class="node-o" cx="210" cy="212" r="9"/><circle class="node-d" cx="210" cy="212" r="2.8"/>
|
||||
<circle class="node-o" cx="285" cy="212" r="9"/><circle class="node-d" cx="285" cy="212" r="2.8"/>
|
||||
</g>
|
||||
<g class="gen g3">
|
||||
<circle class="node-o" cx="45" cy="280" r="7.5"/><circle class="node-d" cx="45" cy="280" r="2.3"/>
|
||||
<circle class="node-o" cx="95" cy="280" r="7.5"/><circle class="node-d" cx="95" cy="280" r="2.3"/>
|
||||
<circle class="node-o" cx="130" cy="280" r="7.5"/><circle class="node-d" cx="130" cy="280" r="2.3"/>
|
||||
<circle class="node-o" cx="170" cy="280" r="7.5"/><circle class="node-d" cx="170" cy="280" r="2.3"/>
|
||||
<circle class="node-o" cx="195" cy="280" r="7.5"/><circle class="node-d" cx="195" cy="280" r="2.3"/>
|
||||
<circle class="node-o" cx="232" cy="280" r="7.5"/><circle class="node-d" cx="232" cy="280" r="2.3"/>
|
||||
<circle class="node-o" cx="268" cy="280" r="7.5"/><circle class="node-d" cx="268" cy="280" r="2.3"/>
|
||||
<circle class="node-o" cx="315" cy="280" r="7.5"/><circle class="node-d" cx="315" cy="280" r="2.3"/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Earn deeper as your people buy</h2>
|
||||
<p class="muted">Levels unlock by performance, never by payment. Refer buyers, and their
|
||||
shares of every future purchase route to you automatically.</p>
|
||||
<div class="chips">
|
||||
<button class="chip-t on" data-lvl="1" type="button">Level 1: activate</button>
|
||||
<button class="chip-t" data-lvl="2" type="button">Level 2: 2 buyers</button>
|
||||
<button class="chip-t" data-lvl="3" type="button">Level 3: 5 buyers</button>
|
||||
</div>
|
||||
<p class="muted" id="lvlDesc" style="min-height:72px">Activate with the $20 starter package and switch on payouts from your wallet. From then on your direct referrals each pay you 50 percent of every package they ever buy, in POL, straight to your wallet. Until you activate, you earn ad credits, not POL.</p>
|
||||
<ul class="checks">
|
||||
<li>No withdrawal requests, ever. Every payment sends straight to your wallet.</li>
|
||||
<li>Unqualified shares visibly pass up to the next qualified person</li>
|
||||
<li>Qualification never expires and can never be bought</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="wrap">
|
||||
<div class="sectionhead">
|
||||
<h2>Real ad inventory. Real eyeballs.</h2>
|
||||
<p>This is not phantom traffic or empty impressions. Seven ad formats run right now,
|
||||
reaching members who are themselves marketers buying traffic — and members earn credits back
|
||||
for the attention they give.</p>
|
||||
</div>
|
||||
<div class="plates">
|
||||
<div class="platecard">
|
||||
<div class="plate"><svg viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="12" rx="2"/><path d="M7 21h10M12 17v4"/></svg></div>
|
||||
<h3>Display banners <span class="badge">live</span></h3>
|
||||
<p>All standard sizes, live across our network, priced per impression. You pay for views, not guesses.</p>
|
||||
</div>
|
||||
<div class="platecard">
|
||||
<div class="plate"><svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h9"/></svg></div>
|
||||
<h3>Text ads <span class="badge">live</span></h3>
|
||||
<p>A headline plus a support line, placed where members actually look. Also per impression, also live right now.</p>
|
||||
</div>
|
||||
<div class="platecard">
|
||||
<div class="plate"><svg viewBox="0 0 24 24"><circle cx="12" cy="9" r="3.5"/><path d="M5 20c1.5-3.5 4-5 7-5s5.5 1.5 7 5"/><rect x="3" y="3" width="18" height="18" rx="3"/></svg></div>
|
||||
<h3>Login ads <span class="badge">live</span></h3>
|
||||
<p>The moment a member signs in, your ad is the toll gate: they open your page in a fresh tab
|
||||
while a countdown holds their dashboard. Just a link is enough — add a banner if you have
|
||||
one. Priced per day.</p>
|
||||
</div>
|
||||
<div class="platecard">
|
||||
<div class="plate"><svg viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m10 9 5 3-5 3z"/></svg></div>
|
||||
<h3>Video ads <span class="badge">live</span></h3>
|
||||
<p>Upload a video or drop a link, and pick how long members must watch — 10, 30 or 60 seconds.
|
||||
They watch in a player that can't be skipped, the clock runs on our server, and you pay only
|
||||
for completed views. There's a full-screen Shorts feed too.</p>
|
||||
</div>
|
||||
<div class="platecard">
|
||||
<div class="plate"><svg viewBox="0 0 24 24"><path d="M3 7l9 6 9-6"/><rect x="3" y="5" width="18" height="14" rx="2"/></svg></div>
|
||||
<h3>Solo ads <span class="badge">live</span></h3>
|
||||
<p>Your full message — rich text, an image or video, and a call-to-action button — delivered
|
||||
straight into member inboxes on-site and by email. Priced per guaranteed delivery, and readers
|
||||
earn credits for a real read, so your message gets opened, not skimmed past.</p>
|
||||
</div>
|
||||
<div class="platecard">
|
||||
<div class="plate"><svg viewBox="0 0 24 24"><path d="M12 3v4M12 17v4M3 12h4M17 12h4"/><circle cx="12" cy="12" r="4.5"/></svg></div>
|
||||
<h3>Featured rotation <span class="badge">live</span></h3>
|
||||
<p>Book your link into the featured rotation by the day — 1, 2 or 7 days — with a hard cap on how
|
||||
many links share a day and the occupancy shown before you buy. The only rotator that tells you
|
||||
the dilution up front, because hiding it is a sucker move.</p>
|
||||
</div>
|
||||
<div class="platecard">
|
||||
<div class="plate"><svg viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></svg></div>
|
||||
<h3>Verified visits <span class="badge">live</span></h3>
|
||||
<p>Buy a pack of guaranteed unique human visits. Each one is a different member who stayed the
|
||||
full dwell and passed a human check — no bots, no recycled clicks, no repeats, just real visits
|
||||
you can count on.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="wrap">
|
||||
<div class="sectionhead">
|
||||
<h2>What you get as a member</h2>
|
||||
<p>Free membership gets you in the door with a working account and the ability to earn
|
||||
from day one. Buy any package and the real firepower unlocks.</p>
|
||||
</div>
|
||||
<div class="grid c2" style="margin-bottom:18px">
|
||||
<div class="card">
|
||||
<h3>Credits: everyone earns these</h3>
|
||||
<p class="muted small" style="margin:0 0 8px">Ad credits are the currency of the ad platform. One credit is one cent of ad delivery. They are not money and are never withdrawn.</p>
|
||||
<ul class="checks">
|
||||
<li>Earned free by viewing ads, watching videos, verified visits, inbox ads and the daily sign-in</li>
|
||||
<li>Welcome credits on day one, badge bonuses as your team grows</li>
|
||||
<li>Spent on your own banner, text, video and solo campaigns</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>POL: activated members earn this</h3>
|
||||
<p class="muted small" style="margin:0 0 8px">POL is real crypto, paid to your own wallet by the contract in the same transaction someone in your line buys a package. Nobody holds it for you.</p>
|
||||
<ul class="checks">
|
||||
<li>Activate: the $20 starter package (2,000 credits to advertise with) and payouts switched on from your wallet</li>
|
||||
<li>Level 1 pays 50 percent of every package your direct referrals buy, from the day you are activated</li>
|
||||
<li>Levels 2 and 3 open when 2, then 5, of your referrals buy a $20 or larger package</li>
|
||||
<li>If you are not activated when someone in your line buys, that share passes up to the next member above you who is</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid c2">
|
||||
<div class="card">
|
||||
<h3>Free membership includes</h3>
|
||||
<ul class="checks">
|
||||
<li>A member account and the live ledger</li>
|
||||
<li>Welcome credits to taste real ad delivery</li>
|
||||
<li>Earn more credits by viewing ads, watching videos, making verified visits and reading solo ads</li>
|
||||
<li>Your line banner, shown to your next three levels as they join</li>
|
||||
<li>Your own shareable profile page with a scannable join QR code</li>
|
||||
<li>Achievement badges and credit bonuses as your team grows</li>
|
||||
<li>Your personal referral link, working from day one</li>
|
||||
<li>Your invite link and line from day one; activate with the $20 starter package to earn POL on your referrals' purchases</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Any package adds</h3>
|
||||
<ul class="checks">
|
||||
<li>On-chain ad credits minted the moment you buy</li>
|
||||
<li>All seven ad formats, with live stats and a dashboard of charts per campaign</li>
|
||||
<li>Top up any campaign anytime, and message your whole downline</li>
|
||||
<li>A full arsenal of promotional tools and a ready-made banner kit, personalized with your link</li>
|
||||
<li>Packages of $20 or more count toward qualification</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="packages">
|
||||
<div class="wrap">
|
||||
<div class="sectionhead">
|
||||
<h2>The ad packages</h2>
|
||||
<p>Priced in dollars, settled in POL at the moment you buy. Packages of $20 or more
|
||||
count toward your sponsor's qualification.</p>
|
||||
</div>
|
||||
<div class="tiles" id="tiles"><div class="tile"><span class="muted small">Loading live prices from the contract…</span></div></div>
|
||||
<div class="split" aria-label="Where every dollar goes">
|
||||
<div class="s50">50%<br>direct sponsor</div>
|
||||
<div class="s20">20%<br>level 2</div>
|
||||
<div class="s10">10%<br>level 3</div>
|
||||
<div class="sa">20%<br>platform</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="wrap">
|
||||
<div class="sectionhead">
|
||||
<h2>Run your what-if</h2>
|
||||
<p>Play with a scenario and see how the contract would split it. This is arithmetic on the
|
||||
locked percentages, not a prediction and not a promise. Nobody earns a cent unless real
|
||||
people really buy advertising.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="grid c2">
|
||||
<div>
|
||||
<p class="small muted" style="margin-bottom:4px">Direct referrals who each buy a package</p>
|
||||
<p><input type="range" id="dcDirects" min="0" max="20" value="2" style="width:100%">
|
||||
<span class="mono" id="dcDirectsV">2</span></p>
|
||||
<p class="small muted" style="margin-bottom:4px">The package they buy</p>
|
||||
<p><select id="dcPkg" style="width:100%">
|
||||
<option value="5">Micro, $5</option>
|
||||
<option value="20" selected>Activation, $20</option>
|
||||
<option value="50">Builder, $50</option>
|
||||
<option value="100">Growth, $100</option>
|
||||
<option value="250">Leader, $250</option>
|
||||
</select></p>
|
||||
<p class="small muted" style="margin-bottom:4px">Referrals each of them brings who also buy</p>
|
||||
<p><input type="range" id="dcSpread" min="0" max="10" value="2" style="width:100%">
|
||||
<span class="mono" id="dcSpreadV">2</span></p>
|
||||
<p class="small muted" id="dcQualNote"></p>
|
||||
</div>
|
||||
<div>
|
||||
<div class="tablewrap"><table>
|
||||
<thead><tr><th>Level</th><th>People buying</th><th class="num">Your share</th><th class="num">You receive</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Level 1 <span class="badge" id="dcB1">open</span></td><td class="num" id="dcN1">–</td><td class="num">50%</td><td class="num mono" id="dcE1">–</td></tr>
|
||||
<tr><td>Level 2 <span class="badge amber" id="dcB2">locked</span></td><td class="num" id="dcN2">–</td><td class="num">20%</td><td class="num mono" id="dcE2">–</td></tr>
|
||||
<tr><td>Level 3 <span class="badge amber" id="dcB3">locked</span></td><td class="num" id="dcN3">–</td><td class="num">10%</td><td class="num mono" id="dcE3">–</td></tr>
|
||||
<tr><td colspan="3"><b>If every one of those purchases happens</b></td><td class="num mono" style="color:var(--mint)"><b id="dcTotal">–</b></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
<p class="small muted" style="margin-top:10px">And that is one round of purchases. The same
|
||||
split runs again on every future package the same people buy.</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="small muted" style="margin-top:14px;border-top:1px solid var(--line);padding-top:14px">
|
||||
<b style="color:var(--mint)">The pass-up rule:</b> a locked level's share does not disappear.
|
||||
The contract climbs the sponsor line, checking up to 25 positions, and pays the first qualified
|
||||
person it finds; only if nobody in those 25 qualifies does the share go to the platform. It cuts
|
||||
both ways: stay qualified and you catch the shares that under-qualified positions below you let
|
||||
slip. Every pass-up is a visible event on the <a href="/ledger">ledger</a>, so you can see
|
||||
exactly where money climbed past someone and why.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="wrap">
|
||||
<div class="card" id="adSlotHome" hidden style="text-align:center"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="wrap">
|
||||
<div class="sectionhead">
|
||||
<h2>Questions people actually ask</h2>
|
||||
</div>
|
||||
<details><summary>Is this a pyramid or ponzi scheme?</summary>
|
||||
<p>No, and here is the plain version of why. A pyramid scheme pays you just for recruiting, with no
|
||||
real product behind it. Here every payment is for ad delivery, and the payouts only come from real
|
||||
buyers who buy real ad packages. You earn from people actually advertising, not from building a chain
|
||||
of signups. The whole thing runs on a public ledger you can verify yourself, so nothing stays hidden.</p></details>
|
||||
<details><summary>What happens if the company disappears?</summary>
|
||||
<p>The important part is that there is no company holding the money. Every sale is split by the smart
|
||||
contract itself, on the blockchain, in the same transaction. Nobody can pause it, change it, or run
|
||||
off with the funds, because the code does the paying automatically. If the site vanished tomorrow,
|
||||
the contract would keep paying exactly what it is set to pay.</p></details>
|
||||
<details><summary>Do I need crypto experience or a wallet to join?</summary>
|
||||
<p>No. You can start with a free email signup and use the platform the same way you would use any ad
|
||||
site. If you want payouts to a wallet, setting one up takes a few minutes and the platform walks you
|
||||
through it. But you do not need to know anything about crypto to get in and get moving.</p></details>
|
||||
<details><summary>How fast do I really get paid?</summary>
|
||||
<p>Payouts happen inside the very transaction that pays for the package. There is no approval step
|
||||
and nobody holding funds. Your share lands in your own wallet the moment your referral's purchase
|
||||
confirms. The public ledger lets you watch each one go through.</p></details>
|
||||
<details><summary>What am I actually buying?</summary>
|
||||
<p>You are buying ad delivery. Credits are one cent of delivery each, recorded on-chain when you buy.
|
||||
The 5 dollar package mints 500 credits, and the bigger packages mint bonus credits on top, up to
|
||||
32,500 on the 250 dollar package. That is the product, plain and simple. Your earnings come from
|
||||
referring people who also buy real ad delivery, and the contract splits every purchase automatically.</p></details>
|
||||
|
||||
<div class="closer">
|
||||
<div>
|
||||
<h2>See a payment land before you decide.</h2>
|
||||
<p class="muted" style="margin:0 0 22px">The ledger is open to everyone. Watch real purchases
|
||||
split and settle, then join free when you have seen enough.</p>
|
||||
<a class="btn" href="/my">Join free</a>
|
||||
<a class="btn sec" href="/ledger">Open the live ledger</a>
|
||||
</div>
|
||||
<div class="mockup">
|
||||
<div class="bar"><i></i><i></i><i></i><span class="addr">live · polygon</span></div>
|
||||
<div class="body">
|
||||
<div class="mrow"><span>💸 payout → member #3</span><span>instant</span></div>
|
||||
<div class="mrow"><span>💸 payout → member #2</span><span>instant</span></div>
|
||||
<div class="mrow dim"><span>🔍 verify on explorer</span><span>↗</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<div>LinkSpin · every payment verifiable on-chain · <a href="/ledger">live ledger</a> · <a id="contractLink" href="#" target="_blank" rel="noopener">view the contract ↗</a></div>
|
||||
<div class="small">Advertising services with a performance referral program. Not an investment product; no income guarantees. Crypto transactions are irreversible. Never spend what you cannot afford.</div>
|
||||
<div class="ghost" aria-hidden="true">LinkSpin</div>
|
||||
</footer>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script src="/assets/common.js?v=20260914a"></script>
|
||||
<script src="/assets/wallet.js?v=20260909c"></script>
|
||||
<script src="/assets/home.js?v=20260915b"></script>
|
||||
<script src="/assets/chat.js?v=20260906m"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,163 @@
|
||||
<!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,follow">
|
||||
<title>You're invited | LinkSpin</title>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
|
||||
<link rel="icon" type="image/png" href="/logo-icon.png">
|
||||
<link rel="stylesheet" href="/assets/site.css?v=20260910h">
|
||||
<style>
|
||||
.jn-nav{display:flex;justify-content:space-between;align-items:flex-start;gap:12px;padding:16px 0}
|
||||
.jn-nav>a{white-space:nowrap;flex:0 0 auto;padding-top:8px}
|
||||
.jn-nav .logo-wrap{min-width:0}
|
||||
@media (max-width:480px){.jn-nav .byline{white-space:normal}}
|
||||
.jn-hero{padding:14px 0 4px;text-align:left}
|
||||
.jn-brand{font-family:var(--disp);font-weight:800;font-size:20px;margin:0 0 4px;color:var(--ink)}
|
||||
.jn-brand em{font-style:normal;color:var(--mint)}
|
||||
.jn-brand span{font-weight:500;font-size:14px;color:var(--muted)}
|
||||
.jn-strip{margin-top:22px}
|
||||
.jn-strip .jn-chips{margin:0}
|
||||
.jn-hero h1{font-size:clamp(26px,3.6vw,40px);line-height:1.08;margin:4px 0 8px;max-width:760px;text-align:left;letter-spacing:-.015em}
|
||||
.jn-hero h1 em{font-style:normal;color:var(--mint)}
|
||||
.jn-hero p.lead{margin:0 0 12px;text-align:left;max-width:620px;font-size:15.5px}
|
||||
.jn-grid{display:grid;grid-template-columns:1.15fr .85fr;gap:22px;align-items:start;margin-top:4px}
|
||||
#jnVideo{width:100%;aspect-ratio:16/9;display:block;border-radius:16px;border:1px solid var(--line-strong);background:#000}
|
||||
/* angle pages (?v=) are squeeze pages: hook, video, one call to action. The full page is for the plain link. */
|
||||
body.squeeze .jn-hero{max-width:720px;margin:0 auto;text-align:center;padding-top:6px}
|
||||
body.squeeze .jn-hero h1,body.squeeze .jn-hero p.lead{text-align:center;margin-left:auto;margin-right:auto}
|
||||
body.squeeze .jn-hero h1{font-size:clamp(30px,4.4vw,46px)}
|
||||
body.squeeze .jn-spon,body.squeeze .jn-brand{justify-content:center}
|
||||
body.squeeze .jn-grid{display:flex;flex-direction:column;max-width:720px;margin:0 auto;gap:18px}
|
||||
body.squeeze .jn-grid>div:first-child>p{display:none}
|
||||
body.squeeze .jn-cap{position:static;box-shadow:0 0 80px rgba(67,232,195,.16)}
|
||||
body.squeeze .jn-cap h3{font-size:22px}
|
||||
body.squeeze #jnMock,body.squeeze .jn-strip,body.squeeze .jn-full{display:none}
|
||||
body.squeeze .jn-points{max-width:720px;margin:4px auto 0}
|
||||
body.squeeze .jn-foot{margin-top:28px}
|
||||
.jn-points{list-style:none;margin:14px 0 0;padding:0;display:flex;flex-direction:column;gap:10px}
|
||||
.jn-points li{position:relative;padding-left:26px;font-size:15px;line-height:1.45}
|
||||
.jn-points li::before{content:"✓";position:absolute;left:0;top:0;color:var(--mint);font-weight:800}
|
||||
@media(max-width:860px){.jn-grid{grid-template-columns:1fr}}
|
||||
.jn-cap{border-color:var(--mint);box-shadow:0 0 60px rgba(67,232,195,.12);position:sticky;top:18px}
|
||||
@media(max-width:860px){.jn-cap{position:static}}
|
||||
.jn-grid>div:first-child .mockup{margin:0}
|
||||
.jn-cap h3{margin:0 0 6px}
|
||||
.jn-cap input{width:100%}
|
||||
.jn-cap .btn{width:100%;text-align:center}
|
||||
.jn-chips{display:flex;gap:8px;flex-wrap:wrap;margin:0 0 18px}
|
||||
.jn-chips span{border:1px solid var(--line-strong);border-radius:999px;padding:6px 14px;font-size:13px;color:var(--muted)}
|
||||
.jn-chips span b{color:var(--mint);font-weight:700}
|
||||
.jn-spon{display:flex;align-items:center;gap:10px;margin:0 0 6px}
|
||||
.jn-spon img{width:34px;height:34px;border-radius:50%;object-fit:cover;border:1px solid var(--line-strong)}
|
||||
.jn-full{margin-top:26px}
|
||||
.jn-h{display:flex;justify-content:space-between;align-items:baseline;gap:16px;flex-wrap:wrap;margin:0 0 14px}
|
||||
.jn-h h2{font-size:26px;margin:0}
|
||||
.jn-h p{margin:0;font-size:14.5px}
|
||||
.jn-steps{display:grid;grid-template-columns:repeat(3,1fr);gap:16px}
|
||||
.jn-steps .card h3{font-size:19px;margin:8px 0 8px;min-height:2.4em}
|
||||
.jn-steps .card p{margin:0}
|
||||
@media(max-width:700px){.jn-steps{grid-template-columns:1fr}}
|
||||
.jn-steps .card{margin:0}
|
||||
.jn-steps .n{font-family:var(--mono);color:var(--mint);font-size:12px;letter-spacing:.1em}
|
||||
.jn-ladder{display:grid;grid-template-columns:repeat(5,1fr);gap:12px}
|
||||
@media(max-width:900px){.jn-ladder{grid-template-columns:repeat(3,1fr)}}
|
||||
@media(max-width:560px){.jn-ladder{grid-template-columns:repeat(2,1fr)}}
|
||||
.jn-pk{border:1px solid var(--line);border-radius:12px;padding:14px;text-align:center;background:var(--panel)}
|
||||
.jn-pk .p{font-family:var(--disp);font-size:26px;font-weight:800}
|
||||
.jn-pk .c{color:var(--mint);font-weight:700;font-size:13.5px}
|
||||
.jn-pk .n{color:var(--muted);font-size:11.5px;text-transform:uppercase;letter-spacing:.1em}
|
||||
.jn-foot{color:var(--muted);font-size:13px;border-top:1px solid var(--line);margin-top:40px;padding:22px 0 50px}
|
||||
.jn-err{color:var(--bad)}
|
||||
.jn-spon.cobrand{border:1px solid rgba(67,232,195,.4);border-radius:14px;padding:10px 14px;margin-bottom:12px}.jn-spon.cobrand img{width:56px;height:56px;border-radius:50%}.jn-bio{display:block;margin-top:4px;color:var(--ink);font-size:14px;max-width:60ch}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="jn-nav">
|
||||
<span class="logo-wrap"><a href="/"><img src="/logo.png" alt="LinkSpin" style="height:34px;display:block"></a><span class="byline">Brought to you by the <b>Crypto Team Build Network</b></span></span>
|
||||
<a class="small muted" href="/my">Already a member? Sign in</a>
|
||||
</div>
|
||||
|
||||
<section class="jn-hero">
|
||||
<div class="jn-spon" id="jnSpon" hidden><img id="jnSponImg" alt="" hidden><span class="small muted">Personal invitation from <b id="jnSponName" style="color:var(--mint)"></b><br><span id="jnSponBio" class="jn-bio" hidden></span></span></div>
|
||||
<p class="jn-brand">Instant<em>AdPay</em> <span id="jnEyebrow">· Advertise and earn on Polygon</span></p>
|
||||
<p class="jn-hello" id="jnHello" hidden style="margin:8px 0 12px;padding:10px 14px;border:1px solid var(--mint);border-radius:12px;color:var(--mint);font-weight:700"></p>
|
||||
<h1 id="jnHead">Advertise and earn.<br><em>Paid on-chain, instantly.</em></h1>
|
||||
<p class="lead" id="jnLead">Every ad package splits to real wallets in the same transaction it sells. No pending payouts, no withdraw button, and every payment is public.</p>
|
||||
</section>
|
||||
|
||||
<div class="jn-grid">
|
||||
<div>
|
||||
<div id="jnAngle" hidden>
|
||||
<video id="jnVideo" controls playsinline preload="metadata"></video>
|
||||
</div>
|
||||
<div id="jnMock">
|
||||
<div class="mockup" aria-label="worked example of one purchase">
|
||||
<div class="bar"><i></i><i></i><i></i><span class="addr">linkspin-test.saasy.top/ledger · worked example</span></div>
|
||||
<div class="body">
|
||||
<div class="mrow"><span>🧾 member #7 bought package #2 ($20.00)</span><span>paid</span></div>
|
||||
<div class="mrow"><span>💸 level 1 payout → member #3 (50%)</span><span>same block</span></div>
|
||||
<div class="mrow"><span>💸 level 2 payout → member #2 (20%)</span><span>same block</span></div>
|
||||
<div class="mrow"><span>💸 level 3 payout → member #1 (10%)</span><span>same block</span></div>
|
||||
<div class="mrow dim"><span>🏛 platform fee settled (20%)</span><span>same block</span></div>
|
||||
<div class="mrow dim"><span>⭐ member #3 now has 2 qualifying buyers</span><span>level 2 ✓</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="small muted" style="margin:10px 0 0">One purchase, one transaction, four payments. <a href="/ledger" target="_blank" rel="noopener">Open the real ledger →</a> <span id="jnStats"></span></p>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="card jn-cap" id="jnCap">
|
||||
<h3 id="jnCapH">Join free</h3>
|
||||
<p class="muted small" style="margin:0 0 12px" id="jnCapSub">Type your email and we send a 6-digit code. No password, no wallet needed today.</p>
|
||||
<p><input id="jnEmail" type="email" placeholder="Your email" autocomplete="email"></p>
|
||||
<input id="jnWebsite" class="hp-field" type="text" name="hp_field_x9" tabindex="-1" autocomplete="off" aria-hidden="true">
|
||||
<div id="jnCheck" hidden></div>
|
||||
<p id="jnCodeRow" hidden><input id="jnCode" inputmode="numeric" placeholder="6-digit code from your inbox"></p>
|
||||
<p id="jnUnder" class="small muted" hidden style="margin:-4px 0 12px"></p>
|
||||
<label class="small muted" style="display:flex;gap:8px;align-items:flex-start;margin:0 0 12px;cursor:pointer">
|
||||
<input type="checkbox" id="jnNews" checked style="margin-top:3px;width:auto">
|
||||
<span>Send me the getting-started emails (a few short ones over the first week). Unsubscribe any time.</span>
|
||||
</label>
|
||||
<p id="jnErr" class="small jn-err" hidden></p>
|
||||
<button class="btn" id="jnSend" type="button">Email me a code</button>
|
||||
<button class="btn" id="jnVerify" type="button" hidden>Create my free account</button>
|
||||
<button class="btn sec small" id="jnResend" type="button" hidden style="margin-top:8px">Send a fresh code</button>
|
||||
<p class="small muted" style="margin:14px 0 0">Advertising, not investing. No income is guaranteed; results depend on your effort. Crypto carries risk of loss.</p>
|
||||
</div>
|
||||
<ul class="jn-points" id="jnPoints" hidden></ul>
|
||||
</div>
|
||||
|
||||
<div class="jn-strip">
|
||||
<div class="jn-chips">
|
||||
<span><b>Paid in POL</b>, Polygon's own coin, to your wallet</span>
|
||||
<span><b>Same transaction</b> payouts</span>
|
||||
<span><b>Public ledger</b> on Polygon</span>
|
||||
<span><b>Free</b> to join by email</span>
|
||||
</div>
|
||||
<p class="small muted" style="margin:10px 0 0">The one crypto here is <b style="color:var(--ink)">POL on the Polygon network</b>: packages are paid in POL and every payout arrives as POL in your own wallet. Any Polygon wallet works (MetaMask, SafePal, Phantom, Coinbase Wallet). Never held crypto? Buy POL with a card inside the member area.</p>
|
||||
</div>
|
||||
<section class="jn-full">
|
||||
<div class="jn-h"><h2>How it works</h2><p class="muted">Three steps. The first one takes a minute and costs nothing.</p></div>
|
||||
<div class="jn-steps">
|
||||
<div class="card"><div class="n">STEP 1</div><h3>Join free by email</h3><p class="muted small">A 6-digit code, no password. You get an invite link and welcome credits to try real ads.</p></div>
|
||||
<div class="card"><div class="n">STEP 2</div><h3>Advertise or earn</h3><p class="muted small">Seven ad formats. View ads to earn credits, or buy a package from $5 when you want reach.</p></div>
|
||||
<div class="card"><div class="n">STEP 3</div><h3>Get paid in the same transaction</h3><p class="muted small">Activate with the $20 starter package and switch on payouts, and anyone who buys through your link pays you 50 percent, on-chain, the moment it happens.</p></div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="jn-full">
|
||||
<div class="jn-h"><h2>The packages</h2><p class="muted">Priced in dollars, settled in POL at the live rate. One credit is one cent of ad delivery.</p></div>
|
||||
<div class="jn-ladder" id="jnLadder"></div>
|
||||
<p class="small muted" style="margin:14px 0 0;text-align:center">Every package pays 50 / 20 / 10 up the line the moment it sells, on a public ledger. <a href="#jnCap">Join free</a> and look around first.</p>
|
||||
</section>
|
||||
|
||||
<div class="jn-foot">
|
||||
LinkSpin · <a href="/contract">Contract</a> · <a href="/terms">Terms</a> · <a href="/privacy">Privacy</a> · <a href="/disclaimer">Disclaimer</a>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/assets/common.js?v=20260914a"></script>
|
||||
<script src="/assets/join.js?v=20260914a"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,110 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Founding week checklist | LinkSpin</title>
|
||||
<meta name="description" content="The eight things a leader gets done before launch, and why the order matters. Member training.">
|
||||
<meta name="robots" content="noindex">
|
||||
<meta name="theme-color" content="#043b2f">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
|
||||
<link rel="icon" type="image/png" href="/logo-icon.png">
|
||||
<link rel="stylesheet" href="/assets/site.css?v=20260911c">
|
||||
<style>
|
||||
.lw-rule{border-left:4px solid #f2c94c;background:rgba(242,201,76,.08);padding:16px 20px;border-radius:0 12px 12px 0;margin:0 0 24px}
|
||||
.lw-rule b{font-family:var(--disp);font-size:17px;display:block;margin-bottom:6px}
|
||||
.lw-top{display:flex;justify-content:space-between;align-items:center;gap:16px;flex-wrap:wrap;margin:0 0 18px}
|
||||
.lw-count{font-family:var(--disp);font-weight:800;font-size:15px}
|
||||
.lw-count b{color:var(--mint);font-size:22px}
|
||||
.lw-bar{height:10px;border-radius:99px;background:rgba(255,255,255,.06);overflow:hidden;margin:8px 0 0;width:100%}
|
||||
.lw-bar i{display:block;height:100%;background:linear-gradient(90deg,var(--mint),#9ff2dc);width:0;transition:width .4s}
|
||||
.lw-when{font-family:var(--mono);font-size:12px;color:var(--muted)}
|
||||
.lw-when b{color:var(--ink);font-family:var(--disp);font-size:18px;display:block}
|
||||
.chk{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:10px}
|
||||
.chk li{display:grid;grid-template-columns:34px 1fr auto;gap:14px;align-items:start;background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:14px 16px}
|
||||
.chk li.done{border-color:rgba(67,232,195,.45)}
|
||||
.chk .box{width:30px;height:30px;border-radius:9px;border:2px solid var(--line-strong);display:flex;align-items:center;justify-content:center;font-weight:800;color:var(--mint);font-size:18px}
|
||||
.chk li.done .box{background:rgba(67,232,195,.14);border-color:var(--mint)}
|
||||
.chk h3{margin:0 0 3px;font-size:16px}
|
||||
.chk p{margin:0;color:var(--muted);font-size:14px}
|
||||
.chk .why{margin-top:6px;font-size:13px;color:var(--ink)}
|
||||
.chk .act{white-space:nowrap}
|
||||
.chk .act .btn{margin-top:0}
|
||||
.days{display:grid;grid-template-columns:repeat(4,1fr);gap:10px}
|
||||
.days>div{background:rgba(4,8,7,.45);border:1px solid var(--line);border-radius:10px;padding:12px 14px;font-size:14px}
|
||||
.days .k{font-family:var(--disp);font-weight:800;color:var(--mint);margin-bottom:6px}
|
||||
.days ul{margin:0;padding-left:18px}
|
||||
blockquote{margin:0 0 10px;background:rgba(4,8,7,.45);border:1px solid var(--line);border-radius:10px;padding:12px 16px;color:var(--muted)}
|
||||
blockquote span{display:block;font-family:var(--mono);font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--mint);margin-bottom:6px}
|
||||
.swipe{margin:0 0 12px;background:rgba(4,8,7,.45);border:1px solid var(--line);border-radius:10px;padding:12px 16px}
|
||||
.swipe .cap{font-family:var(--mono);font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--mint);margin-bottom:6px;display:flex;justify-content:space-between;gap:10px;align-items:center}
|
||||
.swipe .subj{font-weight:700;margin:0 0 8px}
|
||||
.swipe pre{white-space:pre-wrap;font:inherit;color:var(--muted);margin:0;line-height:1.5}
|
||||
.swipe .btn{font-size:12px;padding:5px 10px}
|
||||
.lg-imgs{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin:0 0 14px} .lg-imgs figure{margin:0} .lg-imgs img{width:100%;height:auto;border-radius:10px;border:1px solid var(--line)} .lg-imgs figcaption{font-size:12px;color:var(--muted);margin-top:6px} .lg-imgs a{color:var(--mint)}
|
||||
@media (max-width:640px){.lg-imgs{grid-template-columns:1fr}}
|
||||
h2{margin:34px 0 10px;font-size:26px}
|
||||
#gate{display:none}
|
||||
@media (max-width:720px){.days{grid-template-columns:1fr 1fr}.chk li{grid-template-columns:34px 1fr}.chk .act{grid-column:2}}
|
||||
@media print{body{background:#fff;color:#000}.jn-nav,nav,footer,.act,#printBtn{display:none!important}.chk li{border-color:#999}.chk p,.chk .why{color:#000}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<section class="hero" style="padding:70px 0 20px">
|
||||
<p class="eyebrow">Leaders · founding week</p>
|
||||
<h1>Eight things before <em>launch day</em>.</h1>
|
||||
<p class="lead">Get these done this week, in this order, and your first commissions land in your wallet instead of climbing past you. Everything here reads from your live account.</p>
|
||||
</section>
|
||||
|
||||
<div class="card" id="gate"><h3>Members only</h3><p class="muted small">Sign in to your member area to open the checklist. <a href="/my">Sign in</a></p></div>
|
||||
|
||||
<div id="body">
|
||||
<div class="lw-top">
|
||||
<div style="flex:1 1 300px"><div class="lw-count"><b id="lwDone">0</b> of 8 ready</div><div class="lw-bar"><i id="lwBar"></i></div></div>
|
||||
<div class="lw-when" id="lwWhen" hidden>Launch<b id="lwWhenAt"></b><span id="lwWhenIn"></span></div>
|
||||
<button class="btn small sec" id="printBtn" type="button">Print</button>
|
||||
</div>
|
||||
|
||||
<div class="lw-rule">
|
||||
<b>The one rule that makes this week matter: unqualified levels pass up.</b>
|
||||
Level 2 pays you only after two of your people have bought a $20 or more package. Level 3 only after five. If your team's team starts buying before you are qualified, those 20% and 10% payments climb to the next qualified member above you, or to the platform, and they never come back. Qualify first, then open the doors.
|
||||
</div>
|
||||
|
||||
<ol class="chk" id="chk"></ol>
|
||||
|
||||
<h2>The week, day by day</h2>
|
||||
<div class="days">
|
||||
<div><div class="k">Day 1</div><ul><li>Items 1 to 3 done in one sitting.</li><li>Decide your play, and whether you are going for level 2 or all three.</li></ul></div>
|
||||
<div><div class="k">Days 2 to 3</div><ul><li>Qualify: two real buyers, or Qualified Start. Leaders: go to five and open level 3.</li><li>Line banner uploaded.</li></ul></div>
|
||||
<div><div class="k">Days 4 to 6</div><ul><li>Place your first two personally.</li><li>Walk them through items 1 to 3 on their accounts.</li></ul></div>
|
||||
<div><div class="k">Launch day</div><ul><li>Everyone releases links at the same time.</li><li>Watch the ledger and the Telegram proof feed fill.</li></ul></div>
|
||||
</div>
|
||||
|
||||
<h2>What to send your two this week</h2>
|
||||
<blockquote><span>Text or DM · before launch</span>I'm bringing a small group in early on something before it opens publicly next week. Free to join, real advertising, and every payment lands in your own wallet the second it happens. I want you positioned before the doors open. Set up takes five minutes: <span data-link>your link</span></blockquote>
|
||||
<blockquote><span>Text or DM · after they join</span>Three quick things before launch so your first commission lands with you and not past you: pick your username, link your wallet, switch on payouts. All on the Wallet and Profile tabs. Then send me your two names and we'll get them placed.</blockquote>
|
||||
|
||||
<h2 id="share">Launch graphics and posts</h2>
|
||||
<p class="small muted" style="margin:0 0 10px">Two images, no text on them but the headline, so they work anywhere. Post one with the day's line below it; your link and the FOUNDER code are already in each line.</p>
|
||||
<div class="lg-imgs">
|
||||
<figure><img src="/banners/iap-launch-doors-1200x630.jpg" alt="Doors open Monday" loading="lazy"><figcaption>Wide, 1200x630 · for X, Facebook, LinkedIn, link previews <a href="/banners/iap-launch-doors-1200x630.jpg" download>Download</a></figcaption></figure>
|
||||
<figure><img src="/banners/iap-launch-doors-1080x1080.jpg" alt="Doors open Monday" loading="lazy"><figcaption>Square, 1080x1080 · for Instagram, Telegram, WhatsApp <a href="/banners/iap-launch-doors-1080x1080.jpg" download>Download</a></figcaption></figure>
|
||||
</div>
|
||||
<div id="postList"></div>
|
||||
|
||||
<h2 id="swipes">Launch week swipes: four emails for your list</h2>
|
||||
<p class="small muted" style="margin:0 0 10px">Send one a day to your own list or contacts, in this order. Your invite link and the FOUNDER code (500 free credits for anyone who joins before Monday 9 AM Central) are already filled in. Copy, paste, send from your own email. Edit anything you like.</p>
|
||||
<div id="swipeList"></div>
|
||||
|
||||
<p class="small muted" style="margin:26px 0 0">No income is guaranteed. Results depend on your effort. Crypto carries risk of loss. LinkSpin sells advertising; it is not an investment.</p>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<div>LinkSpin · <a href="/my#training">back to Training</a> · <a href="/plays">team-building plays</a> · <a href="/wallets">wallets and buying POL</a></div>
|
||||
<div class="small">Advertising services with a performance referral program. Not an investment product; no income guarantees.</div>
|
||||
</footer>
|
||||
</div>
|
||||
<script src="/assets/common.js?v=20260914a"></script>
|
||||
<script src="/assets/launch.js?v=20260915b"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,44 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Live ledger | LinkSpin</title>
|
||||
<meta name="description" content="Every purchase, payout, and pass-up on LinkSpin, streamed straight from the blockchain with a verify link on every line.">
|
||||
<link rel="canonical" href="https://linkspin-test.saasy.top/ledger">
|
||||
<meta property="og:type" content="website"><meta property="og:site_name" content="LinkSpin">
|
||||
<meta property="og:url" content="https://linkspin-test.saasy.top/ledger">
|
||||
<meta property="og:title" content="LinkSpin — the live payout ledger">
|
||||
<meta property="og:description" content="Watch real ad purchases split into instant on-chain payouts, live. Every line links to the raw transaction.">
|
||||
<meta property="og:image" content="https://linkspin-test.saasy.top/banners/iap-hero-1200x630.png">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="LinkSpin — the live payout ledger">
|
||||
<meta name="twitter:description" content="Real ad purchases splitting into instant on-chain payouts, live and verifiable.">
|
||||
<meta name="twitter:image" content="https://linkspin-test.saasy.top/banners/iap-hero-1200x630.png">
|
||||
<meta name="theme-color" content="#043b2f">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
|
||||
<link rel="icon" type="image/png" href="/logo-icon.png">
|
||||
<link rel="stylesheet" href="/assets/site.css?v=20260910h">
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<section class="hero" style="padding-bottom:20px">
|
||||
<h1>The ledger <em>does not lie</em>.</h1>
|
||||
<p class="lead">This page streams every payment the contract has ever made. If it is not here,
|
||||
it did not happen. Every line carries a verify link straight to the block explorer. Go click one.</p>
|
||||
<p><span class="badge" id="liveBadge">connecting…</span>
|
||||
<span class="small muted" id="statLine"></span></p>
|
||||
</section>
|
||||
<div class="card" id="adSlotBanner" hidden></div>
|
||||
<div class="card" style="padding:0">
|
||||
<div class="feed" id="feed"><div class="row muted">Loading recent history…</div></div>
|
||||
</div>
|
||||
<div class="card small" id="adSlotText" hidden></div>
|
||||
<footer>
|
||||
<div>LinkSpin · <a href="/">how it works</a> · <a id="contractLink" href="#" target="_blank" rel="noopener">contract source ↗</a></div>
|
||||
</footer>
|
||||
</div>
|
||||
<script src="/assets/common.js?v=20260914a"></script>
|
||||
<script src="/assets/ledger.js?v=20260906m"></script>
|
||||
<script src="/assets/chat.js?v=20260906m"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 276 KiB |
@@ -0,0 +1,134 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>For site owners | LinkSpin</title>
|
||||
<meta name="description" content="Marty's partner kit for site owners who run a downline builder: the platform in three minutes, how the money moves, what your members get, and what you get for listing LinkSpin.">
|
||||
<meta name="robots" content="noindex">
|
||||
<meta name="theme-color" content="#043b2f">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
|
||||
<link rel="icon" type="image/png" href="/logo-icon.png">
|
||||
<link rel="stylesheet" href="/assets/site.css?v=20260910h">
|
||||
<style>
|
||||
.pk{max-width:820px}
|
||||
.pk h2{font-size:24px;margin:44px 0 12px;padding-top:20px;border-top:1px solid var(--line)}
|
||||
.pk p{max-width:68ch}
|
||||
.pk ul{margin:0 0 14px 20px;padding:0;max-width:68ch} .pk li{margin:7px 0}
|
||||
.pk ol{margin:0 0 14px 20px;padding:0;max-width:68ch} .pk ol li{margin:9px 0}
|
||||
.hello{display:none;margin:0 0 18px;padding:12px 16px;border:1px solid var(--mint);border-radius:12px;color:var(--mint);font-weight:700}
|
||||
.vid{background:#000;border:1px solid var(--line);border-radius:14px;overflow:hidden;margin:18px 0 8px}
|
||||
.vid video{display:block;width:100%;aspect-ratio:16/9}
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px;margin:16px 0}
|
||||
.grid .card{margin:0;padding:16px} .grid .card h3{margin:0 0 6px;font-size:16px} .grid .card p{margin:0;font-size:14px;color:var(--muted)}
|
||||
.split{display:grid;grid-template-columns:5fr 2fr 1fr 2fr;gap:8px;margin:16px 0 8px;text-align:center}
|
||||
.split>div{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px 8px;font-size:13px;color:var(--muted)}
|
||||
.split .n{font-family:var(--disp);font-size:26px;font-weight:800;color:var(--mint)}
|
||||
table{border-collapse:collapse;width:100%;margin:10px 0 18px;font-variant-numeric:tabular-nums;font-size:15px}
|
||||
th,td{text-align:left;padding:9px 10px;border-bottom:1px solid var(--line)} th{color:var(--muted);font-family:var(--mono);font-size:11px;letter-spacing:.08em;text-transform:uppercase;font-weight:500}
|
||||
td:nth-child(2),td:nth-child(3),th:nth-child(2),th:nth-child(3){text-align:right}
|
||||
.get{border:1px solid #f2c94c;background:rgba(242,201,76,.08);border-radius:14px;padding:18px 22px;margin:16px 0}
|
||||
.get h3{color:#f2c94c;margin:0 0 8px}
|
||||
.cta{margin:26px 0 8px;padding:22px;border:1px solid var(--mint);border-radius:14px;background:rgba(67,232,195,.06)}
|
||||
.cta .btn{margin-top:10px}
|
||||
code{background:rgba(4,8,7,.6);padding:2px 7px;border-radius:6px;font-size:13.5px;word-break:break-all}
|
||||
@media (max-width:640px){.split{grid-template-columns:1fr 1fr}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap pk">
|
||||
<section class="hero" style="padding:60px 0 10px">
|
||||
<p class="eyebrow">For site owners with a downline builder</p>
|
||||
<h1>I built the ad platform I always wanted to run. <em>I want it in your builder.</em></h1>
|
||||
<p class="lead">A note from me, plus everything you need to list LinkSpin in your builder and hand your members free ad credits with a code of your own.</p>
|
||||
</section>
|
||||
|
||||
<p class="hello" id="pkHello"></p>
|
||||
|
||||
<div class="vid"><video id="pkVideo" controls playsinline preload="metadata" poster="/promo/partner-overview.jpg" src="https://coolify-saasytop.nyc3.digitaloceanspaces.com/promo/partner-overview.mp4"></video></div>
|
||||
<p class="muted small">Watch first: the whole platform in about five minutes. Free and paid members, the seven ad formats, how people get paid, and the offer for your builder.</p>
|
||||
|
||||
<h2>Why I built it</h2>
|
||||
<p>You know I've been running ad sites for years, the kind your members already know: buy a package, run banners and text ads, click for credits. Two of mine, Faucet Wave and Tier One Ads, ran on a licensed script. The vendor went out of business, their license server went dark, and it crippled licenses that were fully paid. The sites died overnight and nothing I could do would bring them back.</p>
|
||||
<p>So I built LinkSpin from scratch as part of the Crypto Team Build Network. No vendor, no license server, and the part that always went wrong on ad sites, the money, is handled by a verified smart contract on Polygon instead of by me. When a package sells, the contract splits the payment and sends it in the same transaction. I never hold member funds, so there is no back office, no payday, and nothing anyone can switch off.</p>
|
||||
<p class="muted">Marty Bostick · Crypto Team Build Network</p>
|
||||
|
||||
<h2>What LinkSpin is</h2>
|
||||
<p>An advertising platform where the ad spend in your line pays you. Members join free with an email address, no password and no wallet on day one. They earn credits by viewing ads and can run their first campaign for zero dollars. When they want more reach they buy an ad package, and every package that sells is split by the contract the moment it sells.</p>
|
||||
<div class="grid">
|
||||
<div class="card"><h3>Seven ad formats</h3><p>Banners, text ads, login ads, solo ads to member inboxes, video, featured links and verified visits. Views are timed on the server, so a real person saw the ad.</p></div>
|
||||
<div class="card"><h3>Beyond the site</h3><p>Banner and text ads also push out to Network Ad Space, a partner rotation across other member sites. Those impressions count in the member's stats.</p></div>
|
||||
<div class="card"><h3>WalletConnect built in</h3><p>Full WalletConnect integration: members link MetaMask, Trust, Phantom, SafePal or any WalletConnect wallet with one tap, sign once, and buy or get paid straight from that wallet. No custody on my side.</p></div>
|
||||
<div class="card"><h3>Public, verified, immutable</h3><p>Every payout is a public transaction on Polygon. The split percentages and qualification rules are constants in a verified contract that the operator cannot change.</p></div>
|
||||
</div>
|
||||
|
||||
<h2>How the money moves</h2>
|
||||
<p>Every ad package splits the same way, in the same transaction it sells in:</p>
|
||||
<div class="split">
|
||||
<div><div class="n">50%</div>direct sponsor</div>
|
||||
<div><div class="n">20%</div>level 2</div>
|
||||
<div><div class="n">10%</div>level 3</div>
|
||||
<div><div class="n">20%</div>platform</div>
|
||||
</div>
|
||||
<p class="muted small">On a $20 package: $10 to the direct sponsor, $4 to level 2, $2 to level 3, $4 to the platform. If a level has no qualified member, that share passes up to the next qualified person above.</p>
|
||||
<p>Qualification is earned, never bought. Every direct buyer pays their sponsor 50% from their very first package. Two qualifying buyers, people who bought a $20 or larger package, open level 2. Five open level 3. Until a level opens, its share climbs to the next qualified member above, which is why the plan rewards the people who actually build.</p>
|
||||
<table>
|
||||
<tr><th>Package</th><th>Price</th><th>Credits</th></tr>
|
||||
<tr><td>Micro</td><td>$5</td><td>500</td></tr>
|
||||
<tr><td>Activation, the qualifying buy</td><td>$20</td><td>2,000</td></tr>
|
||||
<tr><td>Builder</td><td>$50</td><td>5,500</td></tr>
|
||||
<tr><td>Growth</td><td>$100</td><td>12,000</td></tr>
|
||||
<tr><td>Leader</td><td>$250</td><td>32,500</td></tr>
|
||||
</table>
|
||||
|
||||
<h2>What your members get</h2>
|
||||
<ul>
|
||||
<li><b>A free start.</b> Join by email, view a few ads, earn credits, run a real banner or text campaign for nothing.</li>
|
||||
<li><b>Your promo credits on top.</b> Members who arrive with your code get free ad credits added the moment they join, in addition to everything else.</li>
|
||||
<li><b>Instant, public payouts.</b> When anyone in their line buys ads, the contract pays them in POL to their own wallet in the same transaction. They can check every payment on Polygonscan.</li>
|
||||
<li><b>A seamless wallet step.</b> Full WalletConnect integration means linking a wallet is one tap and one free signature from any major wallet app, and purchases confirm inside the wallet they already use.</li>
|
||||
<li><b>Tools that do the work.</b> A ready-to-send invite message, social posts, email swipes, a full banner kit, objection answers, and a public profile wall with their own banner slots.</li>
|
||||
<li><b>Training that keeps growing.</b> A video series that walks the whole member area, plus written plays for building a line.</li>
|
||||
<li><b>A holding tank.</b> Members who arrive without a sponsor are not lost. Qualified builders adopt them, first come, first served.</li>
|
||||
<li><b>Coaching built in.</b> A next-move card on every dashboard, nudges when a referral stalls, and a live payments topic on Telegram where every payout posts as it lands.</li>
|
||||
</ul>
|
||||
|
||||
<div class="get">
|
||||
<h3>What you get for listing it in your builder</h3>
|
||||
<ul>
|
||||
<li><b>Your spot at the top.</b> You join directly under the company at the top, no sponsor in between. All it takes to claim that spot is activating your account with at least the $20 package, and that locks you in at the top of your own line from day one.</li>
|
||||
<li><b>Your own promo code.</b> A reusable code that adds free ad credits for every member who redeems it, on your builder link or in the dashboard. I set the credit amount, an optional cap and an optional expiry. One redemption per account, every redemption logged, and you can see uses any time.</li>
|
||||
<li><b>Your own line.</b> Every member who comes through your builder link lands under you. Each one who buys pays you 50% of their first package and every package after it, and their buyers open your level 2 and level 3 shares.</li>
|
||||
<li><b>Ready-made creatives.</b> Banners in every builder size (468x60, 728x90, 300x250, 160x600, 120x600, 1200x630), text ad copy, email swipes and a program description you can paste into your listing.</li>
|
||||
<li><b>A bridge page for your brand, on request.</b> A landing page in this design that names your site, explains the connection, and carries your code.</li>
|
||||
<li><b>Attribution you can check.</b> Signups and buyers are tagged with the source they came from, and the promo code report shows exactly who redeemed yours.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h2>Setting it up takes about fifteen minutes</h2>
|
||||
<ol>
|
||||
<li>Claim your spot with the button below. It places you directly under the company at the top. Join with your email and pick your username; your own invite link is live immediately: <code>linkspin-test.saasy.top/join/yourname</code>.</li>
|
||||
<li>Send me your username and the site you are listing it on. I mint your code with the credit amount we agree on.</li>
|
||||
<li>Add LinkSpin to your downline builder with your link plus the code: <code id="pkExample">https://linkspin-test.saasy.top/join/yourname?promo=YOURCODE</code>. Members who click it land under you and their credits apply the moment their account exists.</li>
|
||||
<li>Use the banners and text ads from the kit. Members who already have an account can type the code into the "Have a promo code?" box on their Overview.</li>
|
||||
<li>Link a wallet, switch on payouts, and activate with at least the $20 package. That claims your spot at the top and makes you a qualifying buyer in your own right.</li>
|
||||
</ol>
|
||||
|
||||
<div class="cta">
|
||||
<b id="pkCtaHead">Claim your spot at the top</b>
|
||||
<p class="muted small" id="pkCtaSub" style="margin:6px 0 0">Free account by email. No password, no wallet today. The link below places you directly under the company.</p>
|
||||
<a class="btn" id="pkCta" href="/join/company">Claim my spot</a>
|
||||
</div>
|
||||
|
||||
<h2>The honest part</h2>
|
||||
<p>LinkSpin sells advertising. Members earn from the ad packages people in their line buy, and nothing else. There is no earn-without-referring option, on purpose, because sites that pay you just for buying in are the ones that collapse. No income is guaranteed, results depend on effort, and cryptocurrency involves risk of loss. The contract, the ledger and every payout are public, so you never have to take my word for any of it.</p>
|
||||
<p>Marty Bostick · <a href="mailto:marty@marketingwithmarty.com">marty@marketingwithmarty.com</a> · <a href="https://t.me/cryptoteambuild">t.me/cryptoteambuild</a></p>
|
||||
|
||||
<footer>
|
||||
<div>LinkSpin · <a href="/">home</a> · <a href="/ledger">live ledger</a> · <a href="/contract">the contract</a></div>
|
||||
<div class="small">Advertising services with a performance referral program. Not an investment product; no income guarantees. Crypto transactions are irreversible. Never spend what you cannot afford to lose.</div>
|
||||
</footer>
|
||||
</div>
|
||||
<script src="/assets/common.js?v=20260914a"></script>
|
||||
<script src="/assets/partners.js?v=20260914a"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,198 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Team-building plays | LinkSpin</title>
|
||||
<meta name="description" content="Three ways to build your LinkSpin line, plus the Qualified Start opening move. Member training.">
|
||||
<meta name="robots" content="noindex">
|
||||
<meta name="theme-color" content="#043b2f">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
|
||||
<link rel="icon" type="image/png" href="/logo-icon.png">
|
||||
<link rel="stylesheet" href="/assets/site.css?v=20260910h">
|
||||
<style>
|
||||
.pl-rule{border-left:4px solid #f2c94c;background:rgba(242,201,76,.08);padding:16px 20px;border-radius:0 12px 12px 0;margin:0 0 28px}
|
||||
.pl-rule b{font-family:var(--disp);font-size:17px;display:block;margin-bottom:6px}
|
||||
.pl-split{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:0 0 10px}
|
||||
.pl-split>div{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px 10px;text-align:center}
|
||||
.pl-split .n{font-family:var(--disp);font-size:28px;font-weight:800;color:var(--mint);font-variant-numeric:tabular-nums}
|
||||
.pl-split .l{font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin-top:2px}
|
||||
.pl-ladder{display:grid;grid-template-columns:repeat(5,1fr);border:1px solid var(--line);border-radius:12px;overflow:hidden;background:var(--panel);margin:0 0 34px}
|
||||
.pl-ladder>div{padding:12px 10px;border-right:1px solid var(--line);font-size:13.5px}
|
||||
.pl-ladder>div:last-child{border-right:0}
|
||||
.pl-ladder .k{font-family:var(--mono);font-size:11px;color:var(--muted);letter-spacing:.08em;text-transform:uppercase}
|
||||
.pl-ladder .v{font-weight:700;margin-top:2px}
|
||||
.pl-ladder .b{color:var(--mint);font-size:12.5px}
|
||||
.play header{display:flex;align-items:baseline;gap:12px;flex-wrap:wrap;margin-bottom:6px}
|
||||
.play h2{font-size:24px;margin:0}
|
||||
.play .tag{font-family:var(--mono);font-size:11.5px;color:var(--mint);border:1px solid rgba(67,232,195,.4);border-radius:999px;padding:3px 10px}
|
||||
.play ol{margin:0 0 14px 20px;padding:0;line-height:1.6}
|
||||
.play ol li{margin:0 0 8px}
|
||||
.meta{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:12px}
|
||||
.meta>div{background:rgba(4,8,7,.45);border:1px solid var(--line);border-radius:10px;padding:12px 14px;font-size:14px}
|
||||
.meta .k{font-family:var(--mono);font-size:11px;color:var(--muted);letter-spacing:.08em;text-transform:uppercase;margin-bottom:4px}
|
||||
.rec{border-color:var(--mint)}
|
||||
.cal{display:grid;grid-template-columns:repeat(4,1fr);gap:10px}
|
||||
.cal>div{background:rgba(4,8,7,.45);border:1px solid var(--line);border-radius:10px;padding:12px 14px;font-size:14px}
|
||||
.cal .k{font-family:var(--disp);font-weight:800;color:var(--mint);margin-bottom:6px}
|
||||
blockquote{margin:0 0 10px;background:rgba(4,8,7,.45);border:1px solid var(--line);border-radius:10px;padding:12px 16px;color:var(--muted);font-style:italic}
|
||||
blockquote span{display:block;font-style:normal;font-family:var(--mono);font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--mint);margin-bottom:4px}
|
||||
.fit-table th{text-align:left;color:var(--muted);font-family:var(--mono);font-size:11px;letter-spacing:.08em;text-transform:uppercase;font-weight:500}
|
||||
.fit-table td,.fit-table th{padding:9px 10px;border-bottom:1px solid var(--line);vertical-align:top}
|
||||
#gate{display:none}
|
||||
#printable{display:none}
|
||||
@media print{ body{background:#fff;color:#000} .wrap>*{display:none!important} #printable{display:block!important;color:#000} #printable h2,#printable h3{color:#000;font-family:Georgia,serif} #printable li{margin:6px 0} }
|
||||
@media (max-width:720px){.pl-split{grid-template-columns:repeat(2,1fr)}.pl-ladder{grid-template-columns:1fr}.pl-ladder>div{border-right:0;border-bottom:1px solid var(--line)}.meta,.cal{grid-template-columns:1fr}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<section class="hero" style="padding:70px 0 24px">
|
||||
<p class="eyebrow">Member training</p>
|
||||
<h1>Three ways to <em>build a line</em>.</h1>
|
||||
<p class="lead">Pick one and run it. Every number here comes from the live contract and the rate table, not from a slide.</p>
|
||||
</section>
|
||||
|
||||
<div class="card" id="gate"><h3>Members only</h3><p class="muted small">Sign in to your member area to read the plays. <a href="/my">Sign in</a></p></div>
|
||||
|
||||
<div id="body">
|
||||
<div class="pl-rule">
|
||||
<b>The one rule under all three plays: unqualified levels pass up.</b>
|
||||
If someone on your level 2 buys before you have two qualifying buyers, that 20% does not wait for you. It goes to the next qualified sponsor above you, or to the platform. Same for level 3 and five. Whatever play you run, the first job is the same: get qualified before your line gets busy.
|
||||
</div>
|
||||
|
||||
<p class="eyebrow">How a package splits, the moment it sells</p>
|
||||
<div class="pl-split">
|
||||
<div><div class="n">50%</div><div class="l">Direct sponsor</div></div>
|
||||
<div><div class="n">20%</div><div class="l">Level 2 · needs 2</div></div>
|
||||
<div><div class="n">10%</div><div class="l">Level 3 · needs 5</div></div>
|
||||
<div><div class="n">20%</div><div class="l">Platform</div></div>
|
||||
</div>
|
||||
<p class="eyebrow" style="margin-top:18px">The ladder on your Overview, and what each rung unlocks</p>
|
||||
<div class="pl-ladder">
|
||||
<div><div class="k">Rung 1</div><div class="v">Joined</div><div class="b">Welcome tour: 25 credits</div></div>
|
||||
<div><div class="k">Rung 2</div><div class="v">Payouts on</div><div class="b">Spark badge · 10 credits</div></div>
|
||||
<div><div class="k">Rung 3</div><div class="v">First buyer</div><div class="b">Surge · 25 credits · 50% starts</div></div>
|
||||
<div><div class="k">Rung 4</div><div class="v">2 qualifying</div><div class="b">Circuit · 50 credits · level 2 · wall position 2</div></div>
|
||||
<div><div class="k">Rung 5</div><div class="v">5 qualifying</div><div class="b">Nexus · 100 credits · level 3 · wall position 3</div></div>
|
||||
</div>
|
||||
|
||||
<div class="card play">
|
||||
<header><h2>Opening move · Qualified Start</h2><span class="tag">works with any play</span></header>
|
||||
<p class="muted"><b>Fits:</b> anyone who would rather start qualified than wait for their first two buyers.</p>
|
||||
<p>Qualification is earned by buyers, never bought. But you can be your own first buyers, openly. Under <a href="/my#buy">Buy packages</a>, link a second wallet you own as a <b>position</b>. When it buys a $20 package the contract counts it as a qualifying buyer, half the purchase comes straight back to your main wallet, and its credits pool with yours. Two positions open level 2 the same day; five open level 3. Then run whichever play fits you with the ladder already climbed. The three Qualified Start videos in <a href="/my#training">Training</a> show every click.</p>
|
||||
<div class="meta">
|
||||
<div><div class="k">Net cost</div>Level 2: about $20 net for $40 of ad credits. Level 3: about $50 net for $100 of credits. Plus a little POL for gas in each wallet.</div>
|
||||
<div><div class="k">Say it plainly</div>Your own money, your own wallets, a faster start. Never an income promise: qualification only pays on future purchases in your line.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card play">
|
||||
<header><h2>Play 1 · Wide and teach</h2><span class="tag">the fifty play</span></header>
|
||||
<p class="muted"><b>Fits:</b> someone with an audience, a list, a group, or traffic they can point somewhere.</p>
|
||||
<p>Every direct who buys is 50% to you, instantly, forever. Directs are the only thing that qualifies you. The teaching is what fills levels 2 and 3 without extra work from you: your directs' buyers are your 20%, their buyers are your 10%.</p>
|
||||
<ol>
|
||||
<li><b>One new conversation a day, minimum.</b> Text a friend and Social posts in Promo tools already carry your link.</li>
|
||||
<li><b>Send paid traffic to an angle lander, not the bare link.</b> Add <span class="mono">?v=adspend</span> for advertisers, <span class="mono">?v=free</span> for freebie seekers, <span class="mono">?v=instant</span> for the crypto-curious.</li>
|
||||
<li><b>Every new direct gets the same three sentences inside 24 hours</b> (sponsor chat or the daily broadcast): pick your username, link your wallet and switch on payouts, send your link to one person today. That is the whole teaching. They pass it down.</li>
|
||||
<li><b>Run the network's own ads at your link.</b> Buy a package or claim the daily 5 credits, then spend credits on a Featured link (40 credits a day) or a text ad pointed at your angle lander.</li>
|
||||
</ol>
|
||||
<div class="meta">
|
||||
<div><div class="k">Scoreboard</div>Joined your line climbing daily · Qualifying buyers 2, then 5 · level 2 and 3 rows appearing in My line</div>
|
||||
<div><div class="k">Ceiling and weakness</div>No ceiling on width. Shallow lines churn if you skip step 3.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card play">
|
||||
<header><h2>Play 2 · Two, then down</h2><span class="tag">the depth play</span></header>
|
||||
<p class="muted"><b>Fits:</b> someone with a small circle who would rather coach two people well than pitch twenty.</p>
|
||||
<p>Two qualifying buyers open level 2, wall position 2, the Circuit badge and 50 bonus credits. From there every person your two bring in pays you 20%, and every person those people bring in pays you 10% once you reach five. Your effort goes into two relationships instead of a funnel.</p>
|
||||
<ol>
|
||||
<li><b>Get two directs to a $20+ package.</b> Sit with them on the buy if you have to. Trust Wallet needs a POL cushion; SafePal or MetaMask are smoother.</li>
|
||||
<li><b>Coach them to their two.</b> Sponsor chat daily for the first week. One broadcast a day to your directs with a single ask each time.</li>
|
||||
<li><b>Set your line banner to your team's meeting place</b> (a Telegram group, a training page). Every new member three levels down meets it on their welcome tour.</li>
|
||||
<li><b>Keep adding directs until you have five.</b> This is the catch: level 3 only opens on five qualifying directs of your own. Two deep, coached well, earns a healthy 20% level. It does not open the 10% level.</li>
|
||||
</ol>
|
||||
<div class="meta">
|
||||
<div><div class="k">Scoreboard</div>Qualifying buyers 2 · level 2 count in My line rising · your directs' own qualifying counts</div>
|
||||
<div><div class="k">Ceiling and strength</div>Level 2 income until you personally hit five. The stickiest lines come from this play.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card play rec">
|
||||
<p class="eyebrow" style="margin:0 0 6px">Recommended default</p>
|
||||
<header><h2>Play 3 · Five and wide</h2><span class="tag">the combination</span></header>
|
||||
<p class="muted"><b>Fits:</b> anyone willing to do both. This is the play the dashboard ladder is actually built for.</p>
|
||||
<ol>
|
||||
<li><b>Sprint to five qualifying directs.</b> Nothing else matters until level 3 is open: Nexus, wall position 3 (your whole public page runs your own links), 100 bonus credits, and the full 50 / 20 / 10.</li>
|
||||
<li><b>Then split the day.</b> Mornings wide: one new conversation, one post, one ad running. Evenings deep: read My line, message the three newest directs, send the broadcast.</li>
|
||||
<li><b>Coach the 2-then-5 rule down the line.</b> Each of your five gets pushed to two (your level 2 fills), then to five (your level 3 fills). Use the achievements Share links; people copy what they see rewarded.</li>
|
||||
<li><b>Book the featured strip for 7 days</b> whenever you have 280 credits spare. Ten slots a day, every member sees it.</li>
|
||||
</ol>
|
||||
<div class="meta">
|
||||
<div><div class="k">Scoreboard</div>All four Overview tiles, plus Earning levels: buyers referred, level open, how many to next</div>
|
||||
<div><div class="k">Why it wins</div>Width qualifies you. Depth pays you on other people's effort. Only this play does both on purpose.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Which play fits you</h3>
|
||||
<div class="tablewrap"><table class="fit-table">
|
||||
<tr><th>You have</th><th>Run</th><th>First target</th></tr>
|
||||
<tr><td>A list, a group, or ad budget</td><td>Wide and teach</td><td>Five qualifying directs in 30 days</td></tr>
|
||||
<tr><td>A few close people and patience</td><td>Two, then down</td><td>Two qualifying directs in 14 days, both coached to their two</td></tr>
|
||||
<tr><td>An hour a day and a phone</td><td>Five and wide</td><td>Five qualifying, then one wide and one deep action every day</td></tr>
|
||||
</table></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>First 30 days, any play</h3>
|
||||
<div class="cal">
|
||||
<div><div class="k">Day 1</div>Username. Wallet linked. Payouts on. Welcome tour done (25 credits). Link sent to one person.</div>
|
||||
<div><div class="k">Days 2 to 7</div>One conversation a day. Claim the daily 5 credits. First buyer (25 bonus credits).</div>
|
||||
<div><div class="k">Days 8 to 14</div>Second qualifying buyer. Level 2 open. Line banner set. First broadcast sent.</div>
|
||||
<div><div class="k">Days 15 to 30</div>Coach the two to their two. Add directs three, four, five. Level 3 open by day 30 is the stretch goal.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Messages that fit each play</h3>
|
||||
<blockquote><span>Wide</span>I run ads anyway. This one pays me in the same transaction the buyer's package sells, on a public ledger. Free to join by email: <span class="mono" data-link>your link</span></blockquote>
|
||||
<blockquote><span>Depth</span>I need two people who will actually do this with me, not twenty who will look at it. You are one of the two I thought of. <span class="mono" data-link>your link</span></blockquote>
|
||||
<blockquote><span>Combination · to a new direct</span>Three things today: username, wallet on, one person. I will check in tomorrow.</blockquote>
|
||||
</div>
|
||||
|
||||
<p class="muted small">No income is guaranteed. Results depend on your effort. Cryptocurrency involves risk of loss. LinkSpin sells advertising; it is not an investment.</p>
|
||||
<p><button class="btn sec" type="button" id="printBtn">Print the Qualified Start checklist and 30-day calendar</button></p>
|
||||
<div class="card" id="adSlotPlays" hidden style="text-align:center"></div>
|
||||
</div>
|
||||
|
||||
<div id="printable">
|
||||
<h2>LinkSpin: Qualified Start checklist</h2>
|
||||
<ol>
|
||||
<li>Username chosen (Profile tab). It is permanent: it becomes your invite link.</li>
|
||||
<li>Main wallet linked (Wallet tab, one free signature).</li>
|
||||
<li>Payouts switched on (Wallet tab, one free transaction).</li>
|
||||
<li>Extra wallet accounts created in your wallet app: IAP Position 2, 3, 4, 5.</li>
|
||||
<li>Each extra account funded with enough POL for a $20 package plus gas.</li>
|
||||
<li>Buy packages: Add a position, tick only the new account, sign once.</li>
|
||||
<li>Buy from: choose the position, Buy $20, confirm in the wallet.</li>
|
||||
<li>Repeat. Two positions open level 2. Five open level 3 and the Nexus badge.</li>
|
||||
</ol>
|
||||
<h2>First 30 days, any play</h2>
|
||||
<h3>Day 1</h3><ul><li>Username, wallet linked, payouts on, welcome tour done.</li><li>Link sent to one person.</li></ul>
|
||||
<h3>Days 2 to 7</h3><ul><li>One conversation a day.</li><li>Claim the daily 5 credits.</li><li>First buyer.</li></ul>
|
||||
<h3>Days 8 to 14</h3><ul><li>Second qualifying buyer. Level 2 open.</li><li>Line banner set. First broadcast sent.</li></ul>
|
||||
<h3>Days 15 to 30</h3><ul><li>Coach the two to their two.</li><li>Add directs three, four, five. Level 3 open by day 30 is the stretch goal.</li></ul>
|
||||
<p>My invite link: <span data-link>__________________________</span></p>
|
||||
<p style="font-size:12px">No income is guaranteed. Results depend on your effort. Cryptocurrency involves risk of loss. LinkSpin sells advertising; it is not an investment.</p>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<div>LinkSpin · <a href="/my#training">back to Training</a> · <a href="/ledger">live ledger</a></div>
|
||||
<div class="small">Advertising services with a performance referral program. Not an investment product; no income guarantees. Crypto transactions are irreversible. Never spend what you cannot afford.</div>
|
||||
</footer>
|
||||
</div>
|
||||
<script src="/assets/common.js?v=20260914a"></script>
|
||||
<script src="/assets/plays.js?v=20260910b"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,34 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Privacy Policy | LinkSpin</title>
|
||||
<link rel="icon" type="image/png" href="/logo-icon.png">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
|
||||
<link rel="stylesheet" href="/assets/site.css?v=20260910h">
|
||||
</head>
|
||||
<body>
|
||||
<div id="nav"></div>
|
||||
<section><div class="wrap" style="max-width:820px">
|
||||
<div class="sectionhead" style="text-align:left"><h2>Privacy Policy</h2><p class="muted small">Last updated: September 2026</p></div>
|
||||
<div class="card">
|
||||
<h3>What we collect</h3>
|
||||
<p class="muted small">A minimal set: the email you sign up with, a username and any profile details you choose to add (avatar, bio, social links), and, only if you link one, your public wallet address. Campaign content you create and reports you submit are stored to run the service. We also read on-chain activity that is already public on the blockchain.</p>
|
||||
<h3>What we do not collect</h3>
|
||||
<p class="muted small">We never take custody of your funds or private keys, and we do not sell your personal data.</p>
|
||||
<h3>How we use it</h3>
|
||||
<p class="muted small">To run your account, deliver and measure ads, attribute referrals, send service and notification emails (payouts, messages, onboarding), and keep the Platform secure. You can set your email and chat notification preferences in your dashboard.</p>
|
||||
<h3>Cookies</h3>
|
||||
<p class="muted small">We use a session cookie to keep you signed in and a referral cookie to credit the sponsor whose link you arrived through. That is it. No third-party ad-tracking cookies.</p>
|
||||
<h3>Sharing</h3>
|
||||
<p class="muted small">Your username, public profile, and public wall are visible to others by design, and on-chain transactions are public by nature. We share data with infrastructure providers (hosting, email delivery) only as needed to operate the service, and when required by law.</p>
|
||||
<h3>Your choices</h3>
|
||||
<p class="muted small">You can edit your profile, adjust notification and chat settings, and request account deletion by contacting us. Note that on-chain records cannot be deleted by anyone.</p>
|
||||
<h3>Security</h3>
|
||||
<p class="muted small">We use reasonable safeguards, but no system is perfectly secure. Protect your email and your wallet.</p>
|
||||
</div>
|
||||
</div></section>
|
||||
<script src="/assets/common.js?v=20260914a"></script>
|
||||
<script src="/assets/legal.js?v=20260908a"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 81 KiB |
@@ -0,0 +1,6 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
Disallow: /my
|
||||
Disallow: /admin
|
||||
Disallow: /api/
|
||||
Sitemap: https://linkspin-test.saasy.top/sitemap.xml
|
||||
@@ -0,0 +1,50 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Shorts | LinkSpin</title>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
|
||||
<link rel="icon" type="image/png" href="/logo-icon.png">
|
||||
<link rel="stylesheet" href="/assets/site.css?v=20260910h">
|
||||
<style>
|
||||
html,body{height:100%;margin:0;overflow:hidden;background:#000}
|
||||
.sh{position:fixed;inset:0;display:flex;flex-direction:column;background:#000;color:var(--ink,#e8fff7)}
|
||||
.sh-bar{flex:0 0 auto;display:flex;align-items:center;gap:12px;padding:10px 16px;background:var(--panel-solid,#071a12);
|
||||
border-bottom:1px solid var(--line,rgba(67,232,195,.18));flex-wrap:wrap}
|
||||
.sh-brand{font-family:var(--disp,sans-serif);font-weight:800;font-size:15px}
|
||||
.sh-brand em{color:var(--mint,#43e8c3);font-style:normal}
|
||||
.sh-stat{margin-left:auto;font-family:var(--mono,monospace);font-size:13px;color:var(--muted,#8ba69c)}
|
||||
.sh-stage{flex:1;display:flex;align-items:center;justify-content:center;position:relative;min-height:0}
|
||||
.sh-video{max-height:100%;max-width:min(100%,440px);width:100%;background:#000;border-radius:0}
|
||||
.sh-over{position:absolute;left:0;right:0;bottom:0;padding:18px;background:linear-gradient(transparent,rgba(0,0,0,.75));
|
||||
display:flex;flex-direction:column;gap:10px}
|
||||
.sh-title{font-weight:700}
|
||||
.sh-timer{font-family:var(--mono,monospace);font-size:13px;color:var(--amber,#ffb238)}
|
||||
.sh-row{display:flex;gap:10px;flex-wrap:wrap}
|
||||
.sh-msg{padding:30px;text-align:center;color:var(--muted,#8ba69c);font-size:16px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="sh">
|
||||
<div class="sh-bar">
|
||||
<span class="sh-brand">Instant<em>AdPay</em> · Shorts</span>
|
||||
<span class="sh-stat" id="shStat">…</span>
|
||||
<a class="btn small sec" href="/my#earn">Done</a>
|
||||
</div>
|
||||
<div class="sh-stage" id="shStage">
|
||||
<div class="sh-msg" id="shMsg">Loading your first short…</div>
|
||||
<video class="sh-video" id="shVideo" playsinline hidden></video>
|
||||
<div class="sh-over" id="shOver" hidden>
|
||||
<div class="sh-title" id="shTitle"></div>
|
||||
<div class="sh-timer" id="shTimer"></div>
|
||||
<div class="sh-row">
|
||||
<a class="btn small" id="shCta" target="_blank" rel="noopener nofollow" hidden>Learn more</a>
|
||||
<button class="btn small sec" id="shNext" type="button" hidden>Next short →</button>
|
||||
</div>
|
||||
<a href="#" id="shReport" class="sh-report">⚠ report this short</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/assets/shorts.js?v=20260907v"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url><loc>https://instantadpay.com/</loc><changefreq>daily</changefreq><priority>1.0</priority></url>
|
||||
<url><loc>https://instantadpay.com/ledger</loc><changefreq>always</changefreq><priority>0.8</priority></url>
|
||||
<url><loc>https://instantadpay.com/contract</loc><changefreq>monthly</changefreq><priority>0.7</priority></url>
|
||||
</urlset>
|
||||
@@ -0,0 +1,42 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Terms of Service | LinkSpin</title>
|
||||
<link rel="icon" type="image/png" href="/logo-icon.png">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
|
||||
<link rel="stylesheet" href="/assets/site.css?v=20260910h">
|
||||
</head>
|
||||
<body>
|
||||
<div id="nav"></div>
|
||||
<section><div class="wrap" style="max-width:820px">
|
||||
<div class="sectionhead" style="text-align:left"><h2>Terms of Service</h2><p class="muted small">Last updated: September 2026</p></div>
|
||||
<div class="card">
|
||||
<p class="muted small">Please read these Terms carefully. By creating an account or using LinkSpin ("the Platform"), you agree to them. If you do not agree, do not use the Platform.</p>
|
||||
<h3>1. What LinkSpin is</h3>
|
||||
<p class="muted small">LinkSpin is an advertising platform with a referral program. Members buy advertising packages that mint on-chain ad credits and are delivered as banner, text, video, solo, featured, and verified-visit placements. Payments between members are split by an immutable smart contract and settle directly to members' own wallets. LinkSpin is not a bank, an investment product, a security, or a money-transmission service.</p>
|
||||
<h3>2. Eligibility</h3>
|
||||
<p class="muted small">You must be at least 18 and legally able to enter contracts where you live. You are responsible for complying with the laws of your jurisdiction, including any that restrict crypto activity. You may not use the Platform where doing so is unlawful.</p>
|
||||
<h3>3. Accounts</h3>
|
||||
<p class="muted small">You join free with an email. Keep your access secure; you are responsible for activity under your account. Your wallet is your own. We never hold, custody, or control your funds or private keys.</p>
|
||||
<h3>4. Purchases and the smart contract</h3>
|
||||
<p class="muted small">Package purchases execute on the blockchain. On-chain transactions are final and irreversible. Prices are shown in USD and settled in the network token at the live rate at the moment of purchase. The contract splits each purchase and pays members' wallets in the same transaction; LinkSpin never touches the money. You are responsible for network fees.</p>
|
||||
<h3>5. Referrals and qualification</h3>
|
||||
<p class="muted small">Referral earnings depend on real purchases in your line and on the qualification rules published on the site and enforced by the contract. Nobody earns unless real advertising is bought. We do not promise or guarantee any income.</p>
|
||||
<h3>6. Advertising rules</h3>
|
||||
<p class="muted small">Ads you submit must be lawful and must not be deceptive, adult, hateful, malicious, or infringing. Ads are auto-approved for speed; we may remove or pause any ad or campaign at any time, and members can report ads for review. You are solely responsible for the ads you run and the sites they point to.</p>
|
||||
<h3>7. Acceptable use</h3>
|
||||
<p class="muted small">No fraud, bots, fake traffic, self-dealing to farm rewards, attempts to manipulate the contract, or abuse of other members. We may suspend or terminate accounts that break these rules.</p>
|
||||
<h3>8. No warranty</h3>
|
||||
<p class="muted small">The Platform is provided "as is," without warranties of any kind. Blockchains, wallets, oracles, and third-party sites can fail or behave unexpectedly. We do not guarantee uptime, delivery volumes, or results.</p>
|
||||
<h3>9. Limitation of liability</h3>
|
||||
<p class="muted small">To the fullest extent permitted by law, LinkSpin and its operators are not liable for indirect, incidental, or consequential damages, or for losses arising from crypto volatility, irreversible transactions, third-party sites, or your own decisions.</p>
|
||||
<h3>10. Changes and termination</h3>
|
||||
<p class="muted small">We may update these Terms and the Platform, and continued use means you accept the changes. We may discontinue features. The immutable contract's rules cannot be changed by anyone, including us.</p>
|
||||
<p class="muted small" style="margin-top:18px">See also the <a href="/disclaimer">Disclaimer</a> and <a href="/privacy">Privacy Policy</a>.</p>
|
||||
</div>
|
||||
</div></section>
|
||||
<script src="/assets/common.js?v=20260914a"></script>
|
||||
<script src="/assets/legal.js?v=20260908a"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,40 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Transaction | LinkSpin</title>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
|
||||
<link rel="icon" type="image/png" href="/logo-icon.png">
|
||||
<link rel="stylesheet" href="/assets/site.css?v=20260910h">
|
||||
</head>
|
||||
<body>
|
||||
<div id="nav"></div>
|
||||
<section>
|
||||
<div class="wrap" style="max-width:860px">
|
||||
<div class="sectionhead" style="text-align:left">
|
||||
<h2>One transaction, <em>fully public</em></h2>
|
||||
<p>Pulled straight from the chain this site settles on. Every field below is read from the node,
|
||||
not from our database.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<p><span class="badge" id="txStatus">looking it up…</span>
|
||||
<span class="small muted" id="txChain"></span></p>
|
||||
<p class="mono small" id="txHash" style="overflow-wrap:anywhere"></p>
|
||||
<div class="tablewrap"><table id="txTable" hidden>
|
||||
<tbody></tbody>
|
||||
</table></div>
|
||||
<p class="small muted" id="txHint" hidden>Nothing under that hash on this chain yet. If the
|
||||
transaction just happened, give the next block a moment and refresh.</p>
|
||||
</div>
|
||||
<div class="card" id="evCard" hidden>
|
||||
<h3>What this transaction did</h3>
|
||||
<p class="muted small">The contract events inside it, decoded to plain language.</p>
|
||||
<div class="feed" id="evFeed"></div>
|
||||
</div>
|
||||
<p><a href="/ledger">← Back to the live ledger</a> · <a href="/contract">Read the contract review</a></p>
|
||||
</div>
|
||||
</section>
|
||||
<script src="/assets/common.js?v=20260914a"></script>
|
||||
<script src="/assets/tx.js?v=20260906m"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,50 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Viewing ad — LinkSpin</title>
|
||||
<link rel="icon" type="image/png" href="/logo-icon.png">
|
||||
<link rel="stylesheet" href="/assets/site.css?v=20260910h">
|
||||
<style>
|
||||
html,body{height:100%;margin:0;overflow:hidden}
|
||||
.vw{display:flex;flex-direction:column;height:100vh;height:100dvh;background:var(--bg,#04110c);color:var(--ink,#e8fff7)}
|
||||
/* right padding keeps the check buttons clear of the dashboard overlay's ✕ close button */
|
||||
.vbar{flex:0 0 auto;display:flex;align-items:center;gap:14px;padding:10px 68px 10px 16px;
|
||||
background:var(--panel-solid,#071a12);border-bottom:1px solid var(--line,rgba(67,232,195,.18));flex-wrap:wrap}
|
||||
.vbrand{font-family:var(--disp,sans-serif);font-weight:800;font-size:15px;white-space:nowrap}
|
||||
.vbrand em{color:var(--mint,#43e8c3);font-style:normal}
|
||||
.vtimer{font-family:var(--mono,monospace);font-weight:700;font-size:14px;border:1px solid var(--line-strong,rgba(67,232,195,.32));
|
||||
border-radius:999px;padding:5px 14px;min-width:104px;text-align:center}
|
||||
.vtimer.paused{color:var(--amber,#ffb238);border-color:rgba(255,178,56,.55)}
|
||||
.vtimer.done{color:var(--mint,#43e8c3)}
|
||||
.vmsg{font-size:13px;color:var(--muted,#8ba69c);flex:1;min-width:140px}
|
||||
.vcheck{display:none;align-items:center;gap:8px;flex-wrap:wrap}
|
||||
.vcheck.on{display:flex}
|
||||
.vcheck .prompt{font-size:13.5px;font-weight:700}
|
||||
.vcheck button{font-size:20px;line-height:1;background:var(--panel,rgba(9,26,19,.85));color:inherit;
|
||||
border:1px solid var(--line-strong,rgba(67,232,195,.32));border-radius:10px;padding:7px 12px;cursor:pointer;margin-right:6px}
|
||||
.vcheck button:hover,.vcheck button:focus-visible{border-color:var(--mint,#43e8c3);outline:none}
|
||||
.vdone{display:none;gap:8px;align-items:center}
|
||||
.vdone.on{display:flex}
|
||||
.vframe{flex:1;border:0;width:100%;background:#fff}
|
||||
.vfail{flex:1;display:grid;place-items:center;color:var(--muted,#8ba69c);padding:30px;text-align:center;font-size:15px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="vw">
|
||||
<div class="vbar">
|
||||
<span class="vbrand">Instant<em>AdPay</em></span>
|
||||
<span class="vtimer" id="vTimer">…</span>
|
||||
<span class="vmsg" id="vMsg">Loading the ad…</span>
|
||||
<span class="vcheck" id="vCheck"><span class="prompt" id="vPrompt"></span><span id="vOpts"></span></span>
|
||||
<span class="vdone" id="vDone">
|
||||
<a class="btn small" href="/my#earn">Back to dashboard</a>
|
||||
<button class="btn small sec" id="vClose" type="button">Close tab</button>
|
||||
</span>
|
||||
</div>
|
||||
<iframe class="vframe" id="vFrame" sandbox="allow-scripts allow-same-origin allow-forms allow-popups" referrerpolicy="no-referrer" title="Advertiser site"></iframe>
|
||||
</div>
|
||||
<script src="/assets/view.js?v=20260908b"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,53 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Banner wall | LinkSpin</title>
|
||||
<!--OG-->
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
|
||||
<link rel="icon" type="image/png" href="/logo-icon.png">
|
||||
<link rel="stylesheet" href="/assets/site.css?v=20260911w">
|
||||
</head>
|
||||
<body>
|
||||
<div id="nav"></div>
|
||||
<section>
|
||||
<div class="wrap" style="max-width:920px">
|
||||
<div class="wall-hero" style="text-align:center;padding:26px 0 18px">
|
||||
<p style="margin:0 0 8px;font-family:var(--disp);font-weight:800;font-size:clamp(22px,3vw,30px);color:var(--ink);letter-spacing:-.01em">Instant<span style="color:var(--mint)">AdPay</span> <span style="font-weight:500;font-size:.62em;color:var(--muted)">member wall</span></p>
|
||||
<h1 style="font-size:clamp(28px,4.4vw,44px);margin:0 0 10px;line-height:1.1">Advertise and earn. <em style="font-style:normal;color:var(--mint)">Paid on-chain, instantly.</em></h1>
|
||||
<p class="muted" style="max-width:640px;margin:0 auto;font-size:15.5px">Real ad packages from $5. Every payout arrives as <b style="color:var(--ink)">POL on Polygon</b> in your own wallet, in the same transaction the package sells, on a public ledger. Free to join by email, and <span id="wallHost">this member</span> is your sponsor if you join from here.</p>
|
||||
</div>
|
||||
<div class="bio-head" id="bioHead" hidden>
|
||||
<img id="bioAvatar" class="bio-avatar" alt="" hidden>
|
||||
<img id="bioBadge" class="bio-badge" alt="achievement badge" hidden>
|
||||
<div class="bio-meta">
|
||||
<p class="eyebrow">Your sponsor on LinkSpin</p>
|
||||
<h2 id="wallTitle" style="margin:.1em 0">Loading…</h2>
|
||||
<p id="bioText" class="lead" style="font-size:16px"></p>
|
||||
<div id="bioSocials" class="bio-socials"></div>
|
||||
</div>
|
||||
<div class="bio-qr"><img id="bioQr" alt="Join QR code" width="150" height="150"><div class="small muted" style="text-align:center">scan to join</div></div>
|
||||
</div>
|
||||
<div class="bio-video" id="bioVideo" hidden></div>
|
||||
<div class="sectionhead" style="text-align:left;margin-top:10px">
|
||||
<h3 style="margin:0">The line, three levels deep</h3>
|
||||
<p>Three positions, three levels — the exact levels the contract pays. Every banner here belongs
|
||||
to a real member of this line, and every payment between them settles on-chain, instantly.</p>
|
||||
</div>
|
||||
<div class="grid c3" id="wallGrid"></div>
|
||||
<div class="card" id="adSlotWallText" hidden style="margin-top:16px"></div>
|
||||
<div class="card" id="adSlotWallBanner" hidden></div>
|
||||
<div class="card" style="margin-top:22px;text-align:center">
|
||||
<h3 id="wallCtaHead">Join this line</h3>
|
||||
<p class="muted small">Free to join with just an email. Your wallet only comes out if you buy —
|
||||
and payments go straight to member wallets, never through an admin.</p>
|
||||
<p id="wallGate" class="small" style="color:var(--amber);font-weight:600" hidden></p>
|
||||
<p><a class="btn" id="wallJoin">Join free through this wall</a></p>
|
||||
<p class="small muted"><a href="/ledger">Watch the live ledger</a> · <a href="/contract">Read the contract</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<script src="/assets/common.js?v=20260914a"></script>
|
||||
<script src="/assets/wall.js?v=20260911v"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,127 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Wallets and buying POL | LinkSpin</title>
|
||||
<meta name="description" content="Which wallets to use with LinkSpin, how to set one up, and how to buy POL with a card through MoonPay. Member training.">
|
||||
<meta name="robots" content="noindex">
|
||||
<meta name="theme-color" content="#043b2f">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
|
||||
<link rel="icon" type="image/png" href="/logo-icon.png">
|
||||
<link rel="stylesheet" href="/assets/site.css?v=20260910j">
|
||||
<style>
|
||||
.wl-rule{border-left:4px solid #f2c94c;background:rgba(242,201,76,.08);padding:16px 20px;border-radius:0 12px 12px 0;margin:0 0 28px}
|
||||
.wl-rule b{font-family:var(--disp);font-size:17px;display:block;margin-bottom:6px}
|
||||
.wl-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:12px;margin:0 0 8px}
|
||||
.wl-grid>div{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:16px 18px}
|
||||
.wl-grid h3{margin:0 0 4px;font-size:18px}
|
||||
.wl-grid .tag{font-family:var(--mono);font-size:11px;color:var(--mint);border:1px solid rgba(67,232,195,.4);border-radius:999px;padding:2px 9px;margin-left:8px;vertical-align:middle}
|
||||
.wl-grid .tag.warn{color:#f2c94c;border-color:rgba(242,201,76,.5)}
|
||||
.wl-grid p{margin:6px 0 0;font-size:14px;color:var(--muted)}
|
||||
.wl-grid .rec{border-color:var(--mint);box-shadow:0 0 40px rgba(67,232,195,.1)}
|
||||
.steps{counter-reset:s;margin:0;padding:0;list-style:none}
|
||||
.steps li{counter-increment:s;position:relative;padding:0 0 16px 46px;font-size:15px;line-height:1.5}
|
||||
.steps li::before{content:counter(s);position:absolute;left:0;top:0;width:32px;height:32px;border-radius:50%;background:rgba(67,232,195,.12);border:1px solid rgba(67,232,195,.45);color:var(--mint);font-family:var(--disp);font-weight:800;display:flex;align-items:center;justify-content:center;font-size:14px}
|
||||
.steps li b{color:var(--ink)}
|
||||
.wl-links{margin:10px 0 0;display:flex;gap:6px;flex-wrap:wrap}
|
||||
.wl-links .btn{margin-top:0}
|
||||
.note{background:rgba(4,8,7,.45);border:1px solid var(--line);border-radius:10px;padding:12px 16px;font-size:14px;color:var(--muted);margin:0 0 12px}
|
||||
.note b{color:var(--ink)}
|
||||
h2{margin:34px 0 10px;font-size:26px}
|
||||
.faq details{margin:0 0 8px}
|
||||
#gate{display:none}
|
||||
@media (max-width:720px){.wl-grid{grid-template-columns:1fr}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<section class="hero" style="padding:70px 0 24px">
|
||||
<p class="eyebrow">Member training</p>
|
||||
<h1>Wallets, and <em>buying POL</em>.</h1>
|
||||
<p class="lead">Which wallet to use, how to set it up in five minutes, and how to buy POL with a debit or credit card through MoonPay so it lands in your own wallet.</p>
|
||||
</section>
|
||||
|
||||
<div class="card" id="gate"><h3>Members only</h3><p class="muted small">Sign in to your member area to read this guide. <a href="/my">Sign in</a></p></div>
|
||||
|
||||
<div id="body">
|
||||
<div class="wl-rule">
|
||||
<b>One coin, one network: POL on Polygon.</b>
|
||||
Packages are paid in POL and every payout arrives as POL in your own wallet. Nothing else is used. When you buy, choose <b>POL</b> on the <b>Polygon</b> network, never MATIC on Ethereum and never another chain. The site never holds your money: you pay the contract from your wallet and the contract pays your line in the same transaction.
|
||||
</div>
|
||||
|
||||
<h2 style="margin-top:0">Preferred wallets</h2>
|
||||
<p class="muted" style="margin:0 0 14px">Any wallet that supports the Polygon network works. These are the ones we have tested end to end.</p>
|
||||
<div class="wl-grid">
|
||||
<div class="rec">
|
||||
<h3>MetaMask <span class="tag">recommended</span></h3>
|
||||
<p>Browser extension on desktop, app on phone. The one to use for Qualified Start, because it lets you add extra accounts under the same wallet, one per position. Connects directly on desktop and through WalletConnect on mobile.</p>
|
||||
<p class="wl-links"><a class="btn small sec" href="https://metamask.io/download" target="_blank" rel="noopener">Website</a> <a class="btn small sec" href="https://apps.apple.com/app/id1438144202" target="_blank" rel="noopener">App Store</a> <a class="btn small sec" href="https://play.google.com/store/apps/details?id=io.metamask" target="_blank" rel="noopener">Google Play</a> <a class="btn small sec" href="https://metamask.io/download" target="_blank" rel="noopener">Browser extension</a></p>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Phantom</h3>
|
||||
<p>Clean phone and desktop apps, no purchase-size block. Good choice if you are new and only ever plan to run one position. <b>One setup step:</b> Phantom ships with Polygon switched off. Open Settings, then Active Networks (Developer Settings on some versions), and turn Polygon on before you connect. Until it is on, the connect and buy buttons here will fail or show the wrong network.</p>
|
||||
<p class="wl-links"><a class="btn small sec" href="https://phantom.com/download" target="_blank" rel="noopener">Website</a> <a class="btn small sec" href="https://apps.apple.com/app/id1598432977" target="_blank" rel="noopener">App Store</a> <a class="btn small sec" href="https://play.google.com/store/apps/details?id=app.phantom" target="_blank" rel="noopener">Google Play</a> <a class="btn small sec" href="https://phantom.com/download" target="_blank" rel="noopener">Browser extension</a></p>
|
||||
</div>
|
||||
<div>
|
||||
<h3>SafePal</h3>
|
||||
<p>Phone app with a built-in card purchase option for POL. Connects through WalletConnect. Works well for members who do everything on a phone.</p>
|
||||
<p class="wl-links"><a class="btn small sec" href="https://www.safepal.com/download" target="_blank" rel="noopener">Website</a> <a class="btn small sec" href="https://apps.apple.com/app/id1548297139" target="_blank" rel="noopener">App Store</a> <a class="btn small sec" href="https://play.google.com/store/apps/details?id=io.safepal.wallet" target="_blank" rel="noopener">Google Play</a></p>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Coinbase Wallet</h3>
|
||||
<p>The self-custody wallet from Coinbase, not the exchange account. Handy if you already buy crypto on Coinbase: buy POL there and send it to this wallet on the Polygon network.</p>
|
||||
<p class="wl-links"><a class="btn small sec" href="https://www.coinbase.com/wallet" target="_blank" rel="noopener">Website</a> <a class="btn small sec" href="https://apps.apple.com/app/id1278383455" target="_blank" rel="noopener">App Store</a> <a class="btn small sec" href="https://play.google.com/store/apps/details?id=org.toshi" target="_blank" rel="noopener">Google Play</a> <a class="btn small sec" href="https://www.coinbase.com/wallet" target="_blank" rel="noopener">Browser extension</a></p>
|
||||
</div>
|
||||
<div style="grid-column:1/-1">
|
||||
<h3>Trust Wallet <span class="tag warn">works, with one catch</span></h3>
|
||||
<p>Trust Wallet blocks any purchase that would spend most of the POL in the wallet. You see a red "this transaction will drain your wallet" screen with no way past it. If you use Trust, keep about <b>twice the package cost</b> in POL, or buy a smaller package first. The extra POL stays yours. If a blocked attempt leaves the connection dead, open Wallet, tap Disconnect, then Connect again.</p>
|
||||
<p class="wl-links"><a class="btn small sec" href="https://trustwallet.com/download" target="_blank" rel="noopener">Website</a> <a class="btn small sec" href="https://apps.apple.com/app/id1288339409" target="_blank" rel="noopener">App Store</a> <a class="btn small sec" href="https://play.google.com/store/apps/details?id=com.wallet.crypto.trustapp" target="_blank" rel="noopener">Google Play</a> <a class="btn small sec" href="https://trustwallet.com/download" target="_blank" rel="noopener">Browser extension</a></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Set up a wallet in five minutes</h2>
|
||||
<ol class="steps">
|
||||
<li><b>Install it from the official source only.</b> MetaMask: metamask.io. Phantom: phantom.app. SafePal: safepal.io. Coinbase Wallet: wallet.coinbase.com. On a phone, use the app store listing those sites point to.</li>
|
||||
<li><b>Create a new wallet</b> and set a device password or PIN.</li>
|
||||
<li><b>Write down the recovery phrase</b> (12 or 24 words) on paper and keep it offline. Anyone who has those words has your money. Nobody from LinkSpin will ever ask for them, and the site never sees them.</li>
|
||||
<li><b>Make sure Polygon is available.</b> MetaMask asks to switch to Polygon the first time LinkSpin needs it; approve that. SafePal and Coinbase Wallet include Polygon already. Phantom has Polygon built in but switched off by default: Settings, then Active Networks, turn on Polygon.</li>
|
||||
<li><b>Copy your address.</b> It starts with 0x and is the same on every EVM network. This is where your POL goes and where your payouts arrive.</li>
|
||||
<li><b>Link it in Members</b>: open the Wallet tab, tap Connect, and sign the free message. Then <b>Switch on payouts</b>, one small transaction that registers your address with the contract. Do both before your people start buying, so their first purchase pays you.</li>
|
||||
</ol>
|
||||
|
||||
<h2>Buy POL with a card through MoonPay</h2>
|
||||
<p class="muted" style="margin:0 0 14px">MoonPay is a licensed card-to-crypto service. You pay them, they send POL to your wallet. LinkSpin is never in the middle of that payment.</p>
|
||||
<p style="margin:0 0 18px"><button class="btn" id="wlMoonpay" type="button">💳 Buy POL with a card (MoonPay)</button> <span class="small muted" id="wlMoonpayNote">Opens in a new tab with POL on Polygon selected.</span></p>
|
||||
<ol class="steps">
|
||||
<li><b>Have your wallet connected first</b> (Wallet tab). Then open <b>Buy packages</b> and look at the live POL price for the package you want. Plan to buy that amount <b>plus 2 or 3 POL</b> for network fees. Trust Wallet users: buy about double.</li>
|
||||
<li><b>Tap "Buy POL with a card"</b> under the packages. MoonPay opens in a new tab with <b>POL on Polygon</b> selected and <b>your own wallet address filled in</b>. If for any reason it is not filled in, choose POL (Polygon) yourself and paste your address from step 5 above.</li>
|
||||
<li><b>Enter the amount</b> in dollars or POL and pay with a debit card, credit card, Apple Pay or Google Pay. MoonPay has a minimum order, usually around $30, so the $5 package on its own is below it. Buy enough for the package you actually want.</li>
|
||||
<li><b>First time only: identity check.</b> MoonPay asks for your email, phone and a photo ID. This is their legal requirement, not ours. It normally takes a few minutes; occasionally a review takes longer, and MoonPay emails you when it clears.</li>
|
||||
<li><b>Wait for the POL to land.</b> Usually a few minutes. Your wallet balance shows on the Wallet tab and next to your positions.</li>
|
||||
<li><b>Go back to Buy packages</b> and buy. The wallet asks you to confirm one transaction. The moment it settles, your credits are minted and your line is paid.</li>
|
||||
</ol>
|
||||
|
||||
<div class="note"><b>Already own crypto on an exchange?</b> Coinbase, Kraken, Binance and most others sell POL. Withdraw it to your wallet address and pick the <b>Polygon</b> network on the withdrawal screen. Sending on the wrong network can lose the funds.</div>
|
||||
<div class="note"><b>Prefer to buy inside the wallet?</b> MetaMask, Trust, SafePal and Phantom all have a Buy button that uses MoonPay or a similar provider. Same rule: POL, Polygon network, your own address.</div>
|
||||
|
||||
<h2>Common questions</h2>
|
||||
<div class="faq">
|
||||
<details><summary>Do I need a wallet to join?</summary><p>No. Join with your email. The wallet comes out only when you buy a package or switch on payouts.</p></details>
|
||||
<details><summary>How much POL do I need?</summary><p>The Buy packages tab shows the live POL cost of each package. Buy that plus 2 or 3 POL for fees. There is no other cost.</p></details>
|
||||
<details><summary>Why did MoonPay decline my card?</summary><p>Some banks block crypto purchases. Try a different card, Apple Pay or Google Pay, or buy on an exchange and withdraw to your wallet on Polygon.</p></details>
|
||||
<details><summary>Can I use the same wallet for more than one position?</summary><p>One address is one position on the contract. For Qualified Start, add extra accounts inside MetaMask, each with its own address, and link them on the Buy packages tab. The three Qualified Start videos in Training walk through it.</p></details>
|
||||
<details><summary>Where do my payouts go?</summary><p>To the wallet address you switched payouts on with, in the same transaction as the purchase that earned them. Nothing is held on the site.</p></details>
|
||||
</div>
|
||||
|
||||
<p class="small muted" style="margin:26px 0 0">Advertising, not investing. No income is guaranteed; results depend on your effort. Crypto transactions are irreversible and carry risk of loss. MoonPay is an independent, licensed provider; its fees and limits are its own.</p>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<div>LinkSpin · <a href="/my#training">back to Training</a> · <a href="/my#buy">Buy packages</a> · <a href="/my#wallet">Wallet</a></div>
|
||||
<div class="small">Advertising services with a performance referral program. Not an investment product; no income guarantees. Crypto transactions are irreversible. Never share your recovery phrase.</div>
|
||||
</footer>
|
||||
</div>
|
||||
<script src="/assets/common.js?v=20260914a"></script>
|
||||
<script src="/assets/wallets.js?v=20260911b"></script>
|
||||
</body>
|
||||
</html>
|
||||