- 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>
96 lines
7.0 KiB
JavaScript
96 lines
7.0 KiB
JavaScript
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>
|
|
);
|
|
}
|