From 743d09967dc65df7f00d6c04e2bd085242483dbc Mon Sep 17 00:00:00 2001 From: Deepak Thomas Date: Tue, 31 Mar 2026 23:00:08 +0530 Subject: [PATCH 1/5] feat(marketing-pages): add dynamic page builder, APIs, and Next.js slug rendering --- .../README.md | 11 + .../app/[slug]/marketing-page-content.tsx | 38 +++ .../app/[slug]/page.tsx | 27 +++ .../lib/api.ts | 30 +++ apps/ottabase-template-app-tanstack/README.md | 17 ++ .../ottabase/db/schema.ts | 1 + .../ottabase/models/MarketingPage.schema.ts | 79 ++++++ .../ottabase/models/MarketingPage.ts | 87 +++++++ .../src/hooks/marketingPageHooks.ts | 22 ++ .../src/pages/admin/AdminIndexPage.tsx | 7 + .../admin/pages/AdminPageBuilderPage.tsx | 228 ++++++++++++++++++ .../pages/admin/pages/AdminPagesListPage.tsx | 110 +++++++++ .../pages/marketing/MarketingPageRenderer.tsx | 55 +++++ .../src/router.tsx | 36 +++ .../src/types/marketing-pages.ts | 51 ++++ .../worker/lib/db-utils.ts | 3 +- .../routes/__tests__/marketing-pages.test.ts | 21 ++ .../worker/routes/marketing-pages.ts | 163 +++++++++++++ .../worker/routes/router.ts | 15 ++ 19 files changed, 1000 insertions(+), 1 deletion(-) create mode 100644 apps/ottabase-template-app-nextjs-homepage/app/[slug]/marketing-page-content.tsx create mode 100644 apps/ottabase-template-app-nextjs-homepage/app/[slug]/page.tsx create mode 100644 apps/ottabase-template-app-nextjs-homepage/lib/api.ts create mode 100644 apps/ottabase-template-app-tanstack/ottabase/models/MarketingPage.schema.ts create mode 100644 apps/ottabase-template-app-tanstack/ottabase/models/MarketingPage.ts create mode 100644 apps/ottabase-template-app-tanstack/src/hooks/marketingPageHooks.ts create mode 100644 apps/ottabase-template-app-tanstack/src/pages/admin/pages/AdminPageBuilderPage.tsx create mode 100644 apps/ottabase-template-app-tanstack/src/pages/admin/pages/AdminPagesListPage.tsx create mode 100644 apps/ottabase-template-app-tanstack/src/pages/marketing/MarketingPageRenderer.tsx create mode 100644 apps/ottabase-template-app-tanstack/src/types/marketing-pages.ts create mode 100644 apps/ottabase-template-app-tanstack/worker/routes/__tests__/marketing-pages.test.ts create mode 100644 apps/ottabase-template-app-tanstack/worker/routes/marketing-pages.ts diff --git a/apps/ottabase-template-app-nextjs-homepage/README.md b/apps/ottabase-template-app-nextjs-homepage/README.md index ed44fc089..dc515d941 100644 --- a/apps/ottabase-template-app-nextjs-homepage/README.md +++ b/apps/ottabase-template-app-nextjs-homepage/README.md @@ -304,3 +304,14 @@ CI/CD is handled by the shared `.github/workflows/deploy.yml` — deploys on pus The workflow reads `cloudflare-config.json` from each app folder to determine app type (`nextjs`), build commands, output paths and wrangler config — no hardcoded names in yml. + +## Dynamic Marketing Pages (Worker-driven) + +In addition to static homepage slots, the app now supports dynamic route rendering from the TanStack Worker API: + +- Route: `app/[slug]/page.tsx` +- Data source: `GET {NEXT_PUBLIC_TANSTACK_API_URL}/api/pages/:slug` +- Nav prebuild: `GET .../api/pages/nav` in `generateStaticParams()` +- Draft preview: append `?preview=true` + +Set `NEXT_PUBLIC_TANSTACK_API_URL` in `.env.local` when homepage and worker run on different hosts. 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..89ee66487 --- /dev/null +++ b/apps/ottabase-template-app-nextjs-homepage/app/[slug]/marketing-page-content.tsx @@ -0,0 +1,38 @@ +import { SlotRendererStatic } from '../../components/SlotRenderer'; + +export function MarketingPageContent({ page }: { page: any }) { + return ( +
+ {page.status === 'draft' ? ( +
+ Draft preview mode +
+ ) : null} + {page.sections.map((section: any) => { + if ( + section.slot === 'hero' || + section.slot === 'features' || + section.slot === 'cta' || + section.slot === 'navbar' || + section.slot === 'footer' + ) { + return ( + + ); + } + + return ( +
+

{section.title || section.slot}

+ {section.body ?

{section.body}

: null} +
+ ); + })} +
+ ); +} 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..a53cbe259 --- /dev/null +++ b/apps/ottabase-template-app-nextjs-homepage/app/[slug]/page.tsx @@ -0,0 +1,27 @@ +import { notFound } from 'next/navigation'; +import { fetchMarketingNav, fetchMarketingPage } from '../../lib/api'; +import { MarketingPageContent } from './marketing-page-content'; + +export async function generateStaticParams() { + const nav = await fetchMarketingNav(); + return nav.pages.map((page) => ({ slug: page.slug })); +} + +export default async function DynamicMarketingPage({ + params, + searchParams, +}: { + params: Promise<{ slug: string }>; + searchParams: Promise<{ preview?: string }>; +}) { + const { slug } = await params; + const { preview } = await searchParams; + const previewEnabled = preview === 'true'; + + const response = await fetchMarketingPage(slug, previewEnabled); + if (!response?.page) { + notFound(); + } + + return ; +} diff --git a/apps/ottabase-template-app-nextjs-homepage/lib/api.ts b/apps/ottabase-template-app-nextjs-homepage/lib/api.ts new file mode 100644 index 000000000..7dd80184a --- /dev/null +++ b/apps/ottabase-template-app-nextjs-homepage/lib/api.ts @@ -0,0 +1,30 @@ +const DEFAULT_API_URL = 'http://localhost:3004'; + +export function getWorkerApiUrl() { + return process.env.NEXT_PUBLIC_TANSTACK_API_URL || DEFAULT_API_URL; +} + +export async function fetchMarketingPage(slug: string, preview: boolean) { + const url = `${getWorkerApiUrl()}/api/pages/${slug}${preview ? '?preview=true' : ''}`; + const response = await fetch(url, { + next: { revalidate: preview ? 0 : 60 }, + }); + + if (!response.ok) { + return null; + } + + return (await response.json()) as { page: any }; +} + +export async function fetchMarketingNav() { + const response = await fetch(`${getWorkerApiUrl()}/api/pages/nav`, { + next: { revalidate: 60 }, + }); + + if (!response.ok) { + return { pages: [] as Array<{ slug: string }> }; + } + + return (await response.json()) as { pages: Array<{ slug: string }> }; +} diff --git a/apps/ottabase-template-app-tanstack/README.md b/apps/ottabase-template-app-tanstack/README.md index 2419396f5..a141cedbd 100644 --- a/apps/ottabase-template-app-tanstack/README.md +++ b/apps/ottabase-template-app-tanstack/README.md @@ -469,3 +469,20 @@ In production apps, you can safely delete: - [Migrations Guide](./ottabase/migrations/README.md) - Database migrations - [Cloudflare Deploy](../../CLOUDFLARE_DEPLOY.md) - Deployment guide - [Cloudflare Config](../../CLOUDFLARE_CONFIGURATION_GUIDE.md) - Bindings setup + +## Marketing Pages Builder (new) + +Ottabase now includes a dynamic marketing pages system with OttaORM CRUD + drag-and-drop admin builder. + +### Endpoints + +- `GET /api/blocks` — discover available blocks (including app-registered custom blocks) +- `GET /api/pages/nav` — published pages list for static param generation/navigation +- `GET /api/pages/:slug?preview=true` — fetch full page payload (sections + features + actions) +- `GET/POST/PUT/DELETE /api/ottaorm/pages` and related entities via generic OttaORM CRUD + +### Admin UI + +- `/admin/pages` — list/create/duplicate/delete marketing pages +- `/admin/pages/$pageId` — block builder with drag-and-drop reordering and inline editor +- Public preview route in TanStack app: `/pages/$slug` diff --git a/apps/ottabase-template-app-tanstack/ottabase/db/schema.ts b/apps/ottabase-template-app-tanstack/ottabase/db/schema.ts index 6917d87bf..b260fa1f4 100644 --- a/apps/ottabase-template-app-tanstack/ottabase/db/schema.ts +++ b/apps/ottabase-template-app-tanstack/ottabase/db/schema.ts @@ -45,6 +45,7 @@ export { accountsTable, authenticatorsTable, mediaTable, sessionsTable, usersTab // APP-SPECIFIC TABLES // ============================================================ export { changelogEntriesTable } from '../models/ChangelogEntry'; +export { pageActionsTable, pageFeaturesTable, pagesTable, pageSectionsTable } from '../models/MarketingPage'; export { todosTable } from '../models/Todo'; // ============================================================ diff --git a/apps/ottabase-template-app-tanstack/ottabase/models/MarketingPage.schema.ts b/apps/ottabase-template-app-tanstack/ottabase/models/MarketingPage.schema.ts new file mode 100644 index 000000000..386b4c65f --- /dev/null +++ b/apps/ottabase-template-app-tanstack/ottabase/models/MarketingPage.schema.ts @@ -0,0 +1,79 @@ +import { integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'; + +export const pagesTable = sqliteTable( + 'pages', + { + id: text('id') + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + appId: text('app_id').notNull(), + slug: text('slug').notNull(), + title: text('title').notNull(), + status: text('status').notNull().default('draft'), + createdAt: integer('created_at') + .$defaultFn(() => Date.now()) + .notNull(), + updatedAt: integer('updated_at') + .$defaultFn(() => Date.now()) + .$onUpdateFn(() => Date.now()) + .notNull(), + }, + (table) => [uniqueIndex('pages_app_slug_idx').on(table.appId, table.slug)], +); + +export const pageSectionsTable = sqliteTable('page_sections', { + id: text('id') + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + pageId: text('page_id').notNull(), + slot: text('slot').notNull(), + variant: text('variant').notNull(), + title: text('title'), + subtitle: text('subtitle'), + body: text('body'), + enabled: integer('enabled', { mode: 'boolean' }).default(true).notNull(), + sortOrder: integer('sort_order').notNull().default(0), + createdAt: integer('created_at') + .$defaultFn(() => Date.now()) + .notNull(), + updatedAt: integer('updated_at') + .$defaultFn(() => Date.now()) + .$onUpdateFn(() => Date.now()) + .notNull(), +}); + +export const pageFeaturesTable = sqliteTable('page_features', { + id: text('id') + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + sectionId: text('section_id').notNull(), + title: text('title').notNull(), + description: text('description'), + icon: text('icon'), + link: text('link'), + sortOrder: integer('sort_order').notNull().default(0), + createdAt: integer('created_at') + .$defaultFn(() => Date.now()) + .notNull(), +}); + +export const pageActionsTable = sqliteTable('page_actions', { + id: text('id') + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + sectionId: text('section_id').notNull(), + label: text('label').notNull(), + href: text('href').notNull(), + variant: text('variant').notNull().default('primary'), + icon: text('icon'), + external: integer('external', { mode: 'boolean' }).default(false).notNull(), + sortOrder: integer('sort_order').notNull().default(0), + createdAt: integer('created_at') + .$defaultFn(() => Date.now()) + .notNull(), +}); + +export type MarketingPageRow = typeof pagesTable.$inferSelect; +export type MarketingPageSectionRow = typeof pageSectionsTable.$inferSelect; +export type MarketingPageFeatureRow = typeof pageFeaturesTable.$inferSelect; +export type MarketingPageActionRow = typeof pageActionsTable.$inferSelect; diff --git a/apps/ottabase-template-app-tanstack/ottabase/models/MarketingPage.ts b/apps/ottabase-template-app-tanstack/ottabase/models/MarketingPage.ts new file mode 100644 index 000000000..ff0c1d2d4 --- /dev/null +++ b/apps/ottabase-template-app-tanstack/ottabase/models/MarketingPage.ts @@ -0,0 +1,87 @@ +import { BaseModel, type ModelFields, type PackageType } from '@ottabase/ottaorm'; +import { pageActionsTable, pageFeaturesTable, pagesTable, pageSectionsTable } from './MarketingPage.schema'; + +export { + pageActionsTable, + pageFeaturesTable, + pagesTable, + pageSectionsTable, + type MarketingPageActionRow, + type MarketingPageFeatureRow, + type MarketingPageRow, + type MarketingPageSectionRow, +} from './MarketingPage.schema'; + +export class MarketingPage extends BaseModel { + static entity = 'pages'; + static table = pagesTable; + static primaryKey = 'id'; + static packageName = 'app'; + static packageType: PackageType = 'app'; + + protected static fields: ModelFields = { + id: { type: 'id', primaryKey: true, editable: false }, + appId: { type: 'string', editable: true, filterable: true }, + slug: { type: 'string', editable: true, searchable: true, sortable: true }, + title: { type: 'string', editable: true, searchable: true, sortable: true }, + status: { type: 'string', editable: true, sortable: true, filterable: true }, + createdAt: { type: 'date', editable: false, sortable: true }, + updatedAt: { type: 'date', editable: false, sortable: true }, + }; + + protected static validationRules = { + title: { rules: 'required|max:100', fieldName: 'Title' }, + slug: { + rules: 'required', + fieldName: 'Slug', + custom: (value: unknown) => /^[a-z0-9-]+$/.test(String(value ?? '')), + customMessage: 'Slug can only contain lowercase letters, numbers, and hyphens', + }, + }; +} + +export class PageSection extends BaseModel { + static entity = 'page_sections'; + static table = pageSectionsTable; + static primaryKey = 'id'; + static packageName = 'app'; + static packageType: PackageType = 'app'; + + static casts = { + enabled: 'boolean' as const, + }; + + protected static fields: ModelFields = { + id: { type: 'id', primaryKey: true, editable: false }, + pageId: { type: 'string', editable: true, filterable: true }, + slot: { type: 'string', editable: true, searchable: true }, + variant: { type: 'string', editable: true }, + title: { type: 'string', editable: true, searchable: true }, + subtitle: { type: 'string', editable: true }, + body: { type: 'string', editable: true }, + enabled: { type: 'boolean', editable: true, filterable: true }, + sortOrder: { type: 'number', editable: true, sortable: true }, + createdAt: { type: 'date', editable: false, sortable: true }, + updatedAt: { type: 'date', editable: false, sortable: true }, + }; +} + +export class PageFeature extends BaseModel { + static entity = 'page_features'; + static table = pageFeaturesTable; + static primaryKey = 'id'; + static packageName = 'app'; + static packageType: PackageType = 'app'; +} + +export class PageAction extends BaseModel { + static entity = 'page_actions'; + static table = pageActionsTable; + static primaryKey = 'id'; + static packageName = 'app'; + static packageType: PackageType = 'app'; + + static casts = { + external: 'boolean' as const, + }; +} diff --git a/apps/ottabase-template-app-tanstack/src/hooks/marketingPageHooks.ts b/apps/ottabase-template-app-tanstack/src/hooks/marketingPageHooks.ts new file mode 100644 index 000000000..da64566b6 --- /dev/null +++ b/apps/ottabase-template-app-tanstack/src/hooks/marketingPageHooks.ts @@ -0,0 +1,22 @@ +import { api } from '@/lib/api'; +import type { + BlockDefinition, + MarketingAction, + MarketingFeature, + MarketingPage, + MarketingSection, +} from '@/types/marketing-pages'; +import { createModelHooks } from '@ottabase/ottaorm/client'; +import { useQuery } from '@tanstack/react-query'; + +export const pageHooks = createModelHooks({ entityName: 'pages' }); +export const sectionHooks = createModelHooks({ entityName: 'page_sections' }); +export const featureHooks = createModelHooks({ entityName: 'page_features' }); +export const actionHooks = createModelHooks({ entityName: 'page_actions' }); + +export function useBlocksRegistry() { + return useQuery({ + queryKey: ['blocks-registry'], + queryFn: () => api<{ blocks: BlockDefinition[] }>('/api/blocks'), + }); +} diff --git a/apps/ottabase-template-app-tanstack/src/pages/admin/AdminIndexPage.tsx b/apps/ottabase-template-app-tanstack/src/pages/admin/AdminIndexPage.tsx index 704fe3d40..bfb1981d4 100644 --- a/apps/ottabase-template-app-tanstack/src/pages/admin/AdminIndexPage.tsx +++ b/apps/ottabase-template-app-tanstack/src/pages/admin/AdminIndexPage.tsx @@ -12,6 +12,7 @@ import { FileText, Layers, Layout, + LayoutTemplate, Mail, Palette, Power, @@ -87,6 +88,12 @@ const ADMIN_CATEGORIES: AdminCategory[] = [ href: '/admin/changelog', icon: FileText, }, + { + title: 'Marketing Pages', + description: 'Drag-and-drop builder for homepage and landing pages with reusable blocks.', + href: '/admin/pages', + icon: LayoutTemplate, + }, ...(MEDIA_LIBRARY_ENABLED ? [ { diff --git a/apps/ottabase-template-app-tanstack/src/pages/admin/pages/AdminPageBuilderPage.tsx b/apps/ottabase-template-app-tanstack/src/pages/admin/pages/AdminPageBuilderPage.tsx new file mode 100644 index 000000000..8baf8f624 --- /dev/null +++ b/apps/ottabase-template-app-tanstack/src/pages/admin/pages/AdminPageBuilderPage.tsx @@ -0,0 +1,228 @@ +import { actionHooks, pageHooks, sectionHooks, useBlocksRegistry } from '@/hooks/marketingPageHooks'; +import { Button, Card, CardContent, CardHeader, CardTitle, Input, Label, Textarea } from '@ottabase/ui-shadcn'; +import { Link, useParams } from '@tanstack/react-router'; +import { GripVertical, Save } from 'lucide-react'; +import { useMemo, useState } from 'react'; +import { toast } from 'sonner'; + +export function AdminPageBuilderPage() { + const { pageId } = useParams({ from: '/admin/pages/$pageId' }); + const [selectedId, setSelectedId] = useState(null); + + const pageQuery = pageHooks.useDetail(pageId); + const sectionList = sectionHooks.useList({ filters: { pageId } as any }); + const registry = useBlocksRegistry(); + + const createSection = sectionHooks.useCreate(); + const updateSection = sectionHooks.useUpdate(); + const deleteSection = sectionHooks.useDelete(); + const createAction = actionHooks.useCreate(); + + const sections = useMemo(() => { + const rows = (sectionList.data?.data ?? []) as any[]; + return [...rows].sort((a, b) => Number(a.sortOrder) - Number(b.sortOrder)); + }, [sectionList.data?.data]); + + const selected = sections.find((section) => section.id === selectedId) ?? null; + + const reorder = async (dragId: string, dropId: string) => { + if (dragId === dropId) return; + const ordered = [...sections]; + const from = ordered.findIndex((row) => row.id === dragId); + const to = ordered.findIndex((row) => row.id === dropId); + if (from < 0 || to < 0) return; + + const [moved] = ordered.splice(from, 1); + ordered.splice(to, 0, moved); + + await Promise.all( + ordered.map((section, index) => + updateSection.mutateAsync({ + id: section.id, + sortOrder: index, + }), + ), + ); + toast.success('Blocks reordered'); + await sectionList.refetch(); + }; + + return ( +
+
+
+ + ← Back to pages + +

{(pageQuery.data as any)?.data?.title || 'Page Builder'}

+

+ Drag and drop blocks, then edit content in the right panel. +

+
+ +
+ +
+ + + Block Palette + + + {(registry.data?.blocks ?? []).map((block) => ( + + ))} + + + + + + Canvas + + + {sections.map((section) => ( +
event.dataTransfer.setData('text/plain', section.id)} + onDragOver={(event) => event.preventDefault()} + onDrop={async (event) => { + event.preventDefault(); + const dragId = event.dataTransfer.getData('text/plain'); + await reorder(dragId, section.id); + }} + onClick={() => setSelectedId(section.id)} + className={`cursor-pointer rounded-md border p-3 ${selectedId === section.id ? 'border-primary' : ''}`} + > +
+
+

{section.slot}

+

{section.variant}

+
+ +
+
+ ))} +
+
+ + + + Block Editor + + + {!selected ? ( +

Select a block from the canvas to edit.

+ ) : ( + <> +
+ + { + const value = event.target.value; + setSelectedId(selected.id); + selected.title = value; + }} + /> +
+
+ + (selected.subtitle = event.target.value)} + /> +
+
+ +