Quorla look for the dashboard; move a connection to another client
- Quorla logo, navy/coral colours and page titles in the app (PDF reports keep agency branding) - Connections: Move button reassigns an account to another client without reconnecting - Fix double @ on YouTube handles - HANDOFF.md with current status and next steps Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
af2fbb9f8e
commit
059c236d3f
38
HANDOFF.md
Normal file
38
HANDOFF.md
Normal file
@ -0,0 +1,38 @@
|
||||
# Quorla: handoff (21 Oct 2026)
|
||||
|
||||
Where things stand and what's next. Live app: https://app.quorlasocial.com (HestiaCP server, `~/APPS/quorlasocial`,
|
||||
Docker behind the Hestia nginx proxy on 127.0.0.1:4310). Hosting steps: `docs/hosting.html`.
|
||||
|
||||
## Live and working
|
||||
- Dashboard on the server: team logins, admin can add teammates and reset passwords, scheduler runs there.
|
||||
- Meta (Facebook + Instagram) through the system user. Infini is Instagram-only (no Facebook Page by choice).
|
||||
- Connections page with quotas (`data/quota.json`; YouTube 10,000 units/day Pacific time, rolling 24 h for others).
|
||||
- **TikTok:** Sandbox app "Quorla testing" connected (test user @metatron_digitalagency). Domain verified
|
||||
(two DNS TXT records, both must stay). Details: `docs/tiktok-app.md`.
|
||||
- **Google:** connecting works now.
|
||||
- YouTube → Cloud project **Quorla**. GBP → project **GMB Audit** (gmb-audit-489406, GBP API already approved).
|
||||
- Both are in Testing mode. Only test users can connect: dev@metatroncubesolutions.com and
|
||||
metatroncubesoftwaresolutions@gmail.com. Add more under Google Auth Platform → Audience → Test users.
|
||||
- Testing-mode tokens expire after 7 days, so reconnect weekly until the app is verified. Details: `docs/google-app.md`.
|
||||
- Search Console: quorlasocial.com verified. Legal pages: /privacy and /terms.
|
||||
|
||||
## To do next
|
||||
1. **TikTok, YouTube and Google Business Profile are on the wrong client** (C2C Agricorp India). After the next
|
||||
deploy, open Connections → C2C Agricorp India and click **Move** on each card, then pick
|
||||
MetatronCube Software Solutions Canada. No reconnecting needed. Then choose the Metatroncube location for GBP.
|
||||
2. First test posts, as drafts approved by a person before anything publishes:
|
||||
- TikTok: private ("Only me"). Needs a short vertical 9:16 video.
|
||||
- YouTube: unlisted/private upload with a title.
|
||||
- GBP: one update post on the Metatroncube profile.
|
||||
3. TikTok Production: record the demo video (script in `docs/tiktok-app.md`), fill in the listing, and submit for review.
|
||||
4. Google: submit the OAuth app for verification (YouTube also needs its API audit before the quota goes above the default).
|
||||
5. Still to set up: LinkedIn, Pinterest, Threads (Meta permissions) and Reddit apps (`docs/platforms.md`).
|
||||
6. Later: marketing site, Stripe. (The dashboard now uses the Quorla logo and colours; PDF reports keep the agency branding.)
|
||||
7. Optional: on the server, run `git stash drop` (left over from the branding CRLF fix).
|
||||
|
||||
## Rules that don't change
|
||||
- Nothing publishes without a person approving the exact caption and media.
|
||||
- White-label: no Metatroncube mention on client posts.
|
||||
- Keys and secrets live only in `.env` or the encrypted Connections vault, never in chat, Git, or docs.
|
||||
- Tests: `node --test connector/test/platforms.test.mjs connector/test/quota.test.mjs`. On Windows, list the files by name;
|
||||
passing the folder fails.
|
||||
BIN
branding/quorla-logo-app-white.png
Normal file
BIN
branding/quorla-logo-app-white.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
branding/quorla-logo-app.png
Normal file
BIN
branding/quorla-logo-app.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
BIN
branding/quorla-mark-app.png
Normal file
BIN
branding/quorla-mark-app.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
@ -128,6 +128,22 @@ export function updateConnection(id, patch) {
|
||||
return publicConn(c);
|
||||
}
|
||||
|
||||
/** Move a connection to another client (e.g. connected under the wrong one). Keeps tokens and target. */
|
||||
export function moveConnection(id, client, by) {
|
||||
const db = read();
|
||||
const c = db.connections.find((x) => x.id === id);
|
||||
if (!c) throw err("Connection not found.", 404);
|
||||
if (c.client === client) return publicConn(c);
|
||||
if (db.connections.some((x) => x.client === client && x.platform === c.platform)) {
|
||||
throw err("That client already has an account connected on this platform. Disconnect it there first.");
|
||||
}
|
||||
c.client = client;
|
||||
c.movedBy = by || null;
|
||||
c.updatedAt = now();
|
||||
write(db);
|
||||
return publicConn(c);
|
||||
}
|
||||
|
||||
export function removeConnection(id) {
|
||||
const db = read();
|
||||
const before = db.connections.length;
|
||||
|
||||
@ -315,3 +315,14 @@ test("expired tokens are refreshed and saved; failed refresh asks to reconnect",
|
||||
test("posting to an unconnected platform is refused clearly", () => {
|
||||
assert.throws(() => P.validate("linkedin", "nobody", { type: "TEXT", media: [], caption: "x", options: {} }), /isn't connected for this client/);
|
||||
});
|
||||
|
||||
test("vault: a connection can move to another client, but not onto an existing one", () => {
|
||||
const tok = { accessToken: "a", refreshToken: "r" };
|
||||
const a = vault.saveConnection({ client: "move-from", platform: "tiktok", account: { id: "u1", name: "One" }, tokens: tok, by: "t" });
|
||||
vault.saveConnection({ client: "move-taken", platform: "tiktok", account: { id: "u2", name: "Two" }, tokens: tok, by: "t" });
|
||||
assert.throws(() => vault.moveConnection(a.id, "move-taken"), /already has an account/);
|
||||
const moved = vault.moveConnection(a.id, "move-to", "t");
|
||||
assert.equal(moved.client, "move-to");
|
||||
assert.equal(vault.connectionFor("move-from", "tiktok"), null);
|
||||
assert.equal(vault.connectionFor("move-to", "tiktok", { withTokens: true }).tokens.accessToken, "a");
|
||||
});
|
||||
|
||||
@ -47,8 +47,11 @@ app.post("/api/auth/login", login);
|
||||
app.post("/api/auth/logout", logout);
|
||||
app.post("/api/auth/setup", setupFirstAdmin);
|
||||
app.get("/api/auth/me", (req, res) => res.json({ user: currentUser(req), agency: AGENCY_NAME, setupNeeded: !users.hasUsers() }));
|
||||
app.get("/brand/logo.png", (_req, res) => res.sendFile(path.join(BRANDING_DIR, "metatroncube-logo.png")));
|
||||
app.get("/brand/mark.png", (_req, res) => res.sendFile(path.join(BRANDING_DIR, "metatroncube-mark.png")));
|
||||
// App (Quorla) branding. Agency branding for PDF reports stays in branding/ and is used by tools/report-pdf.
|
||||
app.get("/brand/logo.png", (_req, res) => res.sendFile(path.join(BRANDING_DIR, "quorla-logo-app.png")));
|
||||
app.get("/brand/logo-white.png", (_req, res) => res.sendFile(path.join(BRANDING_DIR, "quorla-logo-app-white.png")));
|
||||
app.get("/brand/mark.png", (_req, res) => res.sendFile(path.join(BRANDING_DIR, "quorla-mark-app.png")));
|
||||
app.get("/brand/icon.png", (_req, res) => res.sendFile(path.join(BRANDING_DIR, "quorla-icon-1024.png")));
|
||||
|
||||
// Public legal pages (linked from platform app listings: TikTok, Google, LinkedIn, Pinterest, Meta).
|
||||
const LEGAL_DIR = fileURLToPath(new URL("./legal/", import.meta.url));
|
||||
@ -109,7 +112,14 @@ app.get("/api/oauth/:platform/start", requireAdmin, h(async (req) => {
|
||||
app.post("/api/connections/:id/check", requireAdmin, h(async (req) => ({ connection: await extra.checkConnection(req.params.id) })));
|
||||
app.get("/api/connections/:id/creator-info", h(async (req) => ({ creator: await extra.creatorInfo(req.params.id) })));
|
||||
app.get("/api/connections/:id/targets", h(async (req) => ({ targets: await extra.targetsFor(req.params.id) })));
|
||||
app.patch("/api/connections/:id", requireAdmin, h(async (req) => ({ connection: await extra.setTarget(req.params.id, String(req.body?.targetId || "")) })));
|
||||
app.patch("/api/connections/:id", requireAdmin, h(async (req) => {
|
||||
if (req.body?.client !== undefined) {
|
||||
const to = String(req.body.client || "");
|
||||
if (!clients.getClient(to)) throw Object.assign(new Error("Pick a client to move it to."), { status: 400 });
|
||||
return { connection: extra.vault.moveConnection(req.params.id, to, req.user.name || req.user.email) };
|
||||
}
|
||||
return { connection: await extra.setTarget(req.params.id, String(req.body?.targetId || "")) };
|
||||
}));
|
||||
app.delete("/api/connections/:id", requireAdmin, h(async (req) => { extra.vault.removeConnection(req.params.id); return { ok: true }; }));
|
||||
|
||||
// ---------- team members (admins) ----------
|
||||
|
||||
@ -5,10 +5,12 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<link rel="icon" type="image/png" href="/brand/mark.png" />
|
||||
<link rel="apple-touch-icon" href="/brand/icon.png" />
|
||||
<meta name="theme-color" content="#242142" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Plus+Jakarta+Sans:wght@600;700;800&display=swap" rel="stylesheet" />
|
||||
<title>Social Command · Metatroncube</title>
|
||||
<title>Quorla</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@ -29,15 +29,13 @@ const NAV = [
|
||||
];
|
||||
|
||||
function Shell({ children }) {
|
||||
const { user, signOut } = useAuth();
|
||||
const { user, signOut, agency } = useAuth();
|
||||
return (
|
||||
<div className="min-h-screen flex">
|
||||
<aside className="w-[228px] shrink-0 bg-white border-r border-line flex flex-col fixed inset-y-0 left-0 z-10">
|
||||
<div className="px-4 pt-4 pb-3">
|
||||
<div className="h-[46px] overflow-hidden">
|
||||
<img src="/brand/logo.png" alt="Agency logo" className="h-[64px] -mt-[7px]" />
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] font-semibold tracking-[0.08em] uppercase text-faint">Social command</div>
|
||||
<img src="/brand/logo.png" alt="Quorla" className="h-[30px] w-auto" />
|
||||
{agency && <div className="mt-2.5 text-[11px] font-semibold tracking-[0.08em] uppercase text-faint truncate" title={agency}>{agency}</div>}
|
||||
</div>
|
||||
<nav className="px-2 mt-2 space-y-0.5 flex-1">
|
||||
{NAV.map(({ to, label, icon: Icon, end }) => (
|
||||
@ -89,7 +87,7 @@ export default function App() {
|
||||
api("/api/auth/me")
|
||||
.then((r) => {
|
||||
setState({ loading: false, user: r.user, setupNeeded: r.setupNeeded, agency: r.agency });
|
||||
if (r.agency) document.title = `Social Command · ${r.agency}`;
|
||||
document.title = r.agency ? `Quorla · ${r.agency}` : "Quorla";
|
||||
})
|
||||
.catch(() => setState({ loading: false, user: null }));
|
||||
}, []);
|
||||
@ -110,7 +108,7 @@ export default function App() {
|
||||
if (!state.user) return <Login setupNeeded={state.setupNeeded} agency={state.agency} onSignedIn={refresh} />;
|
||||
|
||||
return (
|
||||
<AuthCtx.Provider value={{ user: state.user, signOut, refresh, isAdmin: state.user.role === "admin" }}>
|
||||
<AuthCtx.Provider value={{ user: state.user, agency: state.agency, signOut, refresh, isAdmin: state.user.role === "admin" }}>
|
||||
<BrowserRouter>
|
||||
<Shell>
|
||||
<Routes>
|
||||
|
||||
@ -9,8 +9,8 @@ import {
|
||||
} from "../ui.jsx";
|
||||
|
||||
const FORMAT = {
|
||||
reel: { label: "Reel", color: "#3a7bbf" },
|
||||
carousel: { label: "Carousel", color: "#52ace0" },
|
||||
reel: { label: "Reel", color: "#3b3778" },
|
||||
carousel: { label: "Carousel", color: "#fc6d45" },
|
||||
image: { label: "Image", color: "#a9afba" },
|
||||
};
|
||||
const formatOf = (p) => (p.media_product_type === "REELS" || p.media_type === "VIDEO" ? "reel" : p.media_type === "CAROUSEL_ALBUM" ? "carousel" : "image");
|
||||
@ -278,15 +278,15 @@ function Performance({ snapshot, history, refreshing, onRefresh }) {
|
||||
<AreaChart data={ig.dailyReach.map((x) => ({ ...x, label: fmtDate(x.date) }))} margin={{ top: 4, right: 8, bottom: 0, left: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="reachFill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#3a7bbf" stopOpacity={0.22} />
|
||||
<stop offset="100%" stopColor="#3a7bbf" stopOpacity={0} />
|
||||
<stop offset="0%" stopColor="#3b3778" stopOpacity={0.18} />
|
||||
<stop offset="100%" stopColor="#3b3778" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid vertical={false} stroke="#eef0f3" />
|
||||
<XAxis dataKey="label" tick={{ fontSize: 11, fill: "#8a8f99" }} tickLine={false} axisLine={{ stroke: "#e4e7ec" }} minTickGap={24} />
|
||||
<YAxis tick={{ fontSize: 11, fill: "#8a8f99" }} tickLine={false} axisLine={false} width={48} allowDecimals={false} tickFormatter={(v) => (v >= 10000 ? `${Math.round(v / 1000)}k` : v)} />
|
||||
<Tooltip contentStyle={{ fontSize: 12, borderRadius: 8, border: "1px solid #e4e7ec" }} formatter={(v) => [fmt(v), "Reached"]} />
|
||||
<Area type="monotone" dataKey="value" stroke="#3a7bbf" strokeWidth={2} fill="url(#reachFill)" isAnimationActive={false} />
|
||||
<Area type="monotone" dataKey="value" stroke="#3b3778" strokeWidth={2} fill="url(#reachFill)" isAnimationActive={false} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { Check, CircleAlert, Copy, ExternalLink, Plug, RefreshCw, Settings2, Unplug } from "lucide-react";
|
||||
import { ArrowRightLeft, Check, CircleAlert, Copy, ExternalLink, Plug, RefreshCw, Settings2, Unplug } from "lucide-react";
|
||||
import { api } from "../api.js";
|
||||
import { useAuth } from "../App.jsx";
|
||||
import { Badge, Button, cx, Dialog, ErrorNote, Field, fmt, Panel, Spinner, timeAgo } from "../ui.jsx";
|
||||
@ -77,8 +77,9 @@ function AppDialog({ platform, onClose, onSaved }) {
|
||||
);
|
||||
}
|
||||
|
||||
function PlatformCard({ platform, conn, client, isAdmin, mediaLinks, onSetup, reload, setError, quota }) {
|
||||
function PlatformCard({ platform, conn, client, clients, isAdmin, mediaLinks, onSetup, onMoved, reload, setError, quota }) {
|
||||
const [busy, setBusy] = useState(null);
|
||||
const [moving, setMoving] = useState(false);
|
||||
const [targets, setTargets] = useState(null);
|
||||
const needsMediaLinks = ["tiktok", "threads", "gbp"].includes(platform.id) && !mediaLinks;
|
||||
|
||||
@ -114,7 +115,7 @@ function PlatformCard({ platform, conn, client, isAdmin, mediaLinks, onSetup, re
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{conn.account?.avatar && <img src={conn.account.avatar} alt="" className="size-6 rounded-full shrink-0" />}
|
||||
<span className="font-medium truncate">{conn.account?.name}</span>
|
||||
{conn.account?.username && <span className="text-muted truncate">@{conn.account.username}</span>}
|
||||
{conn.account?.username && <span className="text-muted truncate">@{String(conn.account.username).replace(/^@+/, "")}</span>}
|
||||
</div>
|
||||
<div className="text-[12px] text-muted">
|
||||
{conn.account?.followers != null && <>{fmt(conn.account.followers)} {platform.id === "youtube" ? "subscribers" : platform.id === "reddit" ? "karma" : "followers"} · </>}
|
||||
@ -138,6 +139,18 @@ function PlatformCard({ platform, conn, client, isAdmin, mediaLinks, onSetup, re
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{isAdmin && conn && moving && (
|
||||
<Field label="Move this account to">
|
||||
<select id={`move-${platform.id}`} className="input" defaultValue="" disabled={busy === "move"}
|
||||
onChange={(e) => { const to = e.target.value; if (!to) return; setBusy("move"); setError(null);
|
||||
api(`/api/connections/${conn.id}`, { method: "PATCH", body: { client: to } })
|
||||
.then(() => { setMoving(false); onMoved(to); }).catch((er) => setError(er)).finally(() => setBusy(null)); }}>
|
||||
<option value="" disabled>Choose a client…</option>
|
||||
{clients.filter((c) => c.slug !== client).map((c) => <option key={c.slug} value={c.slug}>{c.name}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{needsMediaLinks && <div className="text-[12px] text-warn">Image and video posts need PUBLIC_URL set on the server (e.g. https://app.quorlasocial.com).</div>}
|
||||
|
||||
<div className="flex flex-wrap gap-2 mt-auto pt-1">
|
||||
@ -145,6 +158,7 @@ function PlatformCard({ platform, conn, client, isAdmin, mediaLinks, onSetup, re
|
||||
{isAdmin && platform.app.configured && !conn && <Button size="sm" variant="primary" icon={Plug} loading={busy === "connect"} onClick={connect}>Connect</Button>}
|
||||
{isAdmin && conn && <Button size="sm" icon={Plug} loading={busy === "connect"} onClick={connect}>Reconnect</Button>}
|
||||
{isAdmin && conn && <Button size="sm" variant="ghost" icon={RefreshCw} loading={busy === "check"} onClick={() => run("check", () => api(`/api/connections/${conn.id}/check`, { method: "POST" }))}>Check</Button>}
|
||||
{isAdmin && conn && <Button size="sm" variant="ghost" icon={ArrowRightLeft} onClick={() => setMoving(!moving)}>{moving ? "Cancel move" : "Move"}</Button>}
|
||||
{isAdmin && conn && <Button size="sm" variant="ghost" icon={Unplug} loading={busy === "remove"} onClick={() => run("remove", () => api(`/api/connections/${conn.id}`, { method: "DELETE" }))}>Disconnect</Button>}
|
||||
{isAdmin && platform.app.configured && <Button size="sm" variant="ghost" icon={Settings2} onClick={onSetup} aria-label={`${platform.name} app settings`} />}
|
||||
{!isAdmin && !conn && <span className="text-[12.5px] text-muted">An admin connects this.</span>}
|
||||
@ -224,8 +238,8 @@ export default function Connections() {
|
||||
|
||||
<div className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(300px,1fr))]">
|
||||
{platforms.platforms.map((p) => (
|
||||
<PlatformCard key={`${p.id}-${client}`} platform={p} conn={conns.find((c) => c.platform === p.id)} client={client} isAdmin={isAdmin}
|
||||
mediaLinks={platforms.mediaLinks} onSetup={() => setSetup(p)} reload={loadConns} setError={setError} quota={quota[p.id]} />
|
||||
<PlatformCard key={`${p.id}-${client}`} platform={p} conn={conns.find((c) => c.platform === p.id)} client={client} clients={clients} isAdmin={isAdmin}
|
||||
mediaLinks={platforms.mediaLinks} onSetup={() => setSetup(p)} onMoved={pickClient} reload={loadConns} setError={setError} quota={quota[p.id]} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@ import { useState } from "react";
|
||||
import { api } from "../api.js";
|
||||
import { Button, ErrorNote, Field } from "../ui.jsx";
|
||||
|
||||
export default function Login({ onSignedIn, setupNeeded, agency = "Metatroncube" }) {
|
||||
export default function Login({ onSignedIn, setupNeeded, agency }) {
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
@ -28,23 +28,21 @@ export default function Login({ onSignedIn, setupNeeded, agency = "Metatroncube"
|
||||
|
||||
return (
|
||||
<div className="min-h-screen grid lg:grid-cols-[1fr_520px] bg-white">
|
||||
<div className="hidden lg:flex flex-col justify-between bg-[#1f2024] text-white p-12 relative overflow-hidden">
|
||||
<div className="absolute -right-32 -top-32 size-[420px] rounded-full bg-[radial-gradient(circle,rgba(82,172,224,0.28),transparent_65%)]" />
|
||||
<div className="text-[12px] font-semibold tracking-[0.12em] uppercase text-sky">{agency} · Social command</div>
|
||||
<div className="hidden lg:flex flex-col justify-between bg-brand-deep text-white p-12 relative overflow-hidden">
|
||||
<div className="absolute -right-32 -top-32 size-[420px] rounded-full bg-[radial-gradient(circle,rgba(252,109,69,0.30),transparent_65%)]" />
|
||||
<img src="/brand/logo-white.png" alt="Quorla" className="h-[34px] w-auto self-start" />
|
||||
<div className="max-w-md">
|
||||
<h1 className="text-white text-[34px] font-extrabold leading-[1.15]">Every client account, the team's work and the reports, in one place.</h1>
|
||||
<p className="text-[#b7bac3] mt-4 text-[14.5px] leading-relaxed">
|
||||
Live Instagram and Facebook numbers, AI-run audits and weekly plans, branded PDF reports and Odoo tasks.
|
||||
<p className="text-[#c9c7dd] mt-4 text-[14.5px] leading-relaxed">
|
||||
Plan, approve and publish to Instagram, Facebook, TikTok, YouTube, LinkedIn and more, with live numbers, AI-run audits and weekly plans, and reports your clients can read.
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-[12px] text-[#8b8f99]">Internal tool · client data is confidential</div>
|
||||
<div className="text-[12px] text-[#9e9bbd]">{agency ? `${agency} workspace · ` : ""}client data is confidential</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<form onSubmit={submit} className="w-full max-w-[340px]">
|
||||
<div className="h-[54px] overflow-hidden mb-8">
|
||||
<img src="/brand/logo.png" alt={agency} className="h-[76px] -mt-[8px]" />
|
||||
</div>
|
||||
<img src="/brand/logo.png" alt="Quorla" className="h-[36px] w-auto mb-8" />
|
||||
<h2 className="text-[20px] font-extrabold">{setupNeeded ? "Create the admin account" : "Sign in"}</h2>
|
||||
<p className="text-muted text-[13px] mt-1 mb-6">
|
||||
{setupNeeded ? "First run: this person can add the rest of the team from Settings." : "Use your agency email."}
|
||||
|
||||
@ -1,14 +1,17 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Metatroncube design tokens — keep in sync with branding/report.css */
|
||||
/* Quorla app tokens (navy #242142, coral #FC6D45). Agency PDF reports keep their own styles in branding/report.css. */
|
||||
@theme {
|
||||
--font-sans: "Inter", "Segoe UI", system-ui, sans-serif;
|
||||
--font-display: "Plus Jakarta Sans", "Segoe UI", system-ui, sans-serif;
|
||||
|
||||
--color-brand: #3a7bbf;
|
||||
--color-brand-deep: #2c5f96;
|
||||
--color-brand-soft: #eaf2fa;
|
||||
--color-sky: #52ace0;
|
||||
--color-brand: #3b3778;
|
||||
--color-brand-deep: #242142;
|
||||
--color-brand-soft: #efeef7;
|
||||
--color-sky: #fc6d45;
|
||||
--color-accent: #fc6d45;
|
||||
--color-accent-deep: #c2461a;
|
||||
--color-accent-soft: #fff0ea;
|
||||
--color-ink: #1f2024;
|
||||
--color-ink-2: #45474f;
|
||||
--color-muted: #6b6e78;
|
||||
@ -28,7 +31,7 @@
|
||||
html { -webkit-font-smoothing: antialiased; }
|
||||
body { @apply bg-canvas text-ink font-sans text-[13.5px] leading-[1.5]; }
|
||||
h1, h2, h3 { @apply font-display text-ink; letter-spacing: -0.01em; }
|
||||
::selection { background: #cfe2f5; }
|
||||
::selection { background: #ffd9cb; }
|
||||
:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; border-radius: 4px; }
|
||||
}
|
||||
|
||||
|
||||
@ -196,7 +196,7 @@ export function Spark({ values, width = 96, height = 26 }) {
|
||||
<svg width={width} height={height} aria-hidden="true" className="block">
|
||||
{values.map((v, i) => {
|
||||
const h = Math.max(2, (v / max) * (height - 2));
|
||||
return <rect key={i} x={i * w + 1} y={height - h} width={Math.max(2, w - 2)} height={h} rx={1} fill={i === values.length - 1 ? "#3a7bbf" : "#b9d2ea"} />;
|
||||
return <rect key={i} x={i * w + 1} y={height - h} width={Math.max(2, w - 2)} height={h} rx={1} fill={i === values.length - 1 ? "#fc6d45" : "#c9c7e0"} />;
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user