diff --git a/apps/ottabase-template-app-nextjs-homepage/.env.example b/apps/ottabase-template-app-nextjs-homepage/.env.example index 9dbdc953c..0bd7489fe 100644 --- a/apps/ottabase-template-app-nextjs-homepage/.env.example +++ b/apps/ottabase-template-app-nextjs-homepage/.env.example @@ -51,6 +51,10 @@ NODE_ENV="development" # Set to "true" to show on production, or leave unset to auto-show in dev only # NEXT_PUBLIC_SHOW_CONFIG_PANEL="true" +# Ottabase Worker API URL (for CMS pages, homepage data, exposed pages) +# Required for runtime data integration — set to your TanStack worker URL +NEXT_PUBLIC_API_URL="http://localhost:3004" + # ============================================================ # Database Configuration (Prisma + D1) # ============================================================ diff --git a/apps/ottabase-template-app-nextjs-homepage/__tests__/components.test.tsx b/apps/ottabase-template-app-nextjs-homepage/__tests__/components.test.tsx index 03c50152b..af5a450cc 100644 --- a/apps/ottabase-template-app-nextjs-homepage/__tests__/components.test.tsx +++ b/apps/ottabase-template-app-nextjs-homepage/__tests__/components.test.tsx @@ -314,3 +314,218 @@ describe('ThemePresetSwitcher', () => { }); }); }); + +describe('LayoutShell navbar merge', () => { + let mergeNavLinks: any; + + beforeEach(async () => { + ({ mergeNavLinks } = await import('../app/layout-shell')); + }); + + it('returns base links when no exposed pages', () => { + const base = [ + { href: '/', label: 'Home' }, + { href: '/about', label: 'About' }, + ]; + const result = mergeNavLinks(base, []); + expect(result).toEqual(base); + }); + + it('appends exposed pages as /page/slug links', () => { + const base = [{ href: '/', label: 'Home' }]; + const exposedPages = [ + { slug: 'about-us', title: 'About Us' }, + { slug: 'pricing', title: 'Pricing' }, + ]; + const result = mergeNavLinks(base, exposedPages); + expect(result).toEqual([ + { href: '/', label: 'Home' }, + { href: '/page/about-us', label: 'About Us' }, + { href: '/page/pricing', label: 'Pricing' }, + ]); + }); + + it('deduplicates exposed pages by href', () => { + const base = [{ href: '/page/about-us', label: 'Existing About' }]; + const exposedPages = [ + { slug: 'about-us', title: 'About Us' }, + { slug: 'pricing', title: 'Pricing' }, + ]; + const result = mergeNavLinks(base, exposedPages); + // /page/about-us already exists in base, so only pricing is appended + expect(result).toEqual([ + { href: '/page/about-us', label: 'Existing About' }, + { href: '/page/pricing', label: 'Pricing' }, + ]); + }); + + it('handles empty base links with exposed pages', () => { + const result = mergeNavLinks([], [{ slug: 'faq', title: 'FAQ' }]); + expect(result).toEqual([{ href: '/page/faq', label: 'FAQ' }]); + }); +}); + +describe('Homepage API types', () => { + it('fetchHomepageData returns safe fallback when API_URL is empty', async () => { + const { fetchHomepageData } = await import('../lib/api'); + // NEXT_PUBLIC_API_URL is not set in test env, so should return fallback + const result = await fetchHomepageData(); + expect(result).toEqual({ + sections: [], + display: { + variantBySlot: null, + themePreset: null, + fallbackThemePresetId: null, + customCss: null, + seoTitle: null, + seoDescription: null, + }, + exposedPages: [], + }); + }); + + it('fetchExposedPages returns empty array when API_URL is empty', async () => { + const { fetchExposedPages } = await import('../lib/api'); + const result = await fetchExposedPages(); + expect(result).toEqual([]); + }); + + it('fetchPageBySlug returns null when API_URL is empty', async () => { + const { fetchPageBySlug } = await import('../lib/api'); + const result = await fetchPageBySlug('test'); + expect(result).toBeNull(); + }); + + it('HomepageDataPayload types are correctly shaped', async () => { + const api = await import('../lib/api'); + // Verify the type shape exists by constructing a valid object + const payload: api.HomepageDataPayload = { + sections: [ + { + id: '1', + slot: 'hero', + title: 'Title', + subtitle: 'Sub', + body: null, + githubUrl: null, + icon: null, + enabled: true, + cssClasses: null, + metadata: null, + sortOrder: 0, + features: [{ title: 'Fast', description: 'Very fast', icon: 'Zap', imageUrl: null, href: null }], + actions: [{ label: 'Go', href: '/go', variant: 'default', icon: null, external: false }], + }, + ], + display: { + variantBySlot: { hero: 'centered' }, + themePreset: 'neo', + fallbackThemePresetId: null, + customCss: null, + seoTitle: null, + seoDescription: null, + }, + exposedPages: [{ slug: 'about', title: 'About' }], + }; + expect(payload.sections).toHaveLength(1); + expect(payload.sections[0].enabled).toBe(true); + expect(payload.sections[0].features[0].icon).toBe('Zap'); + expect(payload.display.themePreset).toBe('neo'); + expect(payload.exposedPages[0].slug).toBe('about'); + }); + + it('HomepageSectionPayload supports all configurable fields', async () => { + const api = await import('../lib/api'); + const section: api.HomepageSectionPayload = { + id: '2', + slot: 'features', + title: 'Features', + subtitle: 'What we offer', + body: 'Detailed description', + githubUrl: 'https://github.com/test', + icon: 'Sparkles', + enabled: false, + cssClasses: 'bg-gradient-to-r from-blue-500', + metadata: { custom: 'value', count: 42 }, + sortOrder: 1, + features: [ + { + title: 'Feature 1', + description: 'Desc 1', + icon: 'Shield', + imageUrl: 'https://img.test/1.png', + href: '/features/1', + }, + ], + actions: [{ label: 'Learn More', href: '/learn', variant: 'outline', icon: 'ArrowRight', external: false }], + }; + expect(section.icon).toBe('Sparkles'); + expect(section.enabled).toBe(false); + expect(section.cssClasses).toContain('bg-gradient'); + expect(section.metadata).toHaveProperty('custom', 'value'); + expect(section.features[0].icon).toBe('Shield'); + expect(section.features[0].imageUrl).toBeTruthy(); + expect(section.actions[0].icon).toBe('ArrowRight'); + }); +}); + +describe('getHomepageData (Zod validation)', () => { + it('returns validated fallback when API_URL is empty', async () => { + const { getHomepageData } = await import('../lib/get-homepage-data'); + const result = await getHomepageData(); + expect(result.sections).toEqual([]); + expect(result.display.variantBySlot).toBeNull(); + expect(result.exposedPages).toEqual([]); + }); + + it('HomepageDataSchema validates a correct payload', async () => { + const { HomepageDataSchema } = await import('../lib/get-homepage-data'); + const payload = { + sections: [ + { + id: 'test-1', + slot: 'hero', + title: 'Test', + subtitle: null, + body: null, + githubUrl: null, + icon: 'Sparkles', + enabled: true, + cssClasses: null, + metadata: null, + sortOrder: 0, + features: [], + actions: [{ label: 'Go', href: '/go', variant: 'default', icon: null, external: false }], + }, + ], + display: { + variantBySlot: { hero: 'centered' }, + themePreset: 'neo', + fallbackThemePresetId: null, + }, + exposedPages: [{ slug: 'about', title: 'About' }], + }; + const result = HomepageDataSchema.safeParse(payload); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.sections).toHaveLength(1); + expect(result.data.sections[0].icon).toBe('Sparkles'); + expect(result.data.display.themePreset).toBe('neo'); + } + }); + + it('HomepageDataSchema rejects invalid payload shape', async () => { + const { HomepageDataSchema } = await import('../lib/get-homepage-data'); + const result = HomepageDataSchema.safeParse({ sections: 'not-an-array' }); + expect(result.success).toBe(false); + }); +}); + +describe('HomepageConfigProvider with API variants', () => { + it('merges API variant-by-slot into default config', async () => { + const { SLOT_REGISTRY } = await import('../lib/homepage-config'); + // Verify the slot registry has valid variants for testing + expect(SLOT_REGISTRY.hero.variants.some((v) => v.id === 'split')).toBe(true); + expect(SLOT_REGISTRY.features.variants.some((v) => v.id === 'cards')).toBe(true); + }); +}); diff --git a/apps/ottabase-template-app-nextjs-homepage/app/(site)/page.tsx b/apps/ottabase-template-app-nextjs-homepage/app/(site)/page.tsx new file mode 100644 index 000000000..2b1de00bc --- /dev/null +++ b/apps/ottabase-template-app-nextjs-homepage/app/(site)/page.tsx @@ -0,0 +1,164 @@ +'use client'; + +import { Github, Palette, Rocket } from 'lucide-react'; +import { SlotRenderer } from '../../components/SlotRenderer'; +import type { HomepageDataPayload } from '../../lib/api'; +import { useHomepageData } from '../../lib/homepage-data-context'; + +/** + * Fallback homepage data — used when the API is unavailable or returns no sections. + * These are the built-in template defaults that ensure the homepage always renders. + */ + +const FALLBACK_HERO = { + title: ( + <> + Ottabase{' '} + + Homepage + + on Next.js + + + + ), + subtitle: 'Ship a themed, edge-deployed homepage on Cloudflare Workers in minutes.', + actions: [ + { href: '/about', label: 'About', variant: 'default' as const }, + { + href: '/theme-demo', + label: ( + + Theme Demo + + ), + variant: 'secondary' as const, + }, + { + href: 'https://github.com/thinkdj/ottabase', + label: ( + + GitHub + + ), + variant: 'outline' as const, + external: true, + }, + ], +}; + +const FALLBACK_FEATURES = { + features: [ + { title: 'Cloudflare Workers', description: 'Edge-deployed via OpenNext. No origin server needed.' }, + { title: 'Brand Engine', description: '8 theme presets with live switching and dark mode.' }, + { title: 'Next.js 16', description: 'App Router, RSC, and streaming out of the box.' }, + { title: 'TypeScript', description: 'End-to-end type safety across client and server.' }, + ], +}; + +const FALLBACK_CTA = { + title: 'Ready to Ship?', + description: 'Clone the template, customize the brand, and deploy to Cloudflare Workers in minutes.', + actions: [ + { + href: 'https://github.com/thinkdj/ottabase', + label: ( + + Get Started + + ), + external: true, + }, + { href: '/theme-demo', label: 'Explore Themes', variant: 'outline' as const }, + ], +}; + +/** + * Map DB sections to slot-specific data contracts. + * Filters to enabled sections only and transforms to the shapes expected by SlotRenderer. + */ +function buildPageSlotData(sections: HomepageDataPayload['sections']) { + const result: { + hero?: Record; + features?: Record; + cta?: Record; + about?: Record; + } = {}; + + for (const section of sections) { + if (section.enabled === false) continue; + + const slot = section.slot; + if (slot === 'hero') { + result.hero = { + title: section.title ?? '', + subtitle: section.subtitle ?? undefined, + body: section.body ?? undefined, + actions: + section.actions.length > 0 + ? section.actions.map((a) => ({ + label: a.label, + href: a.href, + variant: (a.variant as 'default' | 'secondary' | 'outline' | 'ghost') ?? 'default', + icon: a.icon ?? undefined, + external: a.external, + })) + : undefined, + }; + } else if (slot === 'features') { + result.features = { + title: section.title ?? undefined, + features: section.features.map((f) => ({ + title: f.title, + description: f.description, + icon: f.icon ?? undefined, + imageUrl: f.imageUrl ?? undefined, + href: f.href ?? undefined, + })), + }; + } else if (slot === 'cta') { + result.cta = { + title: section.title ?? '', + description: section.subtitle ?? undefined, + actions: + section.actions.length > 0 + ? section.actions.map((a) => ({ + label: a.label, + href: a.href, + variant: (a.variant as 'default' | 'secondary' | 'outline' | 'ghost') ?? 'default', + icon: a.icon ?? undefined, + external: a.external, + })) + : [], + }; + } else if (slot === 'about') { + result.about = { + title: section.title ?? undefined, + description: section.subtitle ?? undefined, + githubUrl: section.githubUrl ?? undefined, + }; + } + } + + return result; +} + +export default function HomePage() { + const homepageData = useHomepageData(); + const sections = homepageData?.sections ?? []; + + // Build slot data from DB sections, with fallbacks for missing slots + const dbSlots = buildPageSlotData(sections); + const heroData = dbSlots.hero ?? FALLBACK_HERO; + const featuresData = dbSlots.features ?? FALLBACK_FEATURES; + const ctaData = dbSlots.cta ?? FALLBACK_CTA; + + return ( +
+ + + {dbSlots.about && } + +
+ ); +} diff --git a/apps/ottabase-template-app-nextjs-homepage/app/[slug]/marketing-page-content.tsx b/apps/ottabase-template-app-nextjs-homepage/app/[slug]/marketing-page-content.tsx new file mode 100644 index 000000000..332b0fa25 --- /dev/null +++ b/apps/ottabase-template-app-nextjs-homepage/app/[slug]/marketing-page-content.tsx @@ -0,0 +1,213 @@ +'use client'; + +/** + * Marketing Page Content + * + * Renders the block-based sections of a marketing page using existing slot components. + * Transforms PageDataPayload sections into slot-specific data shapes. + */ + +import type { PageDataPayload, PageSectionPayload } from '@ottabase/homepage-contract'; +import { SlotRenderer } from '../../components/SlotRenderer'; +import type { AboutData } from '../../components/variants/about'; +import type { CTAAction, CTAData } from '../../components/variants/cta'; +import type { FeaturesData } from '../../components/variants/features'; +import type { FooterData } from '../../components/variants/footer'; +import type { HeroAction, HeroData } from '../../components/variants/hero'; +import type { NavbarData } from '../../components/variants/navbar'; + +interface MarketingPageContentProps { + pageData: PageDataPayload; + isPreview?: boolean; +} + +// Valid button variants +type ButtonVariant = 'default' | 'secondary' | 'outline' | 'ghost'; +const VALID_VARIANTS: ButtonVariant[] = ['default', 'secondary', 'outline', 'ghost']; + +function toButtonVariant(v: string | null | undefined): ButtonVariant { + if (v && VALID_VARIANTS.includes(v as ButtonVariant)) { + return v as ButtonVariant; + } + return 'default'; +} + +/** + * Transform a PageSectionPayload into the data shape expected by each slot variant. + */ +function transformSection(section: PageSectionPayload): { slot: string; data: Record } | null { + const { slot, title, subtitle, body, features, actions, githubUrl } = section; + + switch (slot) { + case 'hero': + return { + slot: 'hero', + data: { + title: title || 'Welcome', + subtitle: subtitle || undefined, + body: body || undefined, + actions: actions.map( + (a): HeroAction => ({ + label: a.label, + href: a.href, + variant: toButtonVariant(a.variant), + external: a.external, + }), + ), + } satisfies HeroData, + }; + + case 'features': + return { + slot: 'features', + data: { + title: title || undefined, + features: features.map((f) => ({ + title: f.title, + description: f.description || '', + icon: f.icon || undefined, + href: f.href || undefined, + })), + } satisfies FeaturesData, + }; + + case 'cta': + return { + slot: 'cta', + data: { + title: title || 'Ready to Get Started?', + description: subtitle || body || undefined, + actions: actions.map( + (a): CTAAction => ({ + label: a.label, + href: a.href, + variant: toButtonVariant(a.variant), + external: a.external, + }), + ), + } satisfies CTAData, + }; + + case 'about': + return { + slot: 'about', + data: { + title: title || 'About', + description: subtitle || body || undefined, + githubUrl: githubUrl || undefined, + } satisfies AboutData, + }; + + case 'navbar': + return { + slot: 'navbar', + data: { + title: title || 'Ottabase', + githubUrl: githubUrl || undefined, + } satisfies NavbarData, + }; + + case 'footer': + return { + slot: 'footer', + data: { + siteName: title || 'Ottabase', + tagline: subtitle || body || undefined, + } satisfies FooterData, + }; + + // Slots that don't have specific components yet - render as CTA-style sections + case 'testimonials': + case 'gallery': + case 'team': + case 'pricing': + case 'faq': + case 'video': + case 'code': + case 'custom': + return { + slot: 'cta', + data: { + title: title || slot.charAt(0).toUpperCase() + slot.slice(1), + description: subtitle || body || undefined, + actions: actions.map( + (a): CTAAction => ({ + label: a.label, + href: a.href, + variant: toButtonVariant(a.variant), + external: a.external, + }), + ), + } satisfies CTAData, + }; + + default: + return null; + } +} + +export function MarketingPageContent({ pageData, isPreview = false }: MarketingPageContentProps) { + const { sections, page } = pageData; + + // Filter to enabled sections and sort by sortOrder + const enabledSections = sections.filter((s) => s.enabled !== false).sort((a, b) => a.sortOrder - b.sortOrder); + + // Check for navbar and footer + const navbarSection = enabledSections.find((s) => s.slot === 'navbar'); + const footerSection = enabledSections.find((s) => s.slot === 'footer'); + const contentSections = enabledSections.filter((s) => s.slot !== 'navbar' && s.slot !== 'footer'); + + return ( +
+ {/* Preview Banner */} + {isPreview && ( +
+ Preview Mode — This page is {page.status}. Only you can see this preview. +
+ )} + {/* Navbar */} + {navbarSection && + (() => { + const transformed = transformSection(navbarSection); + if (!transformed) return null; + return ; + })()} + + {/* Main content sections */} +
+ {contentSections.map((section) => { + const transformed = transformSection(section); + if (!transformed) return null; + + // Type-safe rendering based on slot + switch (transformed.slot) { + case 'hero': + return ; + case 'features': + return ( + + ); + case 'cta': + return ; + case 'about': + return ; + default: + return null; + } + })} +
+ + {/* Footer */} + {footerSection && + (() => { + const transformed = transformSection(footerSection); + if (!transformed) return null; + return ; + })()} +
+ ); +} diff --git a/apps/ottabase-template-app-nextjs-homepage/app/[slug]/page.tsx b/apps/ottabase-template-app-nextjs-homepage/app/[slug]/page.tsx new file mode 100644 index 000000000..789e8e55a --- /dev/null +++ b/apps/ottabase-template-app-nextjs-homepage/app/[slug]/page.tsx @@ -0,0 +1,69 @@ +/** + * Dynamic Marketing Page Route + * + * Handles marketing pages created via Admin → Marketing Pages. + * Fetches page data from /api/pages/:slug and renders block-based layouts. + * + * Routes: + * - /{slug} → This component (e.g., /pricing, /about-us, /features) + * - / (homepage) → app/(site)/page.tsx + * - /page/{slug} → app/page/[slug]/page.tsx (ottablog content pages) + */ +import type { Metadata } from 'next'; +import { notFound } from 'next/navigation'; +import { fetchPageByPageSlug } from '../../lib/api'; +import { MarketingPageContent } from './marketing-page-content'; + +interface PageProps { + params: Promise<{ slug: string }>; + searchParams: Promise<{ preview?: string }>; +} + +export async function generateMetadata({ params, searchParams }: PageProps): Promise { + const { slug } = await params; + const { preview } = await searchParams; + const isPreview = preview === 'true'; + + // Don't handle homepage here - let (site)/page.tsx handle it + if (slug === 'homepage') { + return { title: 'Home' }; + } + + const pageData = await fetchPageByPageSlug(slug, isPreview); + if (!pageData) return { title: 'Not Found' }; + + return { + title: pageData.display.seoTitle || pageData.page.title, + description: pageData.display.seoDescription || undefined, + openGraph: pageData.display.seoImage + ? { + images: [{ url: pageData.display.seoImage }], + } + : undefined, + }; +} + +export default async function MarketingPage({ params, searchParams }: PageProps) { + const { slug } = await params; + const { preview } = await searchParams; + const isPreview = preview === 'true'; + + // Don't handle homepage here - redirect or let (site)/page.tsx handle it + if (slug === 'homepage') { + notFound(); // Or redirect to / + } + + const pageData = await fetchPageByPageSlug(slug, isPreview); + + // If no marketing page found, show 404 + if (!pageData) { + notFound(); + } + + // Only show published pages (unless in preview mode) + if (!isPreview && pageData.page.status !== 'published') { + notFound(); + } + + return ; +} diff --git a/apps/ottabase-template-app-nextjs-homepage/app/layout-shell.tsx b/apps/ottabase-template-app-nextjs-homepage/app/layout-shell.tsx index 1cd24f54e..4afacbe2e 100644 --- a/apps/ottabase-template-app-nextjs-homepage/app/layout-shell.tsx +++ b/apps/ottabase-template-app-nextjs-homepage/app/layout-shell.tsx @@ -2,13 +2,17 @@ import { ConfigPanel } from '../components/ConfigPanel'; import { SlotRenderer } from '../components/SlotRenderer'; +import type { HomepageDataPayload } from '../lib/api'; +import { HomepageDataProvider } from '../lib/homepage-data-context'; +import type { NavLink } from '../components/variants/navbar/types'; const GITHUB_URL = 'https://github.com/thinkdj/ottabase'; -const NAVBAR_DATA = { - title: 'Ottabase', - githubUrl: GITHUB_URL, -}; +const BASE_NAVBAR_LINKS: NavLink[] = [ + { href: '/', label: 'Home' }, + { href: '/about', label: 'About' }, + { href: '/theme-demo', label: 'Themes' }, +]; const FOOTER_DATA = { siteName: 'Ottabase', @@ -21,13 +25,123 @@ const FOOTER_DATA = { ], }; -export function LayoutShell({ children }: { children: React.ReactNode }) { +/** Merge exposed CMS pages into navbar links, deduplicating by href. */ +export function mergeNavLinks(baseLinks: NavLink[], exposedPages: { slug: string; title: string }[]): NavLink[] { + const pageLinks: NavLink[] = exposedPages.map((p) => ({ + href: `/page/${p.slug}`, + label: p.title, + })); + const seen = new Set(baseLinks.map((l) => l.href)); + const merged = [...baseLinks]; + for (const link of pageLinks) { + if (!seen.has(link.href)) { + seen.add(link.href); + merged.push(link); + } + } + return merged; +} + +/** + * Build slot data overrides from the homepage API sections. + * Maps section data to the SlotRenderer data contracts. + * Only includes enabled sections. + */ +function buildSlotDataFromSections(sections: HomepageDataPayload['sections']) { + const dataBySlot: Record> = {}; + for (const section of sections) { + // Skip disabled sections + if (section.enabled === false) continue; + + const slot = section.slot; + if (slot === 'hero') { + dataBySlot[slot] = { + title: section.title ?? '', + subtitle: section.subtitle ?? undefined, + body: section.body ?? undefined, + actions: section.actions.map((a) => ({ + label: a.label, + href: a.href, + variant: a.variant ?? 'default', + icon: a.icon ?? undefined, + external: a.external, + })), + }; + } else if (slot === 'features') { + dataBySlot[slot] = { + title: section.title ?? undefined, + features: section.features.map((f) => ({ + title: f.title, + description: f.description, + icon: f.icon ?? undefined, + imageUrl: f.imageUrl ?? undefined, + href: f.href ?? undefined, + })), + }; + } else if (slot === 'cta') { + dataBySlot[slot] = { + title: section.title ?? '', + description: section.subtitle ?? undefined, + actions: section.actions.map((a) => ({ + label: a.label, + href: a.href, + variant: a.variant ?? 'default', + icon: a.icon ?? undefined, + external: a.external, + })), + }; + } else if (slot === 'about') { + dataBySlot[slot] = { + title: section.title ?? undefined, + description: section.subtitle ?? undefined, + githubUrl: section.githubUrl ?? undefined, + }; + } else if (slot === 'navbar') { + dataBySlot[slot] = { + title: section.title ?? 'Ottabase', + githubUrl: section.githubUrl ?? undefined, + }; + } else if (slot === 'footer') { + dataBySlot[slot] = { + siteName: section.title ?? 'Ottabase', + tagline: section.subtitle ?? undefined, + }; + } + } + return dataBySlot; +} + +export interface LayoutShellProps { + children: React.ReactNode; + /** Exposed pages from the CMS, passed from server layout. */ + exposedPages?: { slug: string; title: string }[]; + /** Full homepage data payload from the API. */ + homepageData?: HomepageDataPayload; +} + +export function LayoutShell({ children, exposedPages = [], homepageData }: LayoutShellProps) { + const navbarLinks = mergeNavLinks(BASE_NAVBAR_LINKS, exposedPages); + + // Build slot data overrides from DB sections (if available) + const dbSlotData = homepageData?.sections ? buildSlotDataFromSections(homepageData.sections) : {}; + + const navbarData = { + title: 'Ottabase', + githubUrl: GITHUB_URL, + // Override with DB data if present (title/githubUrl from navbar section) + ...(dbSlotData.navbar ?? {}), + // Always include merged links (DB navbar data should not override links) + links: navbarLinks.length > 0 ? navbarLinks : undefined, + }; + + const footerData = dbSlotData.footer ? { ...FOOTER_DATA, ...dbSlotData.footer } : FOOTER_DATA; + return ( - <> - + +
{children}
- + - +
); } diff --git a/apps/ottabase-template-app-nextjs-homepage/app/layout.tsx b/apps/ottabase-template-app-nextjs-homepage/app/layout.tsx index 173a78777..d0bd81870 100644 --- a/apps/ottabase-template-app-nextjs-homepage/app/layout.tsx +++ b/apps/ottabase-template-app-nextjs-homepage/app/layout.tsx @@ -1,6 +1,7 @@ import { buildCriticalCSS } from '@ottabase/brand-engine'; import type { Metadata } from 'next'; import { generateBrandConfig } from '../lib/brand-server'; +import { getHomepageData } from '../lib/get-homepage-data'; import './globals.css'; import { LayoutShell } from './layout-shell'; import { Providers } from './providers'; @@ -13,28 +14,52 @@ export const metadata: Metadata = { authors: [{ name: 'Ottabase' }], }; -export default function RootLayout({ children }: { children: React.ReactNode }) { - // Generate brand config server-side (SSR) +export default async function RootLayout({ children }: { children: React.ReactNode }) { + // Fetch homepage data FIRST to get the theme preset from DB + const homepageData = await getHomepageData(); + const themePresetId = homepageData.display.themePreset ?? null; + + // Generate brand config server-side using the API theme preset // Note: Using 'light' for initial SSR. BrandProvider will handle dynamic theme switching on client. - const brandConfig = generateBrandConfig('light'); + const brandConfig = generateBrandConfig('light', themePresetId); const theme = brandConfig.brandKitsMap.default.theme; // Generate critical CSS for SSR (prevents FOUC) const criticalCSS = buildCriticalCSS(theme); + // Extract display settings for Providers + const initialHomepageConfig = homepageData.display.variantBySlot ?? null; + + // Apply SEO overrides from DB if available + const seoTitle = homepageData.display.seoTitle; + const seoDescription = homepageData.display.seoDescription; + return ( + {/* Runtime SEO injection from DB display settings (bypasses Next.js metadata to support admin-driven content) */} + {seoTitle && {seoTitle}} + {seoDescription && } {/* Inject critical CSS for theme variables */}