- 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>
54 lines
2.8 KiB
JavaScript
54 lines
2.8 KiB
JavaScript
// Workspace (white-label): the agency's name, logo, brand colour and support email.
|
|
// Used in the app (sidebar, sign-in), client approval pages, emails and the PDF reports.
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { DATA_DIR, AGENCY_NAME } from "./config.js";
|
|
|
|
const FILE = path.join(DATA_DIR, "workspace.json");
|
|
const LOGO = path.join(DATA_DIR, "workspace-logo");
|
|
const HEX = /^#[0-9a-f]{6}$/i;
|
|
const err = (msg, status = 400) => Object.assign(new Error(msg), { status });
|
|
const read = () => { try { return JSON.parse(fs.readFileSync(FILE, "utf8")); } catch { return {}; } };
|
|
|
|
export function get() {
|
|
const w = read();
|
|
return { name: w.name || "", accent: w.accent || "", supportEmail: w.supportEmail || "", website: w.website || "", hasLogo: Boolean(logoPath()), updatedAt: w.updatedAt || null };
|
|
}
|
|
|
|
/** The name shown to people: the workspace name, else AGENCY_NAME from .env. */
|
|
export const agencyName = () => read().name || AGENCY_NAME;
|
|
|
|
export function save(patch, by) {
|
|
const cur = read();
|
|
const next = { ...cur };
|
|
if (patch.name !== undefined) next.name = String(patch.name).trim().slice(0, 60);
|
|
if (patch.accent !== undefined) { if (patch.accent && !HEX.test(patch.accent)) throw err("Brand colour must be like #242142."); next.accent = patch.accent || ""; }
|
|
if (patch.supportEmail !== undefined) { const e = String(patch.supportEmail).trim(); if (e && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(e)) throw err("That email doesn't look right."); next.supportEmail = e; }
|
|
if (patch.website !== undefined) { const u = String(patch.website).trim(); if (u && !/^https?:\/\//i.test(u)) throw err("Website must start with https://"); next.website = u; }
|
|
next.updatedAt = new Date().toISOString(); next.updatedBy = by;
|
|
fs.writeFileSync(FILE, JSON.stringify(next, null, 1));
|
|
return get();
|
|
}
|
|
|
|
export function logoPath() {
|
|
for (const ext of [".png", ".jpg"]) if (fs.existsSync(LOGO + ext)) return LOGO + ext;
|
|
return null;
|
|
}
|
|
|
|
/** Save an uploaded logo (PNG or JPG, up to 2 MB). */
|
|
export function saveLogo(buf, filename) {
|
|
if (!Buffer.isBuffer(buf) || !buf.length) throw err("Choose an image.");
|
|
if (buf.length > 2 * 1024 * 1024) throw err("Logo must be 2 MB or smaller.");
|
|
const png = buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
|
|
const jpg = buf[0] === 0xff && buf[1] === 0xd8;
|
|
if (!png && !jpg) throw err("Logo must be a PNG or JPG.");
|
|
removeLogo();
|
|
fs.writeFileSync(LOGO + (png ? ".png" : ".jpg"), buf);
|
|
return get();
|
|
}
|
|
|
|
export function removeLogo() { for (const ext of [".png", ".jpg"]) fs.rmSync(LOGO + ext, { force: true }); }
|
|
|
|
/** Options for tools/report-pdf toPdf(): the agency's logo when one is set. */
|
|
export const reportOptions = () => (logoPath() ? { logoPath: logoPath(), logoAlt: agencyName() } : {});
|