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:
@@ -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(";");
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user