From 467f34b42a401a1508fd5b6a2b13c21b5575815d Mon Sep 17 00:00:00 2001 From: Koncept Kit <63216427+konceptkit@users.noreply.github.com> Date: Tue, 27 Jan 2026 21:32:22 +0700 Subject: [PATCH] - - New ThemeConfigContext provider that fetches theme on app load and applies it to the DOM (title, meta description, favicon, CSS variables, theme-color)/- - Admin Theme settings page under Settings > Theme tab/- All logo references (5 components) now pull from the theme config with fallback to default --- src/App.js | 2 + src/components/AdminSidebar.js | 4 +- src/components/Navbar.js | 6 +- src/components/PublicFooter.js | 4 +- src/components/PublicNavbar.js | 6 +- src/components/SettingsSidebar.js | 45 +- src/context/ThemeConfigContext.js | 161 ++++++ src/index.js | 5 +- src/layouts/SettingsLayout.js | 23 +- src/pages/MissionValues.js | 4 +- src/pages/admin/AdminMemberTiers.js | 11 +- src/pages/admin/AdminRoles.js | 17 +- src/pages/admin/AdminSettings.js | 13 +- src/pages/admin/AdminTheme.js | 743 ++++++++++++++++++++++++++++ 14 files changed, 979 insertions(+), 65 deletions(-) create mode 100644 src/context/ThemeConfigContext.js create mode 100644 src/pages/admin/AdminTheme.js diff --git a/src/App.js b/src/App.js index 82e87c1..f7219db 100644 --- a/src/App.js +++ b/src/App.js @@ -25,6 +25,7 @@ import AdminPermissions from './pages/admin/AdminPermissions'; import AdminSettings from './pages/admin/AdminSettings'; import AdminMemberTiers from './pages/admin/AdminMemberTiers'; import AdminRoles from './pages/admin/AdminRoles'; +import AdminTheme from './pages/admin/AdminTheme'; import AdminEvents from './pages/admin/AdminEvents'; import AdminEventAttendance from './pages/admin/AdminEventAttendance'; import AdminValidations from './pages/admin/AdminValidations'; @@ -302,6 +303,7 @@ function App() { } /> } /> } /> + } /> {/* 404 - Catch all undefined routes */} diff --git a/src/components/AdminSidebar.js b/src/components/AdminSidebar.js index 415f015..d976a20 100644 --- a/src/components/AdminSidebar.js +++ b/src/components/AdminSidebar.js @@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react'; import { Link, useLocation, useNavigate } from 'react-router-dom'; import { useTheme } from 'next-themes'; import { useAuth } from '../context/AuthContext'; +import { useThemeConfig } from '../context/ThemeConfigContext'; import api from '../utils/api'; import { Badge } from './ui/badge'; import { @@ -32,6 +33,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => { const location = useLocation(); const navigate = useNavigate(); const { user, logout } = useAuth(); + const { getLogoUrl } = useThemeConfig(); const { theme, setTheme } = useTheme(); const [pendingCount, setPendingCount] = useState(0); const [storageUsed, setStorageUsed] = useState(0); @@ -281,7 +283,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
LOAF Logo { const { user, logout } = useAuth(); + const { getLogoUrl } = useThemeConfig(); const navigate = useNavigate(); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); - // LOAF logo (local) - const loafLogo = `${process.env.PUBLIC_URL}/loaf-logo.png`; + // Get logo URL from theme config (with fallback to default) + const loafLogo = getLogoUrl(); const handleLogout = () => { logout(); diff --git a/src/components/PublicFooter.js b/src/components/PublicFooter.js index 3648f3e..27c850c 100644 --- a/src/components/PublicFooter.js +++ b/src/components/PublicFooter.js @@ -1,9 +1,11 @@ import React from 'react'; import { Link } from 'react-router-dom'; import { Button } from './ui/button'; +import { useThemeConfig } from '../context/ThemeConfigContext'; const PublicFooter = () => { - const loafLogo = `${process.env.PUBLIC_URL}/loaf-logo.png`; + const { getLogoUrl } = useThemeConfig(); + const loafLogo = getLogoUrl(); return ( <> diff --git a/src/components/PublicNavbar.js b/src/components/PublicNavbar.js index 0575131..ba27c35 100644 --- a/src/components/PublicNavbar.js +++ b/src/components/PublicNavbar.js @@ -2,6 +2,7 @@ import React, { useState } from 'react'; import { Link, useNavigate, useLocation } from 'react-router-dom'; import { Button } from './ui/button'; import { useAuth } from '../context/AuthContext'; +import { useThemeConfig } from '../context/ThemeConfigContext'; import { ChevronDown, Menu, X } from 'lucide-react'; import { DropdownMenu, @@ -12,6 +13,7 @@ import { const PublicNavbar = () => { const { user, logout } = useAuth(); + const { getLogoUrl } = useThemeConfig(); const navigate = useNavigate(); const location = useLocation(); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); @@ -30,8 +32,8 @@ const PublicNavbar = () => { return location.pathname.startsWith('/about'); }; - // LOAF logo (local) - const loafLogo = `${process.env.PUBLIC_URL}/loaf-logo.png`; + // Get logo URL from theme config (with fallback to default) + const loafLogo = getLogoUrl(); const handleAuthAction = () => { if (user) { diff --git a/src/components/SettingsSidebar.js b/src/components/SettingsSidebar.js index 0cb1ee1..d08c781 100644 --- a/src/components/SettingsSidebar.js +++ b/src/components/SettingsSidebar.js @@ -1,46 +1,45 @@ import React from 'react'; -import { NavLink } from 'react-router-dom'; -import { CreditCard, Shield, Settings, Star } from 'lucide-react'; +import { NavLink, useLocation } from 'react-router-dom'; +import { CreditCard, Shield, Star, Palette } from 'lucide-react'; const settingsItems = [ - { label: 'Stripe Integration', path: '/admin/settings/stripe', icon: CreditCard }, + { label: 'Stripe', path: '/admin/settings/stripe', icon: CreditCard }, { label: 'Permissions', path: '/admin/settings/permissions', icon: Shield }, { label: 'Member Tiers', path: '/admin/settings/member-tiers', icon: Star }, + { label: 'Theme', path: '/admin/settings/theme', icon: Palette }, ]; -const SettingsSidebar = () => { +const SettingsTabs = () => { + const location = useLocation(); + return ( - +
); }; -export default SettingsSidebar; +export default SettingsTabs; diff --git a/src/context/ThemeConfigContext.js b/src/context/ThemeConfigContext.js new file mode 100644 index 0000000..9432223 --- /dev/null +++ b/src/context/ThemeConfigContext.js @@ -0,0 +1,161 @@ +import React, { createContext, useState, useContext, useEffect, useCallback } from 'react'; +import axios from 'axios'; + +const ThemeConfigContext = createContext(); + +const API_URL = process.env.REACT_APP_BACKEND_URL || window.location.origin; + +const DEFAULT_THEME = { + site_name: 'LOAF - Lesbians Over Age Fifty', + site_short_name: 'LOAF', + site_description: 'A community organization for lesbians over age fifty in Houston and surrounding areas.', + logo_url: null, + favicon_url: null, + colors: { + primary: '280 47% 27%', + primary_foreground: '0 0% 100%', + accent: '24 86% 55%', + brand_purple: '256 35% 47%', + brand_orange: '24 86% 55%', + brand_lavender: '262 46% 80%' + }, + meta_theme_color: '#664fa3' +}; + +export const ThemeConfigProvider = ({ children }) => { + const [themeConfig, setThemeConfig] = useState(DEFAULT_THEME); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const applyThemeToDOM = useCallback((config) => { + // Apply CSS variables for colors + if (config.colors) { + const root = document.documentElement; + Object.entries(config.colors).forEach(([key, value]) => { + // Convert snake_case to kebab-case for CSS variable names + const cssVarName = `--${key.replace(/_/g, '-')}`; + root.style.setProperty(cssVarName, value); + }); + } + + // Update favicon + if (config.favicon_url) { + let link = document.querySelector("link[rel*='icon']"); + if (!link) { + link = document.createElement('link'); + link.rel = 'icon'; + document.head.appendChild(link); + } + link.href = config.favicon_url; + } + + // Update document title + if (config.site_name) { + document.title = config.site_name; + // Also store for use by pages that want to append their own title + window.__SITE_NAME__ = config.site_name; + } + + // Update meta description + if (config.site_description) { + let metaDesc = document.querySelector("meta[name='description']"); + if (!metaDesc) { + metaDesc = document.createElement('meta'); + metaDesc.name = 'description'; + document.head.appendChild(metaDesc); + } + metaDesc.content = config.site_description; + } + + // Update meta theme-color for PWA + if (config.meta_theme_color) { + let meta = document.querySelector("meta[name='theme-color']"); + if (!meta) { + meta = document.createElement('meta'); + meta.name = 'theme-color'; + document.head.appendChild(meta); + } + meta.content = config.meta_theme_color; + } + }, []); + + const fetchThemeConfig = useCallback(async () => { + try { + setLoading(true); + setError(null); + + const response = await axios.get(`${API_URL}/api/config/theme`); + const config = { ...DEFAULT_THEME, ...response.data }; + + // Merge colors if provided + if (response.data.colors) { + config.colors = { ...DEFAULT_THEME.colors, ...response.data.colors }; + } + + setThemeConfig(config); + applyThemeToDOM(config); + } catch (err) { + console.warn('Failed to fetch theme config, using defaults:', err.message); + setError(err.message); + // Apply default theme to DOM + applyThemeToDOM(DEFAULT_THEME); + } finally { + setLoading(false); + } + }, [applyThemeToDOM]); + + // Fetch theme config on mount + useEffect(() => { + fetchThemeConfig(); + }, [fetchThemeConfig]); + + // Helper function to get logo URL with fallback + const getLogoUrl = useCallback(() => { + return themeConfig.logo_url || `${process.env.PUBLIC_URL}/loaf-logo.png`; + }, [themeConfig.logo_url]); + + // Helper function to get favicon URL with fallback + const getFaviconUrl = useCallback(() => { + return themeConfig.favicon_url || `${process.env.PUBLIC_URL}/favicon.ico`; + }, [themeConfig.favicon_url]); + + const value = { + // Theme configuration + themeConfig, + loading, + error, + + // Convenience accessors + siteName: themeConfig.site_name, + siteShortName: themeConfig.site_short_name, + siteDescription: themeConfig.site_description, + colors: themeConfig.colors, + metaThemeColor: themeConfig.meta_theme_color, + + // Helper functions + getLogoUrl, + getFaviconUrl, + + // Actions + refreshTheme: fetchThemeConfig, + + // Default theme for reference + DEFAULT_THEME + }; + + return ( + + {children} + + ); +}; + +export const useThemeConfig = () => { + const context = useContext(ThemeConfigContext); + if (context === undefined) { + throw new Error('useThemeConfig must be used within a ThemeConfigProvider'); + } + return context; +}; + +export default ThemeConfigContext; diff --git a/src/index.js b/src/index.js index a4a0d50..ee5b950 100644 --- a/src/index.js +++ b/src/index.js @@ -1,6 +1,7 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import { ThemeProvider } from 'next-themes'; +import { ThemeConfigProvider } from './context/ThemeConfigContext'; import '@fontsource/fraunces/600.css'; import '@fontsource/dm-sans/400.css'; import '@fontsource/dm-sans/700.css'; @@ -16,7 +17,9 @@ root.render( enableSystem={false} storageKey="admin-theme" > - + + + ); diff --git a/src/layouts/SettingsLayout.js b/src/layouts/SettingsLayout.js index 0f800fb..772e0b8 100644 --- a/src/layouts/SettingsLayout.js +++ b/src/layouts/SettingsLayout.js @@ -1,12 +1,27 @@ import React from 'react'; import { Outlet } from 'react-router-dom'; -import SettingsSidebar from '../components/SettingsSidebar'; +import SettingsTabs from '../components/SettingsSidebar'; +import { Settings } from 'lucide-react'; const SettingsLayout = () => { return ( -
- -
+
+ {/* Header */} +
+

+ + Settings +

+

+ Manage your platform configuration and preferences +

+
+ + {/* Tabs Navigation */} + + + {/* Content Area */} +
diff --git a/src/pages/MissionValues.js b/src/pages/MissionValues.js index e118c70..ee9f6e1 100644 --- a/src/pages/MissionValues.js +++ b/src/pages/MissionValues.js @@ -2,9 +2,11 @@ import React from 'react'; import PublicNavbar from '../components/PublicNavbar'; import PublicFooter from '../components/PublicFooter'; import { Card } from '../components/ui/card'; +import { useThemeConfig } from '../context/ThemeConfigContext'; const MissionValues = () => { - const loafLogo = `${process.env.PUBLIC_URL}/loaf-logo.png`; + const { getLogoUrl } = useThemeConfig(); + const loafLogo = getLogoUrl(); return (
diff --git a/src/pages/admin/AdminMemberTiers.js b/src/pages/admin/AdminMemberTiers.js index 92a09a1..3c2d764 100644 --- a/src/pages/admin/AdminMemberTiers.js +++ b/src/pages/admin/AdminMemberTiers.js @@ -6,14 +6,9 @@ import { DEFAULT_MEMBER_TIERS } from '../../config/MemberTiers'; const AdminMemberTiers = () => { return (
-
-

- Member Tiers -

-

- Configure tier names, time ranges, and badges used in the members directory. -

-
+

+ Configure tier names, time ranges, and badges used in the members directory. +

diff --git a/src/pages/admin/AdminRoles.js b/src/pages/admin/AdminRoles.js index 5e071c8..724a475 100644 --- a/src/pages/admin/AdminRoles.js +++ b/src/pages/admin/AdminRoles.js @@ -205,16 +205,13 @@ const AdminRoles = () => { const groupedPermissions = groupPermissionsByModule(); return ( -
- {/* Header */} -
-
-

Role Management

-

- Create and manage custom roles with specific permissions -

-
- diff --git a/src/pages/admin/AdminSettings.js b/src/pages/admin/AdminSettings.js index 5d106c6..b008368 100644 --- a/src/pages/admin/AdminSettings.js +++ b/src/pages/admin/AdminSettings.js @@ -126,18 +126,7 @@ export default function AdminSettings() { } return ( -
- {/* Header */} -
-

- - Settings -

-

- Manage system configuration and integrations -

-
- +
{/* Stripe Integration Card */} diff --git a/src/pages/admin/AdminTheme.js b/src/pages/admin/AdminTheme.js new file mode 100644 index 0000000..58a8848 --- /dev/null +++ b/src/pages/admin/AdminTheme.js @@ -0,0 +1,743 @@ +import React, { useEffect, useState, useCallback } from 'react'; +import api from '../../utils/api'; +import { useAuth } from '../../context/AuthContext'; +import { useThemeConfig } from '../../context/ThemeConfigContext'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../components/ui/card'; +import { Button } from '../../components/ui/button'; +import { Input } from '../../components/ui/input'; +import { Label } from '../../components/ui/label'; +import { Textarea } from '../../components/ui/textarea'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '../../components/ui/alert-dialog'; +import { toast } from 'sonner'; +import { Palette, Upload, Trash2, RotateCcw, Save, Image, Globe, AlertTriangle } from 'lucide-react'; + +const AdminTheme = () => { + const { user } = useAuth(); + const { refreshTheme, DEFAULT_THEME } = useThemeConfig(); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [uploadingLogo, setUploadingLogo] = useState(false); + const [uploadingFavicon, setUploadingFavicon] = useState(false); + const [showResetDialog, setShowResetDialog] = useState(false); + const [themeData, setThemeData] = useState({ + site_name: '', + site_short_name: '', + site_description: '', + logo_url: null, + favicon_url: null, + colors: { + primary: '280 47% 27%', + primary_foreground: '0 0% 100%', + accent: '24 86% 55%', + brand_purple: '256 35% 47%', + brand_orange: '24 86% 55%', + brand_lavender: '262 46% 80%' + }, + meta_theme_color: '#664fa3' + }); + const [originalData, setOriginalData] = useState(null); + const [metadata, setMetadata] = useState({ + is_default: true, + updated_at: null, + updated_by: null + }); + + const isSuperAdmin = user?.role === 'superadmin'; + + const fetchThemeSettings = useCallback(async () => { + try { + setLoading(true); + const response = await api.get('/admin/settings/theme'); + const { config, is_default, updated_at, updated_by } = response.data; + + setThemeData(config); + setOriginalData(config); + setMetadata({ is_default, updated_at, updated_by }); + } catch (error) { + toast.error('Failed to fetch theme settings'); + console.error('Fetch theme error:', error); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchThemeSettings(); + }, [fetchThemeSettings]); + + const handleInputChange = (field, value) => { + setThemeData(prev => ({ + ...prev, + [field]: value + })); + }; + + const handleColorChange = (colorKey, value) => { + setThemeData(prev => ({ + ...prev, + colors: { + ...prev.colors, + [colorKey]: value + } + })); + }; + + const handleSaveSettings = async () => { + try { + setSaving(true); + + // Build update payload with only changed fields + const payload = {}; + + if (themeData.site_name !== originalData?.site_name) { + payload.site_name = themeData.site_name; + } + if (themeData.site_short_name !== originalData?.site_short_name) { + payload.site_short_name = themeData.site_short_name; + } + if (themeData.site_description !== originalData?.site_description) { + payload.site_description = themeData.site_description; + } + if (JSON.stringify(themeData.colors) !== JSON.stringify(originalData?.colors)) { + payload.colors = themeData.colors; + } + if (themeData.meta_theme_color !== originalData?.meta_theme_color) { + payload.meta_theme_color = themeData.meta_theme_color; + } + + if (Object.keys(payload).length === 0) { + toast.info('No changes to save'); + return; + } + + await api.put('/admin/settings/theme', payload); + + toast.success('Theme settings saved successfully'); + + // Refresh theme context and re-fetch settings + await refreshTheme(); + await fetchThemeSettings(); + } catch (error) { + toast.error(error.response?.data?.detail || 'Failed to save theme settings'); + console.error('Save theme error:', error); + } finally { + setSaving(false); + } + }; + + const handleLogoUpload = async (event) => { + const file = event.target.files?.[0]; + if (!file) return; + + // Validate file type + const allowedTypes = ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml']; + if (!allowedTypes.includes(file.type)) { + toast.error('Invalid file type. Please upload PNG, JPEG, WebP, or SVG.'); + return; + } + + // Validate file size (5MB) + if (file.size > 5 * 1024 * 1024) { + toast.error('File too large. Maximum size is 5MB.'); + return; + } + + try { + setUploadingLogo(true); + const formData = new FormData(); + formData.append('file', file); + + const response = await api.post('/admin/settings/theme/logo', formData, { + headers: { 'Content-Type': 'multipart/form-data' } + }); + + setThemeData(prev => ({ + ...prev, + logo_url: response.data.logo_url + })); + + toast.success('Logo uploaded successfully'); + await refreshTheme(); + } catch (error) { + toast.error(error.response?.data?.detail || 'Failed to upload logo'); + console.error('Upload logo error:', error); + } finally { + setUploadingLogo(false); + // Reset the input + event.target.value = ''; + } + }; + + const handleFaviconUpload = async (event) => { + const file = event.target.files?.[0]; + if (!file) return; + + // Validate file type + const allowedTypes = ['image/x-icon', 'image/vnd.microsoft.icon', 'image/png', 'image/svg+xml']; + if (!allowedTypes.includes(file.type)) { + toast.error('Invalid file type. Please upload ICO, PNG, or SVG.'); + return; + } + + // Validate file size (1MB) + if (file.size > 1 * 1024 * 1024) { + toast.error('File too large. Maximum size is 1MB.'); + return; + } + + try { + setUploadingFavicon(true); + const formData = new FormData(); + formData.append('file', file); + + const response = await api.post('/admin/settings/theme/favicon', formData, { + headers: { 'Content-Type': 'multipart/form-data' } + }); + + setThemeData(prev => ({ + ...prev, + favicon_url: response.data.favicon_url + })); + + toast.success('Favicon uploaded successfully'); + await refreshTheme(); + } catch (error) { + toast.error(error.response?.data?.detail || 'Failed to upload favicon'); + console.error('Upload favicon error:', error); + } finally { + setUploadingFavicon(false); + // Reset the input + event.target.value = ''; + } + }; + + const handleDeleteLogo = async () => { + try { + await api.delete('/admin/settings/theme/logo'); + + setThemeData(prev => ({ + ...prev, + logo_url: null + })); + + toast.success('Logo deleted successfully'); + await refreshTheme(); + } catch (error) { + toast.error(error.response?.data?.detail || 'Failed to delete logo'); + } + }; + + const handleDeleteFavicon = async () => { + try { + await api.delete('/admin/settings/theme/favicon'); + + setThemeData(prev => ({ + ...prev, + favicon_url: null + })); + + toast.success('Favicon deleted successfully'); + await refreshTheme(); + } catch (error) { + toast.error(error.response?.data?.detail || 'Failed to delete favicon'); + } + }; + + const handleResetToDefaults = async () => { + try { + await api.post('/admin/settings/theme/reset'); + + toast.success('Theme reset to defaults'); + setShowResetDialog(false); + + await refreshTheme(); + await fetchThemeSettings(); + } catch (error) { + toast.error(error.response?.data?.detail || 'Failed to reset theme'); + } + }; + + // Convert HSL string to approximate hex for color picker + const hslToHex = (hslString) => { + if (!hslString) return '#000000'; + + const parts = hslString.split(' '); + if (parts.length !== 3) return '#000000'; + + const h = parseFloat(parts[0]) / 360; + const s = parseFloat(parts[1]) / 100; + const l = parseFloat(parts[2]) / 100; + + const hue2rgb = (p, q, t) => { + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1/6) return p + (q - p) * 6 * t; + if (t < 1/2) return q; + if (t < 2/3) return p + (q - p) * (2/3 - t) * 6; + return p; + }; + + let r, g, b; + if (s === 0) { + r = g = b = l; + } else { + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + r = hue2rgb(p, q, h + 1/3); + g = hue2rgb(p, q, h); + b = hue2rgb(p, q, h - 1/3); + } + + const toHex = (x) => { + const hex = Math.round(x * 255).toString(16); + return hex.length === 1 ? '0' + hex : hex; + }; + + return `#${toHex(r)}${toHex(g)}${toHex(b)}`; + }; + + // Convert hex to HSL string + const hexToHsl = (hex) => { + if (!hex) return '0 0% 0%'; + + // Remove # if present + hex = hex.replace('#', ''); + + const r = parseInt(hex.substring(0, 2), 16) / 255; + const g = parseInt(hex.substring(2, 4), 16) / 255; + const b = parseInt(hex.substring(4, 6), 16) / 255; + + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + let h, s, l = (max + min) / 2; + + if (max === min) { + h = s = 0; + } else { + const d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + switch (max) { + case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break; + case g: h = ((b - r) / d + 2) / 6; break; + case b: h = ((r - g) / d + 4) / 6; break; + default: h = 0; + } + } + + return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`; + }; + + if (loading) { + return ( +
+
+
+ ); + } + + const colorFields = [ + { key: 'primary', label: 'Primary Color', description: 'Main brand color used for buttons and highlights' }, + { key: 'primary_foreground', label: 'Primary Foreground', description: 'Text color on primary backgrounds' }, + { key: 'accent', label: 'Accent Color', description: 'Secondary highlight color' }, + { key: 'brand_purple', label: 'Brand Purple', description: 'Purple brand color' }, + { key: 'brand_orange', label: 'Brand Orange', description: 'Orange brand color' }, + { key: 'brand_lavender', label: 'Brand Lavender', description: 'Lavender brand color' } + ]; + + return ( +
+ {/* Action Buttons */} +
+

+ Customize the appearance of your membership site +

+
+ {isSuperAdmin && ( + + )} + +
+
+ + {/* Metadata Banner */} + {!metadata.is_default && metadata.updated_at && ( +
+ Last updated {new Date(metadata.updated_at).toLocaleDateString()} + {metadata.updated_by && ` by ${metadata.updated_by}`} +
+ )} + +
+ {/* Branding Section */} + + + + + Branding + + + Configure your site name and brand identity + + + + {/* Site Name */} +
+ + handleInputChange('site_name', e.target.value)} + placeholder="LOAF - Lesbians Over Age Fifty" + maxLength={200} + /> +

+ Displayed in the browser title and navigation +

+
+ + {/* Short Name */} +
+ + handleInputChange('site_short_name', e.target.value)} + placeholder="LOAF" + maxLength={50} + /> +

+ Used for PWA home screen icon label +

+
+ + {/* Site Description */} +
+ +