metatroncubeswdev 82814f3757 White-label workspace and getting-started checklist
- Settings → Workspace: agency name, logo, brand colour, support email, website
- Logo replaces Quorla's in the app (with 'Powered by Quorla'), sign-in page, client approval page and PDF reports;
  brand colour drives buttons, links and highlights; emails and reports use the agency name
- Getting started checklist on the Clients page for admins, checked against the real state
  (workspace, Meta, first client, team, platforms, email, library, first post, approval link); dismissible

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:28:23 -04:00

138 lines
9.6 KiB
HTML

<!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>
<img id="logo" src="/brand/agency-logo" alt="" hidden style="height:36px;width:auto;max-width:200px;object-fit:contain;margin-bottom:14px;display:block">
<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.`;
if (d.logo) { $("logo").alt = d.agency; $("logo").hidden = false; }
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>