diff --git a/apps/ottabase-template-app-nextjs-homepage/.env.example b/apps/ottabase-template-app-nextjs-homepage/.env.example index 5b941a510..9dbdc953c 100644 --- a/apps/ottabase-template-app-nextjs-homepage/.env.example +++ b/apps/ottabase-template-app-nextjs-homepage/.env.example @@ -47,6 +47,10 @@ NEXT_PUBLIC_MODEL_DEFAULT_REL_KEY="defaults" # Development NODE_ENV="development" +# Homepage Configurator Panel (FAB at bottom-right) +# Set to "true" to show on production, or leave unset to auto-show in dev only +# NEXT_PUBLIC_SHOW_CONFIG_PANEL="true" + # ============================================================ # Database Configuration (Prisma + D1) # ============================================================ diff --git a/apps/ottabase-template-app-nextjs-homepage/HOMEPAGE_vNEXT.MD b/apps/ottabase-template-app-nextjs-homepage/HOMEPAGE_vNEXT.MD new file mode 100644 index 000000000..4f2725b8c --- /dev/null +++ b/apps/ottabase-template-app-nextjs-homepage/HOMEPAGE_vNEXT.MD @@ -0,0 +1,204 @@ +# Homepage vNEXT — Roadmap + +> Roadmap for the Next.js homepage template: deep integration with the Ottabase core framework and feature enhancements for the dynamic, slot-based homepage system. + +--- + +## 1. Core Framework Integration + +### 1.1 Database-Backed Content (OttaORM + D1) + +**Goal:** Store homepage section content in Cloudflare D1 so that all slots (hero, features, CTA, about, etc.) render from a single source of truth — edit once, render any way you want. + +- [ ] Add Cloudflare D1 binding to `wrangler.jsonc` and `cloudflare-env.d.ts` +- [ ] Create `HomepageSection` model using OttaORM `BaseModel`: + ```typescript + // Schema: id, sectionType (hero|features|cta|about), contentJson, sortOrder, isActive, createdAt, updatedAt + export class HomepageSection extends BaseModel { + static entity = 'homepage_sections'; + static table = homepageSectionsTable; + static primaryKey = 'id'; + } + ``` +- [ ] Create `HomepageFeature` model for the features list (title, description, icon, sortOrder) +- [ ] Create `HomepageAction` model for CTA buttons (label, href, variant, isExternal) +- [ ] Wire models into `db-utils.ts` and register in `schemas-helper.ts` for auto-migration +- [ ] Create API routes: `GET /api/homepage/sections`, `PATCH /api/homepage/sections/:id` +- [ ] Refactor `SlotRenderer` to accept data from DB queries (SSR via `fetch()` in server components) while falling back to static defaults when no DB is configured +- [ ] Add seed script to populate initial content matching current static data + +### 1.2 Brand Engine Persistence + +**Goal:** Save the active theme preset and slot variant selections in the database so configuration survives across browsers and can be managed by admins. + +- [ ] Use `@ottabase/brand-engine/persistence` (`BrandKit`, `LayoutTemplate`, `MenuSlotAssignment`) models +- [ ] Store active theme preset + slot config in `BrandKit` table instead of (or in addition to) localStorage +- [ ] Add admin endpoint: `POST /api/brand/homepage-config` to save config +- [ ] ConfigPanel reads from DB on mount (with localStorage as cache/fallback) +- [ ] Multi-tenant support: different orgs can have different homepage configs + +### 1.3 Authentication Integration + +**Goal:** Add optional auth so that admins can edit homepage content via an inline editing UI. + +- [ ] Wire `@ottabase/auth` with D1 adapter into the homepage worker +- [ ] Add optional login/signup button in navbar (hidden when auth is not configured) +- [ ] Create `AdminGuard` wrapper that shows edit controls only when authenticated as admin +- [ ] Inline edit mode: click-to-edit hero text, feature cards, CTA copy — saves to DB via OttaORM +- [ ] Role check via `@ottabase/rbac` for admin-only editing permissions + +### 1.4 Media & File Uploads + +**Goal:** Allow hero images, feature icons, and logo to be uploaded and served from Cloudflare R2. + +- [ ] Add R2 binding for asset storage +- [ ] Integrate `@ottabase/ottaupload` for drag-and-drop image upload in admin mode +- [ ] Store media references in `@ottabase/medialibrary` `Media` model +- [ ] Hero variants can render a dynamic background image from R2 +- [ ] Feature cards support uploaded icons/illustrations + +### 1.5 Blog Integration + +**Goal:** Surface recent blog posts on the homepage as a dynamic section. + +- [ ] Add a `blog` slot with variants: `BlogLatest` (3-card grid), `BlogFeatured` (single spotlight), `BlogList` (compact list) +- [ ] Fetch posts from `@ottabase/ottablog` `Post` model via API: `GET /api/ottaorm/posts?limit=3&orderBy=createdAt` +- [ ] Link to full blog powered by ottablog package +- [ ] Support draft/published filtering, tag-based filtering + +### 1.6 Analytics & Tracking + +**Goal:** Track page views and section engagement using Cloudflare Analytics Engine. + +- [ ] Integrate `@ottabase/analytics` for page-view tracking +- [ ] Add section-level visibility tracking (which hero/features/CTA variants get more engagement) +- [ ] Dashboard widget showing homepage analytics in admin panel +- [ ] A/B testing support: randomly assign visitors to different slot variants and track conversion + +### 1.7 Email & Newsletter + +**Goal:** Add a newsletter signup component as a homepage slot. + +- [ ] Create `newsletter` slot with variants: inline form, modal popup, footer-embedded +- [ ] Integrate `@ottabase/email` for sending confirmation emails +- [ ] Store subscribers in a `NewsletterSubscriber` OttaORM model +- [ ] Double opt-in flow with verification token + +### 1.8 Comments & Feedback + +**Goal:** Allow visitors to leave feedback or testimonials. + +- [ ] Create `testimonials` slot using `@ottabase/comments` for user-submitted reviews +- [ ] Admin moderation panel for approving/rejecting testimonials +- [ ] Display approved testimonials in a carousel or grid variant + +### 1.9 Real-Time Updates + +**Goal:** Push live content updates to visitors without page reload. + +- [ ] Integrate `@ottabase/cf-realtime` WebSocket pub/sub +- [ ] Admin edits to homepage content broadcast live to connected visitors +- [ ] Live visitor count display (optional widget) + +--- + +## 2. Feature Enhancements + +### 2.1 New Slot Types + +- [ ] **Pricing** — pricing table with tiers (Free, Pro, Enterprise) and feature comparison +- [ ] **FAQ** — accordion-based frequently asked questions section +- [ ] **Team** — team member cards with photos, roles, and social links +- [ ] **Testimonials** — customer quotes in carousel, grid, or marquee layouts +- [ ] **Stats/Metrics** — animated counters showing key numbers (users, uptime, etc.) +- [ ] **Integrations/Partners** — logo cloud with partner/integration badges +- [ ] **Changelog** — recent updates feed (could pull from `ChangelogEntry` model) +- [ ] **Contact** — contact form with email integration via `@ottabase/email` +- [ ] **Blog Preview** — latest posts section (see §1.5) +- [ ] **Newsletter** — email signup section (see §1.7) + +### 2.2 Enhanced Config Panel UX + +- [ ] Drag-and-drop slot reordering (change section order on the homepage) +- [ ] Live preview thumbnails for each variant option +- [ ] Import/export config as JSON for sharing configurations +- [ ] Undo/redo for config changes +- [ ] Preset homepage layouts (e.g., "SaaS Landing", "Developer Docs", "Portfolio") +- [ ] Section visibility toggle (show/hide individual slots without removing them) + +### 2.3 Animation & Transitions + +- [ ] Scroll-triggered fade-in animations for each section +- [ ] Smooth section transitions when switching variants in the config panel +- [ ] Parallax effects for hero backgrounds +- [ ] Animated number counters for stats sections +- [ ] Page transition animations between routes + +### 2.4 SEO & Performance + +- [ ] Dynamic `` tags and Open Graph data from DB content +- [ ] JSON-LD structured data for homepage sections +- [ ] Automatic sitemap generation including dynamic pages +- [ ] Image optimization pipeline (WebP/AVIF via Cloudflare Images) +- [ ] Critical CSS extraction per active variant combination +- [ ] Lighthouse CI integration for automated performance monitoring + +### 2.5 Internationalization (i18n) + +- [ ] Multi-language support for all homepage content +- [ ] Language switcher in navbar +- [ ] DB-backed translations per section and locale +- [ ] RTL layout support +- [ ] Automatic browser locale detection + +### 2.6 Multi-Page Support + +- [ ] Extend slot system to arbitrary pages (not just homepage + about) +- [ ] Page builder: create new pages with drag-and-drop slot composition +- [ ] Dynamic routes from DB: `/pages/:slug` renders slot-composed pages +- [ ] Navigation auto-generated from page registry + +### 2.7 Developer Experience + +- [ ] CLI command: `pnpm ottabase homepage:seed` to populate DB with sample content +- [ ] CLI command: `pnpm ottabase homepage:export` to export current config +- [ ] Storybook stories for every slot variant (visual documentation) +- [ ] Variant development mode: hot-reload preview of variant changes +- [ ] TypeScript codegen for slot data types from DB schema + +### 2.8 Deployment & Multi-Tenancy + +- [ ] Per-tenant homepage configuration (different orgs get different homepages) +- [ ] Custom domain mapping per tenant homepage +- [ ] Preview deployments with config snapshots +- [ ] Staging/production config promotion workflow +- [ ] Config versioning with rollback support + +--- + +## Priority Tiers + +### Tier 1 — Foundation (Do First) +1. §1.1 Database-backed content (OttaORM models for sections) +2. §1.2 Brand engine persistence (save config to DB) +3. §2.2 Enhanced config panel (reorder, show/hide, presets) +4. §2.1 New slots: Pricing, FAQ, Testimonials + +### Tier 2 — Growth +5. §1.3 Authentication + inline editing +6. §1.5 Blog integration +7. §1.4 Media uploads (R2) +8. §2.4 SEO and performance +9. §2.3 Animations + +### Tier 3 — Scale +10. §1.6 Analytics and A/B testing +11. §1.7 Newsletter integration +12. §2.5 Internationalization +13. §2.6 Multi-page support +14. §2.8 Multi-tenancy + +### Tier 4 — Polish +15. §1.8 Comments and testimonials from users +16. §1.9 Real-time updates +17. §2.7 Developer experience tooling diff --git a/apps/ottabase-template-app-nextjs-homepage/README.md b/apps/ottabase-template-app-nextjs-homepage/README.md index d75e48568..d8d8a6077 100644 --- a/apps/ottabase-template-app-nextjs-homepage/README.md +++ b/apps/ottabase-template-app-nextjs-homepage/README.md @@ -1,7 +1,7 @@ # Ottabase Next.js Homepage Template Next.js 16 homepage template deployed to Cloudflare Workers via OpenNext. Uses Brand Engine for theming with 8 presets -and live switching. +and live switching, plus an **extensible slot framework** for hot-swappable homepage sections. > **Monorepo note:** The main TanStack app (`ottabase-template-app-tanstack`) drives its brand config from a D1 > database, editable via the admin UI at `/admin/brand-engine`. This homepage is intentionally **config-first** — no DB, @@ -21,30 +21,145 @@ pnpm dev ``` app/ -├── page.tsx # Homepage -├── about/page.tsx # About -├── theme-demo/page.tsx # Live theme switcher -├── layout.tsx # Root layout (SSR critical CSS) -├── layout-shell.tsx # Navbar + Footer wrapper -├── providers.tsx # Theme providers + saved preset restore -├── globals.css # Global styles -├── not-found.tsx # 404 -├── error.tsx # Error boundary -└── loading.tsx # Route spinner +├── page.tsx # Homepage (uses SlotRenderer for each section) +├── about/page.tsx # About +├── theme-demo/page.tsx # Live theme switcher +├── homepage-config/page.tsx # Slot variant config page +├── layout.tsx # Root layout (SSR critical CSS) +├── layout-shell.tsx # Navbar + Footer wrapper (slot-driven) +├── providers.tsx # Theme providers + HomepageConfigProvider +├── globals.css # Global styles +├── not-found.tsx # 404 +├── error.tsx # Error boundary +└── loading.tsx # Route spinner components/ -├── Navbar.tsx # Sticky navbar, mobile menu, dark mode toggle -├── Footer.tsx # Footer with links -├── Hero.tsx # Hero section -├── FeatureCard.tsx # Feature list items -├── CTASection.tsx # Call-to-action block -├── ThemePresetSwitcher.tsx # Preset picker (persists to localStorage) -└── index.ts # Barrel exports -config/ -└── brand.config.ts # Theme preset + brand overrides +├── SlotRenderer.tsx # Resolves slot → variant component at runtime +├── Navbar.tsx # Legacy navbar (still exported for direct use) +├── Footer.tsx # Legacy footer +├── Hero.tsx # Legacy hero +├── FeatureCard.tsx # Legacy feature grid +├── CTASection.tsx # Legacy CTA +├── ThemePresetSwitcher.tsx # Theme preset picker +├── index.ts # Barrel exports +└── variants/ # ← Slot variant components + ├── hero/ + │ ├── types.ts # HeroData contract + │ ├── HeroCentered.tsx # Large centred headline (default) + │ ├── HeroSplit.tsx # Text left + visual right + │ └── HeroMinimal.tsx # Compact headline + ├── features/ + │ ├── types.ts # FeaturesData contract + │ ├── FeaturesGrid.tsx # Two-column bordered list (default) + │ ├── FeaturesCards.tsx # Card layout with hover + │ └── FeaturesList.tsx # Vertical stacked list + ├── cta/ + │ ├── types.ts # CTAData contract + │ ├── CTADefault.tsx # Centred text + buttons (default) + │ ├── CTABanner.tsx # Full-width coloured banner + │ └── CTAMinimal.tsx # Compact inline + ├── navbar/ + │ ├── types.ts # NavbarData contract + │ ├── NavbarDefault.tsx # Logo left, links right (default) + │ ├── NavbarCentered.tsx # Centred links + │ └── NavbarMinimal.tsx # Logo + dark-mode only + └── footer/ + ├── types.ts # FooterData contract + ├── FooterDefault.tsx # Copyright + links row (default) + ├── FooterMinimal.tsx # Single-line copyright + └── FooterColumns.tsx # Multi-column with grouped links lib/ -└── brand-server.ts # Server-side brand/theme utilities -__tests__/ # Vitest test suite +├── brand-server.ts # Server-side brand/theme utilities +├── homepage-config.ts # Slot registry, types, localStorage persistence +└── homepage-config-context.tsx # React context + useHomepageConfig() hook +config/ +└── brand.config.ts # Theme preset + brand overrides +__tests__/ # Vitest test suite (68 tests) +``` + +## Slot Framework + +The homepage is built around an extensible **slot framework** that separates data from rendering. Each section of the +page (hero, features, CTA, navbar, footer) is a **slot** with multiple **variant** components. All variants for a slot +accept the same data props — you write your content data once and switch the visual presentation via config. + +### Architecture + ``` + ┌──────────────────────┐ + lib/homepage-config.ts ─────▶│ SLOT_REGISTRY │ + (slot definitions, │ navbar: 3 variants │ + variant metadata, │ hero: 3 variants │ + localStorage persist) │ features: 3 variants│ + │ cta: 3 variants │ + │ footer: 3 variants │ + └──────────┬───────────┘ + │ + lib/homepage-config-context.tsx ────────▶│ React Context + (useHomepageConfig hook) │ (config state + setVariant) + │ + ┌──────────▼───────────┐ + components/SlotRenderer.tsx ▶│ VARIANT_COMPONENTS │ + () │ → React component │ + └──────────────────────┘ +``` + +### How to use + +```tsx +// In page.tsx — data is defined once, rendering driven by config +const HERO_DATA = { + title: 'Welcome', + subtitle: 'Build fast.', + actions: [{ href: '/docs', label: 'Get Started' }], +}; + +export default function HomePage() { + return ; +} +``` + +### Config page + +Visit `/homepage-config` to switch variant for each slot. Changes are saved to `localStorage` and applied instantly — no +page reload needed. A "Reset to defaults" button restores the original configuration. + +### Adding a new variant + +1. Create a component in `components/variants//` that accepts the slot's data type: + +```tsx +// components/variants/hero/HeroGradient.tsx +import type { HeroData } from './types'; + +export function HeroGradient({ title, subtitle, actions }: HeroData) { + return
/* your markup */
; +} +``` + +2. Register it in `components/SlotRenderer.tsx` (`VARIANT_COMPONENTS`): + +```tsx +hero: { + centered: HeroCentered, + split: HeroSplit, + minimal: HeroMinimal, + gradient: HeroGradient, // ← add here +}, +``` + +3. Add metadata to `lib/homepage-config.ts` (`SLOT_REGISTRY`): + +```tsx +hero: { + variants: [ + // ...existing... + { id: 'gradient', label: 'Gradient', description: 'Bold gradient background hero.' }, + ], +}, +``` + +The new variant immediately appears in the config page and can be selected. ## Brand Engine Integration diff --git a/apps/ottabase-template-app-nextjs-homepage/__tests__/slot-framework.test.tsx b/apps/ottabase-template-app-nextjs-homepage/__tests__/slot-framework.test.tsx new file mode 100644 index 000000000..e44a78392 --- /dev/null +++ b/apps/ottabase-template-app-nextjs-homepage/__tests__/slot-framework.test.tsx @@ -0,0 +1,511 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { createElement } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// ── Mocks ────────────────────────────────────────────────────────────────── + +vi.mock('@ottabase/ottalayout', () => ({ DEFAULT_LAYOUT: {} })); + +vi.mock('@ottabase/brand-engine', () => ({ + applyBrandTheme: vi.fn(), + registerBuiltInThemes: vi.fn(), + getThemeByName: vi.fn(() => ({ name: 'default', colors: {} })), + resolveTheme: vi.fn(() => ({ + colors: { primary: '220 70% 50%' }, + typography: { + heading: { fontFamily: 'Inter' }, + body: { fontFamily: 'Inter' }, + handwriting: { fontFamily: 'Caveat' }, + }, + radius: '0.5rem', + })), + BUILTIN_THEME_NAMES: ['default'], + PRESET_MAP: {}, + buildCriticalCSS: vi.fn(() => ''), +})); + +vi.mock('@ottabase/brand-engine-react', () => ({ + BrandProvider: ({ children }: any) => createElement('div', null, children), + useBrand: vi.fn(() => ({ config: null })), +})); + +vi.mock('@ottabase/ui-shadcn', () => ({ + Button: ({ children, asChild, variant, size, ...props }: any) => + createElement('button', { 'data-variant': variant, 'data-size': size, ...props }, children), + ShadcnProviders: ({ children }: any) => createElement('div', null, children), +})); + +vi.mock('@ottabase/ui-components/dark-mode-toggle', () => ({ + DarkModeToggle: (props: any) => createElement('button', { 'data-testid': 'dark-mode-toggle', ...props }, 'Toggle'), +})); + +vi.mock('next-themes', () => ({ + ThemeProvider: ({ children }: any) => createElement('div', null, children), + useTheme: () => ({ resolvedTheme: 'light', setTheme: vi.fn() }), +})); + +// ── Homepage config unit tests ───────────────────────────────────────────── + +describe('Homepage Config', () => { + let getDefaultConfig: any; + let loadConfig: any; + let saveConfig: any; + let SLOT_NAMES: any; + let SLOT_REGISTRY: any; + let HOMEPAGE_CONFIG_KEY: any; + + beforeEach(async () => { + localStorage.clear(); + ({ getDefaultConfig, loadConfig, saveConfig, SLOT_NAMES, SLOT_REGISTRY, HOMEPAGE_CONFIG_KEY } = + await import('../lib/homepage-config')); + }); + + it('defines all 6 slot names', () => { + expect(SLOT_NAMES).toEqual(['navbar', 'hero', 'features', 'cta', 'footer', 'about']); + }); + + it('each slot has at least 2 variants', () => { + for (const slot of SLOT_NAMES) { + expect(SLOT_REGISTRY[slot].variants.length).toBeGreaterThanOrEqual(2); + } + }); + + it('each slot has a valid defaultVariant', () => { + for (const slot of SLOT_NAMES) { + const ids = SLOT_REGISTRY[slot].variants.map((v: any) => v.id); + expect(ids).toContain(SLOT_REGISTRY[slot].defaultVariant); + } + }); + + it('getDefaultConfig returns one key per slot', () => { + const config = getDefaultConfig(); + for (const slot of SLOT_NAMES) { + expect(config[slot]).toBe(SLOT_REGISTRY[slot].defaultVariant); + } + }); + + it('loadConfig returns defaults when localStorage is empty', () => { + const config = loadConfig(); + expect(config).toEqual(getDefaultConfig()); + }); + + it('saveConfig persists to localStorage', () => { + const config = getDefaultConfig(); + config.hero = 'split'; + saveConfig(config); + expect(localStorage.getItem(HOMEPAGE_CONFIG_KEY)).toBe(JSON.stringify(config)); + }); + + it('loadConfig reads persisted config', () => { + const config = getDefaultConfig(); + config.hero = 'minimal'; + config.footer = 'columns'; + saveConfig(config); + const loaded = loadConfig(); + expect(loaded.hero).toBe('minimal'); + expect(loaded.footer).toBe('columns'); + }); + + it('loadConfig merges partial config with defaults', () => { + // Only save one slot — all others should get defaults + localStorage.setItem(HOMEPAGE_CONFIG_KEY, JSON.stringify({ hero: 'split' })); + const loaded = loadConfig(); + expect(loaded.hero).toBe('split'); + expect(loaded.navbar).toBe(SLOT_REGISTRY.navbar.defaultVariant); + }); + + it('loadConfig handles corrupt JSON gracefully', () => { + localStorage.setItem(HOMEPAGE_CONFIG_KEY, 'not-json'); + const loaded = loadConfig(); + expect(loaded).toEqual(getDefaultConfig()); + }); +}); + +// ── SlotRenderer tests ───────────────────────────────────────────────────── + +describe('SlotRenderer', () => { + let SlotRenderer: any; + let VARIANT_COMPONENTS: any; + let HomepageConfigProvider: any; + + beforeEach(async () => { + localStorage.clear(); + ({ SlotRenderer, VARIANT_COMPONENTS } = await import('../components/SlotRenderer')); + ({ HomepageConfigProvider } = await import('../lib/homepage-config-context')); + }); + + it('has variant components for every slot', async () => { + const { SLOT_NAMES } = await import('../lib/homepage-config'); + for (const slot of SLOT_NAMES) { + expect(VARIANT_COMPONENTS[slot]).toBeDefined(); + expect(Object.keys(VARIANT_COMPONENTS[slot]).length).toBeGreaterThanOrEqual(2); + } + }); + + it('renders the default hero variant', () => { + render( + + + , + ); + expect(screen.getByText('Test Hero')).toBeDefined(); + }); + + it('renders the default features variant', () => { + render( + + + , + ); + expect(screen.getByText('Feature One')).toBeDefined(); + }); + + it('renders the default CTA variant', () => { + render( + + + , + ); + expect(screen.getByText('CTA Title')).toBeDefined(); + }); + + it('renders the default footer variant', () => { + render( + + + , + ); + expect(screen.getByText(/TestSite/)).toBeDefined(); + }); + + it('renders the default navbar variant', () => { + render( + + + , + ); + expect(screen.getByText('NavTest')).toBeDefined(); + }); +}); + +// ── Hero variant rendering tests ─────────────────────────────────────────── + +describe('Hero Variants', () => { + let HeroCentered: any; + let HeroSplit: any; + let HeroMinimal: any; + + beforeEach(async () => { + ({ HeroCentered } = await import('../components/variants/hero/HeroCentered')); + ({ HeroSplit } = await import('../components/variants/hero/HeroSplit')); + ({ HeroMinimal } = await import('../components/variants/hero/HeroMinimal')); + }); + + it('HeroCentered renders title and subtitle', () => { + render(); + expect(screen.getByText('Centered Title')).toBeDefined(); + expect(screen.getByText('Centered Sub')).toBeDefined(); + }); + + it('HeroSplit renders title and subtitle', () => { + render(); + expect(screen.getByText('Split Title')).toBeDefined(); + expect(screen.getByText('Split Sub')).toBeDefined(); + }); + + it('HeroMinimal renders title', () => { + render(); + expect(screen.getByText('Minimal Title')).toBeDefined(); + }); + + it('HeroCentered renders action buttons', () => { + render(); + expect(screen.getByText('Click Me')).toBeDefined(); + }); +}); + +// ── Features variant rendering tests ─────────────────────────────────────── + +describe('Features Variants', () => { + let FeaturesGrid: any; + let FeaturesCards: any; + let FeaturesList: any; + + const features = [ + { title: 'Fast', description: 'Blazing fast' }, + { title: 'Secure', description: 'Rock solid' }, + ]; + + beforeEach(async () => { + ({ FeaturesGrid } = await import('../components/variants/features/FeaturesGrid')); + ({ FeaturesCards } = await import('../components/variants/features/FeaturesCards')); + ({ FeaturesList } = await import('../components/variants/features/FeaturesList')); + }); + + it('FeaturesGrid renders features', () => { + render(); + expect(screen.getByText('Fast')).toBeDefined(); + expect(screen.getByText('Secure')).toBeDefined(); + }); + + it('FeaturesCards renders features', () => { + render(); + expect(screen.getByText('Fast')).toBeDefined(); + expect(screen.getByText('Secure')).toBeDefined(); + }); + + it('FeaturesList renders features', () => { + render(); + expect(screen.getByText('Fast')).toBeDefined(); + expect(screen.getByText('Secure')).toBeDefined(); + }); + + it('FeaturesGrid renders optional title', () => { + render(); + expect(screen.getByText('Our Features')).toBeDefined(); + }); +}); + +// ── CTA variant rendering tests ──────────────────────────────────────────── + +describe('CTA Variants', () => { + let CTADefault: any; + let CTABanner: any; + let CTAMinimal: any; + + beforeEach(async () => { + ({ CTADefault } = await import('../components/variants/cta/CTADefault')); + ({ CTABanner } = await import('../components/variants/cta/CTABanner')); + ({ CTAMinimal } = await import('../components/variants/cta/CTAMinimal')); + }); + + it('CTADefault renders title and description', () => { + render(); + expect(screen.getByText('Get Started')).toBeDefined(); + expect(screen.getByText('Now.')).toBeDefined(); + }); + + it('CTABanner renders title', () => { + render(); + expect(screen.getByText('Banner CTA')).toBeDefined(); + }); + + it('CTAMinimal renders title', () => { + render(); + expect(screen.getByText('Minimal CTA')).toBeDefined(); + }); +}); + +// ── Footer variant rendering tests ───────────────────────────────────────── + +describe('Footer Variants', () => { + let FooterDefault: any; + let FooterMinimal: any; + let FooterColumns: any; + + beforeEach(async () => { + ({ FooterDefault } = await import('../components/variants/footer/FooterDefault')); + ({ FooterMinimal } = await import('../components/variants/footer/FooterMinimal')); + ({ FooterColumns } = await import('../components/variants/footer/FooterColumns')); + }); + + it('FooterDefault renders site name', () => { + render(); + expect(screen.getByText(/Acme/)).toBeDefined(); + }); + + it('FooterMinimal renders copyright', () => { + render(); + expect(screen.getByText(/Acme/)).toBeDefined(); + }); + + it('FooterColumns renders site name and links', () => { + const links = [ + { href: '/about', label: 'About' }, + { href: '/docs', label: 'Docs' }, + ]; + render(); + expect(screen.getByText('Acme')).toBeDefined(); + expect(screen.getByText('About')).toBeDefined(); + expect(screen.getByText('Docs')).toBeDefined(); + }); +}); + +// ── Navbar variant rendering tests ───────────────────────────────────────── + +describe('Navbar Variants', () => { + let NavbarDefault: any; + let NavbarCentered: any; + let NavbarMinimal: any; + + beforeEach(async () => { + ({ NavbarDefault } = await import('../components/variants/navbar/NavbarDefault')); + ({ NavbarCentered } = await import('../components/variants/navbar/NavbarCentered')); + ({ NavbarMinimal } = await import('../components/variants/navbar/NavbarMinimal')); + }); + + it('NavbarDefault renders title and links', () => { + render(); + expect(screen.getByText('MySite')).toBeDefined(); + // Default links should be present + expect(screen.getByText('Home')).toBeDefined(); + }); + + it('NavbarCentered renders title', () => { + render(); + expect(screen.getByText('CenteredSite')).toBeDefined(); + }); + + it('NavbarMinimal renders title and dark mode toggle', () => { + render(); + expect(screen.getByText('MinSite')).toBeDefined(); + expect(screen.getByTestId('dark-mode-toggle')).toBeDefined(); + }); + + it('NavbarDefault toggles mobile menu', () => { + render(); + const toggle = screen.getByLabelText('Toggle menu'); + fireEvent.click(toggle); + // After opening mobile menu, links appear in both desktop and mobile sections + const homeLinks = screen.getAllByText('Home'); + expect(homeLinks.length).toBeGreaterThanOrEqual(2); + }); + it('NavbarDefault shows GitHub icon and external-link icon', () => { + render(); + // The GitHub button should be present (hidden on mobile, but in DOM) + const githubLink = screen.getAllByTitle('Open on GitHub (new tab)'); + expect(githubLink.length).toBeGreaterThanOrEqual(1); + }); + + it('NavbarCentered shows GitHub icon and external-link icon', () => { + render(); + const githubLink = screen.getAllByTitle('Open on GitHub (new tab)'); + expect(githubLink.length).toBeGreaterThanOrEqual(1); + }); +}); + +// ── About variant rendering tests ────────────────────────────────────────── + +describe('About Variants', () => { + let AboutDefault: any; + let AboutMinimal: any; + let AboutDetailed: any; + + beforeEach(async () => { + ({ AboutDefault } = await import('../components/variants/about/AboutDefault')); + ({ AboutMinimal } = await import('../components/variants/about/AboutMinimal')); + ({ AboutDetailed } = await import('../components/variants/about/AboutDetailed')); + }); + + it('AboutDefault renders title and content', () => { + render(); + expect(screen.getByText(/About/)).toBeDefined(); + expect(screen.getByText(/What is this/)).toBeDefined(); + }); + + it('AboutMinimal renders title', () => { + render(); + expect(screen.getByText(/About/)).toBeDefined(); + }); + + it('AboutDetailed renders feature cards and tech stack', () => { + render(); + expect(screen.getByText(/About/)).toBeDefined(); + expect(screen.getByText('Tech Stack')).toBeDefined(); + expect(screen.getByText('Edge-First')).toBeDefined(); + }); + + it('AboutDefault accepts custom githubUrl', () => { + render(); + const githubLinks = screen.getAllByText(/View on GitHub/); + expect(githubLinks.length).toBeGreaterThanOrEqual(1); + }); + + it('SlotRenderer renders the default about variant', async () => { + const { HomepageConfigProvider } = await import('../lib/homepage-config-context'); + const { SlotRenderer } = await import('../components/SlotRenderer'); + render( + + + , + ); + expect(screen.getByText(/About/)).toBeDefined(); + }); +}); + +describe('HomepageConfigPage', () => { + let HomepageConfigPage: any; + let HomepageConfigProvider: any; + + beforeEach(async () => { + localStorage.clear(); + ({ default: HomepageConfigPage } = await import('../app/homepage-config/page')); + ({ HomepageConfigProvider } = await import('../lib/homepage-config-context')); + }); + + it('renders the config page heading', () => { + render( + + + , + ); + expect(screen.getByText('Homepage Config')).toBeDefined(); + }); + + it('renders a section for each slot', async () => { + const { SLOT_REGISTRY } = await import('../lib/homepage-config'); + render( + + + , + ); + for (const slot of Object.values(SLOT_REGISTRY) as any[]) { + // Label appears both in h2 and in description span, so use getAllByText + const matches = screen.getAllByText(slot.label); + expect(matches.length).toBeGreaterThanOrEqual(1); + } + }); + + it('renders variant buttons for each slot', async () => { + const { SLOT_REGISTRY } = await import('../lib/homepage-config'); + render( + + + , + ); + for (const slot of Object.values(SLOT_REGISTRY) as any[]) { + for (const variant of slot.variants) { + // Labels like "Default" and "Minimal" appear in multiple slots, so use getAllByText + const matches = screen.getAllByText(variant.label); + expect(matches.length).toBeGreaterThanOrEqual(1); + } + } + }); + + it('clicking a variant updates selection', () => { + render( + + + , + ); + // Click the "Split" hero variant + fireEvent.click(screen.getByText('Split')); + // The "Split" button should now show "Active" + const splitButton = screen.getByText('Split').closest('button'); + expect(splitButton?.className).toContain('border-primary'); + }); + + it('reset button restores defaults', async () => { + const { HOMEPAGE_CONFIG_KEY, getDefaultConfig } = await import('../lib/homepage-config'); + // Pre-set a non-default config + localStorage.setItem(HOMEPAGE_CONFIG_KEY, JSON.stringify({ hero: 'split' })); + render( + + + , + ); + fireEvent.click(screen.getByText('Reset to defaults')); + const stored = JSON.parse(localStorage.getItem(HOMEPAGE_CONFIG_KEY)!); + expect(stored).toEqual(getDefaultConfig()); + }); +}); diff --git a/apps/ottabase-template-app-nextjs-homepage/app/about/page.tsx b/apps/ottabase-template-app-nextjs-homepage/app/about/page.tsx index 0f80edf5a..1fabc718a 100644 --- a/apps/ottabase-template-app-nextjs-homepage/app/about/page.tsx +++ b/apps/ottabase-template-app-nextjs-homepage/app/about/page.tsx @@ -1,91 +1,11 @@ -import { Button } from '@ottabase/ui-shadcn'; -import { Github, Rocket } from 'lucide-react'; -import Link from 'next/link'; +'use client'; -export default function AboutPage() { - return ( -
-
- {/* Main Content */} -
-

- About this template -

-

- A modern, production-ready Next.js homepage template for Cloudflare Workers. -

-
- -
-

What is this?

-

- This is a barebone Next.js homepage template designed to be easily deployed to Cloudflare - Workers using OpenNext. It provides a solid foundation for building beautiful, performant - homepages with modern web technologies. -

- -

Key Features

-
    -
  • Next.js 16 with App Router and React Server Components
  • -
  • OpenNext for seamless Cloudflare Workers deployment
  • -
  • Brand Engine integration with 8 theme presets
  • -
  • TypeScript for type safety
  • -
  • Tailwind CSS for rapid styling
  • -
  • Dark mode support out of the box
  • -
  • Fully responsive design
  • -
  • Production-ready configuration
  • -
+import { SlotRenderer } from '../../components/SlotRenderer'; -

Getting Started

-

To get started with this template:

-
    -
  1. Clone the repository
  2. -
  3. - Install dependencies with pnpm install -
  4. -
  5. - Run the dev server with pnpm dev -
  6. -
  7. Customize the homepage and brand configuration
  8. -
  9. Build and deploy to Cloudflare Workers
  10. -
+const ABOUT_DATA = { + githubUrl: 'https://github.com/thinkdj/ottabase', +}; -

Brand Customization

-

- The template includes Brand Engine integration, allowing you to easily customize colors, - typography, and theming. Edit{' '} - config/brand.config.ts to configure your - brand settings. Choose from 8 built-in theme presets or create your own custom theme. -

- -

Deployment

-

- Deploy to Cloudflare Workers with a single command: -

-
-                        pnpm deploy
-                    
-

- Make sure you have configured your Cloudflare credentials before deploying. -

-
- - {/* CTA Section */} -
- - -
-
-
- ); +export default function AboutPage() { + return ; } diff --git a/apps/ottabase-template-app-nextjs-homepage/app/homepage-config/page.tsx b/apps/ottabase-template-app-nextjs-homepage/app/homepage-config/page.tsx new file mode 100644 index 000000000..671f48c9d --- /dev/null +++ b/apps/ottabase-template-app-nextjs-homepage/app/homepage-config/page.tsx @@ -0,0 +1,94 @@ +'use client'; + +import { RotateCcw } from 'lucide-react'; +import { useHomepageConfig } from '../../lib/homepage-config-context'; +import { SLOT_NAMES, SLOT_REGISTRY } from '../../lib/homepage-config'; +import type { SlotName } from '../../lib/homepage-config'; + +export default function HomepageConfigPage() { + const { config, setVariant, resetConfig } = useHomepageConfig(); + + return ( +
+ {/* Header */} +
+
+

Homepage Config

+

+ Switch component variants for each section. Changes are saved automatically and applied + instantly. +

+
+ +
+ + {/* Slot sections */} + {SLOT_NAMES.map((slotName) => ( + setVariant(slotName, variantId)} + /> + ))} +
+ ); +} + +// ── SlotConfigSection ─────────────────────────────────────────────────────── + +function SlotConfigSection({ + slotName, + activeVariant, + onSelect, +}: { + slotName: SlotName; + activeVariant: string; + onSelect: (variantId: string) => void; +}) { + const slot = SLOT_REGISTRY[slotName]; + + return ( +
+
+

{slot.label}

+

+ Choose which {slot.label} variant to render. +

+
+ +
+ {slot.variants.map((variant) => { + const isActive = activeVariant === variant.id; + 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 5fbb9a54c..1cd24f54e 100644 --- a/apps/ottabase-template-app-nextjs-homepage/app/layout-shell.tsx +++ b/apps/ottabase-template-app-nextjs-homepage/app/layout-shell.tsx @@ -1,22 +1,33 @@ 'use client'; -import { Footer } from '../components/Footer'; -import { Navbar } from '../components/Navbar'; +import { ConfigPanel } from '../components/ConfigPanel'; +import { SlotRenderer } from '../components/SlotRenderer'; const GITHUB_URL = 'https://github.com/thinkdj/ottabase'; -const FOOTER_LINKS = [ - { href: '/about', label: 'About' }, - { href: '/theme-demo', label: 'Themes' }, - { href: GITHUB_URL, label: 'GitHub', external: true }, -]; +const NAVBAR_DATA = { + title: 'Ottabase', + githubUrl: GITHUB_URL, +}; + +const FOOTER_DATA = { + siteName: 'Ottabase', + tagline: 'Built with Next.js & Cloudflare Workers', + links: [ + { href: '/about', label: 'About' }, + { href: '/theme-demo', label: 'Themes' }, + { href: '/homepage-config', label: 'Config' }, + { href: GITHUB_URL, label: 'GitHub', external: true }, + ], +}; export function LayoutShell({ children }: { children: React.ReactNode }) { return ( <> - +
{children}
-