Enhance site layout rendering and integrate shared components
- Added read-only access to shared frontend components in `docker-compose.app.yml`. - Updated TypeScript configuration in `tsconfig.app.json` and `tsconfig.json` to support path mapping for shared components. - Modified CSS imports in `index.css` to accommodate shared styles. - Refactored layout rendering logic in `layoutRenderer.tsx` to utilize shared layout components for various site layouts. - Improved template rendering in `templateEngine.tsx` by integrating shared block components for better consistency and maintainability.
This commit is contained in:
Binary file not shown.
@@ -116,6 +116,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- /data/app/igny8/sites:/app:rw
|
- /data/app/igny8/sites:/app:rw
|
||||||
- /data/app/sites-data:/sites:ro # Read-only access to deployed sites
|
- /data/app/sites-data:/sites:ro # Read-only access to deployed sites
|
||||||
|
- /data/app/igny8/frontend:/frontend:ro # Read-only access to shared components
|
||||||
networks: [igny8_net]
|
networks: [igny8_net]
|
||||||
labels:
|
labels:
|
||||||
- "com.docker.compose.project=igny8-app"
|
- "com.docker.compose.project=igny8-app"
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
/* Import shared component styles */
|
||||||
|
/* Note: Using relative path since @shared alias may not work in CSS */
|
||||||
|
/* These will be imported via JavaScript instead */
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
font-family: 'Inter', 'Inter var', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
font-family: 'Inter', 'Inter var', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import { StrictMode } from 'react';
|
|||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import App from './App.tsx';
|
import App from './App.tsx';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
// Import shared component styles - Vite alias @shared resolves to frontend/src/components/shared
|
||||||
|
import '@shared/blocks/blocks.css';
|
||||||
|
import '@shared/layouts/layouts.css';
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
|||||||
@@ -2,20 +2,29 @@
|
|||||||
* Layout Renderer
|
* Layout Renderer
|
||||||
* Phase 5: Sites Renderer & Publishing
|
* Phase 5: Sites Renderer & Publishing
|
||||||
*
|
*
|
||||||
* Renders different layout types for sites.
|
* Renders different layout types for sites using shared components.
|
||||||
*/
|
*/
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { SiteDefinition } from '../types';
|
import type { SiteDefinition } from '../types';
|
||||||
import { renderTemplate } from './templateEngine';
|
import { renderTemplate } from './templateEngine';
|
||||||
|
import {
|
||||||
|
DefaultLayout,
|
||||||
|
MinimalLayout,
|
||||||
|
MagazineLayout,
|
||||||
|
EcommerceLayout,
|
||||||
|
PortfolioLayout,
|
||||||
|
BlogLayout,
|
||||||
|
CorporateLayout,
|
||||||
|
} from '@shared/layouts';
|
||||||
|
|
||||||
export type LayoutType =
|
export type LayoutType =
|
||||||
| 'default'
|
| 'default'
|
||||||
| 'centered'
|
|
||||||
| 'sidebar'
|
|
||||||
| 'grid'
|
|
||||||
| 'magazine'
|
|
||||||
| 'minimal'
|
| 'minimal'
|
||||||
| 'fullwidth';
|
| 'magazine'
|
||||||
|
| 'ecommerce'
|
||||||
|
| 'portfolio'
|
||||||
|
| 'blog'
|
||||||
|
| 'corporate';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render site layout based on site definition.
|
* Render site layout based on site definition.
|
||||||
@@ -24,18 +33,18 @@ export function renderLayout(siteDefinition: SiteDefinition): React.ReactElement
|
|||||||
const layoutType = siteDefinition.layout as LayoutType;
|
const layoutType = siteDefinition.layout as LayoutType;
|
||||||
|
|
||||||
switch (layoutType) {
|
switch (layoutType) {
|
||||||
case 'centered':
|
|
||||||
return renderCenteredLayout(siteDefinition);
|
|
||||||
case 'sidebar':
|
|
||||||
return renderSidebarLayout(siteDefinition);
|
|
||||||
case 'grid':
|
|
||||||
return renderGridLayout(siteDefinition);
|
|
||||||
case 'magazine':
|
|
||||||
return renderMagazineLayout(siteDefinition);
|
|
||||||
case 'minimal':
|
case 'minimal':
|
||||||
return renderMinimalLayout(siteDefinition);
|
return renderMinimalLayout(siteDefinition);
|
||||||
case 'fullwidth':
|
case 'magazine':
|
||||||
return renderFullwidthLayout(siteDefinition);
|
return renderMagazineLayout(siteDefinition);
|
||||||
|
case 'ecommerce':
|
||||||
|
return renderEcommerceLayout(siteDefinition);
|
||||||
|
case 'portfolio':
|
||||||
|
return renderPortfolioLayout(siteDefinition);
|
||||||
|
case 'blog':
|
||||||
|
return renderBlogLayout(siteDefinition);
|
||||||
|
case 'corporate':
|
||||||
|
return renderCorporateLayout(siteDefinition);
|
||||||
case 'default':
|
case 'default':
|
||||||
default:
|
default:
|
||||||
return renderDefaultLayout(siteDefinition);
|
return renderDefaultLayout(siteDefinition);
|
||||||
@@ -44,154 +53,55 @@ export function renderLayout(siteDefinition: SiteDefinition): React.ReactElement
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Default layout: Standard header, content, footer.
|
* Default layout: Standard header, content, footer.
|
||||||
|
* Uses shared DefaultLayout component with fully styled modern design.
|
||||||
*/
|
*/
|
||||||
function renderDefaultLayout(siteDefinition: SiteDefinition): React.ReactElement {
|
function renderDefaultLayout(siteDefinition: SiteDefinition): React.ReactElement {
|
||||||
|
// Find home page for hero
|
||||||
|
const homePage = siteDefinition.pages.find(p => p.slug === 'home');
|
||||||
|
const heroBlock = homePage?.blocks?.find(b => b.type === 'hero');
|
||||||
|
const hero: React.ReactNode = heroBlock ? (renderTemplate(heroBlock) as React.ReactNode) : undefined;
|
||||||
|
|
||||||
|
// Render all pages as sections (excluding hero from home page if it exists)
|
||||||
|
const sections = siteDefinition.pages
|
||||||
|
.filter((page) => page.status !== 'draft')
|
||||||
|
.map((page) => {
|
||||||
|
// Filter out hero block if it's the home page (already rendered as hero)
|
||||||
|
const blocksToRender = page.slug === 'home' && heroBlock
|
||||||
|
? page.blocks?.filter(b => b.type !== 'hero') || []
|
||||||
|
: page.blocks || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="layout-default">
|
<div key={page.id} className="page" data-page-slug={page.slug}>
|
||||||
<header className="site-header">
|
{page.slug !== 'home' && <h2>{page.title}</h2>}
|
||||||
<nav className="site-navigation">
|
{blocksToRender.length > 0 ? (
|
||||||
{renderNavigation(siteDefinition.navigation)}
|
blocksToRender.map((block, index) => (
|
||||||
</nav>
|
<div key={index} className="block" data-block-type={block.type}>
|
||||||
</header>
|
{renderTemplate(block)}
|
||||||
<main className="site-main">
|
</div>
|
||||||
{renderPages(siteDefinition.pages)}
|
))
|
||||||
</main>
|
) : page.slug !== 'home' ? (
|
||||||
<footer className="site-footer">
|
<p>No content available for this page.</p>
|
||||||
<p>© {new Date().getFullYear()} {siteDefinition.name}</p>
|
) : null}
|
||||||
</footer>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Centered layout: Content centered with max-width.
|
|
||||||
*/
|
|
||||||
function renderCenteredLayout(siteDefinition: SiteDefinition): React.ReactElement {
|
|
||||||
return (
|
return (
|
||||||
<div className="layout-centered">
|
<DefaultLayout
|
||||||
<header className="site-header">
|
hero={hero as any}
|
||||||
<nav className="site-navigation">
|
sections={sections}
|
||||||
{renderNavigation(siteDefinition.navigation)}
|
/>
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
<main className="site-main" style={{ maxWidth: '1200px', margin: '0 auto', padding: '2rem' }}>
|
|
||||||
{renderPages(siteDefinition.pages)}
|
|
||||||
</main>
|
|
||||||
<footer className="site-footer">
|
|
||||||
<p>© {new Date().getFullYear()} {siteDefinition.name}</p>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sidebar layout: Sidebar navigation with main content.
|
|
||||||
*/
|
|
||||||
function renderSidebarLayout(siteDefinition: SiteDefinition): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div className="layout-sidebar">
|
|
||||||
<aside className="site-sidebar">
|
|
||||||
<nav className="site-navigation">
|
|
||||||
{renderNavigation(siteDefinition.navigation)}
|
|
||||||
</nav>
|
|
||||||
</aside>
|
|
||||||
<main className="site-main">
|
|
||||||
{renderPages(siteDefinition.pages)}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Grid layout: Grid-based page layout.
|
|
||||||
*/
|
|
||||||
function renderGridLayout(siteDefinition: SiteDefinition): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div className="layout-grid">
|
|
||||||
<header className="site-header">
|
|
||||||
<nav className="site-navigation">
|
|
||||||
{renderNavigation(siteDefinition.navigation)}
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
<main className="site-main" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', gap: '2rem', padding: '2rem' }}>
|
|
||||||
{renderPages(siteDefinition.pages)}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Magazine layout: Multi-column magazine style.
|
|
||||||
*/
|
|
||||||
function renderMagazineLayout(siteDefinition: SiteDefinition): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div className="layout-magazine">
|
|
||||||
<header className="site-header">
|
|
||||||
<h1>{siteDefinition.name}</h1>
|
|
||||||
<nav className="site-navigation">
|
|
||||||
{renderNavigation(siteDefinition.navigation)}
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
<main className="site-main" style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: '2rem', padding: '2rem' }}>
|
|
||||||
{renderPages(siteDefinition.pages)}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal layout: Clean, minimal design.
|
* Minimal layout: Clean, minimal design.
|
||||||
|
* Uses shared MinimalLayout component.
|
||||||
*/
|
*/
|
||||||
function renderMinimalLayout(siteDefinition: SiteDefinition): React.ReactElement {
|
function renderMinimalLayout(siteDefinition: SiteDefinition): React.ReactElement {
|
||||||
return (
|
const mainContent = (
|
||||||
<div className="layout-minimal">
|
<>
|
||||||
<main className="site-main" style={{ padding: '4rem 2rem', maxWidth: '800px', margin: '0 auto' }}>
|
{siteDefinition.pages
|
||||||
{renderPages(siteDefinition.pages)}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fullwidth layout: Full-width content.
|
|
||||||
*/
|
|
||||||
function renderFullwidthLayout(siteDefinition: SiteDefinition): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div className="layout-fullwidth">
|
|
||||||
<header className="site-header">
|
|
||||||
<nav className="site-navigation">
|
|
||||||
{renderNavigation(siteDefinition.navigation)}
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
<main className="site-main" style={{ width: '100%' }}>
|
|
||||||
{renderPages(siteDefinition.pages)}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render navigation items.
|
|
||||||
*/
|
|
||||||
function renderNavigation(navigation: SiteDefinition['navigation']): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<ul style={{ listStyle: 'none', display: 'flex', gap: '1rem', padding: 0 }}>
|
|
||||||
{navigation.map((item) => (
|
|
||||||
<li key={item.slug}>
|
|
||||||
<a href={`/${item.slug}`}>{item.label}</a>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render pages.
|
|
||||||
*/
|
|
||||||
function renderPages(pages: SiteDefinition['pages']): React.ReactElement[] {
|
|
||||||
// Filter pages - include ready, generating, and deployed statuses
|
|
||||||
// Only exclude draft status
|
|
||||||
return pages
|
|
||||||
.filter((page) => page.status !== 'draft')
|
.filter((page) => page.status !== 'draft')
|
||||||
.map((page) => (
|
.map((page) => (
|
||||||
<div key={page.id} className="page" data-page-slug={page.slug}>
|
<div key={page.id} className="page" data-page-slug={page.slug}>
|
||||||
@@ -202,10 +112,168 @@ function renderPages(pages: SiteDefinition['pages']): React.ReactElement[] {
|
|||||||
{renderTemplate(block)}
|
{renderTemplate(block)}
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : null}
|
||||||
<p>No content available for this page.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
));
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MinimalLayout>
|
||||||
|
{mainContent}
|
||||||
|
</MinimalLayout>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Magazine layout: Editorial, content-focused.
|
||||||
|
* Uses shared MagazineLayout component.
|
||||||
|
*/
|
||||||
|
function renderMagazineLayout(siteDefinition: SiteDefinition): React.ReactElement {
|
||||||
|
const mainContent = (
|
||||||
|
<>
|
||||||
|
{siteDefinition.pages
|
||||||
|
.filter((page) => page.status !== 'draft')
|
||||||
|
.map((page) => (
|
||||||
|
<div key={page.id} className="page" data-page-slug={page.slug}>
|
||||||
|
{page.blocks && page.blocks.length > 0 ? (
|
||||||
|
page.blocks.map((block, index) => (
|
||||||
|
<div key={index} className="block" data-block-type={block.type}>
|
||||||
|
{renderTemplate(block)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MagazineLayout
|
||||||
|
mainContent={mainContent}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ecommerce layout: Product-focused.
|
||||||
|
* Uses shared EcommerceLayout component.
|
||||||
|
*/
|
||||||
|
function renderEcommerceLayout(siteDefinition: SiteDefinition): React.ReactElement {
|
||||||
|
const mainContent = (
|
||||||
|
<>
|
||||||
|
{siteDefinition.pages
|
||||||
|
.filter((page) => page.status !== 'draft')
|
||||||
|
.map((page) => (
|
||||||
|
<div key={page.id} className="page" data-page-slug={page.slug}>
|
||||||
|
{page.blocks && page.blocks.length > 0 ? (
|
||||||
|
page.blocks.map((block, index) => (
|
||||||
|
<div key={index} className="block" data-block-type={block.type}>
|
||||||
|
{renderTemplate(block)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EcommerceLayout
|
||||||
|
mainContent={mainContent}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Portfolio layout: Showcase.
|
||||||
|
* Uses shared PortfolioLayout component.
|
||||||
|
*/
|
||||||
|
function renderPortfolioLayout(siteDefinition: SiteDefinition): React.ReactElement {
|
||||||
|
const mainContent = (
|
||||||
|
<>
|
||||||
|
{siteDefinition.pages
|
||||||
|
.filter((page) => page.status !== 'draft')
|
||||||
|
.map((page) => (
|
||||||
|
<div key={page.id} className="page" data-page-slug={page.slug}>
|
||||||
|
{page.blocks && page.blocks.length > 0 ? (
|
||||||
|
page.blocks.map((block, index) => (
|
||||||
|
<div key={index} className="block" data-block-type={block.type}>
|
||||||
|
{renderTemplate(block)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PortfolioLayout
|
||||||
|
mainContent={mainContent}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blog layout: Content-first.
|
||||||
|
* Uses shared BlogLayout component.
|
||||||
|
*/
|
||||||
|
function renderBlogLayout(siteDefinition: SiteDefinition): React.ReactElement {
|
||||||
|
const mainContent = (
|
||||||
|
<>
|
||||||
|
{siteDefinition.pages
|
||||||
|
.filter((page) => page.status !== 'draft')
|
||||||
|
.map((page) => (
|
||||||
|
<div key={page.id} className="page" data-page-slug={page.slug}>
|
||||||
|
{page.blocks && page.blocks.length > 0 ? (
|
||||||
|
page.blocks.map((block, index) => (
|
||||||
|
<div key={index} className="block" data-block-type={block.type}>
|
||||||
|
{renderTemplate(block)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BlogLayout
|
||||||
|
mainContent={mainContent}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Corporate layout: Business.
|
||||||
|
* Uses shared CorporateLayout component.
|
||||||
|
*/
|
||||||
|
function renderCorporateLayout(siteDefinition: SiteDefinition): React.ReactElement {
|
||||||
|
const mainContent = (
|
||||||
|
<>
|
||||||
|
{siteDefinition.pages
|
||||||
|
.filter((page) => page.status !== 'draft')
|
||||||
|
.map((page) => (
|
||||||
|
<div key={page.id} className="page" data-page-slug={page.slug}>
|
||||||
|
{page.blocks && page.blocks.length > 0 ? (
|
||||||
|
page.blocks.map((block, index) => (
|
||||||
|
<div key={index} className="block" data-block-type={block.type}>
|
||||||
|
{renderTemplate(block)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CorporateLayout
|
||||||
|
mainContent={mainContent}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: Navigation and page rendering are now handled within each layout component
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,24 @@
|
|||||||
* Phase 5: Sites Renderer & Publishing
|
* Phase 5: Sites Renderer & Publishing
|
||||||
*
|
*
|
||||||
* Renders blocks using shared components from the component library.
|
* Renders blocks using shared components from the component library.
|
||||||
|
* Uses fully styled modern components from @shared.
|
||||||
*/
|
*/
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { Block } from '../types';
|
import type { Block } from '../types';
|
||||||
|
import {
|
||||||
|
HeroBlock,
|
||||||
|
FeatureGridBlock,
|
||||||
|
TestimonialsBlock,
|
||||||
|
CTABlock,
|
||||||
|
ServicesBlock,
|
||||||
|
StatsPanel,
|
||||||
|
TextBlock,
|
||||||
|
QuoteBlock,
|
||||||
|
ContactFormBlock,
|
||||||
|
ImageGalleryBlock,
|
||||||
|
VideoBlock,
|
||||||
|
ProductsBlock,
|
||||||
|
} from '@shared/blocks';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render a block using the template engine.
|
* Render a block using the template engine.
|
||||||
@@ -76,36 +91,36 @@ export function renderTemplate(block: Block | any): React.ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render hero block.
|
* Render hero block using shared HeroBlock component.
|
||||||
*/
|
*/
|
||||||
function renderHeroBlock(data: Record<string, any>): React.ReactElement {
|
function renderHeroBlock(data: Record<string, any>): React.ReactElement {
|
||||||
// Handle both API format (heading/subheading) and template format (title/subtitle)
|
|
||||||
const title = data.heading || data.title || 'Hero Title';
|
const title = data.heading || data.title || 'Hero Title';
|
||||||
const subtitle = data.subheading || data.subtitle;
|
const subtitle = data.subheading || data.subtitle;
|
||||||
const content = Array.isArray(data.content) ? data.content.join(' ') : data.content;
|
const content = Array.isArray(data.content) ? data.content.join(' ') : data.content;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="block-hero" style={{ padding: '4rem 2rem', textAlign: 'center', background: data.background || '#f0f0f0' }}>
|
<HeroBlock
|
||||||
<h1>{title}</h1>
|
title={title}
|
||||||
{subtitle && <p style={{ fontSize: '1.2rem', color: '#666', marginTop: '1rem' }}>{subtitle}</p>}
|
subtitle={subtitle}
|
||||||
{content && <p style={{ marginTop: '1rem' }}>{content}</p>}
|
ctaLabel={data.buttonText || data.ctaLabel}
|
||||||
{data.buttonText && (
|
onCtaClick={data.buttonLink || data.ctaLink ? () => window.location.href = (data.buttonLink || data.ctaLink) : undefined}
|
||||||
<a href={data.buttonLink || '#'} className="button" style={{ display: 'inline-block', marginTop: '2rem', padding: '0.75rem 1.5rem', background: '#007bff', color: 'white', textDecoration: 'none', borderRadius: '4px' }}>
|
supportingContent={content ? <p>{content}</p> : undefined}
|
||||||
{data.buttonText}
|
/>
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render text block.
|
* Render text block using shared TextBlock component.
|
||||||
*/
|
*/
|
||||||
function renderTextBlock(data: Record<string, any>): React.ReactElement {
|
function renderTextBlock(data: Record<string, any>): React.ReactElement {
|
||||||
|
const content = Array.isArray(data.content) ? data.content.join(' ') : data.content;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="block-text" style={{ padding: '2rem' }}>
|
<TextBlock
|
||||||
{data.content && <div dangerouslySetInnerHTML={{ __html: data.content }} />}
|
title={data.heading || data.title}
|
||||||
</div>
|
content={content || ''}
|
||||||
|
align={data.align || 'left'}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,62 +289,52 @@ function renderAccordionBlock(data: Record<string, any>): React.ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render features block.
|
* Render features block using shared FeatureGridBlock component.
|
||||||
*/
|
*/
|
||||||
function renderFeaturesBlock(data: Record<string, any>): React.ReactElement {
|
function renderFeaturesBlock(data: Record<string, any>): React.ReactElement {
|
||||||
const heading = data.heading || data.title || 'Features';
|
const heading = data.heading || data.title;
|
||||||
const subheading = data.subheading || data.subtitle;
|
|
||||||
const content = Array.isArray(data.content) ? data.content : [];
|
const content = Array.isArray(data.content) ? data.content : [];
|
||||||
const layout = data.layout || 'two-column';
|
const layout = data.layout || 'two-column';
|
||||||
|
const columns = layout === 'two-column' ? 2 : layout === 'three-column' ? 3 : 3;
|
||||||
|
|
||||||
|
// Convert content array to features array
|
||||||
|
const features = content.map((item: string) => ({
|
||||||
|
title: item.split(':')[0] || item,
|
||||||
|
description: item.includes(':') ? item.split(':').slice(1).join(':').trim() : undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="block-features" style={{ padding: '3rem 2rem', background: data.background || 'transparent' }}>
|
<FeatureGridBlock
|
||||||
{heading && <h2 style={{ textAlign: 'center', marginBottom: '1rem' }}>{heading}</h2>}
|
heading={heading}
|
||||||
{subheading && <p style={{ textAlign: 'center', color: '#666', marginBottom: '2rem' }}>{subheading}</p>}
|
features={features}
|
||||||
<div style={{
|
columns={columns as 2 | 3 | 4}
|
||||||
display: 'grid',
|
/>
|
||||||
gridTemplateColumns: layout === 'two-column' ? 'repeat(2, 1fr)' : 'repeat(3, 1fr)',
|
|
||||||
gap: '2rem',
|
|
||||||
maxWidth: '1200px',
|
|
||||||
margin: '0 auto'
|
|
||||||
}}>
|
|
||||||
{content.map((item: string, index: number) => (
|
|
||||||
<div key={index} style={{ padding: '1.5rem', border: '1px solid #eee', borderRadius: '8px' }}>
|
|
||||||
<p>{item}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render testimonials block.
|
* Render testimonials block using shared TestimonialsBlock component.
|
||||||
*/
|
*/
|
||||||
function renderTestimonialsBlock(data: Record<string, any>): React.ReactElement {
|
function renderTestimonialsBlock(data: Record<string, any>): React.ReactElement {
|
||||||
const heading = data.heading || data.title || 'Testimonials';
|
const title = data.heading || data.title;
|
||||||
const subheading = data.subheading || data.subtitle;
|
const subtitle = data.subheading || data.subtitle;
|
||||||
const content = Array.isArray(data.content) ? data.content : [];
|
const content = Array.isArray(data.content) ? data.content : [];
|
||||||
const layout = data.layout || 'cards';
|
const layout = data.layout || 'cards';
|
||||||
|
|
||||||
|
// Convert content array to testimonials array
|
||||||
|
const testimonials = content.map((item: string) => ({
|
||||||
|
quote: item,
|
||||||
|
author: 'Customer', // Default author if not provided
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="block-testimonials" style={{ padding: '3rem 2rem', background: data.background || '#f9f9f9' }}>
|
<TestimonialsBlock
|
||||||
{heading && <h2 style={{ textAlign: 'center', marginBottom: '1rem' }}>{heading}</h2>}
|
title={title}
|
||||||
{subheading && <p style={{ textAlign: 'center', color: '#666', marginBottom: '2rem' }}>{subheading}</p>}
|
subtitle={subtitle}
|
||||||
<div style={{
|
testimonials={testimonials}
|
||||||
display: 'grid',
|
columns={layout === 'cards' ? 3 : 1}
|
||||||
gridTemplateColumns: layout === 'cards' ? 'repeat(auto-fit, minmax(300px, 1fr))' : '1fr',
|
variant={layout === 'cards' ? 'card' : 'default'}
|
||||||
gap: '2rem',
|
/>
|
||||||
maxWidth: '1200px',
|
|
||||||
margin: '0 auto'
|
|
||||||
}}>
|
|
||||||
{content.map((item: string, index: number) => (
|
|
||||||
<div key={index} style={{ padding: '2rem', background: 'white', borderRadius: '8px', boxShadow: '0 2px 4px rgba(0,0,0,0.1)' }}>
|
|
||||||
<p style={{ fontStyle: 'italic', marginBottom: '1rem' }}>"{item}"</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,70 +362,79 @@ function renderFAQBlock(data: Record<string, any>): React.ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render CTA (Call to Action) block.
|
* Render CTA (Call to Action) block using shared CTABlock component.
|
||||||
*/
|
*/
|
||||||
function renderCTABlock(data: Record<string, any>): React.ReactElement {
|
function renderCTABlock(data: Record<string, any>): React.ReactElement {
|
||||||
const heading = data.heading || data.title || 'Call to Action';
|
const title = data.heading || data.title || 'Call to Action';
|
||||||
const subheading = data.subheading || data.subtitle;
|
const subtitle = data.subheading || data.subtitle;
|
||||||
const content = Array.isArray(data.content) ? data.content[0] : data.content;
|
const content = Array.isArray(data.content) ? data.content[0] : data.content;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="block-cta" style={{ padding: '4rem 2rem', textAlign: 'center', background: data.background || '#007bff', color: 'white' }}>
|
<CTABlock
|
||||||
{heading && <h2 style={{ marginBottom: '1rem' }}>{heading}</h2>}
|
title={title}
|
||||||
{subheading && <p style={{ marginBottom: '2rem', fontSize: '1.1rem' }}>{subheading}</p>}
|
subtitle={subtitle}
|
||||||
{content && <p style={{ marginBottom: '2rem' }}>{content}</p>}
|
primaryCtaLabel={data.buttonText || data.ctaLabel}
|
||||||
{data.buttonText && (
|
primaryCtaLink={data.buttonLink || data.ctaLink}
|
||||||
<a href={data.buttonLink || '#'} style={{ display: 'inline-block', padding: '1rem 2rem', background: 'white', color: '#007bff', textDecoration: 'none', borderRadius: '4px', fontWeight: 'bold' }}>
|
backgroundImage={data.backgroundImage}
|
||||||
{data.buttonText}
|
variant={data.layout === 'full-width' ? 'centered' : 'default'}
|
||||||
</a>
|
>
|
||||||
)}
|
{content && <p>{content}</p>}
|
||||||
</section>
|
</CTABlock>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render services block.
|
* Render services block using shared ServicesBlock component.
|
||||||
*/
|
*/
|
||||||
function renderServicesBlock(data: Record<string, any>): React.ReactElement {
|
function renderServicesBlock(data: Record<string, any>): React.ReactElement {
|
||||||
const heading = data.heading || data.title || 'Services';
|
const title = data.heading || data.title;
|
||||||
const subheading = data.subheading || data.subtitle;
|
const subtitle = data.subheading || data.subtitle;
|
||||||
const content = Array.isArray(data.content) ? data.content : [];
|
const content = Array.isArray(data.content) ? data.content : [];
|
||||||
|
|
||||||
|
// Convert content array to services array
|
||||||
|
const services = content.map((item: string) => ({
|
||||||
|
title: item.split(':')[0] || item,
|
||||||
|
description: item.includes(':') ? item.split(':').slice(1).join(':').trim() : undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="block-services" style={{ padding: '3rem 2rem' }}>
|
<ServicesBlock
|
||||||
{heading && <h2 style={{ marginBottom: '1rem' }}>{heading}</h2>}
|
title={title}
|
||||||
{subheading && <p style={{ color: '#666', marginBottom: '2rem' }}>{subheading}</p>}
|
subtitle={subtitle}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))', gap: '2rem' }}>
|
services={services}
|
||||||
{content.map((item: string, index: number) => (
|
columns={3}
|
||||||
<div key={index} style={{ padding: '1.5rem', border: '1px solid #ddd', borderRadius: '8px' }}>
|
variant="card"
|
||||||
<p>{item}</p>
|
/>
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render stats block.
|
* Render stats block using shared StatsPanel component.
|
||||||
*/
|
*/
|
||||||
function renderStatsBlock(data: Record<string, any>): React.ReactElement {
|
function renderStatsBlock(data: Record<string, any>): React.ReactElement {
|
||||||
const heading = data.heading || data.title || 'Statistics';
|
|
||||||
const subheading = data.subheading || data.subtitle;
|
|
||||||
const content = Array.isArray(data.content) ? data.content : [];
|
const content = Array.isArray(data.content) ? data.content : [];
|
||||||
|
|
||||||
|
// Convert content array to stats array
|
||||||
|
const stats = content.map((item: string) => {
|
||||||
|
// Try to parse "Label: Value" format
|
||||||
|
const parts = item.split(':');
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
return {
|
||||||
|
label: parts[0].trim(),
|
||||||
|
value: parts.slice(1).join(':').trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
label: item,
|
||||||
|
value: '',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="block-stats" style={{ padding: '3rem 2rem', background: data.background || '#f9f9f9' }}>
|
<StatsPanel
|
||||||
{heading && <h2 style={{ textAlign: 'center', marginBottom: '1rem' }}>{heading}</h2>}
|
heading={data.heading || data.title}
|
||||||
{subheading && <p style={{ textAlign: 'center', color: '#666', marginBottom: '2rem' }}>{subheading}</p>}
|
stats={stats}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '2rem', maxWidth: '1200px', margin: '0 auto' }}>
|
/>
|
||||||
{content.map((item: string, index: number) => (
|
|
||||||
<div key={index} style={{ textAlign: 'center', padding: '1.5rem' }}>
|
|
||||||
<p style={{ fontSize: '1.1rem' }}>{item}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,12 @@
|
|||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Path mapping */
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@shared/*": ["../frontend/src/components/shared/*"]
|
||||||
|
},
|
||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@shared/*": ["../frontend/src/components/shared/*"]
|
"@shared/*": ["../frontend/src/components/shared/*", "/frontend/src/components/shared/*"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user