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:
parent
7e780549ea
commit
444201e4ec
@ -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
116
dashboard/server/bio.js
Normal 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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>`;
|
||||||
|
}
|
||||||
@ -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); }));
|
||||||
|
|||||||
@ -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}`)} />
|
||||||
|
|||||||
95
dashboard/web/src/pages/LinkInBio.jsx
Normal file
95
dashboard/web/src/pages/LinkInBio.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user