feat: Page Editor v1.0 - canonical schema, SSR parity, and migration
Major improvements to WooNooW Page Editor system: Schema & Architecture: - Canonical section schema with unified sectionSchema.ts - Normalized feature-grid to use items (not features) - Standardized default values across all section types - Schema versioning with automatic migration on read Backend (PHP): - Enhanced PlaceholderRenderer with typed output contracts - Added fallback behavior for empty/invalid dynamic sources - Added caching support for post data resolution - New SchemaMigration class for backward compatibility - New Features class for feature flags - Enhanced PageSSR with full style support - Removed controller-level special-casing for related_posts Frontend (Admin SPA): - Updated CanvasRenderer with schema-aware transformation - Enhanced InspectorPanel with canonical schema metadata - Added new section renderers Frontend (Customer SPA): - New section components: BentoCategoryGrid, MarqueeBanner, ProductCarousel, ShoppableImage - Updated FeatureGridSection for items prop contract Testing: - Add PHP tests: SchemaMigrationTest, PlaceholderRendererTest, PageSSRTest - Add TypeScript tests: schema-integration, feature-grid-regression - Add parity tests for React vs SSR content matching - Add CI script: check-schema-drift.mjs - Add VERIFICATION_CHECKLIST.md Documentation: - RELEASE_NOTES-v1.0.md with full release notes - docs/PAGE_EDITOR_SECTION_SCHEMA_V1.md - docs/PAGE_EDITOR_SSR_COVERAGE_AUDIT.md
This commit is contained in:
@@ -33,6 +33,7 @@ import { InspectorField, SectionProp } from './InspectorField';
|
||||
import { InspectorRepeater } from './InspectorRepeater';
|
||||
import { MediaUploader } from '@/components/MediaUploader';
|
||||
import { SectionStyles, ElementStyle, PageItem } from '../store/usePageEditorStore';
|
||||
import { SECTION_SCHEMAS } from '../schema/sectionSchema';
|
||||
|
||||
interface Section {
|
||||
id: string;
|
||||
@@ -64,64 +65,15 @@ interface InspectorPanelProps {
|
||||
onContainerWidthChange?: (width: 'boxed' | 'fullwidth') => void;
|
||||
}
|
||||
|
||||
// Section field configurations
|
||||
const SECTION_FIELDS: Record<string, { name: string; label: string; type: 'text' | 'textarea' | 'url' | 'image' | 'rte'; dynamic?: boolean }[]> = {
|
||||
hero: [
|
||||
{ name: 'title', label: 'Title', type: 'text', dynamic: true },
|
||||
{ name: 'subtitle', label: 'Subtitle', type: 'text', dynamic: true },
|
||||
{ name: 'image', label: 'Image URL', type: 'url', dynamic: true },
|
||||
{ name: 'cta_text', label: 'Button Text', type: 'text' },
|
||||
{ name: 'cta_url', label: 'Button URL', type: 'url' },
|
||||
],
|
||||
content: [
|
||||
{ name: 'content', label: 'Content', type: 'rte', dynamic: true },
|
||||
{ name: 'cta_text', label: 'Button Text', type: 'text' },
|
||||
{ name: 'cta_url', label: 'Button URL', type: 'url' },
|
||||
],
|
||||
'image-text': [
|
||||
{ name: 'title', label: 'Title', type: 'text', dynamic: true },
|
||||
{ name: 'text', label: 'Text', type: 'textarea', dynamic: true },
|
||||
{ name: 'image', label: 'Image URL', type: 'url', dynamic: true },
|
||||
{ name: 'cta_text', label: 'Button Text', type: 'text' },
|
||||
{ name: 'cta_url', label: 'Button URL', type: 'url' },
|
||||
],
|
||||
'feature-grid': [
|
||||
{ name: 'heading', label: 'Heading', type: 'text' },
|
||||
],
|
||||
'cta-banner': [
|
||||
{ name: 'title', label: 'Title', type: 'text' },
|
||||
{ name: 'text', label: 'Description', type: 'text' },
|
||||
{ name: 'button_text', label: 'Button Text', type: 'text' },
|
||||
{ name: 'button_url', label: 'Button URL', type: 'url' },
|
||||
],
|
||||
'contact-form': [
|
||||
{ name: 'title', label: 'Title', type: 'text' },
|
||||
{ name: 'webhook_url', label: 'Webhook URL', type: 'url' },
|
||||
{ name: 'redirect_url', label: 'Redirect URL', type: 'url' },
|
||||
],
|
||||
};
|
||||
const SECTION_FIELDS = Object.fromEntries(
|
||||
Object.entries(SECTION_SCHEMAS).map(([type, schema]) => [type, schema.fields])
|
||||
);
|
||||
|
||||
const LAYOUT_OPTIONS: Record<string, { value: string; label: string }[]> = {
|
||||
hero: [
|
||||
{ value: 'default', label: 'Centered' },
|
||||
{ value: 'hero-left-image', label: 'Image Left' },
|
||||
{ value: 'hero-right-image', label: 'Image Right' },
|
||||
],
|
||||
'image-text': [
|
||||
{ value: 'image-left', label: 'Image Left' },
|
||||
{ value: 'image-right', label: 'Image Right' },
|
||||
],
|
||||
'feature-grid': [
|
||||
{ value: 'grid-2', label: '2 Columns' },
|
||||
{ value: 'grid-3', label: '3 Columns' },
|
||||
{ value: 'grid-4', label: '4 Columns' },
|
||||
],
|
||||
content: [
|
||||
{ value: 'default', label: 'Full Width' },
|
||||
{ value: 'narrow', label: 'Narrow' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
],
|
||||
};
|
||||
const LAYOUT_OPTIONS = Object.fromEntries(
|
||||
Object.entries(SECTION_SCHEMAS)
|
||||
.filter(([, schema]) => !!schema.layouts)
|
||||
.map(([type, schema]) => [type, schema.layouts || []])
|
||||
);
|
||||
|
||||
const COLOR_SCHEMES = [
|
||||
{ value: 'default', label: 'Default' },
|
||||
@@ -130,42 +82,9 @@ const COLOR_SCHEMES = [
|
||||
{ value: 'muted', label: 'Muted' },
|
||||
];
|
||||
|
||||
const STYLABLE_ELEMENTS: Record<string, { name: string; label: string; type: 'text' | 'image' }[]> = {
|
||||
hero: [
|
||||
{ name: 'title', label: 'Title', type: 'text' },
|
||||
{ name: 'subtitle', label: 'Subtitle', type: 'text' },
|
||||
{ name: 'image', label: 'Image', type: 'image' },
|
||||
{ name: 'cta_text', label: 'Button', type: 'text' },
|
||||
],
|
||||
content: [
|
||||
{ name: 'heading', label: 'Headings', type: 'text' },
|
||||
{ name: 'text', label: 'Body Text', type: 'text' },
|
||||
{ name: 'link', label: 'Links', type: 'text' },
|
||||
{ name: 'image', label: 'Images', type: 'image' },
|
||||
{ name: 'button', label: 'Button', type: 'text' },
|
||||
{ name: 'content', label: 'Container', type: 'text' }, // Keep for backward compat or wrapper style
|
||||
],
|
||||
'image-text': [
|
||||
{ name: 'title', label: 'Title', type: 'text' },
|
||||
{ name: 'text', label: 'Text', type: 'text' },
|
||||
{ name: 'image', label: 'Image', type: 'image' },
|
||||
{ name: 'button', label: 'Button', type: 'text' },
|
||||
],
|
||||
'feature-grid': [
|
||||
{ name: 'heading', label: 'Heading', type: 'text' },
|
||||
{ name: 'feature_item', label: 'Feature Item (Card)', type: 'text' },
|
||||
],
|
||||
'cta-banner': [
|
||||
{ name: 'title', label: 'Title', type: 'text' },
|
||||
{ name: 'text', label: 'Description', type: 'text' },
|
||||
{ name: 'button_text', label: 'Button', type: 'text' },
|
||||
],
|
||||
'contact-form': [
|
||||
{ name: 'title', label: 'Title', type: 'text' },
|
||||
{ name: 'button', label: 'Button', type: 'text' },
|
||||
{ name: 'fields', label: 'Input Fields', type: 'text' },
|
||||
],
|
||||
};
|
||||
const STYLABLE_ELEMENTS = Object.fromEntries(
|
||||
Object.entries(SECTION_SCHEMAS).map(([type, schema]) => [type, schema.stylableElements || []])
|
||||
);
|
||||
|
||||
export function InspectorPanel({
|
||||
page,
|
||||
@@ -454,31 +373,81 @@ export function InspectorPanel({
|
||||
|
||||
{/* Feature Grid Repeater */}
|
||||
{selectedSection.type === 'feature-grid' && (() => {
|
||||
const featuresProp = selectedSection.props.features;
|
||||
const isDynamicFeatures = featuresProp?.type === 'dynamic' && !!featuresProp?.source;
|
||||
const items = Array.isArray(featuresProp?.value) ? featuresProp.value : [];
|
||||
const itemsProp = selectedSection.props.items || selectedSection.props.features;
|
||||
const isDynamicItems = itemsProp?.type === 'dynamic' && !!itemsProp?.source;
|
||||
const items = Array.isArray(itemsProp?.value) ? itemsProp.value : [];
|
||||
return (
|
||||
<div className="pt-4 border-t">
|
||||
<InspectorRepeater
|
||||
label={__('Features')}
|
||||
items={items}
|
||||
onChange={(newItems) => onSectionPropChange('features', { type: 'static', value: newItems })}
|
||||
onChange={(newItems) => onSectionPropChange('items', { type: 'static', value: newItems })}
|
||||
fields={[
|
||||
{ name: 'title', label: 'Title', type: 'text' },
|
||||
{ name: 'description', label: 'Description', type: 'textarea' },
|
||||
{ name: 'icon', label: 'Icon', type: 'icon' },
|
||||
]}
|
||||
itemLabelKey="title"
|
||||
isDynamic={isDynamicFeatures}
|
||||
isDynamic={isDynamicItems}
|
||||
dynamicLabel={
|
||||
isDynamicFeatures
|
||||
? `⚡ Auto-populated from "${featuresProp.source}" at runtime`
|
||||
isDynamicItems
|
||||
? `Auto-populated from "${itemsProp.source}" at runtime`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Bento Category Grid Repeater */}
|
||||
{selectedSection.type === 'bento-category-grid' && (() => {
|
||||
const itemsProp = selectedSection.props.items;
|
||||
const items = Array.isArray(itemsProp?.value) ? itemsProp.value : [];
|
||||
return (
|
||||
<div className="pt-4 border-t">
|
||||
<InspectorRepeater
|
||||
label={__('Grid Items')}
|
||||
items={items}
|
||||
onChange={(newItems) => onSectionPropChange('items', { type: 'static', value: newItems })}
|
||||
fields={[
|
||||
{ name: 'label', label: 'Label', type: 'text' },
|
||||
{ name: 'image', label: 'Image', type: 'image' },
|
||||
{ name: 'url', label: 'Link URL', type: 'text' },
|
||||
{ name: 'size', label: 'Size (small/medium/large/tall)', type: 'text' },
|
||||
]}
|
||||
itemLabelKey="label"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Shoppable Image Hotspots Repeater */}
|
||||
{selectedSection.type === 'shoppable-image' && (() => {
|
||||
const hotspotsProp = selectedSection.props.hotspots;
|
||||
const hotspots = Array.isArray(hotspotsProp?.value) ? hotspotsProp.value : [];
|
||||
return (
|
||||
<div className="pt-4 border-t">
|
||||
<InspectorRepeater
|
||||
label={__('Hotspots')}
|
||||
items={hotspots}
|
||||
onChange={(newItems) => onSectionPropChange('hotspots', { type: 'static', value: newItems })}
|
||||
fields={[
|
||||
{ name: 'product_slug', label: 'Product', type: 'product' },
|
||||
// Allow advanced override/editing of asset/data if needed
|
||||
{ name: 'product_name', label: 'Product Name', type: 'text' },
|
||||
{ name: 'product_price', label: 'Price', type: 'text' },
|
||||
{ name: 'product_image', label: 'Product Image URL', type: 'text' },
|
||||
{ name: 'x', label: 'X Position (%)', type: 'text' },
|
||||
{ name: 'y', label: 'Y Position (%)', type: 'text' },
|
||||
]}
|
||||
itemLabelKey="product_name"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
X and Y are percentages (0-100) from the top-left of the image.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</TabsContent>
|
||||
|
||||
{/* Design Tab */}
|
||||
|
||||
Reference in New Issue
Block a user