[object Object]

← back to Ads Dashboard

Cycle 4: real UTM campaign attribution in /api/acquisition + campaigns panel (from order landing_site)

ceae08819a4fb27880c77e0a7988b3f95a002844 · 2026-08-02 19:22:58 -0700 · Steve Abrams

Files touched

Diff

commit ceae08819a4fb27880c77e0a7988b3f95a002844
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun Aug 2 19:22:58 2026 -0700

    Cycle 4: real UTM campaign attribution in /api/acquisition + campaigns panel (from order landing_site)
---
 public/index.html | 23 +++++++++++++++++++++++
 server.js         | 32 ++++++++++++++++++++++++--------
 2 files changed, 47 insertions(+), 8 deletions(-)

diff --git a/public/index.html b/public/index.html
index ac7d959..540496d 100644
--- a/public/index.html
+++ b/public/index.html
@@ -76,6 +76,14 @@
     <div class="empty" id="acqEmpty" hidden></div>
   </div>
 
+  <div class="section-h">Top campaigns <span style="text-transform:none;font-weight:400">(UTM attribution from converting orders — not paid-ad spend)</span></div>
+  <div class="card">
+    <table id="campUtmTable"><thead><tr>
+      <th>Campaign</th><th>Source</th><th>Medium</th><th>Orders</th><th>Revenue</th>
+    </tr></thead><tbody id="campUtmBody"></tbody></table>
+    <div class="empty" id="campUtmEmpty" hidden></div>
+  </div>
+
   <div class="section-h">Competitor Ad Signals</div>
   <div class="card">
     <div id="signals"></div>
@@ -136,6 +144,21 @@ async function boot(){
     $('acqEmpty').hidden = false;
     $('acqEmpty').textContent = 'Acquisition data unavailable: ' + (acq.reason||'no data') + ' (source: /api/acquisition)';
   }
+
+  // UTM campaigns (attribution)
+  const camps = (acq.campaigns||[]);
+  if (camps.length) {
+    $('campUtmEmpty').hidden = true;
+    $('campUtmBody').innerHTML = camps.map(c=>`<tr>
+      <td><a href="/api/acquisition">${c.campaign}</a></td>
+      <td>${c.source}</td><td>${c.medium}</td>
+      <td>${c.orders}</td><td>$${c.revenue.toLocaleString()}</td>
+    </tr>`).join('') + `<tr style="font-weight:650"><td colspan="3">${acq.totals.utm_tagged||0} of ${acq.totals.orders} orders UTM-tagged</td><td colspan="2"></td></tr>`;
+  } else {
+    $('campUtmBody').innerHTML='';
+    $('campUtmEmpty').hidden = false;
+    $('campUtmEmpty').textContent = acq.present ? 'No UTM-tagged campaigns in the current window.' : 'Unavailable (source: /api/acquisition).';
+  }
 }
 
 let sortKey='newest';
diff --git a/server.js b/server.js
index 3b1746c..6483a3f 100644
--- a/server.js
+++ b/server.js
@@ -55,30 +55,46 @@ function classifyChannel(referrer, sourceName) {
 async function fetchAcquisition() {
   if (!ORDERS_TOKEN) return { present: false, reason: 'SHOPIFY_ORDERS_TOKEN not set on this host', source: 'shopify-orders' };
   if (_acqCache.data && Date.now() - _acqCache.at < 10 * 60 * 1000) return _acqCache.data;
-  const url = `https://${SHOP}/admin/api/2024-10/orders.json?status=any&limit=250&fields=id,created_at,source_name,referring_site,total_price`;
+  const url = `https://${SHOP}/admin/api/2024-10/orders.json?status=any&limit=250&fields=id,created_at,source_name,referring_site,landing_site,total_price`;
   const r = await fetch(url, { headers: { 'X-Shopify-Access-Token': ORDERS_TOKEN } });
   if (!r.ok) return { present: false, reason: `shopify ${r.status}`, source: 'shopify-orders' };
   const j = await r.json();
   const orders = j.orders || [];
-  const bySource = {}, byChannel = {};
-  let revenue = 0;
+  const bySource = {}, byChannel = {}, byCampaign = {};
+  let revenue = 0, utmTagged = 0;
+  const round2 = (n) => Math.round(n * 100) / 100;
   for (const o of orders) {
+    const rev = Number(o.total_price || 0);
     const ch = classifyChannel(o.referring_site, o.source_name);
     byChannel[ch] = byChannel[ch] || { orders: 0, revenue: 0 };
-    byChannel[ch].orders++; byChannel[ch].revenue += Number(o.total_price || 0);
+    byChannel[ch].orders++; byChannel[ch].revenue += rev;
     const s = o.source_name || '(none)';
     bySource[s] = (bySource[s] || 0) + 1;
-    revenue += Number(o.total_price || 0);
+    revenue += rev;
+    // UTM attribution from the landing_site query string
+    try {
+      const q = new URL(o.landing_site || '', `https://${SHOP}`).searchParams;
+      const camp = q.get('utm_campaign');
+      if (camp) {
+        utmTagged++;
+        const key = camp + ' · ' + (q.get('utm_source') || '?');
+        byCampaign[key] = byCampaign[key] || { campaign: camp, source: q.get('utm_source') || '?', medium: q.get('utm_medium') || '?', orders: 0, revenue: 0 };
+        byCampaign[key].orders++; byCampaign[key].revenue += rev;
+      }
+    } catch { /* malformed landing_site — skip */ }
   }
   const channels = Object.entries(byChannel)
-    .map(([channel, v]) => ({ channel, orders: v.orders, revenue: Math.round(v.revenue * 100) / 100 }))
+    .map(([channel, v]) => ({ channel, orders: v.orders, revenue: round2(v.revenue) }))
+    .sort((a, b) => b.orders - a.orders);
+  const campaigns = Object.values(byCampaign)
+    .map(c => ({ ...c, revenue: round2(c.revenue) }))
     .sort((a, b) => b.orders - a.orders);
   const data = {
     present: true, source: 'shopify-orders', shop: SHOP,
     generated_at: new Date().toISOString(),
     window: `last ${orders.length} orders`,
-    totals: { orders: orders.length, revenue: Math.round(revenue * 100) / 100 },
-    channels, by_source: bySource,
+    totals: { orders: orders.length, revenue: round2(revenue), utm_tagged: utmTagged },
+    channels, campaigns, by_source: bySource,
   };
   _acqCache = { at: Date.now(), data };
   return data;

← d022604 Cycle 3: real acquisition panel from Shopify order referrers  ·  back to Ads Dashboard  ·  Cycle 4 fix (Cody): 30d window + net-of-refunds revenue + un bf73874 →