- 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>
117 lines
6.9 KiB
JavaScript
117 lines
6.9 KiB
JavaScript
// 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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>`;
|
||
}
|