quorlasocial/dashboard/web/src/pages/ClientPortal.jsx
metatroncubeswdev 92ee5c28b2 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>
2026-10-23 10:03:23 -04:00

67 lines
4.8 KiB
JavaScript

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>
);
}