Client approval portal: a private link per client to approve posts or ask for changes

- Admin creates/replaces/turns off the link and sets the reviewer on the client page (Client approval tab)
- Drafts get 'Send to client'; the client's answer (approved / changes + note + name) shows on the draft
- Editing a reviewed draft asks the client again; the admin's approval is still required to publish
- Client page is mobile-first, no login, noindex, media served only for posts in review

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
metatroncubeswdev 2026-10-23 10:03:23 -04:00
parent 8b6e1cf835
commit 92ee5c28b2
7 changed files with 344 additions and 1 deletions

View File

@ -147,6 +147,29 @@ export function updatePost(id, patch, by = "team") {
if (patch.localTime) { const t = zonedToUtc(patch.localTime, p.timezone); p.publishAt = t.toISOString(); p.localTime = formatInTz(t, p.timezone); }
p.status = "draft"; p.updatedBy = by; p.updatedAt = new Date().toISOString();
p.warnings = quotaWarnings(p, items);
// Changed after the client looked at it: ask them again.
if (p.clientReview && p.clientReview.status !== "pending") p.clientReview = { ...p.clientReview, status: "pending", previous: p.clientReview.status, requestedAt: p.updatedAt, note: "", by: null };
write(items); return p;
}
/** Put a draft in front of the client (their approval link shows it). The admin's own approval is still needed to publish. */
export function requestClientReview(id, by = "team") {
const items = read(); const p = items.find((x) => x.id === id);
if (!p) throw err("Post not found.", 404);
if (p.status !== "draft") throw err("Only drafts can be sent to the client.");
p.clientReview = { status: "pending", requestedBy: by, requestedAt: new Date().toISOString(), note: "", by: null, decidedAt: null };
write(items); return p;
}
/** The client's answer from their approval link: approved, or changes with a note. */
export function clientDecision(id, client, { decision, note = "", name = "" }) {
if (!["approved", "changes"].includes(decision)) throw err("Choose approve or request changes.");
if (decision === "changes" && !String(note).trim()) throw err("Tell us what to change.");
const items = read(); const p = items.find((x) => x.id === id && x.client === client);
if (!p || !p.clientReview) throw err("Post not found.", 404);
if (p.status !== "draft") throw err("This post has already moved on; ask your agency if something needs changing.");
p.clientReview = { ...p.clientReview, status: decision, note: String(note).slice(0, 2000), by: String(name).slice(0, 80) || "Client", decidedAt: new Date().toISOString() };
log({ action: `client_${decision}`, post: id, client, by: p.clientReview.by });
write(items); return p;
}

View File

@ -9,6 +9,7 @@ import * as users from "./users.js";
import * as clients from "./clients.js";
import * as metrics from "./metrics.js";
import * as published from "./published.js";
import * as portal from "./portal.js";
import * as odoo from "./odoo.js";
import * as jobs from "./jobs.js";
import * as docs from "./docs.js";
@ -62,6 +63,25 @@ app.get("/privacy", (_req, res) => res.sendFile(path.join(LEGAL_DIR, "privacy.ht
app.get("/terms", (_req, res) => res.sendFile(path.join(LEGAL_DIR, "terms.html")));
app.get("/legal/legal.css", (_req, res) => res.sendFile(path.join(LEGAL_DIR, "legal.css")));
// Client approval portal: a private link per client, no login. Read-only except approve / request changes.
const PORTAL_PAGE = fileURLToPath(new URL("./portal/index.html", import.meta.url));
const portalClient = (req, res) => { const c = portal.clientForToken(req.params.token); if (!c) res.status(404).json({ error: "This link isn't active." }); return c; };
const noIndex = (_req, res, next) => { res.setHeader("X-Robots-Tag", "noindex, nofollow"); res.setHeader("Referrer-Policy", "no-referrer"); next(); };
app.get("/p/:token", noIndex, (req, res) => (portal.clientForToken(req.params.token) ? res.sendFile(PORTAL_PAGE) : res.status(404).send("This link isn't active. Ask your agency for a new one.")));
app.get("/p/:token/data", noIndex, (req, res) => { const c = portalClient(req, res); if (c) res.json(portal.view(c)); });
app.get("/p/:token/media/:post/:i", noIndex, (req, res) => {
const c = portalClient(req, res); if (!c) return;
const f = portal.mediaFile(c, req.params.post, req.params.i);
if (!f) return res.status(404).end();
res.setHeader("Cache-Control", "private, max-age=600");
res.sendFile(f);
});
app.post("/p/:token/decision/:post", noIndex, (req, res) => {
const c = portalClient(req, res); if (!c) return;
try { res.json({ post: sched.clientDecision(req.params.post, c, req.body || {}).id }); }
catch (e) { res.status(e.status || 400).json({ error: e.message }); }
});
// 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);
@ -283,6 +303,15 @@ app.post("/api/schedule", h(async (req) => ({ post: sched.createPost({ ...req.bo
app.patch("/api/schedule/:id", h(async (req) => ({ post: sched.updatePost(req.params.id, req.body || {}, req.user.name || req.user.email) })));
app.post("/api/schedule/:id/approve", requireAdmin, h(async (req) => { needPublishing(); return { post: await sched.approvePost(req.params.id, req.user.name || req.user.email) }; }));
app.post("/api/schedule/:id/retry", requireAdmin, h(async (req) => { needPublishing(); return { post: sched.retryPost(req.params.id, req.user.name || req.user.email) }; }));
app.post("/api/schedule/:id/send-to-client", h(async (req) => {
const p = sched.getPost(req.params.id);
if (!portal.linkFor(p.client).enabled) throw Object.assign(new Error("Create this client's approval link first (client page → Client approval)."), { status: 400 });
return { post: sched.requestClientReview(req.params.id, who(req)) };
}));
app.get("/api/portal/:client", h(async (req) => portal.linkFor(req.params.client)));
app.post("/api/portal/:client", requireAdmin, h(async (req) => portal.issue(req.params.client, who(req), req.body || {})));
app.patch("/api/portal/:client", requireAdmin, h(async (req) => portal.updateContact(req.params.client, req.body || {})));
app.delete("/api/portal/:client", requireAdmin, h(async (req) => { portal.revoke(req.params.client); return { enabled: false }; }));
app.post("/api/schedule/:id/cancel", h(async (req) => ({ post: await sched.cancelPost(req.params.id, req.user.name || req.user.email) })));
app.get("/api/schedule/media", h(async (req, res) => {
const f = path.resolve(String(req.query.path || ""));

View File

@ -0,0 +1,82 @@
// Client approval portal: one private link per client (no login). The client sees posts the team sent for review,
// approves them or asks for changes. An admin still approves in Quorla before anything publishes.
import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
import { DATA_DIR, AGENCY_NAME } from "./config.js";
import * as clients from "./clients.js";
import * as sched from "../../connector/src/schedule.js";
const FILE = path.join(DATA_DIR, "portal.json");
const read = () => { try { return JSON.parse(fs.readFileSync(FILE, "utf8")); } catch { return {}; } };
const write = (db) => { const tmp = FILE + ".tmp"; fs.writeFileSync(tmp, JSON.stringify(db, null, 1)); fs.renameSync(tmp, FILE); };
const err = (msg, status = 400) => Object.assign(new Error(msg), { status });
/** Link settings for a client (admin view). */
export function linkFor(slug) {
clients.safeSlug(slug);
const e = read()[slug];
return e?.token ? { enabled: true, token: e.token, createdAt: e.createdAt, createdBy: e.createdBy, contactName: e.contactName || "", contactEmail: e.contactEmail || "" } : { enabled: false };
}
/** Create or replace the client's link (replacing makes the old one stop working). */
export function issue(slug, by, { contactName = "", contactEmail = "" } = {}) {
clients.safeSlug(slug);
const db = read();
db[slug] = { token: crypto.randomBytes(24).toString("base64url"), createdAt: new Date().toISOString(), createdBy: by, contactName: String(contactName).slice(0, 80), contactEmail: String(contactEmail).slice(0, 120) };
write(db);
return linkFor(slug);
}
export function updateContact(slug, { contactName, contactEmail }) {
const db = read();
if (!db[slug]) throw err("Create the client's link first.");
if (contactName !== undefined) db[slug].contactName = String(contactName).slice(0, 80);
if (contactEmail !== undefined) {
const e = String(contactEmail).trim();
if (e && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(e)) throw err("That email address doesn't look right.");
db[slug].contactEmail = e.slice(0, 120);
}
write(db);
return linkFor(slug);
}
export function revoke(slug) {
const db = read(); delete db[slug]; write(db);
}
/** Which client a token belongs to (constant-time compare), or null. */
export function clientForToken(token) {
const t = Buffer.from(String(token || ""));
for (const [slug, e] of Object.entries(read())) {
const k = Buffer.from(e.token || "");
if (k.length === t.length && crypto.timingSafeEqual(k, t)) return slug;
}
return null;
}
const LIST_DAYS = 30;
/** What the client sees: posts waiting for them, and their recent answers. */
export function view(slug) {
const c = clients.getClient(slug);
const since = Date.now() - LIST_DAYS * 86400000;
const posts = sched.listPosts({ client: slug })
.filter((p) => p.clientReview && (p.clientReview.status === "pending" || new Date(p.clientReview.decidedAt || p.clientReview.requestedAt).getTime() >= since))
.filter((p) => p.status !== "cancelled")
.map((p) => ({
id: p.id, type: p.type, platforms: p.platforms, localTime: p.localTime, timezone: p.timezone, status: p.status,
caption: p.caption || "", captions: Object.fromEntries(Object.entries(p.options || {}).filter(([, o]) => o?.caption).map(([k, o]) => [k, o.caption])),
fbCaption: p.fbCaption || null, title: p.options?.youtube?.title || null,
media: (p.media || []).map((m, i) => ({ url: `media/${p.id}/${i}`, video: /\.(mp4|mov|m4v|webm)$/i.test(m) })),
review: { status: p.clientReview.status, note: p.clientReview.note || "", by: p.clientReview.by || null, decidedAt: p.clientReview.decidedAt || null },
}));
return { client: c?.name || slug, agency: AGENCY_NAME, posts };
}
/** A media file of one of this client's posts that is in review (no other files are reachable). */
export function mediaFile(slug, postId, index) {
const p = sched.listPosts({ client: slug }).find((x) => x.id === postId && x.clientReview);
const f = p?.media?.[Number(index)];
if (!f || /^https?:/i.test(f) || !fs.existsSync(f)) return null;
return f;
}

View File

@ -0,0 +1,135 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<meta name="referrer" content="no-referrer">
<title>Posts for your approval</title>
<style>
:root { --ink:#1f2024; --ink2:#45474f; --muted:#6b6e78; --line:#e4e7ec; --canvas:#f5f6f8; --navy:#242142; --navy2:#3b3778;
--ok:#2f7d4f; --okSoft:#e8f4ec; --warn:#a86a12; --warnSoft:#fbf1e1; --bad:#b4372b; --badSoft:#fbeae8; --bg:#fff; }
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { --ink:#ecedf2; --ink2:#c9cad3; --muted:#9a9cab; --line:#2c2d38; --canvas:#15161c; --navy:#c9c7f2; --navy2:#a9a5f0; --bg:#1c1d25; --okSoft:#17301f; --warnSoft:#33270f; --badSoft:#3a1714; --ok:#7fcb9c; --warn:#e6b25f; --bad:#ef8a7f; } }
* { box-sizing: border-box; }
[hidden] { display: none !important; }
.sr-only { position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0 0 0 0); white-space:nowrap; }
body { margin:0; background:var(--canvas); color:var(--ink); font:15px/1.55 Inter, "Segoe UI", system-ui, sans-serif; -webkit-font-smoothing:antialiased; }
main { max-width: 760px; margin: 0 auto; padding: 28px 16px 64px; }
h1 { font-size: 24px; line-height:1.2; margin:0; letter-spacing:-.01em; }
h2 { font-size: 13px; text-transform: uppercase; letter-spacing:.08em; color:var(--muted); margin: 28px 0 10px; }
.sub { color: var(--muted); margin: 6px 0 0; }
.card { background:var(--bg); border:1px solid var(--line); border-radius:12px; padding:16px; margin-bottom:14px; }
.meta { display:flex; flex-wrap:wrap; gap:6px 10px; align-items:center; font-size:13px; color:var(--ink2); }
.chip { font-size:12px; font-weight:600; padding:2px 9px; border-radius:999px; border:1px solid var(--line); color:var(--ink2); }
.status { font-size:12px; font-weight:600; padding:3px 10px; border-radius:999px; }
.s-pending { background:var(--warnSoft); color:var(--warn); } .s-approved { background:var(--okSoft); color:var(--ok); } .s-changes { background:var(--badSoft); color:var(--bad); }
.media { display:flex; gap:8px; overflow-x:auto; margin:12px 0; scroll-snap-type:x mandatory; }
.media img, .media video { height: 280px; max-width: 100%; border-radius:8px; background:#000; object-fit: contain; scroll-snap-align:start; flex-shrink:0; }
.caption { white-space: pre-line; color: var(--ink); margin: 8px 0 0; }
.pc { margin-top:10px; font-size:13.5px; } .pc b { color: var(--ink2); }
.actions { display:flex; flex-wrap:wrap; gap:8px; margin-top:14px; }
button { font: 600 14px/1 inherit; font-family: inherit; min-height: 44px; padding: 0 16px; border-radius: 8px; border: 1px solid var(--line); background: var(--bg); color: var(--ink); cursor:pointer; }
button.primary { background: #242142; color: #fff; border-color: #242142; }
button:focus-visible, textarea:focus-visible, input:focus-visible { outline: 2px solid #fc6d45; outline-offset: 2px; }
button[disabled] { opacity: .6; cursor: default; }
textarea, input { width:100%; font: inherit; color: var(--ink); background: var(--bg); border:1px solid var(--line); border-radius:8px; padding:10px 12px; }
textarea { min-height: 90px; margin-top: 10px; }
label { font-size: 13px; color: var(--ink2); display:block; margin-bottom: 4px; }
.note { margin-top:10px; font-size:13.5px; background:var(--canvas); border-radius:8px; padding:10px 12px; }
.empty { text-align:center; color:var(--muted); padding: 28px 12px; }
.err { color: var(--bad); font-size: 13.5px; margin-top: 8px; }
.who { background:var(--bg); border:1px solid var(--line); border-radius:12px; padding:14px 16px; margin-top:18px; }
footer { color: var(--muted); font-size: 12.5px; margin-top: 32px; text-align:center; }
</style>
</head>
<body>
<main>
<header>
<h1 id="title">Posts for your approval</h1>
<p class="sub" id="sub">Loading…</p>
</header>
<div class="who">
<label for="name">Your name (shown to the team with your answers)</label>
<input id="name" autocomplete="name" placeholder="e.g. Priya">
</div>
<h2 id="h-waiting" hidden>Waiting for you</h2>
<section id="waiting" aria-live="polite"></section>
<h2 id="h-done" hidden>Recently reviewed</h2>
<section id="done"></section>
<footer id="foot"></footer>
</main>
<script>
(() => {
const base = location.pathname.replace(/\/+$/, "");
const $ = (id) => document.getElementById(id);
const PL = { instagram: "Instagram", facebook: "Facebook", tiktok: "TikTok", youtube: "YouTube", gbp: "Google Business", linkedin: "LinkedIn", pinterest: "Pinterest", threads: "Threads", reddit: "Reddit" };
const LABEL = { pending: "Waiting for you", approved: "Approved", changes: "Changes requested" };
try { $("name").value = localStorage.getItem("portal-name") || ""; } catch {}
$("name").addEventListener("change", () => { try { localStorage.setItem("portal-name", $("name").value.trim()); } catch {} });
const el = (tag, attrs = {}, ...kids) => {
const e = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) if (v !== null && v !== undefined && v !== false) (k === "class" ? (e.className = v) : k.startsWith("on") ? e.addEventListener(k.slice(2), v) : e.setAttribute(k, v === true ? "" : v));
for (const k of kids.flat()) if (k !== null && k !== undefined && k !== false) e.append(k instanceof Node ? k : document.createTextNode(String(k)));
return e;
};
function card(p) {
const box = el("article", { class: "card" });
const err = el("div", { class: "err", role: "alert" });
box.append(el("div", { class: "meta" },
el("strong", {}, p.localTime), el("span", {}, `(${p.timezone.split("/").pop().replace("_", " ")} time)`),
...p.platforms.map((x) => el("span", { class: "chip" }, PL[x] || x)),
el("span", { class: `status s-${p.review.status}` }, LABEL[p.review.status])));
if (p.title) box.append(el("div", { class: "pc" }, el("b", {}, "YouTube title: "), p.title));
if (p.media.length) box.append(el("div", { class: "media" }, p.media.map((m, i) => m.video
? el("video", { src: `${base}/${m.url}`, controls: true, preload: "metadata", "aria-label": `Video ${i + 1}` })
: el("img", { src: `${base}/${m.url}`, alt: `Image ${i + 1} of ${p.media.length}`, loading: "lazy" }))));
box.append(el("p", { class: "caption" }, p.caption || "(no caption)"));
for (const [pl, cap] of Object.entries(p.captions || {})) if (cap && cap !== p.caption) box.append(el("div", { class: "pc" }, el("b", {}, `${PL[pl] || pl} version: `), cap));
if (p.fbCaption && p.fbCaption !== p.caption) box.append(el("div", { class: "pc" }, el("b", {}, "Facebook version: "), p.fbCaption));
if (p.review.status !== "pending") {
box.append(el("div", { class: "note" }, `${LABEL[p.review.status]} by ${p.review.by || "you"}${p.review.decidedAt ? " on " + new Date(p.review.decidedAt).toLocaleDateString(undefined, { month: "short", day: "numeric" }) : ""}`, p.review.note ? el("div", {}, `“${p.review.note}”`) : null));
return box;
}
const noteId = `n-${p.id}`;
const note = el("textarea", { id: noteId, hidden: true, placeholder: "What should we change? Be as specific as you like." });
const send = async (decision) => {
err.textContent = "";
if (decision === "changes" && note.hidden) { note.hidden = false; note.focus(); changes.textContent = "Send changes"; return; }
for (const b of box.querySelectorAll("button")) b.disabled = true;
try {
const r = await fetch(`${base}/decision/${p.id}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ decision, note: note.value, name: $("name").value.trim() }) });
const j = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(j.error || "Couldn't save your answer. Try again.");
load();
} catch (e) { err.textContent = e.message; for (const b of box.querySelectorAll("button")) b.disabled = false; }
};
const changes = el("button", { type: "button", onclick: () => send("changes") }, "Request changes");
box.append(el("label", { for: noteId, class: "sr-only" }, "What should we change?"), note,
el("div", { class: "actions" }, el("button", { type: "button", class: "primary", onclick: () => send("approved") }, "Approve"), changes), err);
return box;
}
async function load() {
try {
const r = await fetch(`${base}/data`, { cache: "no-store" });
if (!r.ok) throw new Error(r.status === 404 ? "This link isn't active any more. Ask your agency for a new one." : "Couldn't load your posts. Refresh to try again.");
const d = await r.json();
document.title = `${d.client} · Posts for your approval`;
$("title").textContent = d.client;
$("sub").textContent = "Posts your social media team prepared. Approve them, or tell us what to change.";
$("foot").textContent = `Prepared by ${d.agency}. Nothing is posted until your team gives the final go-ahead.`;
const waiting = d.posts.filter((p) => p.review.status === "pending");
const done = d.posts.filter((p) => p.review.status !== "pending");
$("waiting").replaceChildren(...(waiting.length ? waiting.map(card) : [el("div", { class: "card empty" }, "You're all caught up. Nothing is waiting for your approval.")]));
$("h-waiting").hidden = false;
$("h-done").hidden = !done.length;
$("done").replaceChildren(...done.map(card));
} catch (e) { $("sub").textContent = e.message; }
}
load();
})();
</script>
</body>
</html>

View File

@ -3,6 +3,7 @@ import { Link, useNavigate, useParams } from "react-router-dom";
import { Bar, BarChart, CartesianGrid, Cell, Area, AreaChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { ArrowLeft, ArrowUpRight, ClipboardCheck, CalendarRange, FileBarChart, FileDown, FileText, Eye, RefreshCw, Sparkles, Briefcase, Link2, Facebook, Instagram } from "lucide-react";
import { api } from "../api.js";
import ClientPortal from "./ClientPortal.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,
@ -97,6 +98,7 @@ export default function Client() {
{ value: "performance", label: "Performance" },
{ value: "docs", label: "Documents", count: data.docs.filter((d) => d.kind !== "setup").length },
{ value: "runs", label: "AI runs", count: data.jobs.length },
{ value: "portal", label: "Client approval" },
]}
/>
@ -112,6 +114,7 @@ export default function Client() {
))}
{tab === "docs" && <Documents slug={slug} docs={data.docs} reload={load} />}
{tab === "runs" && <ClientRuns jobs={data.jobs} />}
{tab === "portal" && <ClientPortal slug={slug} clientName={client.name} />}
<RunDialog open={runOpen} onClose={() => setRunOpen(false)} client={client} onStarted={(job) => nav(`/runs/${job.id}`)} />
{linkOpen && <LinkPageDialog client={client} onClose={() => setLinkOpen(false)} onLinked={() => { setLinkOpen(false); load(); }} />}

View File

@ -0,0 +1,66 @@
import { useCallback, useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { Check, Copy, ExternalLink, Link2, RefreshCw, Unlink } from "lucide-react";
import { api } from "../api.js";
import { useAuth } from "../App.jsx";
import { Button, Empty, ErrorNote, Field, Panel, Spinner, timeAgo } from "../ui.jsx";
/** Client page tab: the client's private approval link and who to notify. */
export default function ClientPortal({ slug, clientName }) {
const { isAdmin } = useAuth();
const [link, setLink] = useState(null);
const [error, setError] = useState(null);
const [busy, setBusy] = useState(null);
const [copied, setCopied] = useState(false);
const [contact, setContact] = useState({ contactName: "", contactEmail: "" });
const load = useCallback(() => api(`/api/portal/${slug}`).then((l) => { setLink(l); setContact({ contactName: l.contactName || "", contactEmail: l.contactEmail || "" }); }).catch(setError), [slug]);
useEffect(() => { load(); }, [load]);
const run = async (what, fn) => { setBusy(what); setError(null); try { const l = await fn(); if (l) { setLink(l); } } catch (e) { setError(e); } setBusy(null); };
if (!link) return error ? <ErrorNote error={error} /> : <Spinner />;
const url = link.enabled ? `${location.origin}/p/${link.token}` : null;
const copy = () => navigator.clipboard?.writeText(url).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1500); });
return (
<div className="space-y-4">
<ErrorNote error={error} />
{!link.enabled ? (
<Panel>
<Empty icon={Link2} title="No approval link yet"
action={isAdmin ? <Button variant="primary" icon={Link2} loading={busy === "issue"} onClick={() => run("issue", () => api(`/api/portal/${slug}`, { method: "POST", body: {} }))}>Create approval link</Button> : <span className="text-[12.5px] text-muted">An admin creates the link.</span>}>
Give {clientName} a private link where they see the posts you send them, approve them or ask for changes. No login needed. Your team still gives the final approval before anything is posted.
</Empty>
</Panel>
) : (
<>
<Panel title="Client approval link" subtitle={`Created ${timeAgo(link.createdAt)} by ${link.createdBy}. Anyone with the link can see and answer this client's posts in review, so share it only with the client.`}>
<div className="space-y-3">
<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] break-all flex-1 min-w-0">{url}</code>
<button type="button" onClick={copy} className="p-1.5 rounded hover:bg-white text-muted" aria-label="Copy link">{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 client's page"><ExternalLink className="size-4" /></a>
</div>
<p className="text-[12.5px] text-muted">
To send a post for review: <Link to="/publishing" className="text-brand-deep underline">Publishing</Link> → a draft → <b>Send to client</b>. Their answer shows on the draft.
</p>
{isAdmin && (
<div className="flex flex-wrap gap-2">
<Button size="sm" icon={RefreshCw} loading={busy === "issue"} onClick={() => run("issue", () => api(`/api/portal/${slug}`, { method: "POST", body: contact }))}>New link (old one stops working)</Button>
<Button size="sm" variant="danger" icon={Unlink} loading={busy === "revoke"} onClick={() => run("revoke", () => api(`/api/portal/${slug}`, { method: "DELETE" }))}>Turn off</Button>
</div>
)}
</div>
</Panel>
<Panel title="Who reviews" subtitle="Used for email notifications when posts are waiting for them.">
<form className="grid sm:grid-cols-[1fr_1fr_auto] gap-3 items-end" onSubmit={(e) => { e.preventDefault(); run("contact", () => api(`/api/portal/${slug}`, { method: "PATCH", body: contact })); }}>
<Field label="Name"><input id="portal-contact-name" className="input" value={contact.contactName} disabled={!isAdmin} onChange={(e) => setContact({ ...contact, contactName: e.target.value })} /></Field>
<Field label="Email"><input id="portal-contact-email" type="email" className="input" value={contact.contactEmail} disabled={!isAdmin} onChange={(e) => setContact({ ...contact, contactEmail: e.target.value })} /></Field>
{isAdmin && <Button type="submit" loading={busy === "contact"}>Save</Button>}
</form>
</Panel>
</>
)}
</div>
);
}

View File

@ -1,10 +1,11 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { CalendarClock, Check, Image as ImageIcon, Plus, RotateCcw, Upload, X } from "lucide-react";
import { CalendarClock, Check, Image as ImageIcon, Plus, RotateCcw, Upload, X, Send } from "lucide-react";
import { api } from "../api.js";
import { useAuth } from "../App.jsx";
import { Badge, Button, cx, Dialog, Empty, ErrorNote, Field, Panel, Spinner } from "../ui.jsx";
import PlatformPicker, { defaultPlatforms, selectionFor, useClientPlatforms } from "../PlatformPicker.jsx";
const REVIEW = { pending: { tone: "warn", label: "With client" }, approved: { tone: "ok", label: "Client approved" }, changes: { tone: "bad", label: "Client wants changes" } };
const STATUS = { draft: "neutral", approved: "brand", publishing: "brand", published: "ok", partial: "warn", failed: "bad", cancelled: "neutral" };
const TYPES = [["IMAGE", "Single image"], ["REELS", "Reel / video"], ["CAROUSEL", "Carousel / multi-image"], ["STORY", "Story (Instagram, Facebook)"], ["TEXT", "Text only (no Instagram)"]];
@ -69,6 +70,7 @@ export default function Publishing() {
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-[13.5px]">{p.clientName}</span>
<Badge tone={STATUS[p.status]} dot>{p.status}</Badge>
{p.clientReview && p.status === "draft" && <Badge tone={REVIEW[p.clientReview.status].tone}>{REVIEW[p.clientReview.status].label}</Badge>}
<span className="text-[12px] text-muted">{p.type.toLowerCase()} · {p.platforms.join(" + ")}</span>
</div>
<div className="text-[12.5px] text-ink-2 mt-0.5"><b>{p.localTime}</b> <span className="text-muted">({p.timezone.split("/").pop().replace("_", " ")} time)</span></div>
@ -76,6 +78,8 @@ export default function Publishing() {
{p.collaborators?.length > 0 && <div className="text-[12px] text-muted mt-1">Collab: {p.collaborators.map((c) => "@" + c).join(", ")}</div>}
{Object.entries(p.errors || {}).filter(([k]) => !p.waiting?.[k]).map(([k, v]) => <div key={k} className="text-[12px] text-bad mt-1">{k}: {v}</div>)}
{Object.entries(p.waiting || {}).map(([k, until]) => <div key={k} className="text-[12px] text-warn mt-1">{k}: daily limit reached. Goes out automatically after {new Date(until).toLocaleString("en-CA", { weekday: "short", hour: "numeric", minute: "2-digit" })}.</div>)}
{p.clientReview?.status === "changes" && p.status === "draft" && <div className="text-[12.5px] mt-1.5 rounded-md bg-bad-soft text-bad px-2.5 py-1.5"><b>{p.clientReview.by || "Client"}:</b> {p.clientReview.note}</div>}
{p.clientReview?.status === "approved" && p.status === "draft" && <div className="text-[12px] text-ok mt-1">Client approved{p.clientReview.by ? ` (${p.clientReview.by})` : ""}. Ready for your final approval.</div>}
{p.status === "draft" && (p.warnings || []).map((w) => <div key={w} className="text-[12px] text-warn mt-1">{w}</div>)}
{Object.entries(p.results || {}).filter(([, v]) => v?.permalink).map(([k, v]) => <a key={k} href={v.permalink} target="_blank" rel="noreferrer" className="text-[12px] text-brand mr-3">View on {k}</a>)}
{p.results?.tiktok?.inbox && <div className="text-[12px] text-muted mt-1">TikTok: sent to the account's TikTok inbox. Finish and post it in the TikTok app.</div>}
@ -83,6 +87,7 @@ export default function Publishing() {
<div className="text-[11.5px] text-faint mt-1">by {p.createdBy}{p.approvedBy ? ` · approved by ${p.approvedBy}` : ""}</div>
</div>
<div className="flex gap-2 shrink-0">
{p.status === "draft" && p.clientReview?.status !== "pending" && <Button size="sm" icon={Send} loading={busy === p.id + "send-to-client"} onClick={() => act(p, "send-to-client")}>{p.clientReview ? "Send again" : "Send to client"}</Button>}
{p.status === "draft" && isAdmin && <Button size="sm" variant="primary" icon={Check} loading={busy === p.id + "approve"} disabled={!data.publishing} onClick={() => act(p, "approve")}>Approve</Button>}
{["failed", "partial"].includes(p.status) && isAdmin && <Button size="sm" icon={RotateCcw} loading={busy === p.id + "retry"} disabled={!data.publishing} onClick={() => act(p, "retry")}>Retry failed</Button>}
{!["published", "cancelled", "publishing"].includes(p.status) && <Button size="sm" variant="danger" icon={X} loading={busy === p.id + "cancel"} onClick={() => act(p, "cancel")}>Cancel</Button>}