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:
+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