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:
parent
8b6e1cf835
commit
92ee5c28b2
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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 || ""));
|
||||
|
||||
82
dashboard/server/portal.js
Normal file
82
dashboard/server/portal.js
Normal 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;
|
||||
}
|
||||
135
dashboard/server/portal/index.html
Normal file
135
dashboard/server/portal/index.html
Normal 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>
|
||||
@ -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(); }} />}
|
||||
|
||||
66
dashboard/web/src/pages/ClientPortal.jsx
Normal file
66
dashboard/web/src/pages/ClientPortal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user