From 444201e4ec599973896eb58bba62ae9805159837 Mon Sep 17 00:00:00 2001 From: metatroncubeswdev Date: Fri, 23 Oct 2026 10:20:52 -0400 Subject: [PATCH] Link-in-bio page per client with click counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Public page /l/
: Instagram name, picture and bio (or custom), link buttons, latest 6 posts - Every click counted per link per day through /l/
/go/; 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 --- connector/src/meta.js | 2 +- dashboard/server/bio.js | 116 ++++++++++++++++++++++++++ dashboard/server/index.js | 19 +++++ dashboard/web/src/pages/Client.jsx | 3 + dashboard/web/src/pages/LinkInBio.jsx | 95 +++++++++++++++++++++ 5 files changed, 234 insertions(+), 1 deletion(-) create mode 100644 dashboard/server/bio.js create mode 100644 dashboard/web/src/pages/LinkInBio.jsx diff --git a/connector/src/meta.js b/connector/src/meta.js index 9658eb2..bd2f927 100644 --- a/connector/src/meta.js +++ b/connector/src/meta.js @@ -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 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 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"); diff --git a/dashboard/server/bio.js b/dashboard/server/bio.js new file mode 100644 index 0000000..2ec8152 --- /dev/null +++ b/dashboard/server/bio.js @@ -0,0 +1,116 @@ +// Link-in-bio page per client: a fast public page (/l/) with the client's links and latest posts. +// Every link goes through /l//go/, 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 ` + +${esc(name)} +${avatar ? `` : ""} +
+${avatar ? `` : ""} +

${esc(name)}

${bio ? `

${esc(bio)}

` : ""} +${page.links.map((l) => `${esc(l.title)}`).join("\n")} +${posts.length ? `
${posts.map((p) => ``).join("")}
` : ""} +
`; +} diff --git a/dashboard/server/index.js b/dashboard/server/index.js index 24e2c2c..ed0436b 100644 --- a/dashboard/server/index.js +++ b/dashboard/server/index.js @@ -13,6 +13,7 @@ import * as portal from "./portal.js"; import * as notify from "./notify.js"; import * as notifyEvents from "./notify-events.js"; import * as monthly from "./monthly.js"; +import * as bio from "./bio.js"; import * as odoo from "./odoo.js"; import * as jobs from "./jobs.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 }); } }); +// Link-in-bio pages (public): /l/ and counted redirects /l//go/. +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. app.get("/m/:token/:name", (req, res) => { 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.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 ---------- 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); })); diff --git a/dashboard/web/src/pages/Client.jsx b/dashboard/web/src/pages/Client.jsx index 5507055..11f5f8e 100644 --- a/dashboard/web/src/pages/Client.jsx +++ b/dashboard/web/src/pages/Client.jsx @@ -6,6 +6,7 @@ import { api } from "../api.js"; import ClientPortal from "./ClientPortal.jsx"; import MonthlyReport from "./MonthlyReport.jsx"; import BrandKit from "./BrandKit.jsx"; +import LinkInBio from "./LinkInBio.jsx"; import { useAuth } from "../App.jsx"; import { 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: "runs", label: "AI runs", count: data.jobs.length }, { value: "brand", label: "Brand kit" }, + { value: "bio", label: "Link in bio" }, { value: "portal", label: "Client approval" }, ]} /> @@ -118,6 +120,7 @@ export default function Client() { {tab === "docs" &&
} {tab === "runs" && } {tab === "brand" && } + {tab === "bio" && } {tab === "portal" && } setRunOpen(false)} client={client} onStarted={(job) => nav(`/runs/${job.id}`)} /> diff --git a/dashboard/web/src/pages/LinkInBio.jsx b/dashboard/web/src/pages/LinkInBio.jsx new file mode 100644 index 0000000..8d35871 --- /dev/null +++ b/dashboard/web/src/pages/LinkInBio.jsx @@ -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 ? : ; + + 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 ( +
+ +
+ +
+ + set("path", e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""))} /> + + set("title", e.target.value)} /> +
+