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>
This commit is contained in:
metatroncubeswdev 2026-10-23 10:20:52 -04:00
parent 7e780549ea
commit 444201e4ec
5 changed files with 234 additions and 1 deletions

View File

@ -126,7 +126,7 @@ const days = (n) => Math.floor(Date.now() / 1000) - n * 86400;
export const IG_ACCOUNT_METRICS = ["reach", "views", "accounts_engaged", "total_interactions", "likes", "comments", "shares", "saves", "follows_and_unfollows", "profile_links_taps"]; export const IG_ACCOUNT_METRICS = ["reach", "views", "accounts_engaged", "total_interactions", "likes", "comments", "shares", "saves", "follows_and_unfollows", "profile_links_taps"];
export async function igAccountInsights(igId, { sinceDays = 28, metrics = IG_ACCOUNT_METRICS } = {}) { export async function igAccountInsights(igId, { sinceDays = 28, metrics = IG_ACCOUNT_METRICS } = {}) {
const profile = await graph(igId, { fields: "id,username,name,biography,followers_count,follows_count,media_count,website" }); const profile = await graph(igId, { fields: "id,username,name,biography,followers_count,follows_count,media_count,website,profile_picture_url" });
// total_value metrics allow max 30-day windows // total_value metrics allow max 30-day windows
const window = { period: "day", metric_type: "total_value", since: days(Math.min(sinceDays, 30)), until: days(0) }; const window = { period: "day", metric_type: "total_value", since: days(Math.min(sinceDays, 30)), until: days(0) };
const plain = metrics.filter((m) => m !== "follows_and_unfollows"); const plain = metrics.filter((m) => m !== "follows_and_unfollows");

116
dashboard/server/bio.js Normal file
View File

@ -0,0 +1,116 @@
// 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>`;
}

View File

@ -13,6 +13,7 @@ import * as portal from "./portal.js";
import * as notify from "./notify.js"; import * as notify from "./notify.js";
import * as notifyEvents from "./notify-events.js"; import * as notifyEvents from "./notify-events.js";
import * as monthly from "./monthly.js"; import * as monthly from "./monthly.js";
import * as bio from "./bio.js";
import * as odoo from "./odoo.js"; import * as odoo from "./odoo.js";
import * as jobs from "./jobs.js"; import * as jobs from "./jobs.js";
import * as docs from "./docs.js"; import * as docs from "./docs.js";
@ -86,6 +87,20 @@ app.post("/p/:token/decision/:post", noIndex, (req, res) => {
catch (e) { res.status(e.status || 400).json({ error: e.message }); } catch (e) { res.status(e.status || 400).json({ error: e.message }); }
}); });
// Link-in-bio pages (public): /l/<path> and counted redirects /l/<path>/go/<link id>.
app.get("/l/:path", (req, res) => {
const page = bio.byPath(req.params.path);
if (!page) return res.status(404).send("Not found");
res.setHeader("Cache-Control", "public, max-age=300");
res.type("html").send(bio.render(page));
});
app.get("/l/:path/go/:id", (req, res) => {
const to = bio.click(req.params.path, req.params.id);
if (!to) return res.status(404).send("Not found");
res.setHeader("Cache-Control", "no-store");
res.redirect(302, to);
});
// Signed, expiring media links that platforms (TikTok, Threads, Google Business Profile) fetch posts from. // Signed, expiring media links that platforms (TikTok, Threads, Google Business Profile) fetch posts from.
app.get("/m/:token/:name", (req, res) => { app.get("/m/:token/:name", (req, res) => {
const f = resolveMediaToken(req.params.token); const f = resolveMediaToken(req.params.token);
@ -339,6 +354,10 @@ app.get("/api/brandkit/:client", h(async (req) => brandkit.get(req.params.client
app.put("/api/brandkit/:client", h(async (req) => brandkit.save(req.params.client, req.body || {}, who(req)))); app.put("/api/brandkit/:client", h(async (req) => brandkit.save(req.params.client, req.body || {}, who(req))));
app.post("/api/brandkit/:client/check", h(async (req) => ({ warnings: brandkit.checkCaption(req.params.client, req.body?.caption, { platforms: req.body?.platforms || [] }) }))); app.post("/api/brandkit/:client/check", h(async (req) => ({ warnings: brandkit.checkCaption(req.params.client, req.body?.caption, { platforms: req.body?.platforms || [] }) })));
// ---------- link in bio ----------
app.get("/api/bio/:client", h(async (req) => ({ page: bio.get(req.params.client), stats: bio.stats(req.params.client, Number(req.query.days) || 30) })));
app.put("/api/bio/:client", requireAdmin, h(async (req) => ({ page: bio.save(req.params.client, req.body || {}), stats: bio.stats(req.params.client) })));
// ---------- notifications ---------- // ---------- notifications ----------
app.get("/api/notifications", h(async (req) => notify.listFor(req.user.id))); app.get("/api/notifications", h(async (req) => notify.listFor(req.user.id)));
app.post("/api/notifications/read", h(async (req) => { notify.markRead(req.user.id, Array.isArray(req.body?.ids) ? req.body.ids : null); return notify.listFor(req.user.id); })); app.post("/api/notifications/read", h(async (req) => { notify.markRead(req.user.id, Array.isArray(req.body?.ids) ? req.body.ids : null); return notify.listFor(req.user.id); }));

View File

@ -6,6 +6,7 @@ import { api } from "../api.js";
import ClientPortal from "./ClientPortal.jsx"; import ClientPortal from "./ClientPortal.jsx";
import MonthlyReport from "./MonthlyReport.jsx"; import MonthlyReport from "./MonthlyReport.jsx";
import BrandKit from "./BrandKit.jsx"; import BrandKit from "./BrandKit.jsx";
import LinkInBio from "./LinkInBio.jsx";
import { useAuth } from "../App.jsx"; import { useAuth } from "../App.jsx";
import { import {
Badge, Button, cx, Dialog, Empty, ErrorNote, fmt, fmtDate, fmtDateTime, Panel, Spinner, Stat, StatCell, StatRow, STATUS_TONE, Tabs, timeAgo, Badge, Button, cx, Dialog, Empty, ErrorNote, fmt, fmtDate, fmtDateTime, Panel, Spinner, Stat, StatCell, StatRow, STATUS_TONE, Tabs, timeAgo,
@ -101,6 +102,7 @@ export default function Client() {
{ value: "docs", label: "Documents", count: data.docs.filter((d) => d.kind !== "setup").length }, { value: "docs", label: "Documents", count: data.docs.filter((d) => d.kind !== "setup").length },
{ value: "runs", label: "AI runs", count: data.jobs.length }, { value: "runs", label: "AI runs", count: data.jobs.length },
{ value: "brand", label: "Brand kit" }, { value: "brand", label: "Brand kit" },
{ value: "bio", label: "Link in bio" },
{ value: "portal", label: "Client approval" }, { value: "portal", label: "Client approval" },
]} ]}
/> />
@ -118,6 +120,7 @@ export default function Client() {
{tab === "docs" && <div className="space-y-5"><MonthlyReport slug={slug} onBuilt={load} /><Documents slug={slug} docs={data.docs} reload={load} /></div>} {tab === "docs" && <div className="space-y-5"><MonthlyReport slug={slug} onBuilt={load} /><Documents slug={slug} docs={data.docs} reload={load} /></div>}
{tab === "runs" && <ClientRuns jobs={data.jobs} />} {tab === "runs" && <ClientRuns jobs={data.jobs} />}
{tab === "brand" && <BrandKit slug={slug} />} {tab === "brand" && <BrandKit slug={slug} />}
{tab === "bio" && <LinkInBio slug={slug} />}
{tab === "portal" && <ClientPortal slug={slug} clientName={client.name} />} {tab === "portal" && <ClientPortal slug={slug} clientName={client.name} />}
<RunDialog open={runOpen} onClose={() => setRunOpen(false)} client={client} onStarted={(job) => nav(`/runs/${job.id}`)} /> <RunDialog open={runOpen} onClose={() => setRunOpen(false)} client={client} onStarted={(job) => nav(`/runs/${job.id}`)} />

View File

@ -0,0 +1,95 @@
import { useEffect, useState } from "react";
import { ArrowDown, ArrowUp, Check, Copy, ExternalLink, Plus, Save, Trash2 } from "lucide-react";
import { api } from "../api.js";
import { useAuth } from "../App.jsx";
import { Button, ErrorNote, Field, fmt, Panel, Spinner } from "../ui.jsx";
/** Client page tab: the client's link-in-bio page, its links and click counts. */
export default function LinkInBio({ slug }) {
const { isAdmin } = useAuth();
const [page, setPage] = useState(null);
const [stats, setStats] = useState(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
const [saved, setSaved] = useState(false);
const [copied, setCopied] = useState(false);
useEffect(() => { api(`/api/bio/${slug}`).then((r) => { setPage(r.page); setStats(r.stats); }).catch(setError); }, [slug]);
if (!page) return error ? <ErrorNote error={error} /> : <Spinner />;
const set = (k, v) => { setSaved(false); setPage((p) => ({ ...p, [k]: v })); };
const setLink = (i, k, v) => set("links", page.links.map((l, j) => (j === i ? { ...l, [k]: v } : l)));
const move = (i, d) => { const ls = [...page.links]; const [x] = ls.splice(i, 1); ls.splice(i + d, 0, x); set("links", ls); };
const save = async () => {
setBusy(true); setError(null);
try { const r = await api(`/api/bio/${slug}`, { method: "PUT", body: page }); setPage(r.page); setStats(r.stats); setSaved(true); } catch (e) { setError(e); }
setBusy(false);
};
const url = `${location.origin}/l/${page.path}`;
const clicks = Object.fromEntries((stats?.links || []).map((l) => [l.id, l.clicks]));
return (
<div className="space-y-4">
<Panel title="Link-in-bio page" subtitle="One link for the Instagram or TikTok bio, with the client's important links and latest posts. Every click is counted.">
<div className="space-y-4">
<label htmlFor="bio-enabled" className="flex items-center gap-2 text-[13.5px] font-medium">
<input id="bio-enabled" type="checkbox" checked={page.enabled} disabled={!isAdmin} onChange={(e) => set("enabled", e.target.checked)} /> Page is live
</label>
<div className="grid sm:grid-cols-2 gap-3">
<Field label="Page address" hint={url}>
<input id="bio-path" className="input" value={page.path} disabled={!isAdmin} onChange={(e) => set("path", e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""))} />
</Field>
<Field label="Name shown (optional)" hint="Defaults to the Instagram name."><input id="bio-title" className="input" value={page.title} disabled={!isAdmin} onChange={(e) => set("title", e.target.value)} /></Field>
</div>
<Field label="Short bio (optional)" hint="Defaults to the Instagram bio."><textarea id="bio-bio" className="input h-16 py-2" maxLength={300} value={page.bio} disabled={!isAdmin} onChange={(e) => set("bio", e.target.value)} /></Field>
{page.enabled && (
<div className="flex items-center gap-2 rounded-md border border-line bg-canvas px-2.5 py-1.5">
<code className="font-mono text-[12.5px] flex-1 truncate">{url}</code>
<button type="button" className="p-1.5 rounded hover:bg-white text-muted" aria-label="Copy page link" onClick={() => navigator.clipboard?.writeText(url).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1500); })}>{copied ? <Check className="size-4 text-ok" /> : <Copy className="size-4" />}</button>
<a href={url} target="_blank" rel="noreferrer" className="p-1.5 rounded hover:bg-white text-muted" aria-label="Open the page"><ExternalLink className="size-4" /></a>
</div>
)}
</div>
</Panel>
<Panel title="Links" subtitle={stats ? `${fmt(stats.total)} clicks in the last ${stats.days} days.` : null}>
<div className="space-y-2">
{page.links.map((l, i) => (
<div key={l.id || i} className="grid grid-cols-[auto_1fr_1.4fr_auto_auto] gap-2 items-center">
<div className="flex flex-col">
<button type="button" disabled={!isAdmin || i === 0} onClick={() => move(i, -1)} className="p-0.5 text-muted disabled:opacity-30" aria-label={`Move ${l.title || "link"} up`}><ArrowUp className="size-3.5" /></button>
<button type="button" disabled={!isAdmin || i === page.links.length - 1} onClick={() => move(i, 1)} className="p-0.5 text-muted disabled:opacity-30" aria-label={`Move ${l.title || "link"} down`}><ArrowDown className="size-3.5" /></button>
</div>
<input aria-label="Button text" className="input" value={l.title} disabled={!isAdmin} placeholder="Order online" onChange={(e) => setLink(i, "title", e.target.value)} />
<input aria-label="Link" className="input" value={l.url} disabled={!isAdmin} placeholder="https://…" onChange={(e) => setLink(i, "url", e.target.value)} />
<span className="text-[12px] text-muted num w-16 text-right">{l.id ? `${fmt(clicks[l.id] || 0)} clicks` : "new"}</span>
{isAdmin && <button type="button" onClick={() => set("links", page.links.filter((_, j) => j !== i))} className="p-1.5 rounded hover:bg-bad-soft text-muted hover:text-bad" aria-label={`Remove ${l.title || "link"}`}><Trash2 className="size-4" /></button>}
</div>
))}
{isAdmin && <Button size="sm" icon={Plus} onClick={() => set("links", [...page.links, { title: "", url: "" }])}>Add a link</Button>}
</div>
</Panel>
<Panel title="Look and tracking">
<div className="space-y-3">
<div className="flex flex-wrap gap-4">
{[["bg", "Background"], ["fg", "Text"], ["button", "Buttons"], ["buttonText", "Button text"]].map(([k, l]) => (
<label key={k} htmlFor={`bio-c-${k}`} className="flex items-center gap-2 text-[13px]">
<input id={`bio-c-${k}`} type="color" className="size-8 rounded border border-line" value={page.theme[k]} disabled={!isAdmin} onChange={(e) => set("theme", { ...page.theme, [k]: e.target.value })} />{l}
</label>
))}
</div>
<label htmlFor="bio-posts" className="flex items-center gap-2 text-[13px]"><input id="bio-posts" type="checkbox" checked={page.showPosts} disabled={!isAdmin} onChange={(e) => set("showPosts", e.target.checked)} />Show the latest 6 Instagram posts</label>
<label htmlFor="bio-utm" className="flex items-center gap-2 text-[13px]"><input id="bio-utm" type="checkbox" checked={page.utm} disabled={!isAdmin} onChange={(e) => set("utm", e.target.checked)} />Add UTM tags to website links (so Google Analytics shows visits came from the bio)</label>
</div>
</Panel>
{isAdmin && (
<div className="flex items-center gap-3">
<Button variant="primary" icon={Save} loading={busy} onClick={save}>Save</Button>
{saved && <span className="text-[12.5px] text-ok">Saved.{page.enabled ? " The page is live." : ""}</span>}
</div>
)}
<ErrorNote error={error} />
</div>
);
}