354 lines
12 KiB
TypeScript
354 lines
12 KiB
TypeScript
import { useMemo } from 'react';
|
|
import {
|
|
FeatureGridBlock,
|
|
HeroBlock,
|
|
MarketingTemplate,
|
|
StatsPanel,
|
|
type FeatureGridBlockProps,
|
|
type StatItem,
|
|
} from '@shared';
|
|
import { useSiteDefinitionStore } from '../../state/siteDefinitionStore';
|
|
import { useBuilderStore } from '../../state/builderStore';
|
|
import type { PageBlock, PageBlueprint, SiteStructure } from '../../types/siteBuilder';
|
|
|
|
type StructuredContent = Record<string, unknown> & {
|
|
items?: unknown[];
|
|
eyebrow?: string;
|
|
ctaLabel?: string;
|
|
supportingCopy?: string;
|
|
columns?: number;
|
|
};
|
|
|
|
export function PreviewCanvas() {
|
|
const { structure, pages, selectedSlug, selectPage } = useSiteDefinitionStore();
|
|
const { selectedPageIds, togglePageSelection, selectAllPages, clearPageSelection, activeBlueprint } =
|
|
useBuilderStore();
|
|
|
|
const page = useMemo(() => {
|
|
if (structure?.pages?.length) {
|
|
return structure.pages.find((p) => p.slug === selectedSlug) ?? structure.pages[0];
|
|
}
|
|
return pages.find((p) => p.slug === selectedSlug) ?? pages[0];
|
|
}, [structure, pages, selectedSlug]);
|
|
|
|
if (!structure && !pages.length) {
|
|
return (
|
|
<div className="preview-placeholder">
|
|
<p>Generate a blueprint to see live previews of every page.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const navItems = structure?.site?.primary_navigation ?? pages.map((p) => p.slug);
|
|
const blocks = getBlocks(page);
|
|
const heroBlock = blocks.find((block) => normalizeType(block.type) === 'hero');
|
|
const contentBlocks = heroBlock ? blocks.filter((block) => block !== heroBlock) : blocks;
|
|
|
|
const heroSection =
|
|
heroBlock || page
|
|
? renderBlock(heroBlock ?? buildFallbackHero(page, structure))
|
|
: null;
|
|
|
|
const sectionNodes =
|
|
contentBlocks.length > 0
|
|
? contentBlocks.map((block, index) => <div key={`${block.type}-${index}`}>{renderBlock(block)}</div>)
|
|
: buildFallbackSections(page);
|
|
|
|
const sidebar = (
|
|
<div className="preview-sidebar">
|
|
<p className="preview-label">Page objective</p>
|
|
<h4>{page?.objective ?? 'Launch a high-converting page'}</h4>
|
|
<ul className="preview-sidebar__list">
|
|
{buildSidebarInsights(page, structure).map((insight) => (
|
|
<li key={insight.label}>
|
|
<span>{insight.label}</span>
|
|
<strong>{insight.value}</strong>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
);
|
|
|
|
// Only show page selection if we have actual PageBlueprint objects with IDs
|
|
const hasPageBlueprints = pages.length > 0 && pages.every((p) => p.id > 0);
|
|
|
|
const allSelected = hasPageBlueprints && pages.length > 0 && selectedPageIds.length === pages.length;
|
|
const someSelected = hasPageBlueprints && selectedPageIds.length > 0 && selectedPageIds.length < pages.length;
|
|
|
|
return (
|
|
<div className="preview-canvas">
|
|
<div className="preview-nav">
|
|
{hasPageBlueprints && activeBlueprint && (
|
|
<div className="preview-page-selection" style={{ marginBottom: '12px', padding: '8px', background: '#f5f5f5', borderRadius: '4px' }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={allSelected}
|
|
ref={(input) => {
|
|
if (input) input.indeterminate = someSelected;
|
|
}}
|
|
onChange={(e) => {
|
|
if (e.target.checked) {
|
|
selectAllPages();
|
|
} else {
|
|
clearPageSelection();
|
|
}
|
|
}}
|
|
style={{ cursor: 'pointer' }}
|
|
/>
|
|
<label style={{ cursor: 'pointer', fontSize: '14px', fontWeight: '500' }}>
|
|
Select pages for bulk generation ({selectedPageIds.length} selected)
|
|
</label>
|
|
</div>
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
|
{pages.map((p) => {
|
|
const isSelected = selectedPageIds.includes(p.id);
|
|
return (
|
|
<label
|
|
key={p.id}
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '4px',
|
|
cursor: 'pointer',
|
|
fontSize: '12px',
|
|
padding: '4px 8px',
|
|
background: isSelected ? '#e3f2fd' : 'white',
|
|
border: `1px solid ${isSelected ? '#2196f3' : '#ddd'}`,
|
|
borderRadius: '4px',
|
|
}}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={isSelected}
|
|
onChange={() => togglePageSelection(p.id)}
|
|
style={{ cursor: 'pointer' }}
|
|
/>
|
|
<span>{p.title || p.slug.replace('-', ' ')}</span>
|
|
</label>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{navItems?.map((slug) => (
|
|
<button
|
|
key={slug}
|
|
type="button"
|
|
onClick={() => selectPage(slug)}
|
|
className={slug === (page?.slug ?? '') ? 'is-active' : ''}
|
|
>
|
|
{slug.replace('-', ' ')}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<MarketingTemplate hero={heroSection} sections={sectionNodes} sidebar={sidebar} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function getBlocks(
|
|
page: (SiteStructure['pages'][number] & { blocks_json?: PageBlock[] }) | PageBlueprint | undefined,
|
|
) {
|
|
if (!page) return [];
|
|
const fromStructure = (page as { blocks?: PageBlock[] }).blocks;
|
|
if (Array.isArray(fromStructure)) return fromStructure;
|
|
const fromBlueprint = (page as PageBlueprint).blocks_json;
|
|
return Array.isArray(fromBlueprint) ? fromBlueprint : [];
|
|
}
|
|
|
|
function renderBlock(block?: PageBlock) {
|
|
if (!block) return null;
|
|
const type = normalizeType(block.type);
|
|
const structuredContent = extractStructuredContent(block);
|
|
const listContent = extractListContent(block, structuredContent);
|
|
|
|
if (type === 'hero') {
|
|
return (
|
|
<HeroBlock
|
|
eyebrow={structuredContent.eyebrow as string | undefined}
|
|
title={block.heading ?? 'Untitled hero'}
|
|
subtitle={block.subheading ?? (structuredContent.supportingCopy as string | undefined)}
|
|
ctaLabel={(structuredContent.ctaLabel as string | undefined) ?? undefined}
|
|
supportingContent={
|
|
listContent.length > 0 ? (
|
|
<ul>
|
|
{listContent.map((item) => (
|
|
<li key={String(item)}>{String(item)}</li>
|
|
))}
|
|
</ul>
|
|
) : undefined
|
|
}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (type === 'feature-grid' || type === 'features' || type === 'value-props') {
|
|
const features = toFeatureList(listContent, structuredContent.items);
|
|
const columns = normalizeColumns(structuredContent.columns, features.length);
|
|
return <FeatureGridBlock heading={block.heading} features={features} columns={columns} />;
|
|
}
|
|
|
|
if (type === 'stats' || type === 'metrics') {
|
|
const stats = toStatItems(listContent, structuredContent.items, block);
|
|
if (!stats.length) return defaultBlock(block);
|
|
return <StatsPanel heading={block.heading} stats={stats} />;
|
|
}
|
|
|
|
return defaultBlock(block);
|
|
}
|
|
|
|
function defaultBlock(block: PageBlock) {
|
|
return (
|
|
<div className="preview-block preview-block--legacy">
|
|
{block.heading && <h4>{block.heading}</h4>}
|
|
{block.subheading && <p>{block.subheading}</p>}
|
|
{Array.isArray(block.content) && (
|
|
<ul>
|
|
{block.content.map((item) => (
|
|
<li key={item}>{item}</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function normalizeType(type?: string) {
|
|
return (type ?? '').toLowerCase();
|
|
}
|
|
|
|
function extractStructuredContent(block: PageBlock): StructuredContent {
|
|
if (Array.isArray(block.content)) {
|
|
return {};
|
|
}
|
|
return (block.content ?? {}) as StructuredContent;
|
|
}
|
|
|
|
function extractListContent(block: PageBlock, structuredContent: StructuredContent): unknown[] {
|
|
if (Array.isArray(block.content)) {
|
|
return block.content;
|
|
}
|
|
if (Array.isArray(structuredContent.items)) {
|
|
return structuredContent.items;
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function toFeatureList(listItems: unknown[], structuredItems?: unknown[]): FeatureGridBlockProps['features'] {
|
|
const source = structuredItems && Array.isArray(structuredItems) && structuredItems.length > 0 ? structuredItems : listItems;
|
|
return source.map((item) => {
|
|
if (typeof item === 'string') {
|
|
return { title: item };
|
|
}
|
|
if (typeof item === 'object' && item) {
|
|
const record = item as Record<string, unknown>;
|
|
return {
|
|
title: String(record.title ?? record.heading ?? 'Feature'),
|
|
description: record.description ? String(record.description) : undefined,
|
|
icon: record.icon ? String(record.icon) : undefined,
|
|
};
|
|
}
|
|
return { title: String(item) };
|
|
});
|
|
}
|
|
|
|
function toStatItems(
|
|
listItems: unknown[],
|
|
structuredItems: unknown[] | undefined,
|
|
block: PageBlock,
|
|
): StatItem[] {
|
|
const source = structuredItems && Array.isArray(structuredItems) && structuredItems.length > 0 ? structuredItems : listItems;
|
|
return source
|
|
.map((item, index) => {
|
|
if (typeof item === 'string') {
|
|
return {
|
|
label: block.heading ?? `Metric ${index + 1}`,
|
|
value: item,
|
|
};
|
|
}
|
|
if (typeof item === 'object' && item) {
|
|
const record = item as Record<string, unknown>;
|
|
const label = record.label ?? record.title ?? `Metric ${index + 1}`;
|
|
const value = record.value ?? record.metric ?? record.score;
|
|
if (!value) return null;
|
|
return {
|
|
label: String(label),
|
|
value: String(value),
|
|
description: record.description ? String(record.description) : undefined,
|
|
};
|
|
}
|
|
return null;
|
|
})
|
|
.filter((stat): stat is StatItem => Boolean(stat));
|
|
}
|
|
|
|
function normalizeColumns(
|
|
candidate: StructuredContent['columns'],
|
|
featureCount: number,
|
|
): FeatureGridBlockProps['columns'] {
|
|
const inferred = typeof candidate === 'number' ? candidate : featureCount >= 4 ? 4 : featureCount === 2 ? 2 : 3;
|
|
if (inferred <= 2) return 2;
|
|
if (inferred >= 4) return 4;
|
|
return 3;
|
|
}
|
|
|
|
function buildFallbackHero(
|
|
page: SiteStructure['pages'][number] | PageBlueprint | undefined,
|
|
structure: SiteStructure | undefined,
|
|
): PageBlock {
|
|
return {
|
|
type: 'hero',
|
|
heading: page?.title ?? 'Site Builder preview',
|
|
subheading: structure?.site?.hero_message ?? 'Preview updates as the AI hydrates your blueprint.',
|
|
content: Array.isArray(structure?.site?.secondary_navigation) ? structure?.site?.secondary_navigation : [],
|
|
};
|
|
}
|
|
|
|
function buildFallbackSections(page: SiteStructure['pages'][number] | PageBlueprint | undefined) {
|
|
return [
|
|
<FeatureGridBlock
|
|
key="fallback-features"
|
|
heading="Generated sections"
|
|
features={[
|
|
{ title: 'AI messaging kit', description: 'Structured copy generated for each funnel stage.' },
|
|
{ title: 'Audience resonance', description: 'Language tuned to your target segment.' },
|
|
{ title: 'Conversion spine', description: 'CTA hierarchy anchored to your objectives.' },
|
|
]}
|
|
/>,
|
|
<StatsPanel
|
|
key="fallback-stats"
|
|
heading="Blueprint signals"
|
|
stats={[
|
|
{ label: 'Page type', value: page?.type ?? 'Landing' },
|
|
{ label: 'Status', value: page?.status ?? 'Draft' },
|
|
{ label: 'Blocks', value: '0' },
|
|
]}
|
|
/>,
|
|
];
|
|
}
|
|
|
|
function buildSidebarInsights(
|
|
page: SiteStructure['pages'][number] | PageBlueprint | undefined,
|
|
structure: SiteStructure | undefined,
|
|
) {
|
|
return [
|
|
{
|
|
label: 'Primary CTA',
|
|
value: page?.primary_cta ?? 'Book a demo',
|
|
},
|
|
{
|
|
label: 'Tone',
|
|
value: structure?.site?.tone ?? 'Confident & clear',
|
|
},
|
|
{
|
|
label: 'Status',
|
|
value: page?.status ?? 'Draft',
|
|
},
|
|
];
|
|
}
|
|
|
|
|
|
|