+ );
+}
+```
+
+- [ ] **Step 4: Create `footer.tsx`**
+
+```tsx
+// footer.tsx
+import { usePage } from '@inertiajs/react';
+import type { SharedProps } from './types';
+
+export function Footer() {
+ const { props } = usePage>();
+ const footer = props.branding?.footer;
+ const appName = props.branding?.appName ?? 'SimpleModule';
+ if (!footer?.enabled) return null;
+
+ const year = new Date().getFullYear();
+
+ return (
+
+ );
+}
+```
+
+- [ ] **Step 5: Edit `app-layout.tsx`**
+
+1. Add imports at top:
+```tsx
+import { BrandMark } from './brand-mark';
+import { Footer } from './footer';
+import { TopBar } from './top-bar';
+```
+2. Read `branding` and set the document title (after the existing `const { auth, menus, csrfToken } = props;` line, change it to include branding and add an effect):
+```tsx
+ const { auth, menus, csrfToken, branding } = props;
+ React.useEffect(() => {
+ if (branding?.appName) document.title = branding.appName;
+ }, [branding?.appName]);
+```
+3. Replace the **mobile header** logo block (the `SSimpleModule` inside the mobile header ``) so the `` renders `` instead of the hardcoded badge+text. Keep the `` wrapper.
+4. Replace the **sidebar** logo block similarly: inside the sidebar logo ``, render ``.
+5. Render `` as the very first child inside the top-level `
` (above the mobile header) so it spans full width.
+6. Render `` inside `` after the `{children}` wrapper:
+```tsx
+
+
{children}
+
+
+```
+
+> Keep the existing class names and structure; only swap the hardcoded brand markup for `` and add ``/``. The mobile-header badge used `w-7 h-7`/`text-xs`; `BrandMark` standardizes to `w-8 h-8`. That minor size change is acceptable; if pixel-fidelity matters, add a `size` prop to `BrandMark`.
+
+- [ ] **Step 6: Edit `public-layout.tsx`**
+
+1. Add imports:
+```tsx
+import { BrandMark } from './brand-mark';
+import { Footer } from './footer';
+import { TopBar } from './top-bar';
+```
+2. Replace the hardcoded badge+text inside the nav's brand `` with ``.
+3. Render `` as the first element returned (above the `
);
diff --git a/packages/SimpleModule.UI/components/layouts/brand-mark.tsx b/packages/SimpleModule.UI/components/layouts/brand-mark.tsx
new file mode 100644
index 00000000..d3a517db
--- /dev/null
+++ b/packages/SimpleModule.UI/components/layouts/brand-mark.tsx
@@ -0,0 +1,35 @@
+import { usePage } from '@inertiajs/react';
+import type { SharedProps } from './types';
+
+/**
+ * Renders the app's brand: an uploaded logo if branding provides one, otherwise a
+ * colored badge with the app name's first letter plus the app name text. Reads the
+ * `branding` shared prop, falling back to "SimpleModule".
+ */
+export function BrandMark() {
+ const { props } = usePage>();
+ const branding = props.branding;
+ const appName = branding?.appName ?? 'SimpleModule';
+
+ if (branding?.logoUrl) {
+ return (
+
+ );
+ }
+
+ return (
+ <>
+
+ {appName.charAt(0).toUpperCase()}
+
+ {appName}
+ >
+ );
+}
diff --git a/packages/SimpleModule.UI/components/layouts/footer.tsx b/packages/SimpleModule.UI/components/layouts/footer.tsx
new file mode 100644
index 00000000..85571196
--- /dev/null
+++ b/packages/SimpleModule.UI/components/layouts/footer.tsx
@@ -0,0 +1,41 @@
+import { usePage } from '@inertiajs/react';
+import { safeUrl } from './safe-url';
+import type { SharedProps } from './types';
+
+/**
+ * A configurable site footer rendered below page content when branding enables it.
+ */
+export function Footer() {
+ const { props } = usePage>();
+ const footer = props.branding?.footer;
+ const appName = props.branding?.appName ?? 'SimpleModule';
+ if (!footer?.enabled) return null;
+
+ const year = new Date().getFullYear();
+
+ return (
+
+ );
+}
diff --git a/packages/SimpleModule.UI/components/layouts/index.ts b/packages/SimpleModule.UI/components/layouts/index.ts
index 3035d236..d31ff4ca 100644
--- a/packages/SimpleModule.UI/components/layouts/index.ts
+++ b/packages/SimpleModule.UI/components/layouts/index.ts
@@ -1,6 +1,17 @@
export { AppLayout } from './app-layout';
+export { BrandMark } from './brand-mark';
export { DarkModeToggle } from './dark-mode-toggle';
+export { Footer } from './footer';
export { resolveLayout } from './layout-provider';
export { PublicLayout } from './public-layout';
-export type { MenuItem, PublicMenuItem, SharedProps } from './types';
+export { TopBar } from './top-bar';
+export type {
+ BrandingLink,
+ BrandingProps,
+ FooterConfig,
+ MenuItem,
+ PublicMenuItem,
+ SharedProps,
+ TopBarConfig,
+} from './types';
export { UserDropdown } from './user-dropdown';
diff --git a/packages/SimpleModule.UI/components/layouts/public-layout.tsx b/packages/SimpleModule.UI/components/layouts/public-layout.tsx
index 0f492940..86a1f880 100644
--- a/packages/SimpleModule.UI/components/layouts/public-layout.tsx
+++ b/packages/SimpleModule.UI/components/layouts/public-layout.tsx
@@ -1,18 +1,26 @@
import { Link, usePage } from '@inertiajs/react';
import * as React from 'react';
+import { BrandMark } from './brand-mark';
import { DarkModeToggle } from './dark-mode-toggle';
+import { Footer } from './footer';
import { MobileOverlay } from './public-layout-mobile';
import { DesktopMenu } from './public-layout-nav';
+import { TopBar } from './top-bar';
import type { SharedProps } from './types';
export function PublicLayout({ children }: { children: React.ReactNode }) {
const { props } = usePage>();
- const { publicMenu = [] } = props;
+ const { publicMenu = [], branding } = props;
const [mobileOpen, setMobileOpen] = React.useState(false);
const closeMobile = React.useCallback(() => setMobileOpen(false), []);
+ React.useEffect(() => {
+ if (branding?.appName) document.title = branding.appName;
+ }, [branding?.appName]);
+
return (
<>
+
-
- S
-
- SimpleModule
+
@@ -78,6 +78,7 @@ export function PublicLayout({ children }: { children: React.ReactNode }) {
{children}
+
>
);
}
diff --git a/packages/SimpleModule.UI/components/layouts/safe-url.ts b/packages/SimpleModule.UI/components/layouts/safe-url.ts
new file mode 100644
index 00000000..f6b975e0
--- /dev/null
+++ b/packages/SimpleModule.UI/components/layouts/safe-url.ts
@@ -0,0 +1,16 @@
+const DANGEROUS_SCHEMES = ['javascript:', 'data:', 'vbscript:'];
+
+/**
+ * Returns a link href safe to render. Branding link URLs are admin-configured but
+ * rendered to every visitor (top bar / footer, including public pages), so a
+ * `javascript:`/`data:`/`vbscript:` URL would be stored XSS. Whitespace (including
+ * tabs/newlines used to obfuscate the scheme, e.g. `java\tscript:`) is stripped
+ * before the check; anything dangerous collapses to `#`.
+ */
+export function safeUrl(raw: string | null | undefined): string {
+ const original = (raw ?? '').trim();
+ if (!original) return '#';
+ const collapsed = original.replace(/\s+/g, '').toLowerCase();
+ if (DANGEROUS_SCHEMES.some((scheme) => collapsed.startsWith(scheme))) return '#';
+ return original;
+}
diff --git a/packages/SimpleModule.UI/components/layouts/top-bar.tsx b/packages/SimpleModule.UI/components/layouts/top-bar.tsx
new file mode 100644
index 00000000..0b1d4fc8
--- /dev/null
+++ b/packages/SimpleModule.UI/components/layouts/top-bar.tsx
@@ -0,0 +1,68 @@
+import { usePage } from '@inertiajs/react';
+import * as React from 'react';
+import { safeUrl } from './safe-url';
+import type { SharedProps } from './types';
+
+const DISMISS_KEY = 'branding-topbar-dismissed';
+
+/**
+ * A full-width announcement/utility bar rendered above the app chrome when branding
+ * enables it. Optionally dismissible (remembered in localStorage).
+ */
+export function TopBar() {
+ const { props } = usePage>();
+ const topBar = props.branding?.topBar;
+ const [dismissed, setDismissed] = React.useState(false);
+
+ React.useEffect(() => {
+ if (topBar?.dismissible) {
+ setDismissed(localStorage.getItem(DISMISS_KEY) === 'true');
+ }
+ }, [topBar?.dismissible]);
+
+ if (!topBar?.enabled || dismissed || !topBar.message) return null;
+
+ const dismiss = () => {
+ setDismissed(true);
+ localStorage.setItem(DISMISS_KEY, 'true');
+ };
+
+ return (
+