1009 lines
24 KiB
Markdown

# Block Library
Reusable page component system with JSON schema definitions, default values, and campaign-specific customization.
---
## Overview
The Block Library provides a database-driven system for managing reusable page components (blocks) in the GrapesJS editor. Administrators can use pre-configured blocks or create custom ones tailored to their campaign needs.
### Key Features
- **Database-Driven**: Blocks stored in PostgreSQL (PageBlock model)
- **JSON Schema**: Define configurable properties for each block type
- **Default Values**: Pre-populate blocks with campaign-specific content
- **Category Organization**: Group blocks (Headers, Content, Actions, etc.)
- **Sort Order**: Control block position in editor panel
- **6 Default Blocks**: Hero, Text, Features, CTA, Testimonials, Contact Form
- **Custom Blocks**: Create campaign-specific blocks via admin API
---
## Architecture
```mermaid
graph LR
A[(PageBlock Table)] -->|GET /api/page-blocks| B[API Service]
B --> C[LandingPageEditor]
C --> D[GrapesJSEditor]
D --> E[BlockManager]
E --> F[Left Panel]
G[Admin] -->|POST /api/page-blocks| B
G -->|Define Schema| H[JSON Schema]
G -->|Set Defaults| I[Default Values]
H --> A
I --> A
style A fill:#3498db
style E fill:#9d4edd
style F fill:#2ecc71
```
**Flow:**
1. **Seed**: Default blocks created in `api/prisma/seed.ts`
2. **Fetch**: Editor loads all blocks via `GET /api/page-blocks`
3. **Register**: GrapesJSEditor registers each block with BlockManager
4. **Render**: Blocks appear in left panel (grouped by category)
5. **Customize**: Admin creates custom blocks via API (future enhancement)
---
## Database Model
### PageBlock Table
**Schema:**
```typescript
model PageBlock {
id String @id @default(uuid())
type String @unique // Block type identifier (e.g., 'hero', 'text')
label String // Display name in editor ("Hero Section")
category String? // Group blocks ("Headers", "Content", "Actions")
sortOrder Int @default(0) // Position in left panel
schema Json // JSON schema for configurable properties
defaults Json // Default values for schema fields
thumbnail String? // Preview image URL (future enhancement)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([category, sortOrder])
}
```
**Fields:**
| Field | Type | Description |
|-------|------|-------------|
| `id` | String (UUID) | Primary key |
| `type` | String | Unique identifier (e.g., `"hero"`, `"features"`) |
| `label` | String | Human-readable name shown in editor |
| `category` | String? | Group blocks in collapsible sections |
| `sortOrder` | Int | Order within category (lower = higher in list) |
| `schema` | JSON | Property definitions (field name, type, label) |
| `defaults` | JSON | Default values for each schema field |
| `thumbnail` | String? | Preview image URL (not implemented) |
**Indexes:**
- `type` (unique)
- `category + sortOrder` (composite, for sorted listing)
---
## Default Blocks
### 1. Hero Section
**Type:** `hero`
**Category:** Headers
**Schema:**
```json
{
"title": { "type": "string", "label": "Title" },
"subtitle": { "type": "string", "label": "Subtitle" },
"backgroundImage": { "type": "string", "label": "Background Image URL" },
"ctaText": { "type": "string", "label": "Button Text" },
"ctaUrl": { "type": "string", "label": "Button URL" }
}
```
**Defaults:**
```json
{
"title": "Welcome to Our Campaign",
"subtitle": "Join us in making a difference in your community.",
"backgroundImage": "",
"ctaText": "Get Involved",
"ctaUrl": "#"
}
```
**Rendered HTML:**
```html
<section style="padding: 80px 40px; text-align: center; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); color: #fff;">
<h1 style="font-size: 2.5rem; margin-bottom: 16px;">Welcome to Our Campaign</h1>
<p style="font-size: 1.25rem; opacity: 0.85; margin-bottom: 32px;">Join us in making a difference in your community.</p>
<a href="#" style="display: inline-block; padding: 12px 32px; background: #9d4edd; color: #fff; text-decoration: none; border-radius: 6px; font-weight: 600;">Get Involved</a>
</section>
```
---
### 2. Text Block
**Type:** `text`
**Category:** Content
**Schema:**
```json
{
"heading": { "type": "string", "label": "Heading" },
"body": { "type": "text", "label": "Body Text" }
}
```
**Defaults:**
```json
{
"heading": "About Us",
"body": "Tell your story here. Explain your mission, values, and what drives your campaign forward."
}
```
**Rendered HTML:**
```html
<section style="padding: 60px 40px; max-width: 800px; margin: 0 auto;">
<h2 style="font-size: 1.75rem; margin-bottom: 16px;">About Us</h2>
<p style="font-size: 1rem; line-height: 1.7; opacity: 0.85;">Tell your story here. Explain your mission, values, and what drives your campaign forward.</p>
</section>
```
---
### 3. Features Grid
**Type:** `features`
**Category:** Content
**Schema:**
```json
{
"features": {
"type": "array",
"label": "Features",
"items": {
"title": "string",
"description": "string",
"icon": "string"
}
}
}
```
**Defaults:**
```json
{
"features": [
{ "title": "Community Action", "description": "Organize local events and initiatives.", "icon": "" },
{ "title": "Advocacy", "description": "Email your representatives directly.", "icon": "" },
{ "title": "Volunteer", "description": "Sign up for shifts and make a difference.", "icon": "" }
]
}
```
**Rendered HTML:**
```html
<section style="padding: 60px 40px;">
<div style="display: flex; flex-wrap: wrap; gap: 24px; justify-content: center;">
<div style="flex: 1; min-width: 250px; padding: 24px; text-align: center;">
<h3 style="font-size: 1.25rem; margin-bottom: 8px;">Community Action</h3>
<p style="opacity: 0.8;">Organize local events and initiatives.</p>
</div>
<div style="flex: 1; min-width: 250px; padding: 24px; text-align: center;">
<h3 style="font-size: 1.25rem; margin-bottom: 8px;">Advocacy</h3>
<p style="opacity: 0.8;">Email your representatives directly.</p>
</div>
<div style="flex: 1; min-width: 250px; padding: 24px; text-align: center;">
<h3 style="font-size: 1.25rem; margin-bottom: 8px;">Volunteer</h3>
<p style="opacity: 0.8;">Sign up for shifts and make a difference.</p>
</div>
</div>
</section>
```
---
### 4. Call to Action
**Type:** `cta`
**Category:** Actions
**Schema:**
```json
{
"heading": { "type": "string", "label": "Heading" },
"description": { "type": "string", "label": "Description" },
"buttonText": { "type": "string", "label": "Button Text" },
"buttonUrl": { "type": "string", "label": "Button URL" }
}
```
**Defaults:**
```json
{
"heading": "Ready to Take Action?",
"description": "Join thousands of community members making their voices heard.",
"buttonText": "Join Now",
"buttonUrl": "#"
}
```
**Rendered HTML:**
```html
<section style="padding: 60px 40px; text-align: center; background: linear-gradient(135deg, #9d4edd 0%, #7b2cbf 100%); color: #fff;">
<h2 style="font-size: 2rem; margin-bottom: 12px;">Ready to Take Action?</h2>
<p style="font-size: 1.1rem; margin-bottom: 24px; opacity: 0.9;">Join thousands of community members making their voices heard.</p>
<a href="#" style="display: inline-block; padding: 12px 32px; background: #fff; color: #9d4edd; text-decoration: none; border-radius: 6px; font-weight: 600;">Join Now</a>
</section>
```
---
### 5. Testimonials
**Type:** `testimonials`
**Category:** Content
**Schema:**
```json
{
"quotes": {
"type": "array",
"label": "Quotes",
"items": {
"text": "string",
"author": "string",
"role": "string"
}
}
}
```
**Defaults:**
```json
{
"quotes": [
{ "text": "This platform made it so easy to contact my representatives.", "author": "Jane D.", "role": "Community Member" },
{ "text": "I signed up for a volunteer shift and it changed my perspective.", "author": "Mark S.", "role": "Volunteer" }
]
}
```
**Rendered HTML:**
```html
<section style="padding: 60px 40px;">
<div style="display: flex; flex-wrap: wrap; gap: 24px; justify-content: center;">
<div style="flex: 1; min-width: 280px; padding: 24px; background: rgba(255,255,255,0.05); border-radius: 8px;">
<p style="font-style: italic; margin-bottom: 12px;">"This platform made it so easy to contact my representatives."</p>
<p style="font-weight: 600; margin-bottom: 2px;">Jane D.</p>
<p style="font-size: 0.85rem; opacity: 0.7;">Community Member</p>
</div>
<div style="flex: 1; min-width: 280px; padding: 24px; background: rgba(255,255,255,0.05); border-radius: 8px;">
<p style="font-style: italic; margin-bottom: 12px;">"I signed up for a volunteer shift and it changed my perspective."</p>
<p style="font-weight: 600; margin-bottom: 2px;">Mark S.</p>
<p style="font-size: 0.85rem; opacity: 0.7;">Volunteer</p>
</div>
</div>
</section>
```
---
### 6. Contact Form
**Type:** `contact-form`
**Category:** Actions
**Schema:**
```json
{
"heading": { "type": "string", "label": "Heading" },
"fields": {
"type": "array",
"label": "Fields",
"items": {
"name": "string",
"type": "string",
"required": "boolean"
}
}
}
```
**Defaults:**
```json
{
"heading": "Get in Touch",
"fields": [
{ "name": "name", "type": "text", "required": true },
{ "name": "email", "type": "email", "required": true },
{ "name": "message", "type": "textarea", "required": true }
]
}
```
**Rendered HTML:**
```html
<section style="padding: 60px 40px; max-width: 600px; margin: 0 auto;">
<h2 style="text-align: center; margin-bottom: 24px;">Get in Touch</h2>
<form style="display: flex; flex-direction: column; gap: 16px;">
<input type="text" placeholder="Name" style="padding: 10px 14px; border: 1px solid rgba(255,255,255,0.2); border-radius: 6px; background: rgba(255,255,255,0.05); color: inherit;" />
<input type="email" placeholder="Email" style="padding: 10px 14px; border: 1px solid rgba(255,255,255,0.2); border-radius: 6px; background: rgba(255,255,255,0.05); color: inherit;" />
<textarea placeholder="Message" rows="4" style="padding: 10px 14px; border: 1px solid rgba(255,255,255,0.2); border-radius: 6px; background: rgba(255,255,255,0.05); color: inherit; resize: vertical;"></textarea>
<button type="submit" style="padding: 12px 24px; background: #9d4edd; color: #fff; border: none; border-radius: 6px; font-weight: 600; cursor: pointer;">Send Message</button>
</form>
</section>
```
**Note:** Form submission not wired (static HTML). Use grapesjs-plugin-forms for backend integration.
---
## API Endpoints
### Admin Routes
**Prefix:** `/api/page-blocks`
**Authentication:** Requires admin role (SUPER_ADMIN, INFLUENCE_ADMIN, or MAP_ADMIN)
#### List Blocks
```http
GET /api/page-blocks?category=Headers
```
**Query Parameters:**
- `category` (string?) — Filter by category
**Response:**
```json
[
{
"id": "default-hero",
"type": "hero",
"label": "Hero Section",
"category": "Headers",
"sortOrder": 1,
"schema": {
"title": { "type": "string", "label": "Title" },
"subtitle": { "type": "string", "label": "Subtitle" }
},
"defaults": {
"title": "Welcome to Our Campaign",
"subtitle": "Join us in making a difference."
},
"thumbnail": null,
"createdAt": "2026-01-10T00:00:00Z",
"updatedAt": "2026-01-10T00:00:00Z"
}
]
```
**Sorting:**
- Results ordered by `category ASC, sortOrder ASC`
- Blocks in same category appear in sortOrder sequence
#### Get Block
```http
GET /api/page-blocks/:id
```
**Response:** Single `PageBlock` object
**Errors:**
- `404 BLOCK_NOT_FOUND` — Block doesn't exist
#### Create Block
```http
POST /api/page-blocks
Content-Type: application/json
{
"type": "campaign-stats",
"label": "Campaign Stats",
"category": "Campaign",
"sortOrder": 10,
"schema": {
"volunteers": { "type": "number", "label": "Volunteers" },
"emails": { "type": "number", "label": "Emails Sent" }
},
"defaults": {
"volunteers": 1250,
"emails": 5400
}
}
```
**Request Body:**
- `type` (string, required) — Unique type identifier (alphanumeric + hyphens)
- `label` (string, required) — Display name
- `category` (string?) — Group name (default: `null`)
- `sortOrder` (number?, default: 0) — Position in list
- `schema` (JSON, required) — Property definitions
- `defaults` (JSON, required) — Default values matching schema
- `thumbnail` (string?) — Preview image URL
**Response:** Created `PageBlock` object (201 status)
**Errors:**
- `400 VALIDATION_ERROR` — Invalid schema or type collision
#### Update Block
```http
PUT /api/page-blocks/:id
Content-Type: application/json
{
"label": "Updated Label",
"defaults": {
"volunteers": 2000
}
}
```
**Request Body:** (all fields optional except constraints)
- `type` (string?) — Cannot change after creation (immutable)
- `label` (string?)
- `category` (string?)
- `sortOrder` (number?)
- `schema` (JSON?)
- `defaults` (JSON?)
**Response:** Updated `PageBlock` object
**Errors:**
- `404 BLOCK_NOT_FOUND` — Block doesn't exist
- `400 VALIDATION_ERROR` — Invalid schema or defaults
#### Delete Block
```http
DELETE /api/page-blocks/:id
```
**Response:** 204 No Content
**Errors:**
- `404 BLOCK_NOT_FOUND` — Block doesn't exist
**Side Effects:**
- Pages using this block will still render (HTML is cached)
- Block removed from editor panel for new pages
---
## Schema Format
### Property Types
**Supported Types:**
| Type | Description | Example |
|------|-------------|---------|
| `string` | Short text field | Title, subtitle, URL |
| `text` | Multi-line text | Body paragraph |
| `number` | Numeric value | Volunteer count, price |
| `boolean` | True/false toggle | Show/hide element |
| `array` | List of items | Features, testimonials |
### Simple Property
```json
{
"title": {
"type": "string",
"label": "Title"
}
}
```
**Rendered in GrapesJS:** Text input labeled "Title"
### Array Property
```json
{
"features": {
"type": "array",
"label": "Features",
"items": {
"title": "string",
"description": "string",
"icon": "string"
}
}
}
```
**Rendered in GrapesJS:**
- Repeatable item group
- Add/remove buttons
- Each item has 3 fields (title, description, icon)
### Defaults Matching
**Schema:**
```json
{
"heading": { "type": "string", "label": "Heading" },
"count": { "type": "number", "label": "Count" }
}
```
**Valid Defaults:**
```json
{
"heading": "Our Impact",
"count": 42
}
```
**Invalid Defaults:**
```json
{
"heading": 123, // Type mismatch (should be string)
"count": "foo" // Type mismatch (should be number)
}
```
---
## Admin Workflow
### Using Default Blocks
1. **Open Editor:** Admin → Pages → Click "Edit" on any page
2. **Locate Block:** Left panel → Expand "Headers" category
3. **Drag Block:** Drag "Hero Section" to canvas
4. **Configure:** Click block → Right panel shows properties
- Title: `"Join the Movement"`
- Subtitle: `"Together we can make a difference."`
- CTA Text: `"Sign Up"`
- CTA URL: `"/shifts"`
5. **Save:** Press `Ctrl+S` → Block HTML stored in database
### Creating Custom Blocks
**Note:** Custom block creation UI not implemented. Use API directly.
**Example: Campaign Stats Block**
```bash
curl -X POST http://localhost:4000/api/page-blocks \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"type": "campaign-stats",
"label": "Campaign Stats",
"category": "Campaign",
"sortOrder": 10,
"schema": {
"volunteers": { "type": "number", "label": "Volunteers" },
"emails": { "type": "number", "label": "Emails Sent" },
"events": { "type": "number", "label": "Events" }
},
"defaults": {
"volunteers": 1250,
"emails": 5400,
"events": 32
}
}'
```
**Result:**
- New block appears in left panel under "Campaign" category
- Dragging block inserts HTML (requires `generateBlockHtml` update)
### Updating Block Defaults
**Use Case:** Update hero CTA text for all new pages
```bash
curl -X PUT http://localhost:4000/api/page-blocks/default-hero \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"defaults": {
"title": "Welcome to Our 2026 Campaign",
"subtitle": "Join us in making a difference.",
"ctaText": "Get Started Today",
"ctaUrl": "/shifts"
}
}'
```
**Effect:**
- New pages using hero block get updated defaults
- Existing pages unchanged (HTML already rendered)
---
## Code Examples
### Fetching Blocks for Editor
```typescript
import { api } from '@/lib/api';
import type { PageBlock } from '@/types/api';
async function loadBlocks(): Promise<PageBlock[]> {
const { data } = await api.get<PageBlock[]>('/page-blocks');
return data.sort((a, b) => {
// Sort by category, then sortOrder
const catCompare = (a.category || '').localeCompare(b.category || '');
return catCompare !== 0 ? catCompare : a.sortOrder - b.sortOrder;
});
}
```
### Creating Custom Block
```typescript
async function createCampaignStatsBlock() {
const { data } = await api.post<PageBlock>('/page-blocks', {
type: 'campaign-stats',
label: 'Campaign Stats',
category: 'Campaign',
sortOrder: 10,
schema: {
volunteers: { type: 'number', label: 'Volunteers' },
emails: { type: 'number', label: 'Emails Sent' },
events: { type: 'number', label: 'Events' },
},
defaults: {
volunteers: 1250,
emails: 5400,
events: 32,
},
});
console.log('Created block:', data.id);
return data;
}
```
### Extending generateBlockHtml()
```typescript
// In admin/src/components/GrapesJSEditor.tsx
function generateBlockHtml(type: string, defaults: Record<string, unknown>): string {
switch (type) {
// ... existing cases ...
case 'campaign-stats': {
const volunteers = defaults.volunteers || 0;
const emails = defaults.emails || 0;
const events = defaults.events || 0;
return `
<section style="padding: 60px 40px; background: #f8f9fa; text-align: center;">
<h2 style="margin-bottom: 32px; font-size: 2rem;">Our Impact</h2>
<div style="display: flex; gap: 48px; justify-content: center; flex-wrap: wrap;">
<div>
<div style="font-size: 3rem; font-weight: 700; color: #9d4edd;">${(volunteers as number).toLocaleString()}</div>
<div style="font-size: 1rem; color: #666;">Volunteers</div>
</div>
<div>
<div style="font-size: 3rem; font-weight: 700; color: #9d4edd;">${(emails as number).toLocaleString()}</div>
<div style="font-size: 1rem; color: #666;">Emails Sent</div>
</div>
<div>
<div style="font-size: 3rem; font-weight: 700; color: #9d4edd;">${events}</div>
<div style="font-size: 1rem; color: #666;">Events</div>
</div>
</div>
</section>`;
}
default:
return `<section style="padding: 40px; text-align: center;"><p>Custom block: ${type}</p></section>`;
}
}
```
---
## Troubleshooting
### Problem: Block Not Appearing in Editor
**Symptoms:**
- Created block via API
- Not visible in left panel
- Other blocks show correctly
**Causes:**
1. GrapesJSEditor not re-fetching blocks
2. `generateBlockHtml()` missing case
3. Category name mismatch
**Solutions:**
1. **Reload editor:**
- Close page editor → Re-open
- Blocks fetched on mount
2. **Add HTML generation case:**
```typescript
case 'my-new-block':
return `<section>My block HTML</section>`;
```
3. **Check category:**
```sql
SELECT category FROM page_blocks WHERE type = 'my-new-block';
-- Category should match GrapesJS panel (case-sensitive)
```
4. **Verify API response:**
```bash
curl -H "Authorization: Bearer $TOKEN" http://localhost:4000/api/page-blocks
# Should include new block in response
```
---
### Problem: Default Values Not Applying
**Symptoms:**
- Drag block to canvas → Fields are empty
- Expected pre-filled title/subtitle
**Causes:**
1. Defaults not matching schema keys
2. HTML template ignores defaults
3. Type mismatch (string vs number)
**Solutions:**
1. **Verify defaults match schema:**
```json
// Schema
{ "title": { "type": "string" } }
// Defaults (good)
{ "title": "Welcome" }
// Defaults (bad - key mismatch)
{ "heading": "Welcome" }
```
2. **Check HTML template:**
```typescript
// Good - uses defaults
return `<h1>${defaults.title || 'Fallback'}</h1>`;
// Bad - ignores defaults
return `<h1>Hardcoded Title</h1>`;
```
3. **Fix type mismatch:**
```typescript
// If schema says "number", defaults must be number
{ "count": { "type": "number" } }
{ "count": 42 } // Good
{ "count": "42" } // Bad
```
---
### Problem: Block HTML Not Rendering
**Symptoms:**
- Block appears in panel
- Dragging to canvas shows nothing or error
**Causes:**
1. `generateBlockHtml()` returns invalid HTML
2. Inline styles have syntax errors
3. Missing closing tags
**Solutions:**
1. **Validate HTML:**
```typescript
const html = generateBlockHtml('my-block', defaults);
console.log(html); // Check for malformed tags
```
2. **Test inline styles:**
```html
<!-- Bad - missing quotes -->
<div style=padding: 20px>
<!-- Good - quoted attribute -->
<div style="padding: 20px;">
```
3. **Use template literals carefully:**
```typescript
// Ensure all ${} expressions return strings
return `<div>${defaults.title || ''}</div>`;
```
---
## Performance Considerations
### Block Count Impact
**Threshold:** 50+ blocks in library
**Symptoms:**
- Slow editor initialization (~1s+)
- Left panel laggy on scroll
**Mitigations:**
1. **Category filtering:**
- Only fetch blocks for specific category
- Lazy-load categories on expand
2. **Pagination:**
- Load first 20 blocks, fetch more on scroll
- Not implemented in current version
3. **Caching:**
- Store blocks in localStorage
- Refresh only when version changes
### Schema Complexity
**Issue:** Deeply nested array schemas (3+ levels) slow GrapesJS rendering
**Example:**
```json
{
"sections": {
"type": "array",
"items": {
"features": {
"type": "array",
"items": {
"details": {
"type": "array"
}
}
}
}
}
}
```
**Alternative:** Flatten structure or use CODE mode
---
## Security Considerations
### Admin-Only Access
**Protection:** All `/api/page-blocks` endpoints require admin role
```typescript
router.use(authenticate);
router.use(requireRole(UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN));
```
**Risk:** Malicious admin creates XSS block with `<script>` tags
**Mitigation:**
- **Accepted risk:** Admins are trusted users
- Blocks only render on admin-authored pages (not user-submitted)
- Public pages use admin-created HTML (already trusted)
### Type Validation
**Attack:** Submit block with `type` containing SQL injection
**Protection:**
```typescript
// Zod schema in pages.schemas.ts
type: z.string()
.min(1)
.max(50)
.regex(/^[a-z0-9-]+$/, 'Type must be lowercase alphanumeric with hyphens'),
```
**Safe types:** `hero`, `text-block`, `campaign-stats-2026`
**Rejected:** `'; DROP TABLE--`, `<script>alert(1)</script>`
---
## Related Documentation
### Frontend Components
- **[GrapesJSEditor](/v2/frontend/components/GrapesJSEditor)** — Block registration logic
- **[LandingPageEditor](/v2/frontend/pages/LandingPageEditor)** — Fetches blocks for editor
### Backend Modules
- **[blocks.routes](/v2/backend/modules/pages/blocks.routes)** — CRUD endpoints
- **[blocks.service](/v2/backend/modules/pages/blocks.service)** — Business logic
- **[pages.schemas](/v2/backend/modules/pages/pages.schemas)** — Zod schemas
### Database
- **[PageBlock Model](/v2/database/models/pages)** — Schema + indexes
### Features
- **[Page Builder](page-builder.md)** — Landing page system
- **[GrapesJS Editor](grapes-editor.md)** — Editor integration
### Seed Data
- **[api/prisma/seed.ts](https://github.com/changemaker-lite/changemaker.lite/blob/v2/api/prisma/seed.ts)** — Default blocks definition