← 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>