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