- Reports: mc: blocks (score, KPIs, reach, funnel, followers, formats, rhythm, top posts, actions, screenshots, callouts) drawn as SVG from stored account data, frozen per report; same page in PDF and preview - Audit/weekly-report runs refresh account numbers first - Screenshots tab: monthly shot list, uploads, examples; audit reads them Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
143 lines
7.6 KiB
JavaScript
143 lines
7.6 KiB
JavaScript
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { Copy, ImageUp, Star, Trash2 } from "lucide-react";
|
|
import { api } from "../api.js";
|
|
import { useAuth } from "../App.jsx";
|
|
import { Badge, Button, Dialog, ErrorNote, Panel, Spinner, timeAgo } from "../ui.jsx";
|
|
|
|
const monthLabel = (m) => new Date(`${m}-15`).toLocaleDateString("en-CA", { month: "long", year: "numeric" });
|
|
|
|
/** Client page tab: the monthly screenshot checklist the team fills and the audit reads. */
|
|
export default function Screenshots({ slug, clientName }) {
|
|
const { isAdmin } = useAuth();
|
|
const [month, setMonth] = useState(null);
|
|
const [data, setData] = useState(null);
|
|
const [error, setError] = useState(null);
|
|
const [busy, setBusy] = useState(null);
|
|
const [view, setView] = useState(null); // { title, src }
|
|
const [copied, setCopied] = useState(false);
|
|
|
|
const load = useCallback(() => api(`/api/clients/${slug}/screenshots${month ? `?month=${month}` : ""}`).then((d) => { setData(d); setError(null); }).catch(setError), [slug, month]);
|
|
useEffect(() => { load(); }, [load]);
|
|
|
|
const act = async (id, fn) => {
|
|
setBusy(id); setError(null);
|
|
try { setData(await fn()); } catch (e) { setError(e); }
|
|
setBusy(null);
|
|
};
|
|
const q = `?month=${data?.month}`;
|
|
const upload = (shot, file) => act(shot.id, async () => {
|
|
const res = await fetch(`/api/clients/${slug}/screenshots/${shot.id}${q}`, { method: "POST", credentials: "same-origin", headers: { "X-Filename": encodeURIComponent(file.name), "Content-Type": "application/octet-stream" }, body: file });
|
|
const out = await res.json().catch(() => ({}));
|
|
if (!res.ok) throw new Error(out.error || `Upload failed (${res.status})`);
|
|
return out;
|
|
});
|
|
|
|
const copyChecklist = async () => {
|
|
const lines = data.shots.map((s, i) => `${i + 1}. ${s.platform}, ${s.label}${s.required ? "" : " (if it exists)"}: ${s.how}`);
|
|
await navigator.clipboard.writeText(`Screenshots for ${clientName}, ${monthLabel(data.month)}\nUpload them in Quorla → ${clientName} → Screenshots.\n\n${lines.join("\n")}`);
|
|
setCopied(true); setTimeout(() => setCopied(false), 2000);
|
|
};
|
|
|
|
if (!data) return error ? <ErrorNote error={error} /> : <Spinner />;
|
|
const required = data.shots.filter((s) => s.required);
|
|
const done = required.filter((s) => s.file).length;
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<Panel
|
|
title="Monthly screenshots"
|
|
subtitle="Take these in a computer browser before the monthly audit (Windows: Win+Shift+S). The audit reads them for what the numbers can't show: bio, highlights, how the feed looks, buttons and reviews."
|
|
action={
|
|
<div className="flex items-center gap-2">
|
|
<label htmlFor="ss-month" className="sr-only">Month</label>
|
|
<select id="ss-month" className="input h-8 w-auto" value={data.month} onChange={(e) => setMonth(e.target.value)}>
|
|
{data.months.map((m) => <option key={m} value={m}>{monthLabel(m)}</option>)}
|
|
</select>
|
|
<Button size="sm" icon={Copy} onClick={copyChecklist}>{copied ? "Copied" : "Copy checklist"}</Button>
|
|
</div>
|
|
}
|
|
>
|
|
<p className="text-[13px] text-ink-2">
|
|
<span className="num font-semibold text-ink">{done} of {required.length}</span> required screenshots uploaded for {monthLabel(data.month)}.
|
|
{done === required.length && " Ready for the audit."}
|
|
</p>
|
|
</Panel>
|
|
|
|
<ErrorNote error={error} />
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
|
{data.shots.map((s) => (
|
|
<ShotCard key={s.id} shot={s} slug={slug} q={q} busy={busy === s.id} isAdmin={isAdmin}
|
|
onUpload={(f) => upload(s, f)}
|
|
onRemove={() => act(s.id, () => api(`/api/clients/${slug}/screenshots/${s.id}${q}`, { method: "DELETE" }))}
|
|
onExample={() => act(s.id, () => api(`/api/clients/${slug}/screenshots/${s.id}/example${q}`, { method: "POST" }))}
|
|
onView={setView} />
|
|
))}
|
|
</div>
|
|
|
|
<Dialog open={!!view} onClose={() => setView(null)} title={view?.title} wide>
|
|
{view && <img src={view.src} alt={view.title} className="mx-auto max-h-[70vh] w-auto rounded-lg border border-line" />}
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ShotCard({ shot, slug, q, busy, isAdmin, onUpload, onRemove, onExample, onView }) {
|
|
const input = useRef(null);
|
|
const [drag, setDrag] = useState(false);
|
|
const title = `${shot.platform} · ${shot.label}`;
|
|
const stamp = shot.file?.at ? `&v=${encodeURIComponent(shot.file.at)}` : "";
|
|
const src = `/api/clients/${slug}/screenshots/${shot.id}/image${q}${stamp}`;
|
|
const exampleSrc = `/api/screenshot-examples/${shot.id}`;
|
|
const pick = (files) => files?.[0] && onUpload(files[0]);
|
|
|
|
return (
|
|
<section className="rounded-xl border border-line bg-white flex flex-col">
|
|
<header className="px-4 pt-3.5 pb-2 flex items-start justify-between gap-2">
|
|
<div className="min-w-0">
|
|
<p className="text-[11.5px] font-medium text-muted uppercase tracking-wide">{shot.platform}</p>
|
|
<h3 className="text-[14px] font-semibold text-ink">{shot.label}</h3>
|
|
</div>
|
|
{shot.file ? <Badge tone="ok" dot>Uploaded</Badge> : shot.required ? <Badge tone="warn">Required</Badge> : <Badge>Optional</Badge>}
|
|
</header>
|
|
<p className="px-4 text-[12.5px] text-ink-2 leading-relaxed">{shot.how}</p>
|
|
|
|
<div className="p-4 mt-auto">
|
|
{shot.file ? (
|
|
<button type="button" onClick={() => onView({ title, src })} className="block w-full h-56 rounded-lg border border-line bg-canvas overflow-hidden">
|
|
<img src={src} alt={`${title} screenshot`} className="h-full w-full object-contain" loading="lazy" />
|
|
</button>
|
|
) : (
|
|
<div
|
|
onDragOver={(e) => { e.preventDefault(); setDrag(true); }}
|
|
onDragLeave={() => setDrag(false)}
|
|
onDrop={(e) => { e.preventDefault(); setDrag(false); pick(e.dataTransfer.files); }}
|
|
className={`h-56 rounded-lg border border-dashed flex flex-col items-center justify-center gap-2 text-center px-3 ${drag ? "border-brand bg-brand/5" : "border-line bg-canvas"}`}
|
|
>
|
|
{busy ? <Spinner label="Uploading" /> : (
|
|
<>
|
|
<ImageUp className="size-6 text-faint" aria-hidden="true" />
|
|
<p className="text-[12.5px] text-muted">Drop the screenshot here</p>
|
|
<Button size="sm" onClick={() => input.current?.click()}>Choose file</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
<input ref={input} type="file" accept="image/png,image/jpeg,image/webp" className="sr-only" aria-label={`Upload ${title}`} onChange={(e) => { pick(e.target.files); e.target.value = ""; }} />
|
|
|
|
<div className="mt-2.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-[12px] text-muted">
|
|
{shot.file && <span>{shot.file.by ? `${shot.file.by}, ` : ""}{timeAgo(shot.file.at)}</span>}
|
|
{shot.example && <button type="button" className="underline hover:text-ink" onClick={() => onView({ title: `Example: ${title}`, src: exampleSrc })}>See example</button>}
|
|
{shot.file && (
|
|
<span className="ml-auto flex items-center gap-1">
|
|
<Button size="sm" loading={busy} onClick={() => input.current?.click()}>Replace</Button>
|
|
{isAdmin && <Button size="sm" icon={Star} title="Show this shot as the example for every client" onClick={onExample}>Use as example</Button>}
|
|
<button type="button" onClick={onRemove} className="p-1.5 rounded hover:bg-bad-soft text-muted hover:text-bad" aria-label={`Remove ${title}`}><Trash2 className="size-4" /></button>
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|