- 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>
67 lines
4.8 KiB
JavaScript
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>
|
|
);
|
|
}
|