Add admin-configurable branding (colors, logo, favicon) and generic default fallbacks

Site Settings -> Branding now supports a Primary/Secondary/Accent brand color
system applied site-wide (buttons, nav, hover states, links) and to outgoing
email header/CTA colors, plus a favicon upload alongside the existing logo
upload, a live preview panel (website/email x desktop/mobile), and
logo-based color suggestions. The setup wizard's Branding step got the same
treatment. Fixes two related bugs found along the way: the setup wizard's
logo/favicon upload was missing its auth token, and a static favicon.ico in
Next's special app/ convention path was silently overriding the dynamic one.

Also replaces every "Hope Events"/"Hope Family Church" default (org name,
email subjects, WhatsApp messages, report metadata, API docs) with a neutral
"Cross Code" placeholder, and the optional legal settings (operator name, IO
details, website URL, effective date) with obviously-generic placeholders
instead of defaulting to real personal/organisational details -- since this
platform is deployed for multiple organisations. Adds SETTINGS.md documenting
every setting's default behaviour.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 14:49:51 +02:00
co-authored by Claude Sonnet 5
parent 2d99b7cafe
commit 86af7093ac
33 changed files with 1174 additions and 198 deletions

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

@@ -7,6 +7,9 @@ import { apiFetch, API_BASE, resolveToApiOrigin } from "@/lib/api";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, type LucideIcon } from "lucide-react";
import { ColorPickerField } from "@/components/admin/ColorPickerField";
import { BrandingPreviewPanel } from "@/components/admin/BrandingPreviewPanel";
import { extractDominantColors } from "@/lib/extractColors";
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal" | "whatsapp";
@@ -97,11 +100,18 @@ function SiteSettingsPageInner() {
const [appBaseUrl, setAppBaseUrl] = useState("");
// ── Branding ──
const [accentColor, setAccentColor] = useState("#2563eb");
const [primaryColor, setPrimaryColor] = useState("#4F46E5");
const [secondaryColor, setSecondaryColor] = useState("#8B5CF6");
const [accentColor, setAccentColor] = useState("#EC4899");
const [logoUrl, setLogoUrl] = useState("");
const [logoFile, setLogoFile] = useState<File | null>(null);
const [logoPreview, setLogoPreview] = useState<string | null>(null);
const [suggestedColors, setSuggestedColors] = useState<string[]>([]);
const fileRef = useRef<HTMLInputElement>(null);
const [faviconUrl, setFaviconUrl] = useState("");
const [faviconFile, setFaviconFile] = useState<File | null>(null);
const [faviconPreview, setFaviconPreview] = useState<string | null>(null);
const faviconRef = useRef<HTMLInputElement>(null);
// ── Notifications ──
const [notifEmails, setNotifEmails] = useState("");
@@ -135,8 +145,16 @@ function SiteSettingsPageInner() {
setOrgPhone(s.org_phone || "");
setOrgAddress(s.org_address || "");
setAppBaseUrl(s.app_base_url || "");
setAccentColor(s.accent_color || "#2563eb");
// primary_color falls back to the legacy accent_color value (which used
// to be "the one brand color" before this 3-color system existed).
// accent_color is only trusted as the NEW tertiary color once
// primary_color already exists — otherwise it's still doing legacy-
// primary duty and must not also seed the Accent field.
setPrimaryColor(s.primary_color || s.accent_color || "#4F46E5");
setSecondaryColor(s.secondary_color || "#8B5CF6");
setAccentColor(s.primary_color ? (s.accent_color || "#EC4899") : "#EC4899");
setLogoUrl(s.logo_url || "");
setFaviconUrl(s.favicon_url || "");
setNotifEmails(s.reg_notification_emails || "");
setSmtpHost(s.smtp_host || "");
setSmtpPort(s.smtp_port || "587");
@@ -183,31 +201,47 @@ function SiteSettingsPageInner() {
app_base_url: appBaseUrl.trim(),
});
const uploadBrandingAsset = async (file: File, kind: "logo" | "favicon"): Promise<string> => {
const fd = new FormData();
fd.append("image", file);
const res = await fetch(`${API_BASE}/api/uploads/${kind}`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: fd,
});
if (!res.ok) throw new Error(`${kind === "logo" ? "Logo" : "Favicon"} upload failed`);
const data = await res.json();
return data.url || "";
};
const saveBranding = async () => {
if (!token) return;
setSaving(true);
setResult(null);
try {
let finalLogoUrl = logoUrl;
if (logoFile) {
const fd = new FormData();
fd.append("image", logoFile);
const res = await fetch(`${API_BASE}/api/uploads/logo`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: fd,
});
if (!res.ok) throw new Error("Logo upload failed");
const data = await res.json();
finalLogoUrl = data.url || logoUrl;
}
if (logoFile) finalLogoUrl = await uploadBrandingAsset(logoFile, "logo") || logoUrl;
let finalFaviconUrl = faviconUrl;
if (faviconFile) finalFaviconUrl = await uploadBrandingAsset(faviconFile, "favicon") || faviconUrl;
await apiFetch("/api/settings", {
method: "PUT", authToken: token,
body: { accent_color: accentColor, logo_url: finalLogoUrl },
body: {
primary_color: primaryColor,
secondary_color: secondaryColor,
accent_color: accentColor,
logo_url: finalLogoUrl,
favicon_url: finalFaviconUrl,
},
});
setLogoUrl(finalLogoUrl);
setLogoFile(null);
setLogoPreview(null);
setFaviconUrl(finalFaviconUrl);
setFaviconFile(null);
setFaviconPreview(null);
setSuggestedColors([]);
setResult({ ok: true, message: "Saved." });
reloadSettings();
} catch (e: any) {
@@ -217,6 +251,39 @@ function SiteSettingsPageInner() {
}
};
const resetBranding = async () => {
if (!token) return;
if (!confirm("Reset branding to defaults? This clears your custom colors, logo, and favicon.")) return;
setSaving(true);
setResult(null);
try {
await apiFetch("/api/settings", {
method: "PUT", authToken: token,
body: { primary_color: "", secondary_color: "", accent_color: "", logo_url: "", favicon_url: "" },
});
setPrimaryColor("#4F46E5");
setSecondaryColor("#8B5CF6");
setAccentColor("#EC4899");
setLogoUrl(""); setLogoFile(null); setLogoPreview(null);
setFaviconUrl(""); setFaviconFile(null); setFaviconPreview(null);
setSuggestedColors([]);
setResult({ ok: true, message: "Reset to defaults." });
reloadSettings();
} catch (e: any) {
setResult({ ok: false, message: e?.message || "Failed to reset" });
} finally {
setSaving(false);
}
};
const handleLogoFileChange = async (file: File | undefined) => {
if (!file) return;
setLogoFile(file);
const preview = URL.createObjectURL(file);
setLogoPreview(preview);
setSuggestedColors(await extractDominantColors(preview));
};
const saveNotifications = () => save({ reg_notification_emails: notifEmails.trim() });
const saveSmtp = async () => {
@@ -279,9 +346,10 @@ function SiteSettingsPageInner() {
if (loadingInitial) return <div className="p-6 text-sm text-gray-500">Loading settings</div>;
const currentLogoSrc = logoPreview || (logoUrl ? resolveToApiOrigin(logoUrl) : null);
const currentFaviconSrc = faviconPreview || (faviconUrl ? resolveToApiOrigin(faviconUrl) : null);
return (
<div className="max-w-3xl mx-auto w-full">
<div className={activeTab === "branding" ? "max-w-6xl mx-auto w-full" : "max-w-3xl mx-auto w-full"}>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
@@ -318,13 +386,14 @@ function SiteSettingsPageInner() {
})}
</div>
<div className="border rounded-xl p-5 bg-white shadow-sm">
<div className={activeTab === "branding" ? "grid lg:grid-cols-5 gap-5 items-start" : ""}>
<div className={`border rounded-xl p-5 bg-white shadow-sm ${activeTab === "branding" ? "lg:col-span-3" : ""}`}>
{/* ── Organisation ─────────────────────────────────────────────────── */}
{activeTab === "organisation" && (
<div className="space-y-4">
<h2 className="text-lg font-semibold text-gray-900">Organisation details</h2>
<Field label="Organisation name" required>
<input className={inputCls} placeholder="Hope Family Church"
<input className={inputCls} placeholder="Your Organisation"
value={orgName} onChange={e => setOrgName(e.target.value)} />
</Field>
<Field label="Tagline">
@@ -357,15 +426,15 @@ function SiteSettingsPageInner() {
{activeTab === "branding" && (
<div className="space-y-5">
<h2 className="text-lg font-semibold text-gray-900">Branding</h2>
<Field label="Accent / brand colour" hint="Only recolors the organisation name next to the logo in the top navigation bar — every other button, link, and highlight across the site uses a fixed color scheme.">
<div className="flex items-center gap-3">
<input type="color" className="h-10 w-20 border rounded cursor-pointer"
value={accentColor} onChange={e => setAccentColor(e.target.value)} />
<input className={`${inputCls} font-mono`} placeholder="#2563eb"
value={accentColor} onChange={e => setAccentColor(e.target.value)} />
</div>
<div className="mt-2 h-6 rounded-lg transition-colors" style={{ background: accentColor }} />
</Field>
<div className="grid sm:grid-cols-3 gap-4">
<ColorPickerField label="Primary" hint="Buttons, links, nav highlights, and the email header/CTA."
value={primaryColor} onChange={setPrimaryColor} />
<ColorPickerField label="Secondary" hint="Subtle backgrounds and outline-button borders/text."
value={secondaryColor} onChange={setSecondaryColor} />
<ColorPickerField label="Accent" hint="Hover states and highlight chips/badges."
value={accentColor} onChange={setAccentColor} />
</div>
<Field label="Site logo" hint="PNG, JPG, SVG or WebP, max 2 MB. Displayed in the navigation bar.">
{currentLogoSrc && (
@@ -375,21 +444,77 @@ function SiteSettingsPageInner() {
<button
type="button"
className="text-xs text-red-500 hover:text-red-700"
onClick={() => { setLogoUrl(""); setLogoFile(null); setLogoPreview(null); if (fileRef.current) fileRef.current.value = ""; }}
onClick={() => { setLogoUrl(""); setLogoFile(null); setLogoPreview(null); setSuggestedColors([]); if (fileRef.current) fileRef.current.value = ""; }}
>
Remove
</button>
</div>
)}
<input ref={fileRef} type="file" accept="image/*" onChange={e => {
<input ref={fileRef} type="file" accept="image/*" onChange={e => handleLogoFileChange(e.target.files?.[0])} className="text-sm" />
{suggestedColors.length > 0 && (
<div className="mt-3 border rounded-lg p-3 bg-gray-50">
<p className="text-xs font-medium text-gray-600 mb-2">Suggested from your logo click a swatch to apply it</p>
<div className="flex flex-wrap gap-2">
{suggestedColors.map((hex) => (
<div key={hex} className="flex items-center gap-1 border rounded-lg bg-white p-1">
<div className="w-6 h-6 rounded" style={{ background: hex }} title={hex} />
<button type="button" className="text-[10px] font-semibold px-1.5 py-1 rounded hover:bg-gray-100" onClick={() => setPrimaryColor(hex)}>P</button>
<button type="button" className="text-[10px] font-semibold px-1.5 py-1 rounded hover:bg-gray-100" onClick={() => setSecondaryColor(hex)}>S</button>
<button type="button" className="text-[10px] font-semibold px-1.5 py-1 rounded hover:bg-gray-100" onClick={() => setAccentColor(hex)}>A</button>
</div>
))}
</div>
</div>
)}
</Field>
<Field label="Favicon" hint="ICO, PNG, or SVG, max 2 MB, ideally square. Shown in the browser tab — falls back to the default if not set.">
{currentFaviconSrc && (
<div className="mb-3 flex items-center gap-3">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={currentFaviconSrc} alt="Current favicon" className="h-8 w-8 object-contain border rounded p-1 bg-gray-50" />
<button
type="button"
className="text-xs text-red-500 hover:text-red-700"
onClick={() => { setFaviconUrl(""); setFaviconFile(null); setFaviconPreview(null); if (faviconRef.current) faviconRef.current.value = ""; }}
>
Remove
</button>
</div>
)}
<input ref={faviconRef} type="file" accept=".ico,.png,.svg,image/x-icon,image/png,image/svg+xml" onChange={e => {
const file = e.target.files?.[0];
if (!file) return;
setLogoFile(file);
setLogoPreview(URL.createObjectURL(file));
setFaviconFile(file);
setFaviconPreview(URL.createObjectURL(file));
}} className="text-sm" />
</Field>
<SaveBar saving={saving} onSave={saveBranding} result={result} />
<div className="flex items-center justify-between pt-4 border-t mt-6 flex-wrap gap-3">
<button
type="button"
disabled={saving}
onClick={resetBranding}
className="px-4 py-2 text-sm rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 disabled:opacity-50"
>
Reset to defaults
</button>
<div className="flex items-center gap-3">
{result && (
<span className={`text-sm flex items-center gap-1.5 ${result.ok ? "text-green-600" : "text-red-600"}`}>
{result.ok ? "✓" : "✗"} {result.message}
</span>
)}
<button
type="button"
disabled={saving}
onClick={saveBranding}
className="px-6 py-2 bg-brand-600 hover:bg-brand-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium"
>
{saving ? "Saving…" : "Save"}
</button>
</div>
</div>
</div>
)}
@@ -531,6 +656,20 @@ function SiteSettingsPageInner() {
{/* ── WhatsApp ──────────────────────────────────────────────────────── */}
{activeTab === "whatsapp" && <WhatsAppTab active={activeTab === "whatsapp"} />}
</div>
{activeTab === "branding" && (
<div className="lg:col-span-2 lg:sticky lg:top-4">
<BrandingPreviewPanel
primaryColor={primaryColor}
secondaryColor={secondaryColor}
accentColor={accentColor}
logoSrc={currentLogoSrc}
orgName={orgName}
orgTagline={orgTagline}
/>
</div>
)}
</div>
</div>
);
}
+23 -10
View File
@@ -4,17 +4,18 @@
/*
* Design tokens for shadcn/ui primitives (components.json: baseColor "slate",
* cssVariables: true). Base scale is shadcn's stock "slate" theme; --primary
* and --ring are overridden to the site's fixed indigo/purple brand color
* (matches the `brand` scale in tailwind.config.js, brand-600 #4F46E5).
* cssVariables: true). Base scale is shadcn's stock "slate" theme.
*
* IMPORTANT: the org's admin-configurable accent color
* (SiteSettingsContext.settings.accent_color) must NEVER be wired into any
* variable here or into any Tailwind color token. It is applied in exactly
* one place — an inline `style={{ color }}` on the org name text in
* Navbar.tsx — and nowhere else. Every other themed element (buttons, links,
* active nav states, icon chips, the help button, etc.) uses the fixed
* brand/primary tokens below regardless of what the org sets that setting to.
* --primary/--primary-foreground/--ring/--secondary/--secondary-foreground/
* --accent/--accent-foreground/--brand-50..900 below are DEFAULTS ONLY — used
* before any branding setting has been saved, or if the server-side settings
* fetch fails for a request. The real values come from the admin's saved
* primary_color/secondary_color/accent_color settings, computed via
* lib/colorScale.ts and injected per-request as a `<style id="brand-theme">`
* override in layout.tsx (see buildThemeCssVars). This intentionally reverses
* an earlier decision (commit 8e6cb542) that kept the accent color scoped to
* a single inline style on the navbar's org-name text — branding now flows
* through these same tokens site-wide and into outgoing emails.
*/
@layer base {
:root {
@@ -47,6 +48,18 @@
--ring: 243 75% 59%;
--radius: 0.5rem;
/* Fallback brand scale (matches the pre-branding-feature default indigo, brand-600 #4F46E5) */
--brand-50: #EEF2FF;
--brand-100: #E0E7FF;
--brand-200: #C7D2FE;
--brand-300: #A5B4FC;
--brand-400: #818CF8;
--brand-500: #6366F1;
--brand-600: #4F46E5;
--brand-700: #4338CA;
--brand-800: #3730A3;
--brand-900: #312E81;
}
}
+46 -10
View File
@@ -3,10 +3,13 @@ import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { AuthProvider } from "@/contexts/AuthContext";
import { SiteSettingsProvider } from "@/contexts/SiteSettingsContext";
import type { SiteSettings } from "@/contexts/SiteSettingsContext";
import { ToastProvider } from "@/components/shared/ToastProvider";
import { BannerBar } from "@/components/shared/BannerBar";
import { SetupGuard } from "@/components/shared/SetupGuard";
import HelpFab from "@/components/shared/HelpFab";
import { API_BASE, resolveToApiOrigin } from "@/lib/api";
import { buildThemeCssVars } from "@/lib/colorScale";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -18,21 +21,54 @@ const geistMono = Geist_Mono({
subsets: ["latin"],
});
const appName = process.env.NEXT_PUBLIC_APP_NAME || 'Hope Events';
const appName = process.env.NEXT_PUBLIC_APP_NAME || 'Cross Code';
export const metadata: Metadata = {
title: appName,
description: `Manage and register for events with ${appName}`,
icons: {
icon: "/favicon.ico",
},
};
/**
* Server-side settings fetch, shared by generateMetadata() and RootLayout()
* below. Both call this with the identical URL + options so Next's per-request
* fetch memoization dedupes them into a single network call. Degrades to `{}`
* (static defaults everywhere downstream) if the backend is unreachable at
* request time — intentional, not a bug: branding just isn't critical enough
* to fail the whole page render over.
*/
async function getServerSettings(): Promise<SiteSettings> {
try {
const res = await fetch(`${API_BASE}/api/settings`, { next: { revalidate: 60 } });
if (!res.ok) return {};
return await res.json();
} catch {
return {};
}
}
export async function generateMetadata(): Promise<Metadata> {
const settings = await getServerSettings();
const faviconUrl = settings.favicon_url ? resolveToApiOrigin(settings.favicon_url) : null;
return {
title: appName,
description: `Manage and register for events with ${appName}`,
icons: {
icon: faviconUrl || "/favicon.ico",
},
};
}
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const settings = await getServerSettings();
const cssVars = buildThemeCssVars({
primary: settings.primary_color || settings.accent_color,
secondary: settings.secondary_color,
accent: settings.primary_color ? settings.accent_color : undefined,
});
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<SiteSettingsProvider>
{cssVars && (
<style id="brand-theme" dangerouslySetInnerHTML={{ __html: `:root{${cssVars}}` }} />
)}
<SiteSettingsProvider initialSettings={settings}>
<AuthProvider>
<ToastProvider>
<SetupGuard />
+13 -6
View File
@@ -27,12 +27,19 @@ export default function PrivacyPolicyPage() {
const activeId = useScrollSpy(sections.map((s) => s.id));
const { settings } = useSiteSettings();
const orgName = settings.org_name || "Hope Family Church";
const orgEmail = settings.org_email || "admin@hopehenley.co.za";
const websiteUrl = settings.legal_website_url || "events.hopehenley.co.za";
const ioName = settings.legal_io_name || "Joshua Oosthuizen";
const ioEmail = settings.legal_io_email || "joshua@crosscode.co.za";
const effectiveDate = settings.legal_effective_date || "April 2026";
// orgName follows the sitewide org_name default (Cross Code) since org_name is a
// required field at setup — this fallback is practically unreachable in production.
const orgName = settings.org_name || "Cross Code";
// The fields below are all OPTIONAL legal-specific settings that can realistically
// stay unset on a live site. Defaulting them to a real person/org's identity would be
// actively misleading on someone else's deployment, so these are generic placeholders
// that obviously need to be filled in via Admin → Site Settings → Legal, rather than
// silently showing Cross Code's or any other real organisation's details.
const orgEmail = settings.org_email || "privacy@example.com";
const websiteUrl = settings.legal_website_url || "example.com";
const ioName = settings.legal_io_name || "[not yet configured]";
const ioEmail = settings.legal_io_email || "io@example.com";
const effectiveDate = settings.legal_effective_date || "[not yet set]";
return (
<div className="min-h-screen flex flex-col bg-gray-50">
+12 -5
View File
@@ -26,11 +26,18 @@ export default function TermsOfUsePage() {
const activeId = useScrollSpy(sections.map((s) => s.id));
const { settings } = useSiteSettings();
const orgName = settings.org_name || "Hope Family Church";
const orgEmail = settings.org_email || "admin@hopehenley.co.za";
const websiteUrl = settings.legal_website_url || "events.hopehenley.co.za";
const operatorName = settings.legal_operator_name || "Joshua Oosthuizen on behalf of Hope Family Church, Henley-on-Klip, South Africa";
const effectiveDate = settings.legal_effective_date || "April 2026";
// orgName follows the sitewide org_name default (Cross Code) since org_name is a
// required field at setup — this fallback is practically unreachable in production.
const orgName = settings.org_name || "Cross Code";
// The fields below are all OPTIONAL legal-specific settings that can realistically
// stay unset on a live site. Defaulting them to a real person/org's identity would be
// actively misleading on someone else's deployment, so these are generic placeholders
// that obviously need to be filled in via Admin → Site Settings → Legal, rather than
// silently showing Cross Code's or any other real organisation's details.
const orgEmail = settings.org_email || "privacy@example.com";
const websiteUrl = settings.legal_website_url || "example.com";
const operatorName = settings.legal_operator_name || "[operator details not yet configured]";
const effectiveDate = settings.legal_effective_date || "[not yet set]";
return (
<div className="min-h-screen flex flex-col bg-gray-50">
+100 -26
View File
@@ -3,6 +3,9 @@
import React, { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { apiFetch, API_BASE } from "@/lib/api";
import { ColorPickerField } from "@/components/admin/ColorPickerField";
import { BrandingPreviewPanel } from "@/components/admin/BrandingPreviewPanel";
import { extractDominantColors } from "@/lib/extractColors";
type Step = 1 | 2 | 3 | 4 | 5 | 6;
@@ -68,9 +71,13 @@ export default function SetupPage() {
const [adminConfirm, setAdminConfirm] = useState("");
// Step 3 — Branding
const [accentColor, setAccentColor] = useState("#2563eb");
const [primaryColor, setPrimaryColor] = useState("#4F46E5");
const [secondaryColor, setSecondaryColor] = useState("#8B5CF6");
const [accentColor, setAccentColor] = useState("#EC4899");
const [logoFile, setLogoFile] = useState<File | null>(null);
const [logoPreview, setLogoPreview] = useState<string | null>(null);
const [faviconFile, setFaviconFile] = useState<File | null>(null);
const [suggestedColors, setSuggestedColors] = useState<string[]>([]);
const [notifEmail, setNotifEmail] = useState("");
// Step 4 — Legal
@@ -98,11 +105,19 @@ export default function SetupPage() {
const next = () => setStep((s) => Math.min(s + 1, 6) as Step);
const back = () => { setError(null); setStep((s) => Math.max(s - 1, 1) as Step); };
const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const handleLogoChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setLogoFile(file);
setLogoPreview(URL.createObjectURL(file));
const preview = URL.createObjectURL(file);
setLogoPreview(preview);
setSuggestedColors(await extractDominantColors(preview));
};
const handleFaviconChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setFaviconFile(file);
};
// ── Step validators ───────────────────────────────────────────────────────
@@ -171,19 +186,47 @@ export default function SetupPage() {
setError(null);
setLoading(true);
try {
// The admin account already exists by this point (created in step 2),
// so the branding-asset upload endpoints require admin auth — the
// setupToken from /api/setup/register covers that.
let logoUrl = "";
if (logoFile) {
const fd = new FormData();
fd.append("image", logoFile);
const uploadRes = await fetch(`${API_BASE}/api/uploads/logo`, { method: "POST", body: fd });
const uploadRes = await fetch(`${API_BASE}/api/uploads/logo`, {
method: "POST",
headers: setupToken ? { Authorization: `Bearer ${setupToken}` } : undefined,
body: fd,
});
if (uploadRes.ok) {
const up = await uploadRes.json();
logoUrl = up.url || "";
} else {
throw new Error("Logo upload failed. Please try again.");
}
}
let faviconUrl = "";
if (faviconFile) {
const fd = new FormData();
fd.append("image", faviconFile);
const uploadRes = await fetch(`${API_BASE}/api/uploads/favicon`, {
method: "POST",
headers: setupToken ? { Authorization: `Bearer ${setupToken}` } : undefined,
body: fd,
});
if (uploadRes.ok) {
const up = await uploadRes.json();
faviconUrl = up.url || "";
} else {
throw new Error("Favicon upload failed. Please try again.");
}
}
const settings: Record<string, string> = {
org_name: orgName.trim(),
primary_color: primaryColor,
secondary_color: secondaryColor,
accent_color: accentColor,
};
if (orgTagline.trim()) settings.org_tagline = orgTagline.trim();
@@ -193,6 +236,7 @@ export default function SetupPage() {
if (appBaseUrl.trim()) settings.app_base_url = appBaseUrl.trim();
if (notifEmail.trim()) settings.reg_notification_emails = notifEmail.trim();
if (logoUrl) settings.logo_url = logoUrl;
if (faviconUrl) settings.favicon_url = faviconUrl;
// Legal
if (legalOperatorName.trim()) settings.legal_operator_name = legalOperatorName.trim();
if (legalWebsiteUrl.trim()) settings.legal_website_url = legalWebsiteUrl.trim();
@@ -225,7 +269,7 @@ export default function SetupPage() {
return (
<div className="min-h-screen bg-gradient-to-br from-brand-50 to-brand-100 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-lg w-full max-w-lg p-8">
<div className={`bg-white rounded-2xl shadow-lg w-full p-8 transition-all ${step === 3 ? "max-w-4xl" : "max-w-lg"}`}>
{/* Heading */}
<div className="text-center mb-6">
@@ -255,7 +299,7 @@ export default function SetupPage() {
</div>
<Field label="Organisation name" required>
<input className={inputCls} placeholder="e.g. Hope Family Church"
<input className={inputCls} placeholder="e.g. Your Organisation"
value={orgName} onChange={e => setOrgName(e.target.value)} autoFocus />
</Field>
<Field label="Tagline">
@@ -331,28 +375,58 @@ export default function SetupPage() {
<p className="text-sm text-gray-500">Customise the look of your site. You can change this later.</p>
</div>
<Field label="Accent / brand colour">
<div className="flex items-center gap-3">
<input type="color" className="h-10 w-20 border rounded cursor-pointer"
value={accentColor} onChange={e => setAccentColor(e.target.value)} />
<input className={`${inputCls} font-mono`} value={accentColor}
onChange={e => setAccentColor(e.target.value)} placeholder="#2563eb" />
<div className="grid lg:grid-cols-5 gap-6 items-start">
<div className="lg:col-span-3 space-y-4">
<div className="grid sm:grid-cols-3 gap-4">
<ColorPickerField label="Primary" value={primaryColor} onChange={setPrimaryColor} />
<ColorPickerField label="Secondary" value={secondaryColor} onChange={setSecondaryColor} />
<ColorPickerField label="Accent" value={accentColor} onChange={setAccentColor} />
</div>
<Field label="Logo image (optional)" hint="PNG, JPG, SVG or WebP, max 2 MB. If skipped, a default logo is used.">
<input type="file" accept="image/*" onChange={handleLogoChange} className="text-sm" />
{logoPreview && (
<img src={logoPreview} alt="Preview" className="mt-2 h-16 object-contain rounded border" />
)}
{suggestedColors.length > 0 && (
<div className="mt-3 border rounded-lg p-3 bg-gray-50">
<p className="text-xs font-medium text-gray-600 mb-2">Suggested from your logo click a swatch to apply it</p>
<div className="flex flex-wrap gap-2">
{suggestedColors.map((hex) => (
<div key={hex} className="flex items-center gap-1 border rounded-lg bg-white p-1">
<div className="w-6 h-6 rounded" style={{ background: hex }} title={hex} />
<button type="button" className="text-[10px] font-semibold px-1.5 py-1 rounded hover:bg-gray-100" onClick={() => setPrimaryColor(hex)}>P</button>
<button type="button" className="text-[10px] font-semibold px-1.5 py-1 rounded hover:bg-gray-100" onClick={() => setSecondaryColor(hex)}>S</button>
<button type="button" className="text-[10px] font-semibold px-1.5 py-1 rounded hover:bg-gray-100" onClick={() => setAccentColor(hex)}>A</button>
</div>
))}
</div>
</div>
)}
</Field>
<Field label="Favicon (optional)" hint="ICO, PNG, or SVG, max 2 MB, ideally square. If skipped, the default favicon is used.">
<input type="file" accept=".ico,.png,.svg,image/x-icon,image/png,image/svg+xml" onChange={handleFaviconChange} className="text-sm" />
</Field>
<Field label="Registration notification email"
hint="Who to notify when someone registers for an event. Separate multiple addresses with commas.">
<input type="email" className={inputCls} placeholder="registrations@yourchurch.org"
value={notifEmail} onChange={e => setNotifEmail(e.target.value)} />
</Field>
</div>
<div className="mt-2 h-8 rounded-lg" style={{ background: accentColor }} />
</Field>
<Field label="Logo image (optional)" hint="PNG, JPG, SVG or WebP, max 2 MB. If skipped, a default logo is used.">
<input type="file" accept="image/*" onChange={handleLogoChange} className="text-sm" />
{logoPreview && (
<img src={logoPreview} alt="Preview" className="mt-2 h-16 object-contain rounded border" />
)}
</Field>
<Field label="Registration notification email"
hint="Who to notify when someone registers for an event. Separate multiple addresses with commas.">
<input type="email" className={inputCls} placeholder="registrations@yourchurch.org"
value={notifEmail} onChange={e => setNotifEmail(e.target.value)} />
</Field>
<div className="lg:col-span-2">
<BrandingPreviewPanel
primaryColor={primaryColor}
secondaryColor={secondaryColor}
accentColor={accentColor}
logoSrc={logoPreview}
orgName={orgName}
orgTagline={orgTagline}
/>
</div>
</div>
<div className="pt-2 flex justify-between">
<button type="button" onClick={back} className="px-4 py-2 rounded-lg text-sm text-gray-600 hover:bg-gray-100"> Back</button>
@@ -0,0 +1,171 @@
"use client";
import React, { useMemo, useState } from "react";
import { Monitor, Smartphone, RefreshCw } from "lucide-react";
import { buildBrandScale, buildSecondaryTokens, buildAccentTokens, contrastForeground, isValidHex } from "@/lib/colorScale";
type View = "website" | "email";
type Viewport = "desktop" | "mobile";
/**
* Hand-built mockup (not an iframe of the real site) so it can reflect
* in-progress, unsaved color-picker values instantly the real site's CSS
* variables only update once the admin actually saves (see layout.tsx's SSR
* style tag), and this preview must not leak into the surrounding admin
* page's own theme while the admin is still experimenting with colors.
*/
export function BrandingPreviewPanel({
primaryColor, secondaryColor, accentColor, logoSrc, orgName, orgTagline,
}: {
primaryColor: string;
secondaryColor: string;
accentColor: string;
logoSrc: string | null;
orgName: string;
orgTagline?: string;
}) {
const [view, setView] = useState<View>("website");
const [viewport, setViewport] = useState<Viewport>("desktop");
const safePrimary = isValidHex(primaryColor) ? primaryColor : "#4F46E5";
const safeSecondary = isValidHex(secondaryColor) ? secondaryColor : "#8B5CF6";
const safeAccent = isValidHex(accentColor) ? accentColor : "#EC4899";
const scale = useMemo(() => buildBrandScale(safePrimary), [safePrimary]);
const secondary = useMemo(() => buildSecondaryTokens(safeSecondary), [safeSecondary]);
const accent = useMemo(() => buildAccentTokens(safeAccent), [safeAccent]);
const primaryFg = useMemo(() => contrastForeground(safePrimary), [safePrimary]);
return (
<div className="border rounded-xl bg-white shadow-sm overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 border-b bg-gray-50">
<div>
<h3 className="text-sm font-semibold text-gray-900">Live Preview</h3>
<p className="text-xs text-gray-500">See how your branding looks across the site.</p>
</div>
<div className="flex items-center gap-1.5">
<div className="flex items-center rounded-lg border bg-white overflow-hidden">
<button type="button" onClick={() => setView("website")}
className={`px-2.5 py-1.5 text-xs font-medium ${view === "website" ? "bg-brand-600 text-white" : "text-gray-600 hover:bg-gray-100"}`}>
Website
</button>
<button type="button" onClick={() => setView("email")}
className={`px-2.5 py-1.5 text-xs font-medium ${view === "email" ? "bg-brand-600 text-white" : "text-gray-600 hover:bg-gray-100"}`}>
Email
</button>
</div>
{view === "website" && (
<div className="flex items-center rounded-lg border bg-white overflow-hidden">
<button type="button" onClick={() => setViewport("desktop")} title="Desktop"
className={`p-1.5 ${viewport === "desktop" ? "bg-gray-200" : "text-gray-500 hover:bg-gray-100"}`}>
<Monitor className="w-3.5 h-3.5" />
</button>
<button type="button" onClick={() => setViewport("mobile")} title="Mobile"
className={`p-1.5 ${viewport === "mobile" ? "bg-gray-200" : "text-gray-500 hover:bg-gray-100"}`}>
<Smartphone className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
</div>
<div className="p-4 bg-gray-100 flex justify-center overflow-x-auto">
{view === "website" ? (
<div
className="bg-white rounded-lg shadow-md overflow-hidden transition-all"
style={{ width: viewport === "mobile" ? 320 : "100%", maxWidth: viewport === "mobile" ? 320 : 640 }}
>
{/* Header */}
<div className="flex items-center gap-2 px-3 py-2.5 border-b">
{logoSrc ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={logoSrc} alt="Logo" className="h-6 w-6 object-contain rounded-sm" />
) : (
<div className="h-6 w-6 rounded-sm shrink-0" style={{ background: scale["600"] }} />
)}
<span className="text-sm font-bold truncate" style={{ color: scale["600"] }}>{orgName || "Your Organisation"}</span>
<div className="ml-auto flex items-center gap-2 text-[10px] text-gray-400">
<span>Home</span><span>Events</span><span>Contact</span>
</div>
</div>
{/* Hero */}
<div className="px-4 py-6 text-center" style={{ background: `hsl(${secondary.bg})` }}>
<p className="text-base font-bold text-gray-900">Welcome to {orgName || "Your Organisation"}</p>
<p className="text-[11px] text-gray-500 mt-1 mb-4">Experience unforgettable moments.</p>
<div className="flex items-center justify-center gap-2">
<span
className="text-[11px] font-semibold px-3 py-1.5 rounded-lg"
style={{ background: scale["600"], color: primaryFg }}
>
View Events
</span>
<span
className="text-[11px] font-semibold px-3 py-1.5 rounded-lg border bg-white"
style={{ borderColor: `hsl(${secondary.fg})`, color: `hsl(${secondary.fg})` }}
>
My Dashboard
</span>
</div>
</div>
{/* Upcoming events */}
<div className="px-4 py-4">
<p className="text-xs font-semibold text-gray-900 mb-2">Upcoming events</p>
<div className="space-y-2">
{["Sunday Service", "Community Outreach"].map((title) => (
<div key={title} className="flex items-center gap-2 border rounded-lg p-2">
<span
className="w-2 h-2 rounded-full shrink-0"
style={{ background: `hsl(${accent.fg})` }}
/>
<span className="text-[11px] text-gray-700 truncate">{title}</span>
<span
className="ml-auto text-[9px] font-medium px-1.5 py-0.5 rounded"
style={{ background: `hsl(${accent.bg})`, color: `hsl(${accent.fg})` }}
>
New
</span>
</div>
))}
</div>
</div>
{/* Footer — fixed dark neutral, not brand-driven */}
<div className="px-4 py-4 bg-slate-900 text-center">
<p className="text-[10px] text-slate-400">&copy; {new Date().getFullYear()} {orgName || "Your Organisation"}. All rights reserved.</p>
</div>
</div>
) : (
<div className="bg-white rounded-lg shadow-md overflow-hidden w-full max-w-md">
<div
className="px-6 py-8 text-center"
style={{ background: `linear-gradient(135deg, ${scale["600"]} 0%, #2d5287 100%)` }}
>
<p className="text-lg font-extrabold text-white">{orgName || "Your Organisation"}</p>
{orgTagline && <p className="text-xs text-white/90 mt-1">{orgTagline}</p>}
</div>
<div className="px-6 py-6">
<p className="text-sm font-bold text-gray-900 mb-1">Sample notification</p>
<div className="h-2 bg-gray-100 rounded w-full mb-1.5" />
<div className="h-2 bg-gray-100 rounded w-5/6 mb-4" />
<div className="flex justify-center">
<span
className="text-xs font-bold px-6 py-2.5 rounded-lg"
style={{ background: scale["600"], color: primaryFg }}
>
Call to action
</span>
</div>
</div>
</div>
)}
</div>
<div className="px-4 py-2 border-t bg-gray-50 flex items-center gap-1.5 text-[11px] text-gray-400">
<RefreshCw className="w-3 h-3" />
Updates live as you change colors save to apply site-wide.
</div>
</div>
);
}
@@ -0,0 +1,38 @@
"use client";
import React from "react";
import { isValidHex } from "@/lib/colorScale";
/** A native color swatch + hex text input, shared by the admin Branding tab and the setup wizard's Branding step. */
export function ColorPickerField({
label, hint, value, onChange,
}: {
label: string;
hint?: string;
value: string;
onChange: (hex: string) => void;
}) {
const valid = isValidHex(value);
return (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{label}</label>
<div className="flex items-center gap-3">
<input
type="color"
className="h-10 w-20 border rounded cursor-pointer shrink-0"
value={valid ? value : "#000000"}
onChange={e => onChange(e.target.value)}
/>
<input
className={`w-full border rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-brand-400 ${valid ? "" : "border-red-300"}`}
placeholder="#2563eb"
value={value}
onChange={e => onChange(e.target.value)}
/>
</div>
<div className="mt-2 h-6 rounded-lg transition-colors border" style={{ background: valid ? value : "#f1f5f9" }} />
{!valid && <p className="text-xs text-red-500 mt-1">Enter a valid hex color, e.g. #4F46E5</p>}
{hint && <p className="text-xs text-gray-400 mt-1">{hint}</p>}
</div>
);
}
@@ -1,7 +1,7 @@
"use client";
import React from "react";
import { BRAND_600 } from "@/lib/theme";
import { useBrandColorVar } from "@/lib/theme";
export type TrendDatum = { label: string; value: number };
@@ -33,6 +33,10 @@ export function AreaTrendChart({
}) {
const fmt = valueFormatter || ((v: number) => String(v));
const axisFmt = axisFormatter || defaultAxisFormatter;
// Raw SVG needs a literal hex value — reads the live admin-configurable
// primary color off the CSS custom property (see lib/theme.ts). Called
// before the early return below so hook order stays stable.
const brandColor = useBrandColorVar("--brand-600");
if (data.length === 0) {
return <div className="text-xs text-gray-400">No data to chart.</div>;
@@ -75,8 +79,8 @@ export function AreaTrendChart({
<svg viewBox={`0 0 ${WIDTH} ${HEIGHT}`} className="w-full h-auto" role="img" aria-label="Trend chart">
<defs>
<linearGradient id="areaTrendFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={BRAND_600} stopOpacity="0.25" />
<stop offset="100%" stopColor={BRAND_600} stopOpacity="0" />
<stop offset="0%" stopColor={brandColor} stopOpacity="0.25" />
<stop offset="100%" stopColor={brandColor} stopOpacity="0" />
</linearGradient>
</defs>
{gridlines.map((g, i) => (
@@ -88,9 +92,9 @@ export function AreaTrendChart({
</g>
))}
<path d={areaPath} fill="url(#areaTrendFill)" stroke="none" />
<path d={linePath} fill="none" stroke={BRAND_600} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
<path d={linePath} fill="none" stroke={brandColor} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
{points.map((p, i) => (
<circle key={i} cx={p.x} cy={p.y} r={data.length === 1 ? 4 : 3} fill="#fff" stroke={BRAND_600} strokeWidth={2} />
<circle key={i} cx={p.x} cy={p.y} r={data.length === 1 ? 4 : 3} fill="#fff" stroke={brandColor} strokeWidth={2} />
))}
</svg>
<div className="flex justify-between mt-1 ml-12 text-[10px] text-gray-400">
+4 -4
View File
@@ -4,14 +4,14 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { Home, Calendar, Phone, LogIn, UserPlus, LayoutDashboard, LogOut } from "lucide-react";
import { BRAND_600 } from "@/lib/theme";
import { useBrandColorVar } from "@/lib/theme";
export const BottomNav = () => {
const { user, loading: authLoading, logout } = useAuth();
const pathname = usePathname();
// Fixed brand color — unlike the navbar's org name, nothing here is
// admin-configurable (see globals.css for the rule).
const accentColor = BRAND_600;
// Raw SVG needs a literal hex value — reads the live admin-configurable
// primary color off the CSS custom property (see lib/theme.ts).
const accentColor = useBrandColorVar("--brand-600");
const handleLogout = () => {
logout();
+3 -7
View File
@@ -7,7 +7,7 @@ import { useAuth } from "@/hooks/useAuth";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { BottomNav } from "./BottomNav";
import churchLogo from "@/app/church_logo.jpg";
import { appName, brandColor } from "@/lib/siteConfig";
import { appName } from "@/lib/siteConfig";
import { resolveToApiOrigin } from "@/lib/api";
type NavLink = {
@@ -20,11 +20,7 @@ export const Navbar = () => {
const { user, loading: authLoading, logout } = useAuth();
const { settings, loading: settingsLoading } = useSiteSettings();
const pathname = usePathname();
// Admin-configurable — applied ONLY to the org name text below via inline
// style. No other element in the navbar (or anywhere else) should read
// this; everything else uses the fixed brand-* palette.
const displayName = settings.org_name || appName;
const displayColor = settings.accent_color || brandColor;
const displayName = settings.org_name || appName;
const logoSrc = settings.logo_url ? resolveToApiOrigin(settings.logo_url) : null;
const isActive = (href: string) => {
@@ -79,7 +75,7 @@ export const Navbar = () => {
/>
)}
{!settingsLoading && (
<span className="text-xl font-bold" style={{ color: displayColor }}>{displayName}</span>
<span className="text-xl font-bold text-brand-600">{displayName}</span>
)}
</Link>
+18 -3
View File
@@ -9,8 +9,14 @@ export type SiteSettings = {
org_email?: string;
org_phone?: string;
org_address?: string;
// Branding — 3-color system. accent_color is the tertiary/highlight color;
// primary_color falls back to accent_color's legacy value if unset (see
// settingsController.js PUBLIC_KEYS comment).
primary_color?: string;
secondary_color?: string;
accent_color?: string;
logo_url?: string;
favicon_url?: string;
setup_complete?: string;
// Legal pages
legal_operator_name?: string;
@@ -32,9 +38,15 @@ const SiteSettingsContext = createContext<SiteSettingsContextValue>({
reload: () => {},
});
export function SiteSettingsProvider({ children }: { children: React.ReactNode }) {
const [settings, setSettings] = useState<SiteSettings>({});
const [loading, setLoading] = useState(true);
export function SiteSettingsProvider({
children, initialSettings,
}: {
children: React.ReactNode;
/** Server-fetched settings from layout.tsx's generateMetadata/RootLayout — seeds state so there's no loading flash for anything reading useSiteSettings() (e.g. the navbar logo). */
initialSettings?: SiteSettings;
}) {
const [settings, setSettings] = useState<SiteSettings>(initialSettings || {});
const [loading, setLoading] = useState(!initialSettings);
const load = async () => {
try {
@@ -47,6 +59,9 @@ export function SiteSettingsProvider({ children }: { children: React.ReactNode }
}
};
// Always refreshes in the background (cheap, 60s-cached server-side) even
// when seeded from initialSettings — self-heals if the SSR fetch in
// layout.tsx failed, and picks up any change since that request.
useEffect(() => { load(); }, []);
return (
+208
View File
@@ -0,0 +1,208 @@
/**
* Framework-agnostic color math for the admin-configurable brand system.
* No DOM/window/document access this module runs identically server-side
* (the SSR `<style id="brand-theme">` tag in layout.tsx) and client-side
* (the live Branding Preview panel, recomputing on every color-picker
* keystroke before the admin has saved anything).
*/
export type Hsl = { h: number; s: number; l: number };
const HEX_RE = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
export function isValidHex(v: string): boolean {
return HEX_RE.test((v || "").trim());
}
/** Expands 3-digit hex to 6-digit, lowercases, ensures a leading #. */
function normalizeHex(hex: string): string {
let h = (hex || "").trim();
if (!h.startsWith("#")) h = `#${h}`;
if (h.length === 4) {
h = `#${h[1]}${h[1]}${h[2]}${h[2]}${h[3]}${h[3]}`;
}
return h.toLowerCase();
}
export function hexToHsl(hex: string): Hsl {
const h6 = normalizeHex(hex);
const r = parseInt(h6.slice(1, 3), 16) / 255;
const g = parseInt(h6.slice(3, 5), 16) / 255;
const b = parseInt(h6.slice(5, 7), 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) / 2;
if (max === min) return { h: 0, s: 0, l: l * 100 };
const d = max - min;
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
let hue: number;
switch (max) {
case r: hue = ((g - b) / d + (g < b ? 6 : 0)); break;
case g: hue = (b - r) / d + 2; break;
default: hue = (r - g) / d + 4; break;
}
hue *= 60;
return { h: hue, s: s * 100, l: l * 100 };
}
export function hslToHex(h: number, s: number, l: number): string {
const hh = ((h % 360) + 360) % 360;
const ss = clamp01(s / 100);
const ll = clamp01(l / 100);
if (ss === 0) {
const v = Math.round(ll * 255);
return rgbToHex(v, v, v);
}
const q = ll < 0.5 ? ll * (1 + ss) : ll + ss - ll * ss;
const p = 2 * ll - q;
const r = hueToRgb(p, q, hh / 360 + 1 / 3);
const g = hueToRgb(p, q, hh / 360);
const b = hueToRgb(p, q, hh / 360 - 1 / 3);
return rgbToHex(Math.round(r * 255), Math.round(g * 255), Math.round(b * 255));
}
function hueToRgb(p: number, q: number, t: number): number {
let tt = t;
if (tt < 0) tt += 1;
if (tt > 1) tt -= 1;
if (tt < 1 / 6) return p + (q - p) * 6 * tt;
if (tt < 1 / 2) return q;
if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6;
return p;
}
function rgbToHex(r: number, g: number, b: number): string {
const toHex = (v: number) => clampByte(v).toString(16).padStart(2, "0");
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
}
function clampByte(v: number): number {
return Math.min(255, Math.max(0, Math.round(v)));
}
function clamp01(v: number): number {
return Math.min(1, Math.max(0, v));
}
export function clampL(l: number): number {
return Math.min(100, Math.max(0, l));
}
/** Rounded "H S% L%" triple matching this codebase's existing shadcn CSS-variable convention (no hsl() wrapper — see globals.css). */
export function hexToHslTriple(hex: string): string {
const { h, s, l } = hexToHsl(hex);
return `${Math.round(h)} ${Math.round(s)}% ${Math.round(l)}%`;
}
/**
* WCAG-style relative luminance picks readable foreground text/icon color
* for a solid fill of `hex`. Used for Primary (a solid button/nav fill needs
* real contrast, unlike Secondary/Accent which stay light tints see
* buildSecondaryTokens/buildAccentTokens).
*/
export function contrastForeground(hex: string): "#ffffff" | "#0a0a0a" {
const h6 = normalizeHex(hex);
const chan = (v: number) => {
const c = v / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
};
const r = chan(parseInt(h6.slice(1, 3), 16));
const g = chan(parseInt(h6.slice(3, 5), 16));
const b = chan(parseInt(h6.slice(5, 7), 16));
const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
return luminance > 0.45 ? "#0a0a0a" : "#ffffff";
}
/** Matches the shadcn-token foreground convention used elsewhere in globals.css (near-white / near-black HSL triples, not pure #fff/#000). */
export function contrastForegroundTriple(hex: string): string {
return contrastForeground(hex) === "#ffffff" ? "210 40% 98%" : "222.2 47.4% 11.2%";
}
const BRAND_SCALE_STEPS = ["50", "100", "200", "300", "400", "500", "600", "700", "800", "900"] as const;
export type BrandScaleStep = (typeof BRAND_SCALE_STEPS)[number];
/** Lightness offsets (percentage points) applied around the picked color, which is anchored exactly at step 600 — matches the site's pre-existing default (brand-600 #4F46E5). */
const BRAND_SCALE_OFFSETS: Record<BrandScaleStep, number> = {
"50": 38, "100": 32, "200": 24, "300": 16, "400": 8,
"500": 4, "600": 0, "700": -8, "800": -16, "900": -24,
};
/** Generates the full brand-50..900 hex scale from one admin-picked "Primary" hex, anchored at 600. */
export function buildBrandScale(primaryHex: string): Record<BrandScaleStep, string> {
const { h, s, l } = hexToHsl(primaryHex);
const scale = {} as Record<BrandScaleStep, string>;
for (const step of BRAND_SCALE_STEPS) {
if (step === "600") {
scale[step] = normalizeHex(primaryHex);
} else {
scale[step] = hslToHex(h, s, clampL(l + BRAND_SCALE_OFFSETS[step]));
}
}
return scale;
}
/**
* Secondary/Accent both stay light background washes (not bold fills) so
* they read the way shadcn's own default `--secondary`/`--accent` tokens do
* (subtle hover/outline surfaces, e.g. Button's `secondary`/`outline`/`ghost`
* variants) regardless of what hue the admin picks. Lightness is PINNED to a
* fixed target rather than offset from the input (like the primary scale is)
* a relative offset would let a pastel input wash out to near-invisible.
* Saturation is capped so the tint doesn't read as a neon pastel.
*/
function buildLightTintTokens(hex: string): { bg: string; fg: string } {
const { h, s } = hexToHsl(hex);
const cappedS = Math.min(s, 60);
const bg = `${Math.round(h)} ${Math.round(cappedS)}% 96%`;
const fg = hexToHslTriple(hex);
return { bg, fg };
}
export function buildSecondaryTokens(secondaryHex: string): { bg: string; fg: string } {
return buildLightTintTokens(secondaryHex);
}
export function buildAccentTokens(accentHex: string): { bg: string; fg: string } {
return buildLightTintTokens(accentHex);
}
/**
* Builds the full CSS custom-property declaration block (no `:root{}` wrapper
* callers embed this string themselves) for the 3-color brand system.
* Any input left undefined/invalid is simply omitted, so callers can pass a
* partial set (e.g. only Primary saved so far) without emitting broken CSS.
*/
export function buildThemeCssVars(input: { primary?: string; secondary?: string; accent?: string }): string {
const decls: string[] = [];
if (input.primary && isValidHex(input.primary)) {
const scale = buildBrandScale(input.primary);
for (const step of BRAND_SCALE_STEPS) {
decls.push(`--brand-${step}:${scale[step]}`);
}
decls.push(`--primary:${hexToHslTriple(input.primary)}`);
decls.push(`--primary-foreground:${contrastForegroundTriple(input.primary)}`);
decls.push(`--ring:${hexToHslTriple(input.primary)}`);
}
if (input.secondary && isValidHex(input.secondary)) {
const { bg, fg } = buildSecondaryTokens(input.secondary);
decls.push(`--secondary:${bg}`);
decls.push(`--secondary-foreground:${fg}`);
}
if (input.accent && isValidHex(input.accent)) {
const { bg, fg } = buildAccentTokens(input.accent);
decls.push(`--accent:${bg}`);
decls.push(`--accent-foreground:${fg}`);
}
return decls.join(";");
}
+81
View File
@@ -0,0 +1,81 @@
/**
* Client-only (canvas + Image) extracts the dominant, non-background colors
* from a locally-selected logo file, to surface as "suggested" swatches in
* the admin Branding tab. Runs off a local blob URL (URL.createObjectURL),
* never a remote URL, so there's no cross-origin/tainted-canvas concern.
*/
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error("Could not load image"));
img.src = src;
});
}
function rgbToHex(r: number, g: number, b: number): string {
const toHex = (v: number) => Math.round(Math.min(255, Math.max(0, v))).toString(16).padStart(2, "0");
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
}
/**
* Downscales the image to a small canvas and buckets pixels by a coarse RGB
* quantization (histogram approach cheap enough at this resolution that a
* real k-means isn't needed). Near-white/near-black/low-saturation pixels are
* discarded since those are almost always logo whitespace/background, not
* the brand color someone would want suggested.
*/
export async function extractDominantColors(imageSrc: string, maxColors = 5): Promise<string[]> {
if (typeof document === "undefined") return [];
let img: HTMLImageElement;
try {
img = await loadImage(imageSrc);
} catch {
return [];
}
const size = 50;
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d");
if (!ctx) return [];
ctx.drawImage(img, 0, 0, size, size);
let data: Uint8ClampedArray;
try {
data = ctx.getImageData(0, 0, size, size).data;
} catch {
return [];
}
const BIN = 24; // quantization step per channel — coarse enough to merge near-duplicate shades, fine enough to keep distinct hues apart
const buckets = new Map<string, { count: number; r: number; g: number; b: number }>();
for (let i = 0; i < data.length; i += 4) {
const r = data[i], g = data[i + 1], b = data[i + 2], a = data[i + 3];
if (a < 200) continue; // transparent/near-transparent — not a real pixel
const max = Math.max(r, g, b), min = Math.min(r, g, b);
const lightness = (max + min) / 2 / 255;
const sat = max === min ? 0 : (max - min) / (255 - Math.abs(max + min - 255));
if (lightness > 0.92 || lightness < 0.08) continue; // near-white / near-black
if (sat < 0.15) continue; // low-saturation gray
const key = `${Math.round(r / BIN)}_${Math.round(g / BIN)}_${Math.round(b / BIN)}`;
const bucket = buckets.get(key);
if (bucket) {
bucket.count++;
bucket.r += r; bucket.g += g; bucket.b += b;
} else {
buckets.set(key, { count: 1, r, g, b });
}
}
return Array.from(buckets.values())
.sort((a, b) => b.count - a.count)
.slice(0, maxColors)
.map(b => rgbToHex(b.r / b.count, b.g / b.count, b.b / b.count));
}
+4 -5
View File
@@ -2,8 +2,7 @@
* Site-wide configuration sourced from environment variables.
* Set these in your .env file (NEXT_PUBLIC_ prefix required for browser access).
*/
export const appName = process.env.NEXT_PUBLIC_APP_NAME || 'Hope Events';
export const orgName = process.env.NEXT_PUBLIC_ORG_NAME || 'Hope Family Church';
export const contactEmail = process.env.NEXT_PUBLIC_CONTACT_EMAIL || 'admin@hopehenley.co.za';
export const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://events.hopehenley.co.za';
export const brandColor = process.env.NEXT_PUBLIC_BRAND_COLOR || '#2563eb';
export const appName = process.env.NEXT_PUBLIC_APP_NAME || 'Cross Code';
export const orgName = process.env.NEXT_PUBLIC_ORG_NAME || 'Cross Code';
export const contactEmail = process.env.NEXT_PUBLIC_CONTACT_EMAIL || 'admin@crosscode.co.za';
export const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://crosscode.co.za';
+28 -18
View File
@@ -1,20 +1,30 @@
"use client";
import { useEffect, useState } from "react";
/**
* Hex mirrors of the `brand` scale in tailwind.config.js, for the rare spot
* that needs a literal color value instead of a Tailwind class (SVG
* stroke/fill, canvas, etc.). Keep these in sync with tailwind.config.js by
* hand there are few enough call sites that a build-time sync step isn't
* worth it.
*
* Do NOT use these for the org name/heading text that stays bound to
* SiteSettingsContext.settings.accent_color, not the fixed brand palette.
* Static fallback for BRAND_600 used as the initial value before the
* client can read the live CSS custom property (see useBrandColorVar below),
* and as the SSR/first-paint value. Keep in sync with globals.css's
* `--brand-600` default.
*/
export const BRAND_50 = "#EEF2FF";
export const BRAND_100 = "#E0E7FF";
export const BRAND_200 = "#C7D2FE";
export const BRAND_300 = "#A5B4FC";
export const BRAND_400 = "#818CF8";
export const BRAND_500 = "#6366F1";
export const BRAND_600 = "#4F46E5";
export const BRAND_700 = "#4338CA";
export const BRAND_800 = "#3730A3";
export const BRAND_900 = "#312E81";
const BRAND_600_FALLBACK = "#4F46E5";
/**
* Reads a live brand CSS custom property (e.g. `--brand-600`) off the
* document root. Needed by the rare spot that requires a literal color value
* instead of a Tailwind class (SVG stroke/fill, canvas, etc.) Tailwind
* classes like `text-brand-600` pick up the admin-configurable primary color
* automatically via CSS variables (see tailwind.config.js), but a literal
* hex string has to be read explicitly since it can't reference `var()`.
*/
export function useBrandColorVar(cssVar: string, fallback: string = BRAND_600_FALLBACK): string {
const [color, setColor] = useState(fallback);
useEffect(() => {
const value = getComputedStyle(document.documentElement).getPropertyValue(cssVar).trim();
if (value) setColor(value);
}, [cssVar]);
return color;
}
+17 -15
View File
@@ -39,22 +39,24 @@ module.exports = {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
// Fixed indigo/purple brand scale — the site's hard-coded design-system
// color. Use this (not `indigo-*`/`blue-*` literals) for anything that
// isn't a shadcn primitive: nav active-states, icon chips, the help FAB,
// etc. Never bind this scale to the admin-configurable accent_color
// setting (see globals.css) — that stays an inline style on the org name only.
// Brand scale — the site's primary design-system color. Use this (not
// `indigo-*`/`blue-*` literals) for anything that isn't a shadcn
// primitive: nav active-states, icon chips, the help FAB, etc.
// Values come from CSS custom properties set at request-time in
// layout.tsx from the admin's saved `primary_color` setting (see
// colorScale.ts's buildBrandScale + globals.css for the static
// fallback defaults used when no setting is saved yet).
brand: {
50: "#EEF2FF",
100: "#E0E7FF",
200: "#C7D2FE",
300: "#A5B4FC",
400: "#818CF8",
500: "#6366F1",
600: "#4F46E5",
700: "#4338CA",
800: "#3730A3",
900: "#312E81",
50: "var(--brand-50)",
100: "var(--brand-100)",
200: "var(--brand-200)",
300: "var(--brand-300)",
400: "var(--brand-400)",
500: "var(--brand-500)",
600: "var(--brand-600)",
700: "var(--brand-700)",
800: "var(--brand-800)",
900: "var(--brand-900)",
},
},
borderRadius: {