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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB |
@@ -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
@@ -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 />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user