# Volubit CMS & Admin Content Control System Product Requirements Document

> **Version**: 1.1
> **Date**: August 2026
> **Status**: Draft
> **Stakeholders**: Volubit Team

---

## Table of Contents

1. [Overview](#1-overview)
2. [Design Philosophy](#2-design-philosophy)
3. [Goals & Non-Goals](#3-goals--non-goals)
4. [Content Inventory (Structure)](#4-content-inventory)
5. [Content Schema](#5-content-schema)
5B. [Content Inventory (Existing Hardcoded Content)](#5b-content-inventory-existing-hardcoded-content)
5C. [Article & Event Tables](#5c-article--event-tables)
6. [API Design](#6-api-design)
7. [Project Structure](#7-project-structure)
8. [Database Schema](#8-database-schema)
9. [User Flows](#9-user-flows)
10. [Implementation Plan](#10-implementation-plan)
11. [Success Metrics](#11-success-metrics)
12. [Operational & Edge Cases](#12-operational--edge-cases)
13. [Open Decisions](#13-open-decisions)

---

## 1. Overview

### 1.1 Current State

Volubit is a premium crypto education community platform built with:

- **Frontend**: TanStack Start (React Router v7), TypeScript, shadcn/ui, Tailwind CSS v4, Framer Motion
- **Backend**: Static-first frontend with all content hardcoded in TypeScript files
- **Content**: Every piece of copy, image reference, stat, schedule item, and CTA is defined in source files at compile time

The current content architecture means:

- Updating a testimonial requires a code change + CI pipeline + deploy
- Adding an event requires a developer
- Changing a headline requires a PR review cycle
- Content teams cannot self-serve

### 1.2 Target State

Split the project into two decoupled repositories:

| Repository         | Role                                                | Technology                      |
| ------------------ | --------------------------------------------------- | ------------------------------- |
| `volubit-frontend` | Public-facing website, SSR rendering, UI components | TanStack Start + existing stack |
| `volubit-backend`  | Content API, admin panel, media storage, auth       | Nitro + Postgres + Drizzle ORM  |

The frontend fetches all content from the backend API. Content editors use the admin panel — no code, no deploys.

### 1.3 Why Split?

- **Deployment independence**: FE deploys on every code change; BE deploys only on logic changes
- **Team clarity**: FE team and content team work independently
- **Scalability**: FE served from CDN/edge; BE scales with actual traffic
- **Security**: Database is only accessible via BE API; FE only holds public API endpoints
- **CMS**: A proper admin panel enables non-technical content management

---

## 2. Design Philosophy

### Core Principle: Structure is Developer-Defined, Content is Editor-Managed

The key principle of this CMS:

> **Developers define the structure. Editors edit the content.**

This means:

- **What content exists** (sections, items, fields) is decided by developers in code
- **What the content says** (text, images, values) is managed by editors via the admin panel
- Editors **cannot** create new sections, delete sections, or change field structure
- Editors **can** add new items within existing sections (up to a defined cap per section)
- Editors **cannot** reorder items — order is fixed as defined by developer
- Changes save **immediately** — no draft state, no publish action

### Why This Approach?

- **Simplicity**: Eliminates draft/publish workflow, versioning, and rollback complexity
- **Safety**: Editors can't accidentally break page structure or remove content
- **Speed**: Content changes go live instantly with no approval step
- **Maintainability**: Backend API is simpler, fewer edge cases, less to debug

### What Developers Control

- Page structure (which sections exist on each page)
- Section field definitions (what data each block type holds)
- Item count caps (maximum items per section)
- Item ordering (fixed, set at seed time)
- Block types and schema

### What Editors Control

- All text content (headlines, descriptions, CTAs, names, etc.)
- All image references (upload new images, assign to content blocks)
- All numeric/string values (prices, dates, counts, etc.)
- Adding new items within the section cap
- Removing existing items (down to a minimum defined by developer)

---

## 3. Goals & Non-Goals

### 3.1 Goals

1. **Decouple content from code** — All marketing copy, section items, images, and data come from the BE API, not source files
2. **Enable non-technical content management** — Any team member can update content via the admin panel
3. **Immediate saves** — No draft/publish step; changes go live instantly
4. **Server-side rendering performance** — FE pages render with content server-side for fast load and SEO
5. **Media management** — Upload, organize, and reference images without external tools
6. **Audit trail** — Track who changed what and when

### 3.2 Non-Goals

1. **User-generated content** — No public-facing forum, comments, or member-posted content
2. **Multi-tenant CMS** — This is for Volubit only, not a multi-site platform
3. **Draft → Publish workflow** — Changes are immediate, no staging state (articles are the one exception — see Section 5C)
4. **Version history & rollback** — Not in scope for v1
5. **Publish scheduler** — Not in scope for v1
6. **A/B testing** — Not in scope for Phase 1
7. **Localization (i18n)** — Content translation not in scope for v1
8. **E-commerce or payment integration** — Checkout and membership billing remain separate
9. **Email automation** — Admin notifications out of scope for v1
10. **Multi-role admin** — Single role for all admin users; no editor/admin/super_admin split
11. **Password reset** — Manual credential management via another admin or setup script; no self-serve reset flow

---

## 4. Content Inventory

### 4.1 Home Page

| Section Key          | Section Name          | Block Type         | Item Count | Soft Cap | Change Frequency |
| -------------------- | --------------------- | ------------------ | ---------- | -------- | ---------------- |
| `hero`               | Hero                  | `HeroBlock`        | 1          | 1        | Low              |
| `why-volubit`        | Why Volubit           | `CardGridBlock`    | 6          | 8        | Low              |
| `community-showcase` | Community Showcase    | `CustomBlock`      | 1          | 1        | Low              |
| `mentors`            | Mentors / Instructors | `MentorBlock`      | 3          | 10       | Medium           |
| `upcoming-events`    | Upcoming Events       | `EventBlock`       | 3          | 10       | High             |
| `gallery`            | Community Gallery     | `GalleryBlock`     | 11         | 20       | Medium           |
| `research-preview`    | Research Preview      | `ResearchBlock`    | 6          | 10       | High             |
| `latest-articles`    | Latest Articles       | `ArticleBlock`     | 4          | 10       | High             |
| `partnership`        | Partnership           | `PartnerBlock`     | 3 groups   | 5 groups | Low              |
| `testimonials`       | Testimonials          | `TestimonialBlock` | 3          | 10       | Medium           |
| `collab-cta`         | Collaboration CTA     | `CTABlock`         | 1          | 1        | Low              |
| `join-cta`           | Join CTA              | `CTABlock`         | 1          | 1        | Low              |

#### 4.1.1 Hero Block Fields

- `eyebrow`: string (e.g. "186 members online right now")
- `headline`: string (e.g. "Learn crypto with people who actually do it.")
- `subheadline`: string (e.g. "Volubit is a members-only education community...")
- `ctaPrimary`: `{ label: string, href: string }` (e.g. "Become a member" → "/checkout")
- `ctaSecondary`: `{ label: string, href: string }` (e.g. "Explore the community" → "/community")
- `stats`: Array of `{ value: number, suffix: string, label: string }` (3 items)
- `trustedBy`: Array of `string` (4 brand names)

#### 4.1.2 Why Volubit CardGridBlock (soft cap: 8 items)

- Each card: `{ icon: string, title: string, description: string, benefit: string }`
- `icon`: string name of Lucide icon to render

#### 4.1.3 Community Showcase CustomBlock

- `feed`: Array of `{ initials: string, name: string, message: string, timeAgo: string }` (4 items)
- `ticker`: Array of `string` (6 event text items for marquee)
- `voiceRoomLabel`: string
- `voiceRoomParticipants`: number
- `studyGroup`: `{ name: string, week: number, totalWeeks: number }`

#### 4.1.4 Mentors MentorBlock (soft cap: 10 items)

- `{ name: string, role: string, experience: string, specialization: string, courses: string, certifications: string, photo: MediaBlock | null }`

#### 4.1.5 Upcoming Events EventBlock (soft cap: 10 items)

- `{ title: string, speaker: string, seats: number, place: string, date: string, time: string, agenda: string[], image: MediaBlock | null }`

#### 4.1.6 Research Preview ResearchBlock (soft cap: 10 items)

- `{ title: string, tag: string, locked: boolean, preview: string | null }`

#### 4.1.7 Latest Articles ArticleBlock (soft cap: 10 items)

- `{ title: string, category: string, readTime: string, author: string, image: MediaBlock | null }`
- Also: `categories`: Array of `string` (6 category names)

### 4.2 Community Page

| Section Key    | Section Name         | Block Type         | Item Count | Soft Cap | Change Frequency |
| -------------- | -------------------- | ------------------ | ---------- | -------- | ---------------- |
| `hero`         | Hero                 | `HeroBlock`        | 1          | 1        | Low              |
| `stats`        | Community Stats      | `StatsBlock`       | 4          | 6        | Low              |
| `showcase`     | Inside the Community | `CustomBlock`      | 1          | 1        | Low              |
| `activities`   | Activities           | `CardGridBlock`    | 4          | 8        | Low              |
| `schedule`     | Weekly Schedule      | `ScheduleBlock`    | 7          | 7 (fixed) | Medium           |
| `instructors`  | Mentors              | `MentorBlock`      | 3          | 10       | Medium           |
| `gallery`      | Gallery              | `GalleryBlock`     | 11         | 20       | Medium           |
| `testimonials` | Testimonials         | `TestimonialBlock` | 3          | 10       | Medium           |
| `faq`          | FAQ                  | `FAQBlock`         | 3          | 10       | Low              |
| `join-cta`     | Join CTA             | `CTABlock`         | 1          | 1        | Low              |

### 4.3 About Page

| Section Key   | Section Name | Block Type      | Item Count | Soft Cap | Change Frequency |
| ------------- | ------------ | --------------- | ---------- | -------- | ---------------- |
| `hero`        | Hero         | `HeroBlock`     | 1          | 1        | Low              |
| `vision`      | Vision       | `RichTextBlock` | 1          | 1        | Very Low         |
| `mission`     | Mission      | `RichTextBlock` | 1          | 1        | Very Low         |
| `timeline`    | Timeline     | `ScheduleBlock` | 5          | 5 (fixed) | Very Low         |
| `instructors` | Mentors      | `MentorBlock`   | 3          | 10       | Medium           |
| `culture`     | Culture      | `CardGridBlock` | 4          | 8        | Very Low         |
| `gallery`     | Gallery      | `GalleryBlock`  | 11         | 20       | Medium           |
| `join-cta`    | Join CTA     | `CTABlock`      | 1          | 1        | Low              |

### 4.4 Events Page

| Section Key       | Section Name    | Block Type         | Item Count | Soft Cap | Change Frequency |
| ----------------- | --------------- | ------------------ | ---------- | -------- | ---------------- |
| `hero`            | Hero            | `HeroBlock`        | 1          | 1        | Low              |
| `featured-event`  | Featured Event  | `EventBlock`       | 1          | 1        | High             |
| `upcoming-events` | Upcoming Events | `EventBlock`       | 3          | 10       | High             |
| `calendar`        | Calendar        | `ScheduleBlock`    | 5          | 5 (fixed) | High             |
| `instructors`     | Mentors         | `MentorBlock`      | 3          | 10       | Medium           |
| `gallery`         | Gallery         | `GalleryBlock`     | 11         | 20       | Medium           |
| `testimonials`    | Testimonials    | `TestimonialBlock` | 3          | 10       | Medium           |
| `join-cta`        | Join CTA        | `CTABlock`         | 1          | 1        | Low              |

### 4.5 Articles Page

| Section Key        | Section Name     | Block Type      | Item Count | Soft Cap | Change Frequency |
| ------------------ | ---------------- | --------------- | ---------- | -------- | ---------------- |
| `hero`             | Hero             | `HeroBlock`     | 1          | 1        | Low              |
| `latest-articles`  | Latest Articles  | `ArticleBlock`  | 4          | 10       | High             |
| `categories`       | Categories       | `Array<string>` | 6          | 10       | Low              |
| `popular`          | Popular Articles | `Array<string>` | 4          | 10       | Medium           |
| `newsletter`       | Newsletter       | `RichTextBlock` | 1          | 1        | Low              |
| `related`          | Related Articles | `ArticleBlock`  | 3          | 10       | High             |
| `join-cta`         | Join CTA         | `CTABlock`      | 1          | 1        | Low              |

### 4.6 FAQ Page

| Section Key  | Section Name | Block Type       | Item Count | Soft Cap | Change Frequency |
| ------------ | ------------ | ---------------- | ---------- | -------- | ---------------- |
| `hero`       | Hero         | `HeroBlock`      | 1          | 1        | Low              |
| `faq-groups` | FAQ Groups   | `FAQGroupBlock`  | 4          | 6        | Low              |

**FAQGroupBlock**: Array of `{ title: string, items: Array<[question: string, answer: string]> }` (max 5 items per group)
**Soft cap** on FAQ groups: 6

### 4.7 Membership / Pricing Page

| Section Key          | Section Name      | Block Type            | Item Count | Soft Cap | Change Frequency |
| -------------------- | ----------------- | --------------------- | ---------- | -------- | ---------------- |
| `hero`               | Hero              | `HeroBlock`           | 1          | 1        | Low              |
| `plans`              | Plans             | `PricingBlock.plans`  | 3          | 5        | Low              |
| `comparison-matrix`  | Comparison Matrix | `PricingBlock.matrix` | 9          | 9 (fixed) | Low              |
| `join-cta`           | Join CTA          | `CTABlock`            | 1          | 1        | Low              |

**PricingBlock.plans**: Array of `{ name: string, price: string, period: string, note: string, featured: boolean }`
**PricingBlock.matrix**: Array of `[featureName: string, monthly: boolean, quarterly: boolean, yearly: boolean]` (fixed structure)

### 4.8 Partnership Page

| Section Key    | Section Name | Block Type      | Item Count | Soft Cap | Change Frequency |
| -------------- | ------------ | --------------- | ---------- | -------- | ---------------- |
| `hero`         | Hero         | `HeroBlock`     | 1          | 1        | Low              |
| `partners`     | Partners     | `PartnerBlock`  | 3 groups   | 5 groups | Low              |
| `benefits`     | Benefits     | `CardGridBlock` | 4          | 8        | Low              |
| `process`      | Process      | `CardGridBlock` | 4          | 8        | Low              |
| `inquiry-form` | Inquiry Form | `RichTextBlock` | 1          | 1        | Very Low         |

### 4.9 Contact Page

| Section Key | Section Name     | Block Type      | Item Count | Soft Cap | Change Frequency |
| ----------- | ---------------- | --------------- | ---------- | -------- | ---------------- |
| `hero`      | Hero             | `HeroBlock`     | 1          | 1        | Low              |
| `channels`  | Contact Channels | `ContactBlock`  | 4          | 6        | Low              |
| `office`    | Office Info      | `RichTextBlock` | 1          | 1        | Very Low         |
| `form`      | Contact Form     | `RichTextBlock` | 1          | 1        | Very Low         |
| `faq`       | Quick FAQ        | `FAQBlock`      | 3          | 10       | Low              |

**ContactBlock**: Array of `{ icon: string, channelName: string, value: string, note: string }`

### 4.10 Membership Detail Page

| Section Key | Section Name       | Block Type      | Item Count | Soft Cap | Change Frequency |
| ----------- | ------------------ | --------------- | ---------- | -------- | ---------------- |
| `hero`      | Hero               | `HeroBlock`     | 1          | 1        | Low              |
| `overview`  | Membership Overview | `RichTextBlock` | 1          | 1        | Very Low         |
| `join-cta`  | Join CTA           | `CTABlock`      | 1          | 1        | Low              |

### 4.11 Checkout Page

| Section Key | Section Name        | Block Type      | Item Count | Soft Cap | Change Frequency |
| ----------- | ------------------- | --------------- | ---------- | -------- | ---------------- |
| `hero`      | Hero                | `HeroBlock`     | 1          | 1        | Low              |
| `checkout`  | Checkout Page Copy  | `RichTextBlock` | 1          | 1        | Very Low         |

> **Note:** Checkout and payment processing (Stripe/Lembar) are handled externally. This page's CMS content covers static copy only (headings, descriptions, trust signals). No plan data is stored in the CMS.

### 4.12 Past Events Page

| Section Key    | Section Name       | Block Type         | Item Count | Soft Cap | Change Frequency |
| -------------- | ------------------ | ------------------ | ---------- | -------- | ---------------- |
| `hero`         | Hero               | `HeroBlock`        | 1          | 1        | Low              |
| `past-events`  | Past Events        | `EventTableRef`    | dynamic    | —        | High             |

> **Note:** Past events are powered by the `events` table (Section 5C). The page lists events with `status = 'past'`. No separate block or CMS content needed for the listing itself — the page fetches directly from `/api/events?status=past`.

### 4.13 Global / Site Settings

| Field Key         | Field Type              | Description                    |
| ----------------- | ----------------------- | ----------------------------- |
| `siteName`        | string                  | Volubit                       |
| `tagline`         | string                  | Primary tagline               |
| `logo`            | MediaBlock              | Primary logo image            |
| `favicon`         | MediaBlock              | Favicon image                 |
| `socialLinks`     | SocialLinksBlock        | All social platform URLs      |
| `defaultOgImage`  | MediaBlock              | Default Open Graph image      |
| `defaultMetaTitle`| string                  | Default SEO title             |
| `defaultMetaDesc` | string                  | Default SEO description       |
| `footerCopyright` | string                  | Copyright text                |
| `announcementBar` | AnnouncementBlock       | Top banner (CMS-controlled)   |
| `navLinks`        | NavLinkBlock            | Navigation link structure      |
| `footerLinks`     | FooterLinksBlock        | Footer column + link structure |

**SocialLinksBlock**:
```typescript
{
  discord?: string;
  telegram?: string;
  twitter?: string;
  instagram?: string;
  linkedin?: string;
  youtube?: string;
}
```

**AnnouncementBlock**: `{ enabled: boolean, text: string, href?: string }`

**NavLinkBlock**: Array of `{ label: string, href: string, children?: Array<{ label: string, href: string }> }`

**FooterLinksBlock**:
```typescript
{
  description?: string;
  columns: Array<{
    heading?: string;
    links: Array<{ label: string; href: string }>;
  }>;
  socialLinks: Array<{ platform: string; href: string }>;
  copyright: string;
  legalLinks: Array<{ label: string; href: string }>;
}
```

#### Per-Page SEO

Each page can optionally override the site-wide SEO defaults. These live as part of the page's `content_blocks` or as a dedicated `seo` block:

| Field Key      | Field Type   | Description                    |
| -------------- | ------------ | ------------------------------ |
| `metaTitle`    | string       | Page-specific SEO title        |
| `metaDesc`     | string       | Page-specific SEO description  |
| `ogImage`      | MediaBlock   | Page-specific Open Graph image |

> If a per-page SEO field is empty, the FE falls back to the site-wide default.

---

## 5. Content Schema

### 5.1 Block Type Definitions

```typescript
// HeroBlock — one per page hero
interface HeroBlock {
  eyebrow: string;
  headline: string;
  description: string;
  ctaPrimary: { label: string; href: string };
  ctaSecondary: { label: string; href: string };
  stats?: Array<{ value: number; suffix: string; label: string }>;
  trustedBy?: string[];
}

// StatsBlock — stat cards row
interface StatsBlock {
  items: Array<{ value: number; suffix: string; label: string }>;
}

// CardGridBlock — generic card grid (features, benefits, culture)
interface CardGridBlock {
  eyebrow?: string;
  title?: string;
  description?: string;
  align?: "left" | "center";
  items: Array<{
    title: string;
    description: string;
    badge?: string;
    icon?: string; // Lucide icon name
    image?: MediaBlock;
  }>;
}

// ScheduleBlock — day-activity rows (order is fixed)
interface ScheduleBlock {
  items: Array<[day: string, activity: string]>;
}

// FAQBlock — Q&A pairs
interface FAQBlock {
  items: Array<[question: string, answer: string]>;
}

// FAQGroupBlock — grouped Q&A
interface FAQGroupBlock {
  groups: Array<{
    title: string;
    items: Array<[question: string, answer: string]>;
  }>;
}

// TestimonialBlock — member quotes
interface TestimonialBlock {
  items: Array<{
    quote: string;
    name: string;
    occupation: string;
    avatar?: string;
  }>;
}

// EventBlock — event cards (used in content blocks for inline event display)
interface EventBlock {
  items: Array<{
    title: string;
    description?: string;
    speaker: string;
    seats: number;
    place: string;
    date: string;
    time: string;
    agenda: string[];
    image?: MediaBlock;
  }>;
}

// ArticleBlock — article cards
interface ArticleBlock {
  categories?: string[];
  items: Array<{
    title: string;
    category: string;
    readTime: string;
    author: string;
    image?: MediaBlock;
  }>;
}

// PartnerBlock — partner listing
interface PartnerBlock {
  groups: Array<{
    groupLabel: string;
    items: string[];
  }>;
}

// MentorBlock — instructor cards
interface MentorBlock {
  items: Array<{
    name: string;
    role: string;
    experience: string;
    specialization: string;
    courses: string;
    certifications: string;
    photo?: MediaBlock;
  }>;
}

// ResearchBlock — research preview cards
interface ResearchBlock {
  items: Array<{
    title: string;
    tag: string;
    locked: boolean;
    previewText?: string;
    chartData?: number[];
  }>;
}

// GalleryBlock — image grid
interface GalleryBlock {
  items: Array<{
    url: string;
    alt: string;
    caption?: string;
    width: number;
    height: number;
    layoutHint?: "portrait" | "landscape" | "square";
  }>;
}

// PricingBlock — plans + comparison
interface PricingBlock {
  plans: Array<{
    name: string;
    price: string;
    period: string;
    note: string;
    featured: boolean;
  }>;
  matrix: Array<[string, boolean, boolean, boolean]>; // [feature, monthly, quarterly, yearly]
}

// CTABlock — call-to-action block
interface CTABlock {
  title: string;
  subtitle?: string;
  ctaLabel: string;
  ctaHref: string;
  ctaSecondaryLabel?: string;
  ctaSecondaryHref?: string;
  chips?: string[];
}

// RichTextBlock — free-form HTML/Markdown
interface RichTextBlock {
  content: string; // HTML or Markdown
}

// ContactBlock — contact channel cards
interface ContactBlock {
  items: Array<{
    icon: string; // Lucide icon name
    channelName: string;
    value: string;
    note: string;
  }>;
}

// FeaturedArticleBlock — hero article card on home page
interface FeaturedArticleBlock {
  title: string;
  author: string;
  readTime: string;
  image?: MediaBlock;
}

// NewsletterBlock — email subscription block
interface NewsletterBlock {
  headline: string;
  description?: string;
  placeholder: string; // e.g. "Enter your email"
  submitLabel: string; // e.g. "Subscribe"
  successMessage: string;
  disclaimer?: string;
}

// FeaturedEventBlock — highlighted event with full description
interface FeaturedEventBlock {
  title: string;
  description: string;
  speaker: string;
  seats: number;
  place: string;
  date: string;
  time: string;
  agenda: string[];
  image?: MediaBlock;
}

// FooterBlock — full footer structure
interface FooterBlock {
  description?: string;
  columns: Array<{
    heading?: string;
    links: Array<{ label: string; href: string }>;
  }>;
  socialLinks: Array<{ platform: string; href: string }>; // Discord, Telegram, X, LinkedIn, YouTube
  copyright: string;
  legalLinks: Array<{ label: string; href: string }>; // Privacy, Terms, Disclaimer
}

// NavBlock — navigation link structure
interface NavBlock {
  items: Array<{
    label: string;
    href: string;
    children?: Array<{ label: string; href: string }>;
  }>;
}

// EventTableRef — reference to the events table (not a block, powers the listing page)
interface EventTableRef {
  // This is not stored in content_blocks — it queries the events table directly
  // Used to indicate which events appear on this page (e.g. past-events page)
  filterStatus: "upcoming" | "past";
  limit?: number;
}

// ArticleTableRef — reference to the articles table
interface ArticleTableRef {
  filterCategory?: string;
  limit?: number;
  showFeatured?: boolean;
}

// SiteSettingsBlock — global settings (singleton)
interface SiteSettingsBlock {
  siteName: string;
  tagline: string;
  logo?: MediaBlock;
  favicon?: MediaBlock;
  socialLinks: {
    discord?: string;
    telegram?: string;
    twitter?: string;
    instagram?: string;
    linkedin?: string;
    youtube?: string;
  };
  defaultOgImage?: MediaBlock;
  defaultMetaTitle?: string;
  defaultMetaDesc?: string;
  footerCopyright: string;
  announcementBar: {
    enabled: boolean;
    text: string;
    href?: string;
  };
}

// MediaBlock — image reference
interface MediaBlock {
  url: string;
  alt: string;
  width: number;
  height: number;
  mimeType: string;
  size: number; // bytes
}
```

### 5.2 Content Block Database Row

Every content block in the database uses this shape:

```typescript
interface ContentBlockRow {
  id: string; // UUID v4
  page: PageKey; // "home" | "community" | "about" | "events" | "articles" | "faq" | "membership" | "membership-detail" | "checkout" | "partnership" | "contact"
  section: string; // e.g. "hero", "upcoming-events", "testimonials"
  blockType: BlockType; // e.g. "HeroBlock", "CardGridBlock"
  locale: "en" | "id"; // future i18n; default "en"
  data: any; // typed as the appropriate block type
  createdAt: string;
  updatedAt: string;
  updatedBy: string; // admin ID
}
```

---

## 5B. Content Inventory (Existing Hardcoded Content)

This section documents every piece of hardcoded content currently in the codebase — the content that will be migrated into the CMS. Each entry shows the current value as it appears in the source files.

> **Note:** Image paths reference local assets (`@/assets/...`). These will be migrated to the media library. Editors can replace these with new uploaded images or keep the same references.

---

### 5B.1 Home Page (`/`)

#### Hero Section (`hero`)

| Field | Current Value | Type |
|-------|--------------|------|
| `eyebrow` | "186 members online right now" | text |
| `headline` | "Learn crypto with people who actually do it." | text |
| `description` | "Volubit is a members-only education community. Professional research, weekly live classes, structured curriculum and mentors who answer in the same room you learn in." | text |
| `ctaPrimary.label` | "Become a member" | text |
| `ctaPrimary.href` | "/checkout" | text |
| `ctaSecondary.label` | "Explore the community" | text |
| `ctaSecondary.href` | "/community" | text |
| `stats[0]` | `{ value: 5200, suffix: "+", label: "Members" }` | stat |
| `stats[1]` | `{ value: 180, suffix: "+", label: "Research notes" }` | stat |
| `stats[2]` | `{ value: 96, suffix: "%", label: "Renew yearly" }` | stat |
| Floating card 1 (Discord) | `#market-talk, "ETH funding flipped negative again."`, `"Chart in the research note, page 4."` | text |
| Floating card 2 (Portfolio) | `"Members' allocation study, Q3"`, `+4.2%` | text |
| Floating card 3 (Research) | `"Macro liquidity and the Bitcoin cycle: what changed this week"`, `"18 pages · published today"` | text |
| Floating card 4 (Voice) | `"Voice · Weekly Class"`, 5 avatar initials | text |
| Image | `@/assets/hero-image` (referenced via community images) | image |

#### Why Volubit Section (`why-volubit`) — CardGridBlock, 6 items

| # | Icon | Title | Description | Benefit |
|---|------|-------|-------------|---------|
| 1 | `LineChart` | "Professional research" | "Weekly market reports written by analysts, not influencers." | "Macro, on-chain and asset notes every week" |
| 2 | `GraduationCap` | "Professional mentors" | "Practitioners with fund, desk and protocol experience." | "Office hours and direct replies" |
| 3 | `BookOpen` | "Weekly class" | "Live sessions with recordings, slides and homework." | "48 sessions per year" |
| 4 | `MessageSquare` | "Discord community" | "Study groups, voice rooms and daily discussion." | "Always-on rooms across timezones" |
| 5 | `ShieldCheck` | "Portfolio discussion" | "Structured reviews focused on risk, not calls." | "Monthly portfolio clinic" |
| 6 | `Handshake` | "Networking" | "Offline meetups, hackathons and hiring circles." | "Events in 6 cities" |

#### Community Showcase (`community-showcase`) — CustomBlock

**Feed (4 items):**
| Initials | Name | Message | Time ago |
|----------|------|---------|----------|
| AR | Andra | "Funding rates reset. Notes in #research." | 2m |
| MJ | Maya | "Joined voice room On-chain 101 starting." | 6m |
| KV | Kevin | "Portfolio clinic recording is up." | 12m |
| TS | Tasya | "Certificate arrived from last workshop!" | 22m |

**Voice Room:**
| Field | Value |
|-------|-------|
| `voiceRoomLabel` | "On-chain Analysis Live" |
| `voiceRoomParticipants` | 6 (avatar initials shown: A, B, C, D, E, F) |
| Pinned message | "📌 Weekly class starts 20:00 WIB in the Main Stage voice room." |

**Mentor Reply:**
| Field | Value |
|-------|-------|
| Quote | "Position sizing before conviction. Write the invalidation level first then decide the size." |
| Attribution | "Rizky · Head of Research" |

**Study Group:**
| Field | Value |
|-------|-------|
| Name | "Technical Analysis Cohort 12" |
| Week | 6 of 8 |

**Ticker (6 items):**
1. "Weekly class · Thursday 20:00"
2. "New research · ETF flows"
3. "Voice room · Macro Q&A"
4. "Meetup · Jakarta, 14 Sep"
5. "Workshop · Risk Management"
6. "Study group · DeFi Cohort 4"

#### Instructors (`mentors`) — MentorBlock, 3 items

| # | Name | Role | Experience | Specialization | Courses | Certifications |
|---|------|------|-----------|---------------|---------|---------------|
| 1 | "Rizky Pratama" | "Head of Research" | "9 yrs · digital asset research" | "Macro & on-chain" | "Macro, On-chain Analysis" | "CFA Level II · CMT I" |
| 2 | "Alina Chandra" | "Lead Mentor" | "7 yrs · portfolio management" | "Risk & allocation" | "Risk Management, Portfolio" | "FRM · CFP" |
| 3 | "Daniel Wijaya" | "Protocol Analyst" | "6 yrs · DeFi engineering" | "DeFi & Web3" | "DeFi, Web3, AI" | "MSc Computer Science" |
| Photo | `@/assets/mentor-1.jpg` | — | — | — | — | (same photo used for all 3 in UI) |

#### Upcoming Events (`upcoming-events`) — EventBlock, 3 items

| # | Title | Speaker | Seats | Place | Date | Agenda |
|---|-------|---------|-------|-------|------|--------|
| 1 | "Macro Outlook Q4 · Live Webinar" | "Rizky Pratama" | 42 | "Online · Zoom" | "12 Sep, 20:00 WIB" | `["Liquidity cycle", "Rates & risk assets", "Positioning Q&A"]` |
| 2 | "On-chain Workshop · Jakarta" | "Daniel Wijaya" | 12 | "Kuningan, Jakarta" | "21 Sep, 13:00 WIB" | `["Dune basics", "Flow metrics", "Build a dashboard"]` |
| 3 | "Portfolio Clinic · Members Only" | "Alina Chandra" | 25 | "Discord Main Stage" | "28 Sep, 19:30 WIB" | `["Allocation review", "Risk scoring", "Rebalancing"]` |
| Images | `@/assets/community-1.jpg`, `@/assets/community-3.jpg` | — | — | — | — | — |

#### Community Gallery (`gallery`) — GalleryBlock, 11 items

| # | Source | Alt text | Layout hint | Dimensions |
|---|--------|---------|------------|------------|
| 1 | `@/assets/community-1.jpg` | "Volubit seminar in Jakarta" | h-72 | 1200×800 |
| 2 | `@/assets/community-2.jpg` | "Mentoring session over coffee" | h-96 | 900×1100 |
| 3 | `@/assets/community-3.jpg` | "Workshop audience" | h-64 | 1000×700 |
| 4 | `@/assets/community-2.jpg` | "Members discussing a portfolio" | h-80 | 900×1100 |
| 5 | `@/assets/community-3.jpg` | "Community meetup" | h-64 | 1000×700 |
| 6 | `@/assets/community-1.jpg` | "Volubit seminar in Jakarta" | h-72 | 1200×800 |
| 7 | `@/assets/community-2.jpg` | "Mentoring session over coffee" | h-96 | 900×1100 |
| 8 | `@/assets/community-3.jpg` | "Workshop audience" | h-64 | 1000×700 |
| 9 | `@/assets/community-2.jpg` | "Members discussing a portfolio" | h-80 | 900×1100 |
| 10 | `@/assets/community-3.jpg` | "Community meetup" | h-64 | 1000×700 |
| 11 | `@/assets/community-1.jpg` | "Trading class in session" | h-80 | 1200×800 |

#### Research Preview (`research-preview`) — ResearchBlock, 6 items

| # | Title | Tag | Locked | Preview Text |
|---|-------|-----|--------|-------------|
| 1 | "Macro Liquidity Monitor" | "Macro" | false | (shows chart + "Weekly update covering flows...") |
| 2 | "Bitcoin Cycle Outlook" | "Bitcoin" | true | — |
| 3 | "Ethereum Supply & Staking" | "Ethereum" | true | — |
| 4 | "Altcoin Rotation Study" | "Altcoins" | true | — |
| 5 | "On-chain Flow Weekly" | "On-chain" | false | (shows chart + "Weekly update...") |
| 6 | "ETF Flows Tracker" | "ETF" | true | — |

#### Latest Articles (`latest-articles`) — ArticleBlock, 4 items + 6 categories

**Categories:** `["All", "Macro", "On-chain", "Education", "DeFi", "Research"]`

**Articles:**
| # | Title | Category | Read time | Author |
|---|-------|---------|-----------|--------|
| 1 | "Why liquidity leads price, and how to track it" | "Macro" | "8 min" | "Rizky" |
| 2 | "A beginner's map of on-chain data" | "On-chain" | "6 min" | "Daniel" |
| 3 | "Risk first: sizing before conviction" | "Education" | "5 min" | "Alina" |
| 4 | "DeFi yields without the fairy tales" | "DeFi" | "9 min" | "Daniel" |

**Featured article:**
| Field | Value |
|-------|-------|
| Title | "The four-year cycle is a story. Here is what the data says instead." |
| Author | "Rizky Pratama" |
| Read time | "12 min read" |
| Image | `@/assets/community-2.jpg` |

#### Partnership Section (`partnership`) — PartnerBlock, 3 groups

| Group | Items |
|-------|-------|
| "Strategic" | `["Nusantara Capital", "Ledgerworks", "Blockchain ID", "Orbit Chain"]` |
| "Corporate" | `["Arta Finance", "Sigma Labs"]` |
| "Community" | `["Dev Circle", "Campus Web3", "Trader Guild", "Node Runners", "Meta Builders"]` |

#### Testimonials (`testimonials`) — TestimonialBlock, 3 items

| # | Quote | Name | Occupation |
|---|-------|------|-----------|
| 1 | "I stopped trading on vibes. The risk framework alone was worth the year." | "Bagas P." | "Product Manager" |
| 2 | "The mentors reply. That sounds small until you've paid for a course where nobody does." | "Ayu R." | "Accountant" |
| 3 | "The weekly research reads like a desk note, not a Telegram signal." | "Fandi S." | "Software Engineer" |

#### Collaboration CTA (`collab-cta`) — CTABlock

| Field | Value |
|-------|-------|
| Title | "Build something with an audience that actually shows up." |
| Description | "We work with corporates, universities, media and communities on education programs, research and events." |
| CTA Label | "Partner with Volubit" |
| CTA Href | "/partnership" |
| CTA Secondary Label | "Talk to us" |
| CTA Secondary Href | "/contact" |
| Chips | `["Corporate Partner", "University Partner", "Media Partner", "Community Partner", "Sponsor"]` |

#### Join CTA (`join-cta`) — CTABlock (shared across pages)

| Field | Value |
|-------|-------|
| Title | "Join 5,200+ people learning crypto properly." (varies per page) |
| Subtitle | "One membership. Research, classes, mentors and the community." |
| CTA Label | "Become a member" |
| CTA Href | "/checkout" |
| CTA Secondary Label | "See pricing" |
| CTA Secondary Href | "/membership/pricing" |

---

### 5B.2 Community Page (`/community`)

#### Page Hero (`hero`)

| Field | Current Value |
|-------|--------------|
| `eyebrow` | "Community" |
| `title` | "The room where the learning actually happens." |
| `description` | "Volubit is not a course you watch alone. It is a room you walk into every day." |

#### Stats (`stats`) — StatsBlock, 4 items

| Value | Suffix | Label |
|-------|--------|-------|
| 5200 | "+" | "Members" |
| 42 | "" | "Study groups" |
| 1420 | "" | "Messages today" |
| 18 | "" | "Cities" |

#### Activities (`activities`) — CardGridBlock, 4 items

| Title | Description |
|-------|-------------|
| "Voice rooms" | "Daily open rooms for questions and chart walkthroughs." |
| "Study groups" | "Cohort-based groups with a mentor and weekly homework." |
| "Community feed" | "Announcements, research drops and member wins." |
| "Offline meetups" | "Coffee sessions, workshops and hackathons." |

#### Weekly Schedule (`schedule`) — ScheduleBlock, 7 items (fixed order)

| Day | Activity |
|-----|---------|
| Monday | "Research drop + weekly outlook" |
| Tuesday | "Beginner study group" |
| Wednesday | "On-chain lab" |
| Thursday | "Weekly live class" |
| Friday | "Mentor office hours" |
| Saturday | "Portfolio clinic" |
| Sunday | "Open voice room" |

#### FAQ (`faq`) — FAQBlock, 3 items

| Question | Answer |
|----------|--------|
| "Is the Discord active?" | "Around 1,400 messages a day across 20+ rooms." |
| "Are meetups included?" | "Yearly members get free entry to offline workshops." |
| "Can beginners keep up?" | "Yes study groups are cohort-based by level." |

> Note: Community page reuses `Instructors`, `CommunityShowcase`, `CommunityGallery`, `Testimonials` sections — see Home Page entries.

---

### 5B.3 About Page (`/about`)

#### Page Hero (`hero`)

| Field | Current Value |
|-------|--------------|
| `eyebrow` | "About" |
| `title` | "Crypto education that respects your intelligence." |
| `description` | "We built the community we wished existed when we started: rigorous, honest and genuinely social." |

#### Vision (`vision`) — RichTextBlock

> "A generation of investors who understand risk before they understand returns."

#### Mission (`mission`) — RichTextBlock

> "Deliver professional-grade research and structured teaching inside a community that keeps people accountable."

#### Timeline (`timeline`) — ScheduleBlock, 5 items (fixed order)

| Year | Event |
|------|-------|
| 2021 | "A Discord server with 40 friends and one weekly call." |
| 2022 | "First paid cohort. The research note becomes weekly." |
| 2023 | "Offline workshops in three cities. 1,200 members." |
| 2024 | "Full curriculum launch and the mentor program." |
| 2026 | "5,200+ members across 18 cities." |

#### Culture (`culture`) — CardGridBlock, 4 items

| Title | Description |
|-------|-------------|
| "Honesty over hype" | "We publish reasoning, not price targets." |
| "Teach the process" | "A framework outlives any single trade." |
| "Show up" | "Community is a habit, not a landing page." |
| "Respect risk" | "Every lesson starts with what can go wrong." |

> Note: About page reuses `Instructors`, `CommunityGallery`, `JoinCTA` — see Home Page entries.

---

### 5B.4 Events Page (`/events`)

#### Page Hero (`hero`)

| Field | Current Value |
|-------|--------------|
| `eyebrow` | "Events" |
| `title` | "Learning that happens in a room, together." |
| `description` | "Webinars every week, workshops every month and offline meetups across the country." |

#### Featured Event (`featured-event`) — EventBlock, 1 item

| Field | Value |
|-------|-------|
| Title | "Macro Outlook Q4 Live Webinar" |
| Description | "A two-hour session on liquidity, rates and how they shape crypto risk appetite going into the final quarter." |
| Speaker | "Rizky Pratama" |
| Date | "12 Sep" |
| Time | "20:00 WIB" |
| Seats | "42 left" |
| Image | `@/assets/community-1.jpg` |

#### Upcoming Events (`upcoming-events`) — EventBlock, 3 items

> Same 3 events as Home Page `upcoming-events` — see Home Page entry.

#### Calendar (`calendar`) — ScheduleBlock, 5 items (fixed order)

| Date | Event | Location |
|------|-------|----------|
| "12 Sep" | "Macro Outlook Q4" | "Online" |
| "21 Sep" | "On-chain Workshop" | "Jakarta" |
| "28 Sep" | "Portfolio Clinic" | "Discord" |
| "05 Oct" | "Risk Management Bootcamp" | "Bandung" |
| "19 Oct" | "Community Meetup" | "Surabaya" |

> Note: Events page reuses `Instructors`, `CommunityGallery`, `Testimonials`, `JoinCTA` — see Home Page entries.

---

### 5B.5 Articles Page (`/articles`)

#### Page Hero (`hero`)

| Field | Current Value |
|-------|--------------|
| `eyebrow` | "Articles" |
| `title` | "Read the thinking behind the research." |

> Note: Articles page currently shows placeholder/empty sections. The `LatestArticles` and `ArticleBlock` components on the Home page show the actual article content — see Home Page entries for `latest-articles`.

---

### 5B.6 FAQ Page (`/faq`)

#### Page Hero (`hero`)

| Field | Current Value |
|-------|--------------|
| `eyebrow` | "FAQ" |
| `title` | "Everything people ask before joining." |
| `description` | "If something is still unclear, message us we answer every question." |

#### FAQ Groups (`faq-groups`) — FAQGroupBlock, 4 groups × 3 items

**Group 1 — Membership:**
| Question | Answer |
|----------|--------|
| "What is included?" | "Courses, weekly research, live classes, Discord and events." |
| "Do I need experience?" | "No. The first track starts from zero." |
| "Can I cancel?" | "Monthly plans cancel any time. Longer plans run to term." |

**Group 2 — Research:**
| Question | Answer |
|----------|--------|
| "Is this financial advice?" | "No. Everything published is educational." |
| "How often is it published?" | "A main report weekly, plus notes through the week." |
| "Can I share it?" | "Research is for members only." |

**Group 3 — Community & events:**
| Question | Answer |
|----------|--------|
| "How do I get into Discord?" | "An invite is emailed instantly after checkout." |
| "Are offline events free?" | "Yearly members get free entry to workshops." |
| "Are sessions recorded?" | "Yes, everything is archived for members." |

**Group 4 — Payment:**
| Question | Answer |
|----------|--------|
| "Which methods work?" | "QRIS, virtual account, credit card and e-wallets." |
| "Do you offer refunds?" | "Yes, within 7 days if you haven't downloaded research." |
| "Do you invoice companies?" | "Yes contact us for corporate billing." |

---

### 5B.7 Membership / Pricing Page (`/membership/pricing`)

#### Page Hero (`hero`)

| Field | Current Value |
|-------|--------------|
| `eyebrow` | "Pricing" |
| `title` | "Priced like a course. Built like a community." |
| `description` | "Every plan includes the community, the classes and the research. Longer plans unlock deeper mentorship." |

#### Plans (`plans`) — PricingBlock.plans, 3 items

| Name | Price | Period | Note | Featured |
|------|-------|--------|------|----------|
| "Monthly" | "Rp 349k" | "/month" | "Cancel any time" | false |
| "Quarterly" | "Rp 899k" | "/3 months" | "Save 14%" | false |
| "Yearly" | "Rp 2.9jt" | "/year" | "Save 31% · most popular" | true |

#### Comparison Matrix (`comparison-matrix`) — PricingBlock.matrix, 9 rows (fixed)

| Feature | Monthly | Quarterly | Yearly |
|---------|---------|-----------|--------|
| "Discord community" | ✓ | ✓ | ✓ |
| "Weekly live class" | ✓ | ✓ | ✓ |
| "Course library" | ✓ | ✓ | ✓ |
| "Weekly research" | ✓ | ✓ | ✓ |
| "Research archive" | ✗ | ✓ | ✓ |
| "Portfolio clinic" | ✗ | ✓ | ✓ |
| "1:1 mentor session" | ✗ | ✗ | ✓ |
| "Offline workshops" | ✗ | ✗ | ✓ |
| "Certificate program" | ✗ | ✗ | ✓ |

---

### 5B.8 Partnership Page (`/partnership`)

#### Page Hero (`hero`)

| Field | Current Value |
|-------|--------------|
| `eyebrow` | "Partnership" |
| `title` | "Reach people who actually show up." |
| `description` | "We collaborate with companies, universities, media and communities on crypto education." |

#### Partner Listing (`partners`) — PartnerBlock, 3 groups

> Same as Home Page `partnership` section — see Home Page entry.

#### Benefits (`benefits`) — CardGridBlock, 4 items

| Title | Description |
|-------|-------------|
| "Engaged audience" | "5,200+ members who attend, not just subscribe." |
| "Education-first" | "Programs built by analysts, not marketers." |
| "Multi-channel" | "Discord, live classes, research and offline events." |
| "Transparent reporting" | "Attendance, engagement and feedback data." |

#### Process (`process`) — CardGridBlock, 4 items (fixed order)

| # | Title | Description |
|---|-------|-------------|
| 01 | "Introduction" | "A 30-minute call to understand your goals." |
| 02 | "Proposal" | "We map a program, audience and timeline." |
| 03 | "Pilot" | "One event or research collaboration to test fit." |
| 04 | "Ongoing" | "A yearly plan with shared reporting." |

#### Inquiry Form (`inquiry-form`) — RichTextBlock

Form labels: "Company", "Contact name", "Email", "Phone", "What do you have in mind?"
Submit button: "Send inquiry"
CTA: Partnership inquiry heading

---

### 5B.9 Contact Page (`/contact`)

#### Page Hero (`hero`)

| Field | Current Value |
|-------|--------------|
| `eyebrow` | "Contact" |
| `title` | "Talk to a human." |
| `description` | "Questions about membership, partnership or events we answer every one." |

#### Contact Channels (`channels`) — ContactBlock, 4 items

| Channel | Icon | Value | Note |
|---------|------|-------|------|
| "WhatsApp" | `MessageCircle` | "+62 811 0000 111" | "Fastest response" |
| "Email" | `Mail` | "hello@volubit.io" | "Within 1 business day" |
| "Office" | `MapPin` | "Kuningan, Jakarta Selatan" | "By appointment" |
| "Business hours" | `Clock` | "Mon–Fri, 09:00–18:00 WIB" | "Discord is 24/7" |

#### Office Info (`office`) — RichTextBlock

| Field | Value |
|-------|-------|
| Label | "Volubit HQ" |
| Address | "Jl. Mars Dirgahayu No.3B Bandung, Indonesia" |

#### Contact Form (`form`) — RichTextBlock

Form labels: "Name", "Email", "Subject", "WhatsApp", "Message"
Submit button: "Send message"
CTA: "Send us a message" heading

#### Quick FAQ (`faq`) — FAQBlock, 3 items

| Question | Answer |
|----------|--------|
| "How fast do you reply?" | "WhatsApp within a few hours on business days." |
| "Can I visit the office?" | "Yes, by appointment message us first." |
| "Where do members get support?" | "Inside Discord, in the #help room." |

---

### 5B.10 Site Settings (Global)

#### Navigation (`nav`)

| Item | Label | To |
|------|-------|----|
| 1 | "Home" | "/" |
| 2 | "Membership" | "/membership" |
| — | "Membership Detail" | "/membership" |
| — | "Pricing" | "/membership/pricing" |
| — | "Checkout" | "/checkout" |
| 3 | "Events" | "/events" |
| — | "Upcoming" | "/events" |
| — | "Past Events" | "/events/past" |
| 4 | "Articles" | "/articles" |

> Note: Community, Partnership, About, Contact are currently commented out in nav.

#### Footer Columns (`footer`)

**Column 1 — Platform:**
| Label | To |
|-------|----|
| "Membership" | "/membership" |
| "Pricing" | "/membership/pricing" |
| "Community" | "/community" |
| "Events" | "/events" |

**Column 2 — Resources:**
| Label | To |
|-------|----|
| "Articles" | "/articles" |
| "FAQ" | "/faq" |
| "Partnership" | "/partnership" |
| "About" | "/about" |

**Column 3 — Contact:**
| Label | To |
|-------|----|
| "Contact us" | "/contact" |
| "Checkout" | "/checkout" |

**Footer Description:**
> "A premium crypto education community. Research, mentorship and a room full of people learning in public."

**Social Links (footer):** `"Discord"`, `"Telegram"`, `"X"`, `"LinkedIn"`, `"YouTube"`

**Copyright:**
> "© {year} Volubit. Educational content only, not financial advice."

**Legal links:** `"Privacy"`, `"Terms"`, `"Disclaimer"`

#### Logo & Branding

| Field | Value |
|-------|-------|
| Logo image | `@/assets/logo.png` |
| Logo alt | "Volubit Logo" |
| Site name | "Volubit" |
| Footer brand mark | "V" (in a primary-colored box) |

#### Announcement Bar

| Field | Value |
|-------|-------|
| `enabled` | `false` (currently not used) |

---

## 5C. Article & Event Tables

Articles and Events are first-class content entities with their own tables, full CRUD API, and dedicated admin management UI. They differ from the rest of the CMS because they support: draft/publish lifecycle (articles), admin-defined ordering, and cross-page relationships.

### 5C.1 Article Table Schema

Articles are the **one exception** to the immediate-save rule. They support a draft/publish workflow since editors may write content over multiple sessions before publishing.

```typescript
interface Article {
  id: string;                    // UUID v4
  slug: string;                  // URL-friendly, unique (e.g. "liquidity-leads-price")
  title: string;
  excerpt: string;                // Short description for card/listing view
  content: string;                // Full HTML from TipTap (or textarea for v1)
  category: string;               // e.g. "Macro", "On-chain", "DeFi"
  tags: string[];                 // e.g. ["bitcoin", "macro", "trading"]
  author: string;                 // e.g. "Rizky Pratama"
  readTime: string;               // e.g. "8 min"
  image?: MediaBlock;
  isFeatured: boolean;             // Shows in the home page featured article slot
  isPublished: boolean;           // Draft (false) or Published (true)
  sortOrder: number;             // Admin-defined sort order for listing pages
  publishedAt?: string;          // Set when isPublished becomes true
  createdAt: string;
  updatedAt: string;
  updatedBy: string;              // admin ID
}
```

**Article Lifecycle:**
- `isPublished = false` → Draft (only visible to admins in the listing table)
- `isPublished = true` → Published (visible on public site; `publishedAt` timestamp set)
- Saving a draft does **not** publish. Editor must explicitly click "Publish."
- Re-saving a published article keeps it published unless editor manually sets `isPublished = false`

### 5C.2 Article Categories

Categories are a lightweight entity managed via simple CRUD in the admin panel:

```typescript
interface ArticleCategory {
  id: string;
  name: string;       // Display name, e.g. "Macro"
  slug: string;       // URL-friendly, e.g. "macro"
  description?: string;
  sortOrder: number;
}
```

### 5C.3 Event Table Schema

Events have their own table to support status filtering (upcoming vs. past), capacity display, and the events listing page.

```typescript
interface Event {
  id: string;                    // UUID v4
  slug: string;                   // URL-friendly, e.g. "macro-outlook-q4-webinar"
  title: string;
  description: string;            // Full event description (for detail page)
  shortDescription: string;       // For cards on home page and events listing

  // Scheduling
  date: string;                   // ISO date, e.g. "2026-09-12"
  time: string;                   // e.g. "20:00 WIB"
  endTime?: string;

  // Location
  locationType: "online" | "offline" | "hybrid";
  place: string;                  // Display label: "Zoom", "Kuningan, Jakarta", etc.
  meetingUrl?: string;             // Video call link for online/hybrid events

  // Capacity
  seats: number;                   // Total capacity
  seatsTaken?: number;             // Display only (not auto-tracked in v1)
  isFull: boolean;                 // Manual override or derived from seatsTaken >= seats

  // Speaker
  speaker: string;
  speakerRole?: string;
  speakerPhoto?: MediaBlock;

  // Content
  agenda: string[];
  coverImage?: MediaBlock;

  // Status & visibility
  status: "draft" | "upcoming" | "ongoing" | "past" | "cancelled";
  isFeatured: boolean;             // Shows in the featured-event section
  isPublished: boolean;           // Hidden from all listings if false
  sortOrder: number;              // Admin-defined sort order

  // Past event content
  recordingUrl?: string;           // Set after event occurs

  // Metadata
  createdAt: string;
  updatedAt: string;
  updatedBy: string;
}
```

**Event Status Rules:**
- `draft` — Not visible anywhere
- `upcoming` — Visible on home page and events listing
- `ongoing` — Marked manually; shows "live now" badge
- `past` — Derived by date comparison (`date < today`); shown on `/events/past`
- `cancelled` — Hidden from all listings

### 5C.4 Events on the Home Page

The home page `upcoming-events` section pulls from the events table, not from a content block. The FE server-side calls `/api/events?status=upcoming&limit=3&featured=false`. Editors manage events from the dedicated Events admin panel.

The events page `featured-event` and `upcoming-events` sections are also powered by the events table query — the `featured-event` block in `content_blocks` is replaced by a query to `events` table filtered by `isFeatured = true`.

---



## 6. API Design

### 6.1 Public Endpoints (No Auth)

| Method | Path                          | Description                     |
| ------ | ----------------------------- | ------------------------------- |
| `GET`  | `/api/content/:page`          | All blocks for a page           |
| `GET`  | `/api/content/:page/:section` | Single section                  |
| `GET`  | `/api/site-settings`           | Public site settings             |
| `GET`  | `/api/events`                 | Events listing (accepts query params) |
| `GET`  | `/api/events/:slug`           | Single event by slug            |
| `GET`  | `/api/articles`               | Articles listing (accepts query params) |
| `GET`  | `/api/articles/:slug`         | Single article by slug          |

**Query params for `/api/events`:**
- `status`: `upcoming` | `past` | `cancelled` (default: `upcoming`)
- `limit`: number (default: 10)
- `featured`: `true` | `false` (optional filter)
- `page`: number (default: 1)

**Query params for `/api/articles`:**
- `category`: string (filter by category slug)
- `limit`: number (default: 10)
- `published`: `true` | `false` (default: `true`)
- `page`: number (default: 1)

**Public API Cache TTL**: 5 minutes. Responses include `cachedAt` timestamp.

**Response Envelope**:

```typescript
{
  success: true,
  data: ContentBlockRow[], // or single block
  cachedAt: string,        // ISO timestamp
}
```

**Error Response**:

```typescript
{
  success: false,
  error: { code: string, message: string }
}
```

### 6.2 Admin Content Endpoints (Auth Required)

| Method   | Path                          | Description                              |
| -------- | ----------------------------- | ---------------------------------------- |
| `GET`    | `/api/admin/content`          | All blocks for a page                    |
| `GET`    | `/api/admin/content/:id`      | Single block by ID                       |
| `PUT`    | `/api/admin/content/:id`      | Update block (immediate, no draft state) |

**Notes**:
- `POST /api/admin/content` is **not exposed** to admin UI — new items are added via the `PUT` endpoint with an `addItem` operation on array-type blocks (subject to section cap)
- `DELETE` — Editors can remove items from array-type blocks via `PUT`. Hard deletion of entire blocks is developer-only via database migration.
- There is **no publish endpoint** — changes are live immediately on save

### 6.3 Article Endpoints (Auth Required)

| Method   | Path                          | Description                              |
| -------- | ----------------------------- | ---------------------------------------- |
| `GET`    | `/api/admin/articles`         | List all articles (including drafts)     |
| `GET`    | `/api/admin/articles/:id`     | Get single article                       |
| `POST`   | `/api/admin/articles`         | Create new article (draft by default)     |
| `PUT`    | `/api/admin/articles/:id`    | Update article                           |
| `DELETE` | `/api/admin/articles/:id`     | Delete article                           |
| `GET`    | `/api/admin/article-categories` | List categories                        |
| `POST`   | `/api/admin/article-categories` | Create category                       |
| `PUT`    | `/api/admin/article-categories/:id` | Update category                  |
| `DELETE` | `/api/admin/article-categories/:id` | Delete category                  |

### 6.4 Event Endpoints (Auth Required)

| Method   | Path                          | Description                              |
| -------- | ----------------------------- | ---------------------------------------- |
| `GET`    | `/api/admin/events`           | List all events (all statuses)           |
| `GET`    | `/api/admin/events/:id`       | Get single event                         |
| `POST`   | `/api/admin/events`           | Create new event                         |
| `PUT`    | `/api/admin/events/:id`       | Update event                             |
| `DELETE` | `/api/admin/events/:id`       | Delete event                             |

### 6.5 Media Endpoints (Auth Required)

| Method   | Path                      | Description                                         |
| -------- | ------------------------- | --------------------------------------------------- |
| `GET`    | `/api/admin/media`         | List media with pagination (`?page=1&limit=20&q=`)  |
| `POST`   | `/api/admin/media/upload`  | Upload file (multipart/form-data)                   |
| `DELETE` | `/api/admin/media/:id`     | Delete media (with usage check)                     |

### 6.6 Admin System Endpoints (Auth Required)

| Method   | Path                    | Description                                      |
| -------- | ----------------------- | ------------------------------------------------ |
| `GET`    | `/api/admin/stats`      | Dashboard statistics                             |
| `GET`    | `/api/admin/audit-log`  | Paginated audit log (`?page=1&limit=50`)         |
| `GET`    | `/api/admin/admins`     | List all admin accounts                          |
| `POST`   | `/api/admin/admins`     | Create new admin (sets password directly)         |
| `PUT`    | `/api/admin/admins/:id` | Update admin (name, password, isActive)         |
| `DELETE` | `/api/admin/admins/:id` | Deactivate admin                                 |

> **Single role.** All logged-in admins can do everything. No role-based access control.

### 6.7 Update Operation Format

```typescript
// PUT /api/admin/content/:id

// Operation types for array blocks (addItem, removeItem, updateItem)
type ContentUpdateOperation =
  | { op: "update"; path: string; value: any }           // Update a field (e.g. hero.headline)
  | { op: "addItem"; path: string; value: any }           // Add to array (e.g. mentors.items)
  | { op: "removeItem"; path: string; index: number }    // Remove from array
  | { op: "replace"; path: string; value: any }          // Replace entire block data

// Full request body
interface UpdateRequest {
  operations: ContentUpdateOperation[];
}
```

### 6.8 Auth Endpoints

| Method | Path                | Description                 |
| ------ | ------------------- | --------------------------- |
| `POST` | `/api/auth/login`   | Login with email + password |
| `POST` | `/api/auth/refresh` | Refresh access token        |
| `GET`  | `/api/auth/me`      | Get current admin profile   |
| `POST` | `/api/auth/logout`  | Logout and invalidate token |

> **No password reset.** If an admin forgets their password, another admin resets it from the admin management panel. If all admins are locked out, use the setup script to generate a new credential.---

## 7. Project Structure

### 7.1 Frontend (`volubit-frontend`)

```
volubit-frontend/
├── src/
│   ├── lib/
│   │   ├── api.ts                    # Typed API client (fetch wrapper)
│   │   ├── api-client.ts             # Runtime API client (reads env)
│   │   ├── content.ts                # Content fetching + TanStack Query hooks
│   │   ├── types/
│   │   │   └── content.ts           # All block type interfaces
│   │   └── static-defaults/          # Fallback content (SSR if BE unreachable)
│   │       ├── home.ts
│   │       ├── community.ts
│   │       ├── about.ts
│   │       ├── events.ts
│   │       ├── articles.ts
│   │       ├── faq.ts
│   │       ├── membership.ts
│   │       ├── partnership.ts
│   │       └── contact.ts
│   ├── components/
│   │   ├── home/
│   │   │   ├── sections.tsx          # Receives content as props
│   │   │   ├── Hero.tsx             # Receives content as props
│   │   │   └── CommunityShowcase.tsx
│   │   ├── site/
│   │   │   ├── Nav.tsx              # Reads site settings (logo, social links)
│   │   │   └── Footer.tsx           # Reads site settings (copyright, links)
│   │   └── ui/                      # shadcn/ui components (unchanged)
│   ├── hooks/
│   │   ├── useContentSection.ts     # TanStack Query hook per section
│   │   └── useSiteSettings.ts       # TanStack Query hook for global settings
│   ├── routes/
│   │   ├── __root.tsx               # Root layout (Nav + Footer + announcement bar)
│   │   ├── index.tsx                # Home page SSR content load
│   │   ├── community.tsx
│   │   ├── about.tsx
│   │   ├── events/
│   │   │   ├── index.tsx           # Events listing (fetches from /api/events)
│   │   │   └── past.tsx            # Past events (fetches from /api/events?status=past)
│   │   ├── articles/
│   │   │   ├── index.tsx           # Articles listing (fetches from /api/articles)
│   │   │   └── [slug].tsx          # Article detail (fetches from /api/articles/:slug)
│   │   ├── faq.tsx
│   │   ├── membership/
│   │   │   ├── index.tsx
│   │   │   └── pricing.tsx
│   │   ├── partnership.tsx
│   │   └── contact.tsx
│   └── styles.css
├── .env.example
└── package.json
```

### 7.2 Backend (`volubit-backend`)

```
volubit-backend/
├── src/
│   ├── routes/
│   │   ├── content/
│   │   │   ├── index.get.ts          # List all blocks (admin) or by page
│   │   │   ├── [page].get.ts         # Public: get page blocks
│   │   │   ├── [page]/[section].get.ts  # Public: get section
│   │   │   ├── [id].get.ts           # Get single block by ID (admin)
│   │   │   └── [id].put.ts           # Update block (admin)
│   │   ├── articles/
│   │   │   ├── index.get.ts         # Public: list articles
│   │   │   ├── [slug].get.ts        # Public: single article
│   │   │   ├── index.post.ts        # Admin: create article
│   │   │   └── [id].put.ts          # Admin: update article
│   │   │   └── [id].delete.ts       # Admin: delete article
│   │   ├── events/
│   │   │   ├── index.get.ts         # Public: list events
│   │   │   ├── [slug].get.ts        # Public: single event
│   │   │   ├── index.post.ts        # Admin: create event
│   │   │   └── [id].put.ts          # Admin: update event
│   │   ├── article-categories/
│   │   │   ├── index.get.ts
│   │   │   ├── index.post.ts
│   │   │   └── [id].put.ts
│   │   ├── media/
│   │   │   ├── index.get.ts          # List media
│   │   │   ├── upload.post.ts        # Upload file
│   │   │   └── [id].delete.ts        # Delete media
│   │   ├── auth/
│   │   │   ├── login.post.ts
│   │   │   ├── refresh.post.ts
│   │   │   ├── me.get.ts
│   │   │   └── logout.post.ts
│   │   ├── admin/
│   │   │   ├── stats.get.ts
│   │   │   ├── audit-log.get.ts
│   │   │   ├── admins.get.ts
│   │   │   ├── admins.post.ts
│   │   │   ├── admins.[id].put.ts
│   │   │   └── admins.[id].delete.ts
│   │   ├── site-settings/
│   │   │   ├── index.get.ts          # Public: site settings
│   │   │   └── index.put.ts          # Admin: update settings
│   │   └── internal/
│   │       └── invalidate.post.ts    # Cache invalidation webhook
│   ├── db/
│   │   ├── index.ts                  # DB connection
│   │   ├── schema.ts                 # Drizzle schema (all tables)
│   │   └── migrations/               # Drizzle migration files
│   ├── middleware/
│   │   ├── auth.ts                   # JWT verification middleware
│   │   ├── role.ts                  # Role-based access middleware
│   │   └── audit.ts                  # Audit log middleware
│   ├── services/
│   │   ├── content.service.ts        # Business logic for content updates
│   │   ├── media.service.ts          # Business logic for media
│   │   ├── auth.service.ts           # Auth logic (password hashing, JWT)
│   │   └── cache.service.ts          # Cache invalidation logic
│   ├── storage/
│   │   └── index.ts                  # File storage abstraction (local / R2 / S3)
│   ├── utils/
│   │   ├── response.ts               # Typed API response helpers
│   │   └── validators.ts             # Zod schemas for request validation
│   └── index.ts                      # App entry
├── drizzle.config.ts
├── package.json
└── .env.example
```

---

## 8. Database Schema

### 8.1 Tables Overview

```
content_blocks       CMS content blocks (structure defined at seed time)
articles            Full article entities with draft/publish lifecycle
events              Full event entities with status and capacity
article_categories  Article category taxonomy
admins              Admin user accounts (single role)
media               Uploaded files
audit_log           All admin actions
```

### 8.2 content_blocks

```sql
CREATE TABLE content_blocks (
  id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  page          TEXT NOT NULL,        -- 'home' | 'community' | 'about' | etc.
  section       TEXT NOT NULL,        -- 'hero' | 'upcoming-events' | etc.
  block_type    TEXT NOT NULL,        -- 'HeroBlock' | 'CardGridBlock' | etc.
  locale        TEXT NOT NULL DEFAULT 'en',
  data          JSONB NOT NULL,
  created_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_by    UUID REFERENCES admins(id),
  UNIQUE (page, section, locale)
);
```

Note: No `status` column — all blocks are always live. No `current_version` — no versioning.

### 8.3 admins

```sql
CREATE TABLE admins (
  id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  email         TEXT UNIQUE NOT NULL,
  name          TEXT NOT NULL,
  password_hash TEXT NOT NULL,
  is_active     BOOLEAN NOT NULL DEFAULT true,
  last_login    TIMESTAMPTZ,
  created_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at    TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```
> **Single role.** All admins have equal permissions. No role column.

### 8.4 articles

```sql
CREATE TABLE articles (
  id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  slug         TEXT UNIQUE NOT NULL,
  title        TEXT NOT NULL,
  excerpt      TEXT NOT NULL,
  content      TEXT NOT NULL,
  category     TEXT NOT NULL,
  tags         TEXT[] NOT NULL DEFAULT '{}',
  author       TEXT NOT NULL,
  read_time    TEXT NOT NULL,
  image_url    TEXT,
  image_alt    TEXT,
  is_featured  BOOLEAN NOT NULL DEFAULT false,
  is_published BOOLEAN NOT NULL DEFAULT false,
  sort_order   INT NOT NULL DEFAULT 0,
  published_at TIMESTAMPTZ,
  created_at   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_by   UUID REFERENCES admins(id)
);
```

### 8.5 article_categories

```sql
CREATE TABLE article_categories (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name        TEXT NOT NULL,
  slug        TEXT UNIQUE NOT NULL,
  description TEXT,
  sort_order  INT NOT NULL DEFAULT 0,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```

### 8.6 events

```sql
CREATE TABLE events (
  id             UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  slug           TEXT UNIQUE NOT NULL,
  title          TEXT NOT NULL,
  description    TEXT NOT NULL,
  short_desc     TEXT NOT NULL,
  date           DATE NOT NULL,
  time           TEXT NOT NULL,
  end_time       TEXT,
  location_type  TEXT NOT NULL CHECK (location_type IN ('online', 'offline', 'hybrid')),
  place          TEXT NOT NULL,
  meeting_url    TEXT,
  seats          INT NOT NULL DEFAULT 0,
  seats_taken    INT NOT NULL DEFAULT 0,
  is_full        BOOLEAN NOT NULL DEFAULT false,
  speaker        TEXT NOT NULL,
  speaker_role   TEXT,
  agenda         TEXT[] NOT NULL DEFAULT '{}',
  cover_image_url TEXT,
  cover_image_alt TEXT,
  status         TEXT NOT NULL DEFAULT 'draft'
                    CHECK (status IN ('draft', 'upcoming', 'ongoing', 'past', 'cancelled')),
  is_featured    BOOLEAN NOT NULL DEFAULT false,
  is_published   BOOLEAN NOT NULL DEFAULT false,
  sort_order     INT NOT NULL DEFAULT 0,
  recording_url  TEXT,
  created_at     TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at     TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_by     UUID REFERENCES admins(id)
);
```

### 8.7 media

```sql
CREATE TABLE media (
  id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  filename      TEXT NOT NULL,
  original_name TEXT NOT NULL,
  url           TEXT NOT NULL,
  mime_type     TEXT NOT NULL,
  size          INT NOT NULL,
  width         INT,
  height        INT,
  alt           TEXT,
  uploaded_by   UUID REFERENCES admins(id),
  created_at    TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```

### 8.8 audit_log

```sql
CREATE TABLE audit_log (
  id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  admin_id      UUID REFERENCES admins(id),
  action        TEXT NOT NULL,       -- 'update' | 'upload' | 'delete' | 'login'
  block_id      UUID,
  block_page    TEXT,
  block_section TEXT,
  metadata      JSONB,
  ip_address    TEXT,
  created_at    TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- audit_log is append-only; no UPDATE or DELETE allowed via application
```

---

## 9. User Flows

### 9.1 Content Editor Updates a Mentor's Name

```
1. Editor logs in at admin.volubit.io
2. Clicks "Home" in the sidebar → expands to show all sections
3. Clicks "Mentors / Instructors" section
4. Section view shows 3 mentor cards in a fixed list (no drag handles)
5. Editor clicks the first mentor card "Daniel Wijaya"
6. Slide-over editor opens with form fields
7. Editor changes "Daniel Wijaya" to "Daniel Wijaya, CFA"
8. Editor clicks "Save"
9. Confirmation: "Changes saved and published immediately"
10. Audit log entry created
11. FE cache invalidated → Public site shows updated name
```

### 9.2 Content Editor Adds a New Mentor

```
1. Editor opens "Mentors" section
2. Clicks "+ Add Mentor" button (button is disabled if cap is reached — 10 mentors)
3. Slide-over opens with empty mentor form
4. Editor fills: name, role, experience, specialization, courses, certifications
5. Editor clicks "Upload Photo" — file picker opens, selects image.jpg
6. Image uploaded to /api/admin/media/upload, returns URL
7. Photo field populated with thumbnail preview
8. Editor clicks "Save"
9. New mentor appears at the end of the mentor list (fixed position)
10. Audit log entry created
11. FE cache invalidated
```

### 9.3 Content Editor Removes an Event

```
1. Editor opens "Upcoming Events" section
2. Clicks the event card to expand it
3. Clicks "Remove" button
4. Confirmation dialog: "Remove this event? This cannot be undone."
5. Editor confirms
6. Item removed from the section's items array
7. Audit log entry created
8. FE cache invalidated
```

### 9.4 Content Editor Uploads a Hero Image

```
1. Editor opens "Home" → "Hero" section
2. Clicks on the hero image field
3. "Upload Image" or "Choose from Library" modal appears
4. Editor clicks "Upload" → selects hero-bg.jpg from file picker
5. File uploaded to /api/admin/media/upload
6. BE returns: { url: "https://media.volubit.io/hero-bg.jpg", width: 1920, height: 1080 }
7. Image preview populated in the field
8. Editor clicks "Save"
9. Audit log entry created
10. Public site hero image updated
```

### 9.5 Public Visitor Page Load

```
1. User types volubit.io/events
2. TanStack Start server receives request
3. Server calls GET https://api.volubit.io/api/content/events
4. BE returns all blocks for events page (cached 5 min)
5. Server renders full HTML with content (SSR)
6. Full HTML sent to user (< 200ms target)
7. React hydrates on client
8. Subsequent client-side navigation uses TanStack Query cache
```

### 9.6 Admin Resizes a Gallery Image

```
1. Editor opens "Home" → "Community Gallery" section
2. Gallery section shows 11 image thumbnails
3. Editor clicks an image thumbnail
4. Image editor modal opens with: src preview, alt text field, layout hint selector
5. Editor changes alt text to "Members at Volubit meetup"
6. Editor changes layout hint from "portrait" to "landscape"
7. Editor clicks "Save"
8. Changes saved immediately
9. FE cache invalidated
```

### 9.7 Admin Creates a New Article

```
1. Editor clicks "Articles" in the admin sidebar
2. Article listing table shows all articles (published + drafts)
3. Editor clicks "+ New Article"
4. Editor fills: title, slug (auto-generated), excerpt, category, tags, author, readTime
5. Editor writes/pastes content in the text editor
6. Editor uploads a cover image
7. Editor clicks "Save Draft" — article saved as isPublished: false
8. Editor reviews the article, then clicks "Publish"
9. isPublished becomes true, publishedAt is set
10. Audit log entry created
11. Public site now shows the article
```

### 9.8 Admin Publishes a Past Event Recording

```
1. Editor clicks "Events" in the admin sidebar
2. Clicks on an event with status "past"
3. Slide-over editor opens
4. Editor pastes a recording URL into the "Recording URL" field
5. Editor clicks "Save"
6. Audit log entry created
7. Past event page now shows a recording link
```

### 9.9 Editor Hits Section Cap

```
1. Editor opens "Mentors" section
2. Section currently has 10 mentors (at the cap)
3. "+ Add Mentor" button is disabled with a tooltip: "Section at capacity (max 10)"
4. Editor cannot add more items
5. No error — the UI prevents the action gracefully
6. If the editor needs to add more, they must contact a developer to raise the cap
```

### 9.10 Editor Uploads an Image and Assigns to a Block

```
1. Editor opens "Home" → "Hero" section
2. Editor clicks the hero image field
3. Modal appears with two options: "Upload New" or "Choose from Library"
4. Editor clicks "Upload New"
5. File picker opens, selects hero-bg.jpg
6. File uploaded to /api/admin/media/upload
7. BE resizes image (max 1920px wide + 400px thumbnail), returns both URLs
8. Image preview populated in the field
9. Editor clicks "Save"
10. Audit log entry created
11. Hero image updated on public site
```

### 9.11 First Admin Setup (Bootstrapping)

```
1. Developer runs the seed/setup script
2. Script creates the first admin account with email + password from environment variables
3. Admin receives credentials via secure channel (not email)
4. First admin logs in at admin.volubit.io
5. Admin changes their password from the profile settings
6. Admin creates additional admin accounts for other team members
7. Setup credentials are discarded
```

---

## 10. Implementation Plan

### Phase 1 — Backend Foundation

**Timeline**: Week 1–2

| Task                     | Description                                                            |
| ------------------------ | ---------------------------------------------------------------------- |
| BE project setup         | Initialize Nitro project, configure TypeScript, set up environment     |
| Database setup           | Spin up PostgreSQL, configure Drizzle ORM, write all migration files   |
| DB schema implementation | Create all 8 tables, indexes, constraints                              |
| Admin auth               | Login endpoint, password hashing (bcrypt), JWT issuance, refresh logic  |
| Auth middleware          | JWT verification middleware for all admin routes                       |
| First-admin setup script | Seed script that creates the first admin account from env vars          |
| Content CRUD API         | Read + Update for content_blocks (no create/delete for blocks)         |
| Media upload API         | File upload handler, local storage, media table                       |
| Image resizing           | Resize uploads to max 1920px + thumbnail 400px                        |
| Audit log                | Middleware that logs every admin mutation                              |
| Seed data                | Populate DB with current hardcoded content as initial blocks           |

**Deliverable**: A working BE API where all content blocks can be read and updated immediately. No admin UI yet.

---

### Phase 2 — Admin Panel Core

**Timeline**: Week 2–3

| Task                   | Description                                                      |
| ---------------------- | ---------------------------------------------------------------- |
| Admin login page       | Email + password form, JWT storage, session management           |
| Dashboard              | Block count per page, recent edits, media usage stats            |
| Page browser sidebar   | Collapsible page list with section tree                          |
| Section editor view    | List view of items (fixed order — no drag handles), item preview |
| Inline item editor     | Form modal/slide-over for all block types                        |
| Add/Remove items       | "Add Item" button with cap enforcement + disabled tooltip         |
| Save action            | Immediate save, confirmation, audit log entry                    |
| Image upload in editor | Upload flow from within item editor (modal or inline)             |

**Deliverable**: A functional admin panel where a logged-in editor can navigate pages, edit section items, add/remove items within cap, and save changes.

---

### Phase 3 — Frontend Integration

**Timeline**: Week 3–4

| Task                  | Description                                        |
| --------------------- | -------------------------------------------------- |
| API client            | Typed fetch wrapper with auth header injection      |
| Content hooks         | TanStack Query hooks per block type                 |
| Static defaults       | Write fallback files for all pages                 |
| Refactor sections.tsx | Replace hardcoded arrays with content hook calls    |
| Refactor route pages  | Update all pages to SSR-load content             |
| Nav + Footer          | Integrate site settings (logo, social, footer)     |
| Webhook on save       | On admin save → POST to configurable webhook URL   |
| SSR fallback          | Static defaults render when BE is unreachable       |

**Deliverable**: Frontend is fully content-driven from BE API. Static defaults ensure site is never empty. Zero visual regressions.

---

### Phase 4 — Articles & Events Management

**Timeline**: Week 4–5

| Task                  | Description                                        |
| --------------------- | -------------------------------------------------- |
| Articles CRUD API     | Full REST endpoints for articles table              |
| Article categories API | Full CRUD for categories                           |
| Events CRUD API       | Full REST endpoints for events table               |
| Articles admin UI     | Article listing table with status filter, CRUD, publish/draft toggle |
| Events admin UI      | Events listing table, status filter, CRUD          |
| Article editor        | Form editor for all article fields                 |
| Event editor          | Form editor for all event fields                   |
| Home page integration | Home page fetches events + articles from BE tables |
| Events page integration | Events page fetches from events table           |
| TipTap or textarea    | Text editor for article content (see note below)   |

> **TipTap note:** For v1, a plain `<textarea>` with Markdown support is sufficient. TipTap can be added later if demand for rich text grows. If using TipTap, sanitize output HTML with DOMPurify before saving.

---

### Phase 5 — Polish & Admin Management

**Timeline**: Week 5–6

| Task                     | Description                                     |
| ------------------------ | ----------------------------------------------- |
| Media library            | Standalone media grid with search, filter, delete |
| Site settings panel      | Global settings form (logo, social, nav, footer, announcement) |
| SEO fields UI            | Per-page meta title/description/og image editors |
| Admin account management | Create, reset password, deactivate admin UI     |
| Audit log viewer         | Paginated log table with filters                |
| Error handling           | BE errors don't crash FE (fallback to static)   |
| Rate limiting             | Public API: 60 req/min per IP; Admin API: 300 req/min |
| Performance audit        | SSR timing, API response times, cache hit rates  |

**Deliverable**: Full CMS system ready for production use.

---

### Phase 6 — Launch Preparation

**Timeline**: Week 6–7

| Task                    | Description                                           |
| ----------------------- | ----------------------------------------------------- |
| BE hosting              | Deploy BE to chosen host (Fly.io / Railway / etc.)   |
| FE hosting              | Deploy FE (Vercel / Cloudflare Pages / etc.)          |
| Environment config      | Set API URL, webhook URL, secrets in FE + BE env       |
| Webhook setup           | Configure webhook URL in admin panel for cache invalidation |
| DNS + domain routing    | Point admin.volubit.io to BE, volubit.io to FE       |
| Seed production data    | Migrate all hardcoded content to CMS via seed script   |
| Smoke test              | Full content update flow, article publish, event create |
| Handoff docs            | Admin guide, developer ops runbook                    |

---

## 11. Success Metrics

| Metric                    | Target                                | Measurement                |
| ------------------------- | ------------------------------------- | -------------------------- |
| Time to update content    | < 2 minutes from login to live        | Observed from content team |
| Time to publish article   | < 1 minute from "Publish" click to live | Observed from content team |
| CMS content coverage      | 100% of static data is CMS-controlled | Audit of all sections      |
| Events table coverage     | 100% of events in events table        | Spot check                 |
| Articles table coverage   | 100% of articles in articles table    | Spot check                 |
| Zero deploys for content  | 100% of content updates via admin     | Deploy frequency tracking  |
| FE page load time (SSR)   | < 300ms (TTFB)                        | Lighthouse / WebPageTest   |
| BE API response time      | < 100ms p95                           | API monitoring             |
| Cache hit rate            | > 90% on public API                   | BE cache metrics           |
| Admin panel load time     | < 2 seconds initial load              | Browser dev tools          |
| Media upload time         | < 5 seconds for a 2MB image           | Browser dev tools          |

---

## 12. Operational & Edge Cases

### 12.1 Cache Invalidation

**Strategy:** On every admin content save, the BE POSTs to a configurable webhook URL (e.g. Vercel's On-demand ISR revalidate endpoint). The webhook URL is stored in admin panel settings.

- If no webhook URL is configured, cache expires naturally at TTL (5 min)
- The `/api/internal/invalidate` endpoint is internal-only; the webhook is the recommended public mechanism
- Cache is invalidated per-page (not global) when possible

### 12.2 Image Compression & Resizing

On every file upload:
1. Original file stored as-is
2. Resized to max 1920px wide → stored as `original` variant
3. Resized to 400px wide → stored as `thumbnail` variant
4. `MediaBlock` stores the `original` URL; admin/media listing returns thumbnail URLs

### 12.3 Media Orphan Cleanup

When an image is deleted from the media library:
- If currently referenced by any `content_blocks` record → deletion is blocked with error: "Image is in use"
- If not referenced → file deleted from storage + DB record removed
- No automatic cleanup of unused images; editors manage manually

### 12.4 Concurrent Edit Handling

**Last-write-wins.** No optimistic locking in v1. If two editors edit the same block simultaneously:
- Both see the current state
- Both save; the last save overwrites the previous
- Audit log records both saves with timestamps

If concurrent editing becomes a real problem, a `version` integer column can be added later (optimistic locking with conflict detection).

### 12.5 Cap Enforcement

- **Add Item**: If current item count >= cap, "+ Add" button is disabled with tooltip "Section at capacity (max N)"
- **Remove Item**: No minimum enforced; editors can remove all items down to 0 (FE should handle empty gracefully)
- **Soft Cap**: Cap is advisory. A developer can raise it via database migration.

### 12.6 Session & Token Management

- JWT access token: 15-minute expiry
- JWT refresh token: 7-day expiry, stored in httpOnly cookie
- On logout: access token invalidated via a denylist (or just rely on expiry)
- Admin session in FE: stored in memory (not localStorage) to reduce XSS surface
- No concurrent session limit in v1

### 12.7 SEO Fallback

When a page's per-page SEO field is empty:
- `metaTitle` falls back to `SiteSettingsBlock.defaultMetaTitle`
- `metaDesc` falls back to `SiteSettingsBlock.defaultMetaDesc`
- `ogImage` falls back to `SiteSettingsBlock.defaultOgImage`

If site defaults are also empty, FE renders without the `<meta>` tags (no hardcoded fallback strings).

### 12.8 TipTap / HTML Sanitization

If TipTap is added in Phase 4:
- Allowed tags: `p`, `h2`, `h3`, `h4`, `ul`, `ol`, `li`, `strong`, `em`, `code`, `pre`, `blockquote`, `a`, `img`
- All `href` attributes on `<a>` tags are validated to be `http`, `https`, or `#` only
- All `src` attributes on `<img>` tags must reference existing uploaded media URLs
- Sanitization runs server-side (in the BE) before saving, not client-side
- Use `isomorphic-dompurify` or `sanitize-html` library

### 12.9 Rate Limiting

| Endpoint group | Limit | Window |
|----------------|-------|--------|
| Public API (`/api/content/*`, `/api/events`, `/api/articles`) | 60 req/min | per IP |
| Public API (`/api/site-settings`) | 120 req/min | per IP |
| Admin API (`/api/admin/*`) | 300 req/min | per token |
| Auth endpoints (`/api/auth/login`) | 10 req/min | per IP |

### 12.10 What Happens When BE Is Unreachable

**FE SSR:** Static default content is used. No error shown to the user. Logged as a warning for ops to investigate.

**Admin panel:** Direct API errors surface to the editor with the error message from the BE.

---

## 13. Open Decisions

These decisions need to be made before implementation begins:

### 13.1 Backend Hosting

Where should `volubit-backend` run?

- **Option A**: Fly.io or Railway — simple, fast deploy, pays for usage
- **Option B**: Supabase managed Postgres + Edge Functions, generous free tier
- **Option C**: Cloudflare Workers (Durable Objects) — global edge, pay-per-request
- **Decision needed**: Before Phase 1

### 13.2 Media Storage

Where should uploaded images be stored?

- **Option A**: Local filesystem (Nitro public folder) — simplest, but not production-scale
- **Option B**: Cloudflare R2 — S3-compatible, no egress fees, cheap
- **Option C**: AWS S3 — familiar, standard, egress costs
- **Decision needed**: Before Phase 2

### 13.3 Frontend Hosting

Where should `volubit-frontend` deploy?

- **Current state**: Appears to be a self-hosted Node.js app (Nitro server.ts)
- **Option A**: Vercel — best TanStack Start support, edge network, preview deployments
- **Option B**: Cloudflare Pages — free, fast, Workers integration
- **Option C**: Fly.io — keep self-hosted, full control
- **Decision needed**: Before Phase 3

### 13.4 BE Project Stack

Keep Nitro or switch?

- **Option A**: Keep Nitro (same stack as FE) — team familiarity, unified DX
- **Option B**: Supabase managed backend — less custom code, faster to ship
- **Decision needed**: Before Phase 1



---

_End of PRD v1.1_
