← back to Analytics Chips

public/categories.html

235 lines

<!doctype html>
<html lang="en"><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Fleet · Categories</title>
<style>
  :root {
    --bg: #0d0d0e; --bg-2: #161618; --bg-3: #1f1f22;
    --ink: #e9e9eb; --ink-mute: #8a8a92; --ink-faint: #5a5a62;
    --rule: #2a2a2e; --gold: #d4a04a; --good: #8fb89a; --warn: #d8b04a; --bad: #c87a6e;
    --serif: ui-serif, Georgia, "Times New Roman", serif;
    --mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
    --card-min: 240px;
  }
  * { box-sizing: border-box; }
  html, body { margin: 0; padding: 0; background: var(--bg); color: var(--ink); font: 400 14px/1.45 -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif; min-height: 100vh; }
  a { color: var(--gold); text-decoration: none; }
  a:hover { text-decoration: underline; }

  header {
    display: flex; align-items: baseline; gap: 14px; padding: 18px 26px;
    border-bottom: 1px solid var(--rule); background: var(--bg-2); position: sticky; top: 0; z-index: 5; flex-wrap: wrap;
  }
  header h1 { margin: 0; font-family: var(--serif); font-size: 22px; font-weight: 500; }
  header h1 em { font-style: italic; color: var(--gold); }
  header nav { display: flex; gap: 14px; margin-left: 16px; }
  header nav a { font-family: var(--mono); font-size: 11px; letter-spacing: 0.10em; text-transform: uppercase; color: var(--ink-mute); }
  header nav a.active { color: var(--gold); }
  header .controls { display: flex; gap: 10px; align-items: center; margin-left: auto; flex-wrap: wrap; }
  header .controls input { background: var(--bg-3); color: var(--ink); border: 1px solid var(--rule); padding: 7px 12px; border-radius: 2px; font-family: var(--mono); font-size: 12px; width: 220px; outline: none; }
  header .controls input:focus { border-color: var(--gold); }
  header .controls select { background: var(--bg-3); color: var(--ink); border: 1px solid var(--rule); padding: 7px 12px; border-radius: 2px; font-family: var(--mono); font-size: 11px; cursor: pointer; }
  header .controls .density { display: flex; align-items: center; gap: 6px; font-family: var(--mono); font-size: 10px; color: var(--ink-mute); letter-spacing: 0.10em; text-transform: uppercase; }
  header .controls input[type=range] { width: 110px; cursor: pointer; }
  header .meta { font-family: var(--mono); font-size: 11px; color: var(--ink-mute); letter-spacing: 0.10em; text-transform: uppercase; width: 100%; padding-top: 6px; }
  header .meta strong { color: var(--gold); font-weight: 600; }

  main { padding: 22px 26px 60px; max-width: 1600px; margin: 0 auto; }

  .cat { margin-bottom: 38px; }
  .cat h2 {
    font-family: var(--serif); font-size: 18px; font-weight: 500; margin: 0 0 6px;
    display: flex; align-items: baseline; gap: 12px; padding-bottom: 8px; border-bottom: 1px solid var(--rule);
  }
  .cat h2 em { color: var(--gold); font-style: italic; }
  .cat h2 .ct { font-family: var(--mono); font-size: 10px; letter-spacing: 0.14em; color: var(--ink-faint); text-transform: uppercase; margin-left: auto; }
  .cat .blurb {
    font-family: var(--serif); font-size: 13px; color: var(--ink-mute); line-height: 1.55;
    margin: 8px 0 14px; padding-left: 12px; border-left: 2px solid var(--gold); max-width: 920px;
  }
  .cat .blurb b { color: var(--ink); font-weight: 500; }

  .chips { display: grid; grid-template-columns: repeat(auto-fill, minmax(var(--card-min), 1fr)); gap: 10px; }
  .chip {
    background: var(--bg-2); border: 1px solid var(--rule); border-radius: 4px;
    padding: 12px 14px 14px; transition: transform 120ms ease, border-color 120ms ease, background 120ms ease;
    position: relative; overflow: hidden; display: flex; flex-direction: column; gap: 6px; min-height: 100px;
  }
  .chip:hover { transform: translateY(-1px); border-color: var(--gold); background: var(--bg-3); }
  .chip .accent { position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--gold); }
  .chip .row1 { display: flex; align-items: baseline; gap: 8px; }
  .chip .name { font-family: var(--serif); font-size: 14px; font-weight: 500; flex: 1; }
  .chip .gid { font-family: var(--mono); font-size: 9px; color: var(--ink-faint); letter-spacing: 0.06em; padding: 2px 5px; background: var(--bg-3); border-radius: 2px; }
  .chip .domain { font-family: var(--mono); font-size: 10px; color: var(--ink-mute); letter-spacing: 0.04em; }
  .chip .metrics { display: flex; gap: 10px; margin-top: auto; padding-top: 8px; border-top: 1px dashed var(--rule); }
  .chip .metric { display: flex; flex-direction: column; }
  .chip .metric .num { font-family: var(--mono); font-size: 13px; color: var(--ink); font-weight: 600; line-height: 1; }
  .chip .metric .lbl { font-family: var(--mono); font-size: 8px; letter-spacing: 0.10em; text-transform: uppercase; color: var(--ink-faint); margin-top: 3px; }
  .chip .empty-msg { font-family: var(--mono); font-size: 10px; color: var(--ink-faint); font-style: italic; margin-top: auto; padding-top: 8px; border-top: 1px dashed var(--rule); }
  .footer { text-align: center; padding: 24px 0; color: var(--ink-faint); font-family: var(--mono); font-size: 10px; letter-spacing: 0.14em; text-transform: uppercase; }
</style>
</head><body>

<header>
  <h1>Fleet <em>Categories</em></h1>
  <nav>
    <a href="/">all chips</a>
    <a href="/categories.html" class="active">categories</a>
  </nav>
  <div class="controls">
    <input id="filter" placeholder="filter by domain…" autocomplete="off">
    <select id="sort">
      <option value="alpha">A→Z</option>
      <option value="users">Users 7d ↓</option>
      <option value="sessions">Sessions 7d ↓</option>
    </select>
    <span class="density">density <input id="density" type="range" min="160" max="380" step="20"></span>
  </div>
  <div class="meta"><strong id="ct">…</strong> properties · <strong id="users-total">…</strong> users · <strong id="sessions-total">…</strong> sessions · <span id="status">loading</span></div>
</header>

<main id="cats"><div class="empty" style="text-align:center;padding:80px 0;color:var(--ink-faint);font-style:italic">Loading fleet…</div></main>

<div class="footer">analytics-chips · :9786 · /categories</div>

<script>
const $ = (s) => document.querySelector(s);
const esc = (s) => String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
const fmtNum = (n) => n==null?'—' : n>=1000 ? (n/1000).toFixed(1)+'k' : String(n);

const CATEGORIES = [
  {
    id: 'material',
    title: 'Material-first wallcovering niches',
    blurb: 'SEO-pure intent. Someone searching "silk wallpaper" has self-selected to the top of the trade funnel — they want the material, not a brand. Each domain rank-targets its term against a dw_unified subset filtered by niche keyword. <b>Cheap to operate, zero brand-building cost</b>, and every site narrows the funnel to memo-sample requests. Risk: doorway-page penalty if templates look identical — that\'s why per-site visual differentiation matters.',
    matches: ['silkwallpaper','silkwallcoverings','linenwallpaper','jutewallpaper','raffiawallcoverings','raffiawalls','corkwallcovering','fabricwallpaper','vinylwallpaper','suedewallpaper','micawallpaper','glassbeadedwallpaper','embroideredwallpaper','goldleafwallpaper','silverleafwallpaper','metallicwallpaper','mylarwallpaper','glitterwalls','grassclothwallpaper','grassclothwallcoverings','recycledwallpaper','flockedwallpaper','blockprintedwallpaper','screenprintedwallpaper','handcraftedwallpaper','pastelwallpaper','naturalwallcoverings','greenwallcoverings','stringwallpaper','madagascarwallpaper'],
  },
  {
    id: 'decade',
    title: 'Decade niches',
    blurb: 'Captures both restoration buyers (period-correct) and aesthetic shoppers (cottagecore, midcentury, deco). Shopify products tag once, surface on the matching decade site. Monetizable on long-tail "<b>1920s art deco wallpaper</b>" type queries that big-box retailers don\'t optimize for.',
    matches: ['1800swallpaper','1890swallpaper','1900swallpaper','1920swallpaper','1930swallpaper','1940swallpaper','1950swallpaper','1960swallpaper','1970swallpaper','1980swallpaper','retrowalls','restorationwallpaper','agedwallpaper','saloonwallpaper','wallpapersback','museumwallpaper'],
  },
  {
    id: 'usecase',
    title: 'Use-case / vertical niches',
    blurb: 'B2B specifier traffic. A hotel architect doesn\'t care about your brand — they care that the line is rated <b>Type II vinyl, fire-code compliant, in-stock</b>. Each site filters dw_unified to the contract-grade subset. Higher AOV than residential, longer sales cycles, but the PO is six-figure.',
    matches: ['hospitalitywallpaper','hospitalitywallcoverings','healthcarewallpaper','hotelwallcoverings','restaurantwallpaper','contractwallpaper','apartmentwallpaper','architecturalwallcoverings','selfadhesivewallpaper','textilewallpaper'],
  },
  {
    id: 'brand',
    title: 'Brand storefronts',
    blurb: 'Full-brand experiences, not SEO funnels. dw-site-build playbook with editorial copy, room settings, lookbooks. Conversion comes from <b>designer aesthetic, not search intent</b>. Different KPI: time-on-site + memo-sample rate, not bounce rate.',
    matches: ['philipperomano','novasuede','hollywoodwallcoverings','hollywood-wallcoverings','wallpaperondemand','wallsondemand','muralsondemand','wallpapercanada','fabricsondemand'],
  },
  {
    id: 'directory',
    title: 'Directories / marketplaces',
    blurb: 'Network-effect plays. Each row is a record someone wants to claim or contact. Revenue model is <b>claim-fee + lead-routing</b>, not transactional. Standalone PG schemas, own auth/admin layer — different project style from the wallpaper microsites.',
    matches: ['nationalpaperhangers','wholivedthere','claimmyaddress','bubbesblock','losgardeners','ffepurchasing','studentdebtlawyers','lawyers'],
  },
  {
    id: 'tool',
    title: 'Tools / utilities',
    blurb: 'Either lead-capture tools that funnel into the directories, or experimental brand bets. Mostly client-side widgets, cheap to maintain. Either they hit, or they don\'t — <b>low-cost optionality</b>.',
    matches: ['boomercalc','67calc','goodquestion','bubbe','deadradio','iwascute','i-was-cute'],
  },
  {
    id: 'platform',
    title: 'Domain assets / platform',
    blurb: '<b>Steve-as-platform.</b> agentabrams.com is the umbrella domain that hosts subdomain spinoffs (forza, lawyers, deadradio). Lets you ship a new brand in 20 minutes without buying a new domain. greendomainbrokers is the meta-business if any of these get acquisition interest.',
    matches: ['agentabrams','greendomainbrokers','forza','nosarabeachfrontrentals'],
  },
];

let RAW = [];

function bareName(domain) {
  return String(domain || '').replace(/^www\./,'').replace(/\.[a-z]{2,}$/,'').toLowerCase();
}

function categorize(domain) {
  const stem = bareName(domain);
  for (const cat of CATEGORIES) {
    if (cat.matches.includes(stem)) return cat.id;
  }
  return 'uncat';
}

function applyDensity(px) {
  document.documentElement.style.setProperty('--card-min', px + 'px');
  $('#density').value = px;
  localStorage.setItem('cat.density', px);
}

function render() {
  const filter = $('#filter').value.trim().toLowerCase();
  const sort = $('#sort').value;
  const filtered = RAW.filter(p => !filter || p.domain.toLowerCase().includes(filter));
  filtered.sort((a,b) => sort==='users' ? (b.users||0)-(a.users||0)
                       : sort==='sessions' ? (b.sessions||0)-(a.sessions||0)
                       : a.domain.localeCompare(b.domain));

  const buckets = {};
  for (const p of filtered) {
    const c = categorize(p.domain);
    (buckets[c] = buckets[c] || []).push(p);
  }

  const groups = CATEGORIES.concat([{id:'uncat',title:'Uncategorized',blurb:'Properties not yet mapped — add their stem to the matching category in <code>categories.html</code> CATEGORIES.matches.',matches:[]}]);

  const html = groups.map(cat => {
    const items = buckets[cat.id] || [];
    if (!items.length && cat.id !== 'uncat') return '';
    if (!items.length) return '';
    return `<section class="cat" id="cat-${cat.id}">
      <h2>${esc(cat.title)} <em>·</em> ${items.length} <span class="ct">${items.length} of fleet</span></h2>
      <div class="blurb">${cat.blurb}</div>
      <div class="chips">${items.map(p => `
        <a class="chip" href="https://${esc(p.domain)}" target="_blank" rel="noopener noreferrer">
          <span class="accent"></span>
          <div class="row1"><div class="name">${esc(bareName(p.domain))}</div><div class="gid">${esc(p.gid||'')}</div></div>
          <div class="domain">${esc(p.domain)}</div>
          ${p.users || p.sessions ? `<div class="metrics">
            <div class="metric"><div class="num">${fmtNum(p.users)}</div><div class="lbl">users</div></div>
            <div class="metric"><div class="num">${fmtNum(p.sessions)}</div><div class="lbl">sessions</div></div>
            <div class="metric"><div class="num">${fmtNum(p.pageviews)}</div><div class="lbl">views</div></div>
          </div>` : `<div class="empty-msg">no traffic last 7d</div>`}
        </a>`).join('')}</div>
    </section>`;
  }).join('');

  $('#cats').innerHTML = html || '<div class="empty" style="text-align:center;padding:80px 0;color:var(--ink-faint);font-style:italic">No properties match filter.</div>';
  $('#ct').textContent = filtered.length;
  $('#users-total').textContent = fmtNum(filtered.reduce((s,p)=>s+(p.users||0),0));
  $('#sessions-total').textContent = fmtNum(filtered.reduce((s,p)=>s+(p.sessions||0),0));
}

async function load() {
  $('#status').textContent = 'fetching';
  try {
    const r = await fetch('/api/metrics');
    const j = await r.json();
    RAW = j.metrics || [];
    $('#status').textContent = j.cached ? 'cached' : 'fresh';
  } catch (e) {
    $('#status').textContent = 'error';
    RAW = [];
  }
  render();
}

$('#filter').addEventListener('input', () => { render(); localStorage.setItem('cat.filter', $('#filter').value); });
$('#sort').addEventListener('change', () => { render(); localStorage.setItem('cat.sort', $('#sort').value); });
$('#density').addEventListener('input', () => applyDensity(parseInt($('#density').value,10)));

// Restore persisted controls
$('#filter').value = localStorage.getItem('cat.filter') || '';
$('#sort').value = localStorage.getItem('cat.sort') || 'alpha';
applyDensity(parseInt(localStorage.getItem('cat.density') || '240', 10));

load();
</script>
</body></html>