# Advanced CMS Editor - Design Document

**Date:** November 25, 2025
**Feature:** Multi-page CMS with structured content sections for Professional-tier dealers
**Status:** Design Approved for Implementation

---

## Overview & Goals

### What We're Building

An Advanced CMS Editor for Professional-tier AMSOIL dealers to create and manage multi-page websites with structured, reusable content sections.

### Key Requirements

- **Professional tier only** (not Starter/Growth/Enhanced)
- **Multi-page support** (Home, About, Blog posts, custom pages)
- **Structured section-based content** (not freeform HTML)
- **Blog system** with auto-generated index at `/blog`
- **Dynamic navigation** (dealers control which pages appear in nav, support nested menus)
- **Draft/Publish workflow** (safe editing without affecting live site)
- **Publishes to flat HTML files** (same infrastructure as current system)
- **Migration support** for 3500+ existing dealer sites (scraped HTML import)

### MVP Section Types

1. **Rich Text/HTML Block** - Tiptap editor with standard formatting (headings H2-H4, lists, bold, italic, links, strikethrough)
2. **Accordion Section** - Collapsible items with title + rich text content
3. **Tabbed Content Section** - Horizontal tabs with sliding animation, same structure as Accordion

### Out of Scope (Post-MVP)

- 2-column sections (requirements TBD)
- Image uploads (dealers select from AMSOIL library later)
- Drag-and-drop section reordering (using up/down arrows for MVP)
- Inline WYSIWYG editing (using form-based editor for MVP)
- Version history/rollback (can add later via separate versions table)
- Page templates (pre-configured section layouts)
- Subheader nav editor (remains static AMSOIL corporate links)

---

## Database Schema

### New Page Model

```prisma
model Page {
  id                  String    @id @default(cuid())
  dealerId            String

  // Core fields
  slug                String    // 'about', 'my-first-post' (no leading slash)
  title               String    // Page title (also used in nav if linked)
  type                String    // 'page' | 'blog'

  // Content (dual-mode for migration support)
  sections            Json?     // Structured sections for new pages
  rawHtml             String?   // Legacy HTML for migrated pages (mutually exclusive with sections)

  // SEO
  metaTitle           String?   // Override title for <title> tag
  metaDescription     String?   // Meta description

  // Blog-specific
  featuredImage       String?   // URL to AMSOIL library image
  excerpt             String?   // Manual excerpt (fallback: first ~300 chars)
  author              String?   // Dealer name or custom

  // Publishing
  status              String    @default("draft")  // 'draft' | 'published'
  publishedAt         DateTime?
  publishScheduledFor DateTime?  // Future: scheduled publishing

  // Timestamps
  createdAt           DateTime  @default(now())
  updatedAt           DateTime  @updatedAt

  // Relations
  dealer              Dealer    @relation(fields: [dealerId], references: [id], onDelete: Cascade)
  navigationItems     NavigationItem[]

  @@unique([dealerId, slug])  // Slugs must be unique per dealer
  @@index([dealerId, type, status])  // Fast queries for dealer's pages/blog
}
```

### New NavigationItem Model

Handles nested navigation with parent/child relationships. Parents can be labels (no link), link to pages, or link to external URLs.

```prisma
model NavigationItem {
  id            String    @id @default(cuid())
  dealerId      String

  // Display
  label         String    // "Products", "About", etc.

  // Link target (mutually exclusive - at most one should be set)
  pageId        String?   // Link to dealer's page
  externalUrl   String?   // Link to external URL (AMSOIL corporate, etc.)
  // If both null, it's a label-only parent (no link)

  // Hierarchy
  parentId      String?   // null = top-level, set = child item
  sortOrder     Int       @default(0)  // Order within level

  // Metadata
  openInNewTab  Boolean   @default(false)  // target="_blank"

  // Relations
  dealer        Dealer    @relation(fields: [dealerId], references: [id], onDelete: Cascade)
  page          Page?     @relation(fields: [pageId], references: [id], onDelete: SetNull)
  parent        NavigationItem? @relation("NavHierarchy", fields: [parentId], references: [id], onDelete: Cascade)
  children      NavigationItem[] @relation("NavHierarchy")

  @@index([dealerId, parentId, sortOrder])  // Fast hierarchy queries
}
```

**Navigation Rules:**

- Max 2 levels deep (parent → child, no grandchildren)
- Top-level items: `parentId = null`, sorted by `sortOrder`
- Child items: `parentId` set, sorted by `sortOrder` within parent
- Editor validates max depth = 2
- Deleting a page sets `pageId = null` (nav item becomes label-only, doesn't break nav)
- Deleting a parent item cascades to children

**Navigation by Tier:**

- **Starter/Growth:** Default nav items seeded on registration, NO editor access (completely fixed)
- **Enhanced/Professional:** Full nav editor access (add/edit/delete/reorder items)

**Default Navigation Items (seeded on registration):**

- Shop Online → External AMSOIL store URL
- Business Opportunities → External AMSOIL corporate URL
- Offers → External AMSOIL preferred customer URL
- Contact Us → `/contact` page (if exists) or anchor link
- About → `/about` page (if exists)

### Update Dealer Model

Add relations:

```prisma
model Dealer {
  // ... existing fields ...
  pages           Page[]
  navigationItems NavigationItem[]
}
```

### Key Design Decisions

- `slug` is relative (no leading `/`) - `/blog/` prefix added at publish time for blog posts
- `sections` OR `rawHtml` (mutually exclusive) - migration flexibility
- `@@unique([dealerId, slug])` prevents duplicate URLs per dealer
- Blog posts: `type='blog'` by default
- `showInNav` removed from Page model - navigation now managed via separate NavigationItem model

---

## Section Types & Data Structures

### Sections JSON Structure

The `Page.sections` field stores an array of section objects:

```typescript
type PageSections = Section[];

type Section = {
  id: string; // Unique ID (cuid) for React keys and reordering
  type: 'richText' | 'accordion' | 'tabs';
  sortOrder: number; // Order on page (0, 1, 2, ...)
  data: RichTextData | AccordionData | TabsData;
};
```

### 1. Rich Text Block

```typescript
type RichTextData = {
  content: string;  // Tiptap HTML output (sanitized)
};

// Example:
{
  id: "clx123",
  type: "richText",
  sortOrder: 0,
  data: {
    content: "<h2>About Us</h2><p>We are a <strong>full-service</strong> AMSOIL dealer...</p>"
  }
}
```

**Allowed Formatting:**

- Bold, italic, underline, strikethrough
- Headings (H2, H3, H4)
- Paragraphs
- Bulleted and numbered lists
- Links
- Line breaks

### 2. Accordion Section

```typescript
type AccordionData = {
  items: AccordionItem[];
};

type AccordionItem = {
  id: string;       // Unique ID for each accordion item
  title: string;    // Plain text title (shown in accordion header)
  content: string;  // Tiptap HTML for accordion body
};

// Example:
{
  id: "clx456",
  type: "accordion",
  sortOrder: 1,
  data: {
    items: [
      {
        id: "clx789",
        title: "What products do you carry?",
        content: "<p>We carry the full line of <strong>AMSOIL products</strong>...</p>"
      },
      {
        id: "clx012",
        title: "Do you offer delivery?",
        content: "<p>Yes! We offer free delivery on orders over $100.</p>"
      }
    ]
  }
}
```

### 3. Tabbed Content Section

```typescript
type TabsData = {
  items: TabItem[];
};

type TabItem = {
  id: string; // Unique ID for each tab
  title: string; // Tab label (plain text)
  content: string; // Tiptap HTML for tab content
};

// Structure identical to Accordion, different display mode
// Example: "Protects Against Wear" and "Applications" tabs
```

**Key Points:**

- Each section has unique `id` for React keys and reordering
- `sortOrder` determines page layout order
- Accordion and Tabs share identical data structure (just different rendering)
- All rich content goes through Tiptap → sanitized HTML

---

## Editor UI & User Experience

### Route Structure

- `/dashboard/editor` - Current Basic Editor (dealer profile info)
- `/dashboard/cms` - **NEW:** Advanced CMS landing page (Professional tier only)
- `/dashboard/cms/pages` - Page list view
- `/dashboard/cms/pages/new` - Create new page
- `/dashboard/cms/pages/[pageId]/edit` - Edit existing page
- `/dashboard/cms/navigation` - Navigation editor (Enhanced/Professional only)

**Tier Gating:**

```typescript
// All /dashboard/cms/* routes check tier
if (dealer.subscriptionTier !== 'professional') {
  redirect('/dashboard'); // Or show upgrade prompt
}

// Navigation editor specifically
if (!['enhanced', 'professional'].includes(dealer.subscriptionTier)) {
  redirect('/dashboard/cms/pages');
}
```

### Page List View (`/dashboard/cms/pages`)

**Layout:**

- Table showing all dealer's pages
- Columns: Title, Type (Page/Blog), Status (Draft/Published), Last Updated, Actions
- Filter by type (All / Pages / Blog)
- Filter by status (All / Draft / Published)
- "New Page" button → page creation flow
- Actions per row: Edit, Preview, Duplicate, Delete

### Page Editor (`/dashboard/cms/pages/[pageId]/edit`)

**Form-based editor with three main sections:**

#### 1. Page Settings (top form):

- **Title** (text input) - Required
- **Slug** (text input) - Auto-generated from title, editable, validated for uniqueness
- **Type** (dropdown: Page / Blog Post)
- **Meta Title** (optional text input) - SEO override
- **Meta Description** (optional textarea) - SEO description
- **Featured Image** (dropdown/picker from AMSOIL library - future, disabled for MVP)
- **Excerpt** (textarea, optional for blog posts) - Manual excerpt or auto-extract first ~300 chars

#### 2. Content Sections (middle area):

- **List of sections**, each displayed as a card
- **Section card shows:** Type icon, preview snippet, up/down arrows, delete button
- **"Add Section" button** with dropdown (Rich Text / Accordion / Tabs)
- **Click section card** → expands inline form to edit that section
- **For Accordion/Tabs:** nested list of items with add/remove/reorder (up/down arrows)

**Section Reordering:**

- Simple up/down arrow buttons on each section card
- Updates `sortOrder` field
- No drag-and-drop for MVP (can add later)

#### 3. Actions (sticky footer):

- **"Save Draft"** button (saves without publishing)
- **"Publish"** button (saves + publishes to live site)
- **"Preview"** button (opens preview in new tab)
- **"Cancel"** button (back to page list, confirm if unsaved changes)

### Navigation Editor (`/dashboard/cms/navigation`)

**Layout:**

- Hierarchical list showing parent/child structure
- Each item shows: Label, Link Target (Page/External/None), Actions
- **Add Top-Level Item** button
- **Add Child Item** button (only for parents, max 2 levels)
- **Up/Down arrows** to reorder within level
- **Edit/Delete** buttons per item

**Edit Item Form:**

- **Label** (text input) - Required
- **Link Type** (radio: None / Page / External URL)
  - If Page: Dropdown of dealer's published pages
  - If External: Text input for URL
- **Open in New Tab** (checkbox)
- **Parent** (dropdown: None / select parent item) - Validates max 2 levels

---

## Publishing Flow & Static HTML Generation

### Draft → Published Workflow

1. **Dealer edits page** → Clicks "Save Draft"
   - `status = 'draft'`
   - `publishedAt = null`
   - Changes saved to database only
   - Not visible on live site

2. **Dealer clicks "Publish"**
   - `status = 'published'`
   - `publishedAt = now()`
   - Triggers HTML generation
   - Deploys to live site (Cloudflare)

### Publishing Process

```typescript
async function publishPage(pageId: string) {
  // 1. Fetch page data
  const page = await prisma.page.findUnique({
    where: { id: pageId },
    include: { dealer: true },
  });

  // 2. Determine output path
  const filename = page.type === 'blog' ? `blog/${page.slug}.html` : `${page.slug}.html`;

  // 3. Render HTML
  const html = page.rawHtml
    ? renderLegacyPage(page) // Use rawHtml for migrated pages
    : renderStructuredPage(page); // Render sections for new pages

  // 4. Write to CDN/storage
  await writeToCloudflare(dealer.subdomain, filename, html);

  // 5. Update page status
  await prisma.page.update({
    where: { id: pageId },
    data: {
      status: 'published',
      publishedAt: new Date(),
      dealer: {
        update: {
          lastPublishedAt: new Date(),
        },
      },
    },
  });

  // 6. Regenerate blog index if this was a blog post
  if (page.type === 'blog') {
    await generateBlogIndex(dealer.id);
  }
}
```

### Rendering Structured Pages

```typescript
function renderStructuredPage(page: Page): string {
  const sections = page.sections as Section[];

  const sectionsHtml = sections
    .sort((a, b) => a.sortOrder - b.sortOrder)
    .map((section) => {
      switch (section.type) {
        case 'richText':
          return renderRichText(section.data);
        case 'accordion':
          return renderAccordion(section.data);
        case 'tabs':
          return renderTabs(section.data);
      }
    })
    .join('\n');

  return `
    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <title>${page.metaTitle || page.title}</title>
        <meta name="description" content="${page.metaDescription || ''}" />
        <link rel="stylesheet" href="/assets/global.css" />
      </head>
      <body>
        ${renderHeader(page.dealer)}
        <main>${sectionsHtml}</main>
        ${renderFooter(page.dealer)}
        <script src="/assets/global.js"></script>
      </body>
    </html>
  `;
}
```

### Rendering Legacy Pages (Migration)

```typescript
function renderLegacyPage(page: Page, dealer: Dealer): string {
  return `
    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <title>${page.metaTitle || page.title}</title>
        <meta name="description" content="${page.metaDescription || ''}" />
        <link rel="stylesheet" href="/assets/global.css" />
      </head>
      <body>
        ${renderHeader(dealer)}
        <main>${page.rawHtml}</main>
        ${renderFooter(dealer)}
        <script src="/assets/global.js"></script>
      </body>
    </html>
  `;
}
```

**Key Publishing Features:**

- Blog posts auto-prefixed with `/blog/` in URL
- Header/footer injected from dealer data (DealerTemplate.tsx)
- Global CSS/JS linked (handles accordion/tab interactions)
- SEO metadata from page fields
- Dual-mode rendering (structured sections OR legacy rawHtml)

---

## Navigation System & Blog Index

### Dynamic Navigation Generation

Navigation items are rendered from the `NavigationItem` model:

```typescript
async function getNavigationLinks(dealerId: string) {
  const navItems = await prisma.navigationItem.findMany({
    where: {
      dealerId,
      parentId: null, // Top-level only
    },
    include: {
      children: {
        orderBy: { sortOrder: 'asc' },
      },
      page: {
        select: { slug: true, type: true },
      },
    },
    orderBy: { sortOrder: 'asc' },
  });

  return navItems.map((item) => ({
    label: item.label,
    href: getNavHref(item),
    openInNewTab: item.openInNewTab,
    children: item.children.map((child) => ({
      label: child.label,
      href: getNavHref(child),
      openInNewTab: child.openInNewTab,
    })),
  }));
}

function getNavHref(item: NavigationItem): string | null {
  if (item.externalUrl) return item.externalUrl;
  if (item.page) {
    return item.page.type === 'blog' ? `/blog/${item.page.slug}` : `/${item.page.slug}`;
  }
  return null; // Label-only parent
}
```

### Auto-Generated Blog Index (`/blog`)

When ANY blog post exists with `status='published'`, the system auto-generates `/blog/index.html`:

```typescript
async function generateBlogIndex(dealerId: string) {
  const dealer = await prisma.dealer.findUnique({
    where: { id: dealerId },
  });

  const posts = await prisma.page.findMany({
    where: {
      dealerId,
      type: 'blog',
      status: 'published',
    },
    orderBy: { publishedAt: 'desc' },
  });

  const html = `
    <h1>Blog</h1>
    <div class="blog-posts">
      ${posts
        .map(
          (post) => `
        <article class="blog-post-preview">
          ${post.featuredImage ? `<img src="${post.featuredImage}" alt="${post.title}" />` : ''}
          <h2><a href="/blog/${post.slug}">${post.title}</a></h2>
          <time datetime="${post.publishedAt?.toISOString()}">${formatDate(post.publishedAt)}</time>
          ${post.author ? `<p class="author">By ${post.author}</p>` : ''}
          <p class="excerpt">${post.excerpt || extractExcerpt(post, 300)}</p>
          <a href="/blog/${post.slug}" class="read-more">Read more →</a>
        </article>
      `
        )
        .join('\n')}
    </div>
  `;

  const fullHtml = renderPage(html, dealer, {
    title: 'Blog',
    metaDescription: `${dealer.businessName} blog - Tips, news, and updates about AMSOIL products.`,
  });

  await writeToCloudflare(dealer.subdomain, 'blog/index.html', fullHtml);
}

function extractExcerpt(page: Page, maxChars: number = 300): string {
  if (page.excerpt) return page.excerpt;

  if (page.rawHtml) {
    // Strip HTML tags, get first maxChars, break at word boundary
    const text = page.rawHtml.replace(/<[^>]+>/g, ' ').trim();
    if (text.length <= maxChars) return text;
    return text.substring(0, maxChars).replace(/\s+\S*$/, '') + '...';
  }

  if (page.sections) {
    const sections = page.sections as Section[];
    const richTextSections = sections.filter((s) => s.type === 'richText');
    if (richTextSections.length > 0) {
      const firstSection = richTextSections[0].data as RichTextData;
      const text = firstSection.content.replace(/<[^>]+>/g, ' ').trim();
      if (text.length <= maxChars) return text;
      return text.substring(0, maxChars).replace(/\s+\S*$/, '') + '...';
    }
  }

  return '';
}
```

**Blog Index Features:**

- Auto-updates when blog posts are published/unpublished
- Shows featured image if set
- Excerpt: uses `post.excerpt` if set, otherwise first ~300 chars of content (split at word boundary)
- Sorted by `publishedAt` descending (newest first)
- Published alongside individual blog post pages

---

## Migration Support for 3500+ Existing Sites

### Dual-Mode Page Rendering

The Page model supports both structured sections AND legacy raw HTML via mutually exclusive fields:

```prisma
model Page {
  sections  Json?    // NEW pages: structured sections
  rawHtml   String?  // MIGRATED pages: scraped HTML
}
```

**Publishing Logic:**

- If `rawHtml` exists: Render our header + rawHtml + our footer
- If `sections` exists: Render our header + structured sections + our footer
- Never both (mutually exclusive)

### Migration Process

1. **CSV Export** from existing CMS (Breezi):
   - Columns: `dealerId`, `slug`, `title`, `htmlContent`, `metaDescription`, `publishedAt`, etc.

2. **Import Script** (`scripts/migrate-legacy-pages.ts`):

   ```typescript
   import { prisma } from '@/lib/prisma';
   import { parseCSV } from '@/lib/csv-parser';

   async function importLegacyPages(csvPath: string) {
     const rows = parseCSV(csvPath);

     for (const row of rows) {
       await prisma.page.create({
         data: {
           dealerId: row.dealerId,
           slug: row.slug,
           title: row.title,
           type: row.slug.startsWith('blog/') ? 'blog' : 'page',
           rawHtml: row.htmlContent, // <-- Legacy HTML
           sections: null, // <-- No structured content
           status: 'published',
           metaDescription: row.metaDescription,
           publishedAt: new Date(row.publishedAt),
         },
       });
     }

     console.log(`Imported ${rows.length} legacy pages`);
   }
   ```

3. **Boss's Scraping Process:**
   - Boss scrapes existing dealer sites
   - Extracts "inner part of page" (between header/footer)
   - Exports to CSV with columns: dealerId, slug, title, htmlContent, etc.
   - We run import script to populate database

**Migration Benefits:**

- Zero data loss - all existing content preserved
- Immediate publish - legacy pages work day one
- Gradual modernization - dealers can convert pages when ready
- No forced migration - legacy pages can stay as-is indefinitely

### Future: Converting Legacy to Structured

Post-MVP feature: **"Convert to Structured"** button in editor

- Shows when `page.rawHtml` exists
- Parses HTML, creates single Rich Text section with content
- Sets `rawHtml = null`, `sections = [{type: 'richText', data: {content: '...'}}]`
- Dealer can then add more sections, reorder, edit with Tiptap, etc.

**Conversion Logic:**

```typescript
async function convertLegacyToStructured(pageId: string) {
  const page = await prisma.page.findUnique({ where: { id: pageId } });

  if (!page.rawHtml) {
    throw new Error('Page is already structured');
  }

  await prisma.page.update({
    where: { id: pageId },
    data: {
      rawHtml: null,
      sections: [
        {
          id: cuid(),
          type: 'richText',
          sortOrder: 0,
          data: {
            content: page.rawHtml, // Raw HTML becomes first Rich Text section
          },
        },
      ],
    },
  });
}
```

---

## Technology Stack & Libraries

### Frontend (Editor UI)

- **Tiptap** (~50KB gzipped) - Rich text editor with React components
  - Extensions: Bold, Italic, Underline, Strike, Heading (H2-H4), BulletList, OrderedList, Link
  - Custom toolbar with standard formatting buttons
  - HTML sanitization built-in
  - Docs: https://tiptap.dev/

- **React Hook Form** - Form state management for editor
  - Already used in project (registration/onboarding)

- **Zod** - Schema validation for sections/pages
  - Already used in project

- **Tailwind CSS** - Styling (already in project)

### Backend

- **Prisma** - ORM for Page/NavigationItem models (already in project)
- **DOMPurify** (server-side) - HTML sanitization for rich text output
  - Installed via `npm install isomorphic-dompurify`
- **Next.js API routes** - CRUD endpoints for pages/nav (already in project)

### Publishing

- **Cloudflare Workers/Pages** - Static HTML hosting (current setup)
- Same deployment pipeline as existing system

### Section Rendering (Client Components)

- **Accordion:** CSS-only collapsible (`<details>/<summary>`) or Headless UI
  - Recommendation: CSS-only for zero dependencies

- **Tabs:** React state + CSS transitions for sliding effect
  - Lightweight custom implementation (avoid heavy component libraries)

### Bundle Impact

- Tiptap + extensions: ~50KB gzipped
- DOMPurify (client-side for preview): ~20KB gzipped
- Total editor bundle: ~150KB (acceptable for Professional tier only)
- Editor code-split (not loaded for other tiers)

---

## Implementation Phases

### Phase 1: Database & Core Models (Week 1)

- [ ] Create Prisma migration for Page model
- [ ] Create Prisma migration for NavigationItem model
- [ ] Update Dealer model with relations
- [ ] Generate Prisma client
- [ ] Create TypeScript types for Section data structures
- [ ] Seed default navigation items on dealer registration (update registration webhook)
- [ ] Write database tests

### Phase 2: Page CRUD API (Week 2)

- [ ] Create `/api/cms/pages` route (list, create)
- [ ] Create `/api/cms/pages/[id]` route (read, update, delete)
- [ ] Add tier gating middleware (Professional only)
- [ ] Implement slug validation (uniqueness per dealer)
- [ ] Implement section validation (Zod schemas)
- [ ] Write API tests

### Phase 3: Page Editor UI (Week 2-3)

- [ ] Create `/dashboard/cms` landing page with tier gate
- [ ] Create `/dashboard/cms/pages` list view
  - [ ] Table with filters (type, status)
  - [ ] Pagination
  - [ ] Search by title/slug
- [ ] Create `/dashboard/cms/pages/new` page creation
- [ ] Create `/dashboard/cms/pages/[id]/edit` editor
  - [ ] Page settings form (top section)
  - [ ] Section list with up/down arrows
  - [ ] "Add Section" dropdown
  - [ ] Integrate Tiptap for Rich Text sections
  - [ ] Build Accordion section editor (nested items)
  - [ ] Build Tabs section editor
  - [ ] Save Draft / Publish buttons
  - [ ] Preview button (opens in new tab)

### Phase 4: Navigation Editor (Week 3-4)

- [ ] Create `/api/cms/navigation` route (CRUD)
- [ ] Create `/dashboard/cms/navigation` UI (Enhanced/Professional only)
  - [ ] Hierarchical list view
  - [ ] Add/Edit item form
  - [ ] Link type selection (None/Page/External)
  - [ ] Parent selection dropdown
  - [ ] Max 2-level validation
  - [ ] Up/down arrows for reordering
- [ ] Write navigation tests

### Phase 5: Publishing & Rendering (Week 4-5)

- [ ] Build section renderers
  - [ ] Rich Text renderer (sanitized HTML output)
  - [ ] Accordion renderer (collapsible UI)
  - [ ] Tabs renderer (with sliding animation)
- [ ] Implement dual-mode rendering (sections vs rawHtml)
- [ ] Implement navigation rendering (nested menus)
- [ ] Generate static HTML files
- [ ] Deploy to Cloudflare (update publishing script)
- [ ] Update blog index auto-generation
- [ ] Write publishing tests

### Phase 6: Migration Tools (Week 5-6)

- [ ] Build CSV import script for legacy pages
- [ ] Test with sample dealer data (5-10 dealers)
- [ ] Document migration process
- [ ] Coordinate with boss on scraping/export process
- [ ] Run full migration for 3500 sites
- [ ] Verify published sites render correctly

### Phase 7: Testing & Polish (Week 6-7)

- [ ] End-to-end testing (Playwright)
- [ ] Accessibility audit (keyboard nav, screen readers)
- [ ] Mobile responsiveness testing
- [ ] Performance testing (page load, editor performance)
- [ ] Security audit (XSS prevention, HTML sanitization)
- [ ] Documentation for dealers (help docs, video tutorials)

### Total Estimated Timeline: 6-8 weeks

---

## Future Enhancements (Post-MVP)

### Phase 2 Features

- **2-Column Section** (requirements TBD by boss)
- **Image selection** from AMSOIL library (dropdown picker)
- **Featured Image picker** for blog posts
- **Drag-and-drop section reordering** (upgrade from up/down arrows)
  - Library: @dnd-kit (modern, accessible, ~30KB)
- **Inline WYSIWYG editing** (upgrade from form-based)
  - Click to edit sections directly on preview
- **Subheader nav editor** (currently static AMSOIL links)

### Phase 3 Features

- **"Convert to Structured"** button for legacy pages
- **Page versioning/rollback** (separate `PageVersion` table)
- **Scheduled publishing** (use `publishScheduledFor` field + cron job)
- **Page templates** (pre-configured section layouts)
  - "About Page", "Services Page", "FAQ Page", etc.
- **Section duplication** (clone existing section)
- **Blog categories/tags** (new `Category` model, many-to-many with Page)
- **"Recent Posts" widget** for other pages (new section type)

### Long-term Features

- **Custom CSS per page** (advanced users, Professional tier only)
- **A/B testing** for page variants (track conversions)
- **Analytics integration** (page views, bounce rate, conversion tracking)
- **Multi-language support** (separate pages per language)
- **Custom domain per page** (different pages on different domains)
- **Revision history UI** (view/compare/restore previous versions)

---

## Success Criteria

✅ Professional-tier dealers can create/edit multi-page websites
✅ Structured sections (Rich Text, Accordion, Tabs) render correctly
✅ Blog system with auto-generated index works
✅ Nested navigation (2 levels) renders correctly
✅ Draft/Publish workflow prevents accidental live changes
✅ Publishing generates static HTML (same infrastructure)
✅ 3500 legacy sites migrated successfully (zero data loss)
✅ Page editor is intuitive (form-based, up/down arrows)
✅ Navigation editor is accessible (Enhanced/Professional only)
✅ Lower tiers (Starter/Growth) have fixed default nav
✅ HTML sanitization prevents XSS attacks
✅ All pages are mobile-responsive
✅ Tiptap editor works smoothly (no lag, clean formatting)

---

## Security Considerations

### HTML Sanitization

All rich text content must be sanitized to prevent XSS attacks:

```typescript
import DOMPurify from 'isomorphic-dompurify';

function sanitizeRichText(html: string): string {
  return DOMPurify.sanitize(html, {
    ALLOWED_TAGS: ['h2', 'h3', 'h4', 'p', 'br', 'strong', 'em', 'u', 's', 'ul', 'ol', 'li', 'a'],
    ALLOWED_ATTR: ['href', 'target', 'rel'],
    ALLOW_DATA_ATTR: false,
  });
}
```

**Applied at:**

1. **Server-side** (before saving to database)
2. **Publish time** (when rendering to HTML)
3. **Preview mode** (client-side sanitization)

### Tier Enforcement

All CMS routes must validate dealer tier:

```typescript
// middleware.ts
export async function middleware(request: NextRequest) {
  const session = await getServerSession();
  const dealer = await prisma.dealer.findUnique({
    where: { userId: session.user.id },
  });

  if (request.nextUrl.pathname.startsWith('/dashboard/cms')) {
    if (dealer.subscriptionTier !== 'professional') {
      return NextResponse.redirect(new URL('/dashboard', request.url));
    }
  }

  if (request.nextUrl.pathname.startsWith('/dashboard/cms/navigation')) {
    if (!['enhanced', 'professional'].includes(dealer.subscriptionTier)) {
      return NextResponse.redirect(new URL('/dashboard/cms/pages', request.url));
    }
  }
}
```

### Slug Validation

Prevent malicious slugs:

```typescript
const SLUG_REGEX = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;

function validateSlug(slug: string): boolean {
  if (slug.length < 1 || slug.length > 100) return false;
  if (!SLUG_REGEX.test(slug)) return false;
  if (['admin', 'api', 'dashboard', '_next'].includes(slug)) return false;
  return true;
}
```

---

## References

- **DealerTemplate.tsx** - Current 9-section wireframe for default landing page
- **index.html** - Professional tier feature showcase (pricing page)
- **Basic Dealer Editor** - `/dashboard/editor` (profile info editing)
- **Publishing Feature** - `docs/plans/2025-11-19-publishing-feature-design.md`
- **Tiptap Documentation** - https://tiptap.dev/
- **Example dealer site** - https://boostedunderground.shopamsoil.com/
  - Blog archive: https://boostedunderground.shopamsoil.com/blog
  - Tabbed content example: https://boostedunderground.shopamsoil.com/shock-therapy-suspension-fluid-medium

---

## Design Sign-off

This design document has been reviewed and approved for implementation.

**Next Steps:**

1. Create detailed implementation plan with task breakdown
2. Set up git worktree for feature development
3. Begin Phase 1: Database migrations
