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
@@ -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>