metatroncubeswdev 444201e4ec Link-in-bio page per client with click counts
- Public page /l/<address>: Instagram name, picture and bio (or custom), link buttons, latest 6 posts
- Every click counted per link per day through /l/<address>/go/<id>; optional UTM tags for Google Analytics
- Client page → Link in bio: address, links (reorder with buttons), colours, clicks in the last 30 days
- Snapshots now keep the Instagram profile picture

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:20:52 -04:00

117 lines
6.9 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Link-in-bio page per client: a fast public page (/l/<path>) with the client's links and latest posts.
// Every link goes through /l/<path>/go/<id>, which counts the click (per day) and adds UTM tags when enabled.
import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
import { DATA_DIR } from "./config.js";
import * as clients from "./clients.js";
import * as metrics from "./metrics.js";
const FILE = path.join(DATA_DIR, "bio.json");
const CLICKS = path.join(DATA_DIR, "bio-clicks.json");
const readJson = (f, d) => { try { return JSON.parse(fs.readFileSync(f, "utf8")); } catch { return d; } };
const writeJson = (f, v) => { const tmp = f + ".tmp"; fs.writeFileSync(tmp, JSON.stringify(v, null, 1)); fs.renameSync(tmp, f); };
const err = (msg, status = 400) => Object.assign(new Error(msg), { status });
const RESERVED = new Set(["api", "go", "admin", "login", "quorla", "static", "assets"]);
const HEX = /^#[0-9a-f]{6}$/i;
export function get(slug) {
clients.safeSlug(slug);
const c = readJson(FILE, {})[slug];
return { enabled: false, path: slug, title: "", bio: "", links: [], showPosts: true, utm: true, theme: { bg: "#ffffff", fg: "#1f2024", button: "#242142", buttonText: "#ffffff" }, ...(c || {}) };
}
export function save(slug, patch) {
clients.safeSlug(slug);
const db = readJson(FILE, {});
const cur = get(slug);
const p = patch.path !== undefined ? String(patch.path).toLowerCase().trim() : cur.path;
if (!/^[a-z0-9][a-z0-9-]{1,40}$/.test(p) || RESERVED.has(p)) throw err("Page address: 2–40 lowercase letters, numbers or dashes.");
if (Object.entries(db).some(([s, v]) => s !== slug && v.path === p)) throw err("Another client already uses that address.");
const links = (patch.links ?? cur.links).slice(0, 20).map((l) => {
const url = String(l.url || "").trim();
if (!/^(https?:\/\/|mailto:|tel:)/i.test(url)) throw err(`“${l.title || url}”: links must start with https://, mailto: or tel:.`);
return { id: l.id || crypto.randomUUID().slice(0, 6), title: String(l.title || "").trim().slice(0, 80) || url, url: url.slice(0, 1000) };
});
const theme = { ...cur.theme };
for (const k of ["bg", "fg", "button", "buttonText"]) if (patch.theme?.[k] !== undefined) { if (!HEX.test(patch.theme[k])) throw err("Colours must be like #242142."); theme[k] = patch.theme[k]; }
db[slug] = {
enabled: patch.enabled !== undefined ? Boolean(patch.enabled) : cur.enabled, path: p,
title: patch.title !== undefined ? String(patch.title).slice(0, 80) : cur.title, bio: patch.bio !== undefined ? String(patch.bio).slice(0, 300) : cur.bio,
links, showPosts: patch.showPosts !== undefined ? Boolean(patch.showPosts) : cur.showPosts, utm: patch.utm !== undefined ? Boolean(patch.utm) : cur.utm, theme,
};
writeJson(FILE, db);
return get(slug);
}
export function byPath(p) {
const hit = Object.entries(readJson(FILE, {})).find(([, v]) => v.path === String(p || "").toLowerCase() && v.enabled);
return hit ? { slug: hit[0], ...get(hit[0]) } : null;
}
function withUtm(url, slug) {
if (!/^https?:/i.test(url)) return url;
try {
const u = new URL(url);
if (!u.searchParams.has("utm_source")) { u.searchParams.set("utm_source", "instagram"); u.searchParams.set("utm_medium", "social"); u.searchParams.set("utm_campaign", "link_in_bio"); u.searchParams.set("utm_content", slug); }
return u.toString();
} catch { return url; }
}
/** Where a click should go (counted). Null if unknown. */
export function click(p, linkId) {
const page = byPath(p);
const link = page?.links.find((l) => l.id === linkId);
if (!link) return null;
const db = readJson(CLICKS, {});
const day = new Date().toISOString().slice(0, 10);
const c = ((db[page.slug] ||= {})[linkId] ||= {});
c[day] = (c[day] || 0) + 1;
// Keep 120 days.
const cutoff = new Date(Date.now() - 120 * 86400000).toISOString().slice(0, 10);
for (const k of Object.keys(c)) if (k < cutoff) delete c[k];
writeJson(CLICKS, db);
return page.utm ? withUtm(link.url, page.slug) : link.url;
}
/** Clicks per link over the last N days. */
export function stats(slug, days = 30) {
const db = readJson(CLICKS, {})[slug] || {};
const cutoff = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10);
const page = get(slug);
const per = page.links.map((l) => ({ id: l.id, title: l.title, clicks: Object.entries(db[l.id] || {}).filter(([d]) => d >= cutoff).reduce((s, [, n]) => s + n, 0) }));
return { days, total: per.reduce((s, x) => s + x.clicks, 0), links: per };
}
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
/** The public page as HTML (no JS needed). */
export function render(page) {
const snap = metrics.readSnapshot(page.slug);
const prof = snap?.ig?.profile || {};
const name = page.title || prof.name || clients.getClient(page.slug)?.name || "";
const bio = page.bio || prof.biography || "";
const avatar = prof.profile_picture_url || null;
const posts = page.showPosts ? (snap?.ig?.posts || []).filter((p) => p.permalink && (p.thumbnail_url || p.media_url)).slice(0, 6) : [];
const t = page.theme;
return `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>${esc(name)}</title><meta name="description" content="${esc(bio.slice(0, 150))}">
<meta property="og:title" content="${esc(name)}">${avatar ? `<meta property="og:image" content="${esc(avatar)}">` : ""}
<style>
*{box-sizing:border-box}body{margin:0;background:${t.bg};color:${t.fg};font:16px/1.5 Inter,"Segoe UI",system-ui,sans-serif;-webkit-font-smoothing:antialiased}
main{max-width:520px;margin:0 auto;padding:40px 16px 48px;text-align:center}
.av{width:88px;height:88px;border-radius:50%;object-fit:cover;margin:0 auto 12px;display:block;background:#ddd}
h1{font-size:22px;margin:0 0 6px}p.bio{margin:0 auto 22px;max-width:420px;opacity:.85;white-space:pre-line}
a.btn{display:block;background:${t.button};color:${t.buttonText};text-decoration:none;font-weight:600;padding:15px 18px;border-radius:12px;margin:0 0 12px;min-height:48px}
a.btn:focus-visible{outline:3px solid ${t.fg};outline-offset:3px}
.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin-top:22px}.grid a{display:block;aspect-ratio:1;overflow:hidden;border-radius:8px}
.grid img{width:100%;height:100%;object-fit:cover;display:block}
</style></head><body><main>
${avatar ? `<img class="av" src="${esc(avatar)}" alt="">` : ""}
<h1>${esc(name)}</h1>${bio ? `<p class="bio">${esc(bio)}</p>` : ""}
${page.links.map((l) => `<a class="btn" href="/l/${esc(page.path)}/go/${esc(l.id)}" rel="nofollow">${esc(l.title)}</a>`).join("\n")}
${posts.length ? `<div class="grid">${posts.map((p) => `<a href="${esc(p.permalink)}" rel="noopener" aria-label="${esc((p.caption || "Instagram post").slice(0, 80))}"><img src="${esc(p.thumbnail_url || p.media_url)}" alt="" loading="lazy"></a>`).join("")}</div>` : ""}
</main></body></html>`;
}