← back to Wallco Ai
public/admin/etsy-setup.html
245 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Etsy activation — wallco.ai admin</title>
<style>
:root{--bg:#0e0f10;--panel:#17191b;--line:#272b2e;--ink:#e9ecef;--mut:#9aa3ab;
--ok:#7bc96f;--bad:#ff4d4d;--etsy:#ffb86b;--blue:#7ec8ff;}
*{box-sizing:border-box;}
body{margin:0;background:var(--bg);color:var(--ink);
font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;}
header{position:sticky;top:0;z-index:20;background:rgba(14,15,16,.96);
backdrop-filter:blur(8px);border-bottom:1px solid var(--line);
padding:14px 20px;display:flex;gap:14px;align-items:center;flex-wrap:wrap;}
h1{font-size:18px;margin:0;font-weight:650;letter-spacing:.2px;} h1 .ico{color:var(--etsy);}
.pill{background:var(--panel);border:1px solid var(--line);border-radius:999px;
padding:3px 10px;font-size:12px;color:var(--mut);} .pill b{color:var(--ink);}
main{max-width:920px;margin:0 auto;padding:24px 20px 80px;}
.matrix{display:grid;grid-template-columns:auto 1fr auto;gap:0;
background:var(--panel);border:1px solid var(--line);border-radius:12px;overflow:hidden;}
.step{display:contents;}
.step > div{padding:18px 20px;border-top:1px solid var(--line);}
.step:first-child > div{border-top:none;}
.step .num{width:48px;text-align:center;font:700 16px ui-monospace,monospace;
background:#0a0b0c;color:var(--mut);border-right:1px solid var(--line);
display:flex;align-items:center;justify-content:center;}
.step.done .num{background:#143b1d;color:var(--ok);}
.step .body{display:flex;flex-direction:column;gap:8px;}
.step .body h3{margin:0;font-size:15px;font-weight:650;display:flex;gap:8px;align-items:center;}
.step .body p{margin:0;color:var(--mut);font-size:13px;}
.step .body code{background:#0a0b0c;padding:2px 7px;border-radius:4px;font:12px ui-monospace,monospace;color:var(--blue);}
.step .body input[type=text]{background:#0a0b0c;color:var(--ink);
border:1px solid var(--line);border-radius:7px;padding:7px 11px;
font:13px ui-monospace,monospace;width:100%;max-width:520px;margin-top:6px;}
.step .ctl{display:flex;align-items:center;justify-content:flex-end;border-left:1px solid var(--line);
min-width:200px;background:#0c0d0e;}
.step .ctl > div{display:flex;flex-direction:column;gap:6px;align-items:flex-end;}
.status{font-size:11px;letter-spacing:.06em;text-transform:uppercase;font-weight:700;
padding:3px 10px;border-radius:999px;background:#1c2024;color:var(--mut);}
.status.ok{background:#143b1d;color:var(--ok);}
.status.warn{background:#3b2e08;color:#f0b400;}
.status.bad{background:#3b0e0e;color:var(--bad);}
button.act{font:700 13px inherit;padding:8px 14px;border-radius:7px;cursor:pointer;
background:#1d3b1d;color:var(--ok);border:1px solid #2a6b35;}
button.act:hover:not(:disabled){background:var(--ok);color:#0a0b0c;}
button.act.amber{background:#3b2e08;color:#f0b400;border-color:#5a4710;}
button.act.amber:hover:not(:disabled){background:#f0b400;color:#0a0b0c;}
button.act.blue{background:#162b3b;color:var(--blue);border-color:#365778;}
button.act.blue:hover:not(:disabled){background:var(--blue);color:#0a0b0c;}
button.act:disabled{opacity:.4;cursor:not-allowed;}
a.act{font:700 13px inherit;padding:8px 14px;border-radius:7px;cursor:pointer;text-decoration:none;text-align:center;display:inline-block;
background:#3b2e08;color:#f0b400;border:1px solid #5a4710;}
a.act:hover{background:#f0b400;color:#0a0b0c;}
.ready{margin-top:24px;padding:20px;background:linear-gradient(180deg,#143b1d22,transparent);
border:1px solid #2a6b35;border-radius:12px;text-align:center;}
.ready h2{margin:0 0 8px;color:var(--ok);font-size:18px;}
.ready p{margin:0 0 12px;color:var(--mut);font-size:13px;}
.ready a.go{display:inline-block;background:var(--ok);color:#0a0b0c;
padding:10px 22px;border-radius:9px;font-weight:700;text-decoration:none;}
.toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);
background:#17191b;border:1px solid var(--line);color:var(--ink);
padding:10px 18px;border-radius:9px;z-index:30;font-size:13px;box-shadow:0 6px 24px rgba(0,0,0,.5);}
.toast.ok{border-color:#2a6b35;color:var(--ok);}
.toast.bad{border-color:#5a1515;color:var(--bad);}
.small{font-size:11.5px;color:var(--mut);font-family:ui-monospace,monospace;}
.count{background:var(--panel);border:1px solid var(--line);border-radius:8px;
padding:8px 14px;display:flex;gap:18px;font-size:13px;}
.count b{color:var(--ink);font-weight:700;font-variant-numeric:tabular-nums;}
</style>
</head>
<body>
<header>
<h1><span class="ico">🛒</span> Etsy activation matrix</h1>
<span class="pill" id="overall">checking…</span>
<span class="count" id="counts" style="display:none">
<span><b id="bktN">0</b> in bucket</span>
<span><b id="bdlN">0</b> bundles ready</span>
</span>
</header>
<main>
<div class="matrix" id="matrix">
<!-- Step 1 — create the Etsy app -->
<div class="step" id="step-app">
<div class="num">1</div>
<div class="body">
<h3>Create an Etsy app</h3>
<p>Go to the Etsy Developer portal and create a new app. Set the <b>Callback URL</b> to exactly: <code>http://localhost:3008/etsy-oauth-callback</code></p>
<p>Copy the <b>Keystring</b> (= your API key) — you'll paste it in Step 2.</p>
</div>
<div class="ctl"><div>
<a class="act" href="https://www.etsy.com/developers/your-apps" target="_blank" rel="noopener noreferrer">Open Etsy developer portal ↗</a>
<span class="status" id="st-app">manual</span>
</div></div>
</div>
<!-- Step 2 — paste API key -->
<div class="step" id="step-key">
<div class="num">2</div>
<div class="body">
<h3>Paste your Etsy API key</h3>
<p>The Keystring from Step 1. Saved to <code>~/Projects/secrets-manager/.env</code>.</p>
<input id="keyInput" type="text" placeholder="paste keystring (16–128 chars)" autocomplete="off" spellcheck="false">
<span class="small" id="keyMask"></span>
</div>
<div class="ctl"><div>
<button class="act" id="saveKey">Save key</button>
<span class="status" id="st-key">missing</span>
</div></div>
</div>
<!-- Step 3 — OAuth dance -->
<div class="step" id="step-oauth">
<div class="num">3</div>
<div class="body">
<h3>Authorize the app for your shop</h3>
<p>Opens Etsy in a new tab. Log in, click <b>Allow access</b>. We capture the callback automatically and write the tokens.</p>
<p class="small">Spawns <code>scripts/etsy-oauth-helper.js</code>; log at <code>data/etsy-oauth.log</code>.</p>
</div>
<div class="ctl"><div>
<button class="act amber" id="startOauth">Start OAuth →</button>
<span class="status" id="st-oauth">waiting</span>
</div></div>
</div>
<!-- Step 4 — confirm shop_id -->
<div class="step" id="step-shop">
<div class="num">4</div>
<div class="body">
<h3>Confirm shop ID</h3>
<p>OAuth normally auto-discovers your <code>ETSY_SHOP_ID</code>. If it didn't (sometimes Etsy returns a different shape), paste it here — find it at <code>etsy.com/your/shops/me/dashboard</code> → URL or the JSON dump.</p>
<input id="shopInput" type="text" placeholder="numeric shop id (4–12 digits)" autocomplete="off" spellcheck="false">
</div>
<div class="ctl"><div>
<button class="act blue" id="saveShop">Save shop id</button>
<span class="status" id="st-shop">missing</span>
</div></div>
</div>
<!-- Step 5 — test connectivity -->
<div class="step" id="step-test">
<div class="num">5</div>
<div class="body">
<h3>Test connectivity</h3>
<p>Calls <code>GET /v3/application/users/me</code> with the stored token. 200 → green.</p>
<pre class="small" id="testOut" style="background:#0a0b0c;padding:8px 11px;border-radius:6px;margin:0;display:none;max-height:200px;overflow:auto"></pre>
</div>
<div class="ctl"><div>
<button class="act" id="testBtn">Test now</button>
<span class="status" id="st-test">untested</span>
</div></div>
</div>
</div>
<div class="ready" id="ready" style="display:none">
<h2>✓ Account activated</h2>
<p>You're ready to push bundles to Etsy. Each push generates assets just-in-time and creates a draft listing.</p>
<a class="go" href="" id="goBucket">→ Open the Etsy bucket</a>
</div>
</main>
<script>
const ADMIN=new URLSearchParams(location.search).get('admin');
// q(path) — build a fetchable URL. Anchors '/'-prefixed paths to location.origin
// so URLs are credentials-free; the Fetch spec rejects Request construction
// when the resolved URL contains userinfo (happens when the page was loaded
// via http://admin:pw@host/...). Appends ?admin=<token> when present.
const q = s => {
const path = s.startsWith('/') ? location.origin + s : s;
return ADMIN ? path + (path.includes('?') ? '&' : '?') + 'admin=' + encodeURIComponent(ADMIN) : path;
};
const $=id=>document.getElementById(id);
function setStatus(id, label, kind){ const e=$('st-'+id); if(e){ e.textContent=label; e.className='status '+(kind||''); } }
function toast(msg, kind='ok'){
const t=document.createElement('div'); t.className='toast '+kind; t.textContent=msg;
document.body.appendChild(t); setTimeout(()=>t.remove(), 4000);
}
async function refresh(){
const r=await fetch(q('/api/etsy-setup/status'));
if(!r.ok){ $('overall').innerHTML='⚠ '+r.status+(r.status===404?' (append ?admin=TOKEN)':''); return null; }
const j=await r.json();
const s=j.steps;
setStatus('app', 'manual', s.client_id.ok ? 'ok' : ''); // step 1 = the act of creating the app; we infer done once a key is saved
setStatus('key', s.client_id.ok ? 'saved' : 'missing', s.client_id.ok ? 'ok' : 'bad');
setStatus('oauth', s.access_token.ok ? 'authorized' : 'waiting', s.access_token.ok ? 'ok' : (s.client_id.ok ? 'warn' : 'bad'));
setStatus('shop', s.shop_id.ok ? `id ${s.shop_id.value}` : 'missing', s.shop_id.ok ? 'ok' : 'warn');
if(s.client_id.ok) $('keyMask').textContent = 'saved: '+s.client_id.value;
if(s.shop_id.ok) $('shopInput').placeholder = 'current: '+s.shop_id.value;
// overall pill
const doneCt = Object.values(s).filter(x=>x.ok).length;
$('overall').innerHTML = `<b>${doneCt}</b>/4 steps complete`;
$('counts').style.display='inline-flex';
$('bktN').textContent = j.counts.bucket_queued;
$('bdlN').textContent = j.counts.bundles_ready;
// Show ready banner when fully wired (client_id + access + shop_id at min)
if(s.client_id.ok && s.access_token.ok && s.shop_id.ok){
$('ready').style.display='block';
$('goBucket').href = q('/admin/etsy-bucket');
}
return j;
}
$('saveKey').onclick = async () => {
const v=$('keyInput').value.trim(); if(!v){ toast('paste a key first', 'bad'); return; }
const r=await fetch(q('/api/etsy-setup/save-key'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ETSY_CLIENT_ID:v})});
const j=await r.json();
if(!r.ok){ toast('save failed: '+(j.error||r.status),'bad'); return; }
$('keyInput').value=''; toast('✓ key saved'); refresh();
};
$('saveShop').onclick = async () => {
const v=$('shopInput').value.trim(); if(!v){ toast('paste your shop id','bad'); return; }
const r=await fetch(q('/api/etsy-setup/save-shop-id'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ETSY_SHOP_ID:v})});
const j=await r.json();
if(!r.ok){ toast('save failed: '+(j.error||r.status),'bad'); return; }
$('shopInput').value=''; toast('✓ shop id saved'); refresh();
};
$('startOauth').onclick = async () => {
const r=await fetch(q('/api/etsy-setup/start-oauth'),{method:'POST'});
const j=await r.json();
if(!r.ok){ toast('start failed: '+(j.error||r.status),'bad'); return; }
toast(j.already_running ? '↻ OAuth already running (pid '+j.pid+')' : '→ OAuth helper started — check the browser tab Etsy just opened');
// Poll every 3s for up to 5 min; refresh status whenever tokens land.
const t0=Date.now();
(function loop(){
refresh().then(j2=>{
if(j2 && j2.steps.access_token.ok) toast('✓ tokens captured — OAuth complete');
else if(Date.now()-t0 < 5*60*1000) setTimeout(loop, 3000);
});
})();
};
$('testBtn').onclick = async () => {
setStatus('test','testing…',''); $('testOut').style.display='none';
const r=await fetch(q('/api/etsy-setup/test'));
const j=await r.json();
$('testOut').textContent=JSON.stringify(j.body, null, 2); $('testOut').style.display='block';
if(r.ok && j.ok){ setStatus('test','✓ '+j.status,'ok'); toast('✓ /users/me returned '+j.status); }
else { setStatus('test','✗ '+(j.status||'error'),'bad'); toast('test failed: '+(j.body?.error||j.status||'?'),'bad'); }
};
refresh();
</script>
<script src="/admin/admin-modal-kit.js" defer></script>
</body>
</html>