← back to Ventura Claw Leads
feat(share): per-business share-count w/ session-dedup tracking
a52f7bd29bdaccdd21a6d28aa2dfead3de7306f7 · 2026-05-07 07:48:33 -0700 · Steve Abrams
- POST /api/businesses/:slug/share-track (CSRF-bypassed at server mount)
- 30-min per-session dedup mirrors view-counter pattern
- frontend share button fires fetch() after navigator.share or clipboard copy
- profile sidebar shows 'shared X times' once count >= 10 (anti-zero guard)
- DB migration 010 adds businesses.share_count INT NOT NULL DEFAULT 0
- migration applied to prod; 46/46 tests stable
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Files touched
A db/migrations/010_share_count.sqlM routes/public.jsM server.jsM views/public/business.ejs
Diff
commit a52f7bd29bdaccdd21a6d28aa2dfead3de7306f7
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu May 7 07:48:33 2026 -0700
feat(share): per-business share-count w/ session-dedup tracking
- POST /api/businesses/:slug/share-track (CSRF-bypassed at server mount)
- 30-min per-session dedup mirrors view-counter pattern
- frontend share button fires fetch() after navigator.share or clipboard copy
- profile sidebar shows 'shared X times' once count >= 10 (anti-zero guard)
- DB migration 010 adds businesses.share_count INT NOT NULL DEFAULT 0
- migration applied to prod; 46/46 tests stable
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
db/migrations/010_share_count.sql | 6 ++++++
routes/public.js | 24 ++++++++++++++++++++++++
server.js | 4 ++++
views/public/business.ejs | 16 ++++++++++++++--
4 files changed, 48 insertions(+), 2 deletions(-)
diff --git a/db/migrations/010_share_count.sql b/db/migrations/010_share_count.sql
new file mode 100644
index 0000000..4b44ecd
--- /dev/null
+++ b/db/migrations/010_share_count.sql
@@ -0,0 +1,6 @@
+-- Per-business share counter. Increments when a visitor taps the 'Share'
+-- button on /business/:slug. Surfaced on profile as social proof at the
+-- ≥10 threshold (below that we don't show — same anti-pattern guard as
+-- views and messages).
+
+ALTER TABLE businesses ADD COLUMN IF NOT EXISTS share_count INTEGER NOT NULL DEFAULT 0;
diff --git a/routes/public.js b/routes/public.js
index 1c684b1..f2c951e 100644
--- a/routes/public.js
+++ b/routes/public.js
@@ -307,6 +307,30 @@ router.get('/map', async (req, res, next) => {
} catch (err) { next(err); }
});
+// Share-track ping. Frontend fires after a successful navigator.share or
+// clipboard copy. CSRF is bypassed at the server-mount level (see server.js).
+// Per-session dedup mirrors the view-counter pattern — same browser hitting
+// the share button repeatedly counts once per 30 minutes.
+router.post('/api/businesses/:slug/share-track', async (req, res, next) => {
+ try {
+ const slug = String(req.params.slug || '').slice(0, 200);
+ const biz = await db.one(`SELECT id FROM businesses WHERE slug = $1 AND status = 'active'`, [slug]);
+ if (!biz) return res.json({ ok: false, reason: 'not_found' });
+
+ if (req.session) {
+ const seenKey = 'shared_' + biz.id;
+ const lastSeen = req.session[seenKey];
+ const now = Date.now();
+ if (lastSeen && (now - lastSeen) <= 30 * 60 * 1000) {
+ return res.json({ ok: true, dedup: true });
+ }
+ req.session[seenKey] = now;
+ }
+ await db.query(`UPDATE businesses SET share_count = share_count + 1 WHERE id = $1`, [biz.id]);
+ res.json({ ok: true });
+ } catch (err) { next(err); }
+});
+
router.get('/api/businesses/suggest', async (req, res, next) => {
try {
const q = String(req.query.q || '').trim().toLowerCase().slice(0, 60);
diff --git a/server.js b/server.js
index bda4dc1..45600ab 100644
--- a/server.js
+++ b/server.js
@@ -83,6 +83,10 @@ app.use(auth.attachBusiness);
// RFC 8058 one-click unsubscribe — email clients POST without browser session.
// The HMAC-derived token IS bearer auth; bypass CSRF.
app.use('/unsubscribe', (req, res, next) => { req.skipCsrf = true; next(); });
+// Share-track: fire-and-forget telemetry POST from the share button on
+// /business/:slug. Public + idempotent (per-session dedup), no PII, low
+// abuse value. Bypass CSRF so the JS doesn't have to plumb the token.
+app.use('/api/businesses/:slug/share-track', (req, res, next) => { req.skipCsrf = true; next(); });
// Photo upload is multipart/form-data — multer must parse it BEFORE
// csrfMiddleware so req.body._csrf is populated for the timing-safe compare.
// The actual route handler (in routes/admin.js) reads req.file directly.
diff --git a/views/public/business.ejs b/views/public/business.ejs
index 10dab53..d3cdaff 100644
--- a/views/public/business.ejs
+++ b/views/public/business.ejs
@@ -105,6 +105,11 @@
</section>
<script>
(function(){
+ var slug = '<%= business.slug %>';
+ function trackShare() {
+ // Fire-and-forget — never block the UX on the counter ping.
+ try { fetch('/api/businesses/' + encodeURIComponent(slug) + '/share-track', { method: 'POST' }); } catch(e) {}
+ }
document.querySelectorAll('[data-share-url]').forEach(function(btn){
btn.addEventListener('click', async function(){
var url = btn.getAttribute('data-share-url');
@@ -112,11 +117,12 @@
var status = btn.querySelector('[data-share-status]');
// Web Share API on mobile/Safari, clipboard fallback elsewhere.
if (navigator.share) {
- try { await navigator.share({ title: title, url: url }); return; }
+ try { await navigator.share({ title: title, url: url }); trackShare(); return; }
catch(e){ /* user cancelled — fall through to clipboard */ }
}
try {
await navigator.clipboard.writeText(url);
+ trackShare();
if (status) { status.textContent = '· copied'; setTimeout(function(){ status.textContent=''; }, 2000); }
} catch(e) {
if (status) { status.textContent = '· press ⌘C'; }
@@ -164,11 +170,17 @@
</div>
<% } %>
<% if (activity.views_30d >= 25) { %>
- <div style="text-align:center">
+ <div style="text-align:center;<%= (business.share_count || 0) >= 10 ? 'border-bottom:1px dotted var(--border);padding-bottom:12px;margin-bottom:12px' : '' %>">
<p style="font-family:var(--serif);font-size:32px;color:var(--brass);margin:0;line-height:1"><%= activity.views_30d %></p>
<p style="margin:6px 0 0;font-size:11px;letter-spacing:0.06em;text-transform:uppercase;color:var(--fg-muted)">profile views · last 30 days</p>
</div>
<% } %>
+ <% if ((business.share_count || 0) >= 10) { %>
+ <div style="text-align:center">
+ <p style="font-family:var(--serif);font-size:32px;color:var(--brass);margin:0;line-height:1"><%= business.share_count %></p>
+ <p style="margin:6px 0 0;font-size:11px;letter-spacing:0.06em;text-transform:uppercase;color:var(--fg-muted)">times shared</p>
+ </div>
+ <% } %>
</div>
<% } %>
<div class="side-card">
← 826d347 yolo tick 29: weekly-digest worker — Monday-morning summary
·
back to Ventura Claw Leads
·
feat(ux): hand-drawn SVG glyphs replace emoji on vertical he f5d2cd9 →