Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
c769244
Initial plan
Copilot Mar 30, 2026
ee7dc1e
feat: add page content type and exposeToHomepage to ottablog + worker…
Copilot Mar 30, 2026
7aae3bb
feat: add Next.js page route, navbar integration, and tests for expos…
Copilot Mar 30, 2026
72478b6
fix: correct import path in (site)/page.tsx for route group, update o…
Copilot Mar 30, 2026
4db1801
feat: add homepage DB models (Section, Feature, Action, DisplaySettin…
Copilot Mar 30, 2026
67d57e2
fix: address code review — remove unused import, add error logging, f…
Copilot Mar 30, 2026
a85fd4f
feat: add admin UI for homepage management — sections, features, acti…
Copilot Mar 30, 2026
eeec6de
refactor: extract shared homepage-constants.ts, fix setTimeout anti-p…
Copilot Mar 30, 2026
0cb155f
feat: enhance homepage system — add enabled/icon/metadata/cssClasses …
Copilot Mar 30, 2026
2661bd2
test: update homepage API type tests for new fields (enabled, icon, m…
Copilot Mar 30, 2026
00a4fc6
fix: replace HTML entities with JSX expressions in admin homepage pages
Copilot Mar 30, 2026
f1d3afc
feat: full runtime homepage integration — Zod-validated API pipeline,…
Copilot Mar 31, 2026
0aa0310
refactor: address code review — improve log messages, rename applyThe…
Copilot Mar 31, 2026
b6f6c5b
feat: add @ottabase/homepage-contract shared package + Seed Defaults …
Copilot Mar 31, 2026
bf5b208
refactor: replace window.location.reload with queryClient.invalidateQ…
Copilot Mar 31, 2026
5363681
Add homepage RLS policies and fix contentType filters
thinkdj Mar 31, 2026
fed02da
Add unified homepage admin builder
thinkdj Mar 31, 2026
8efb3da
Add flexible pages system & marketing pages UI
thinkdj Mar 31, 2026
d9a7d15
SPECS MD: Add Marketing Pages feature specification
thinkdj Mar 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/ottabase-template-app-nextjs-homepage/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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)
# ============================================================
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
164 changes: 164 additions & 0 deletions apps/ottabase-template-app-nextjs-homepage/app/(site)/page.tsx
Original file line number Diff line number Diff line change
@@ -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: (
<>
<span className="text-primary">Ottabase</span>{' '}
<span className="inline-flex items-baseline gap-2">
Homepage
<span className="rounded bg-muted px-2 py-0.5 font-mono text-[0.32em] font-medium text-muted-foreground">
on Next.js
</span>
</span>
</>
),
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: (
<span className="inline-flex items-center gap-1.5">
<Palette className="h-4 w-4" /> Theme Demo
</span>
),
variant: 'secondary' as const,
},
{
href: 'https://github.com/thinkdj/ottabase',
label: (
<span className="inline-flex items-center gap-1.5">
<Github className="h-4 w-4" /> GitHub
</span>
),
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: (
<span className="inline-flex items-center gap-1.5">
<Rocket className="h-4 w-4" /> Get Started
</span>
),
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<string, unknown>;
features?: Record<string, unknown>;
cta?: Record<string, unknown>;
about?: Record<string, unknown>;
} = {};

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 (
<div className="flex flex-col items-center">
<SlotRenderer slot="hero" data={heroData as any} />
<SlotRenderer slot="features" data={featuresData as any} />
{dbSlots.about && <SlotRenderer slot="about" data={dbSlots.about as any} />}
<SlotRenderer slot="cta" data={ctaData as any} />
</div>
);
}
Loading
Loading