feat: refine dynamic page sections, fix renderers, improve styling controls and editor schema

This commit is contained in:
Dwindi Ramadhana
2026-06-11 21:24:57 +07:00
parent 54a3a15f68
commit ec2049913f
33 changed files with 1032 additions and 822 deletions

View File

@@ -1 +1,38 @@
export function ProductCard({ product }: any) { return <div className='p-4 border rounded shadow-sm'>{product?.title || 'Product'}</div>; }
import React from 'react';
import { ShoppingCart } from 'lucide-react';
export function ProductCard({ product }: any) {
const name = product?.name || product?.title || 'Sample Product';
const price = product?.price || '$49.99';
const image = product?.image || product?.image_url || '';
return (
<div className="group h-full flex flex-col border border-border rounded-lg overflow-hidden hover:shadow-md transition-shadow bg-card">
<div className="relative w-full overflow-hidden bg-muted aspect-square">
{image ? (
<img src={image} alt={name} className="absolute inset-0 w-full !h-full object-cover object-center group-hover:scale-105 transition-transform duration-300" />
) : (
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground text-sm font-medium">
No Image
</div>
)}
</div>
<div className="p-4 flex-1 flex flex-col text-left">
<h3 className="text-sm font-medium text-foreground mb-2 line-clamp-2 leading-snug group-hover:text-primary transition-colors">
{name}
</h3>
<div className="flex items-center gap-2 mb-3">
<span className="text-base font-bold text-foreground">
{price}
</span>
</div>
<button
className="w-full mt-auto inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-10 px-4 py-2"
>
<ShoppingCart className="w-4 h-4 mr-2" />
Add to Cart
</button>
</div>
</div>
);
}

View File

@@ -78,161 +78,79 @@ export const SharedContentLayout: React.FC<SharedContentProps> = ({
return (
<div className={containerClasses}>
{containerWidth === 'boxed' ? (
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden px-6 md:px-10 py-10">
<div className={gridClasses}>
{/* Image Side */}
{hasImage && (
<div className={cn(
'relative w-full aspect-[4/3] rounded-2xl overflow-hidden shadow-lg',
imageWrapperOrder,
(isImageTop || isImageBottom) && 'mb-8' // spacing if stacked
)} style={imageStyle}>
<img
src={image}
alt={title || 'Section Image'}
className="absolute inset-0 w-full h-full object-cover"
/>
</div>
)}
{/* Content Side */}
<div className={cn('flex flex-col', hasImage ? 'bg-transparent' : '')}>
{title && (
<h2
className={cn(
"tracking-tight text-current mb-6",
!titleClassName && "text-3xl font-bold sm:text-4xl lg:text-5xl",
titleClassName
)}
style={titleStyle}
>
{title}
</h2>
)}
{text && (
<div
className={cn(
'prose prose-lg max-w-none',
'prose-h1:text-3xl md:prose-h1:text-4xl lg:prose-h1:text-5xl prose-h1:font-bold prose-h1:mt-6 prose-h1:mb-4',
'prose-h2:text-2xl md:prose-h2:text-3xl lg:prose-h2:text-4xl prose-h2:font-bold prose-h2:mt-5 prose-h2:mb-3',
'prose-h3:text-xl md:prose-h3:text-2xl lg:prose-h3:text-3xl prose-h3:font-bold prose-h3:mt-4 prose-h3:mb-2',
'prose-headings:text-[var(--tw-prose-headings)]',
'prose-p:text-[var(--tw-prose-body)]',
'text-[var(--tw-prose-body)]',
className,
textClassName
)}
style={proseStyle}
dangerouslySetInnerHTML={{ __html: text }}
/>
)}
{/* Buttons */}
{buttons && buttons.length > 0 && (
<div className="mt-8 flex flex-wrap gap-4">
{buttons.map((btn, idx) => (
btn.text && btn.url && (
<a
key={idx}
href={btn.url}
className={cn(
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 h-10 px-4 py-2",
!buttonStyle?.style?.backgroundColor && "bg-primary",
!buttonStyle?.style?.color && "text-primary-foreground hover:bg-primary/90",
buttonStyle?.classNames
)}
style={buttonStyle?.style}
>
{btn.text}
</a>
)
))}
</div>
)}
</div>
<div className={gridClasses}>
{/* Image Side */}
{hasImage && (
<div className={cn(
'relative w-full aspect-[4/3] rounded-2xl overflow-hidden shadow-lg',
imageWrapperOrder,
(isImageTop || isImageBottom) && 'mb-8'
)} style={imageStyle}>
<img
src={image}
alt={title || 'Section Image'}
className="absolute inset-0 w-full h-full object-cover"
/>
</div>
</div>
) : (
<div className={gridClasses}>
{/* Image Side */}
{hasImage && (
<div className={cn(
'relative w-full aspect-[4/3] rounded-2xl overflow-hidden shadow-lg',
imageWrapperOrder,
(isImageTop || isImageBottom) && 'mb-8'
)} style={imageStyle}>
<img
src={image}
alt={title || 'Section Image'}
className="absolute inset-0 w-full h-full object-cover"
/>
</div>
)}
{/* Content Side */}
<div className={cn('flex flex-col', hasImage ? 'bg-transparent' : '')}>
{title && (
<h2
className={cn(
"tracking-tight text-current mb-6",
!titleClassName && "text-3xl font-bold sm:text-4xl lg:text-5xl",
titleClassName
)}
style={titleStyle}
>
{title}
</h2>
)}
{/* Content Side */}
<div className={cn('flex flex-col', hasImage ? 'bg-transparent' : '')}>
{title && (
<h2
className={cn(
"tracking-tight text-current mb-6",
!titleClassName && "text-3xl font-bold sm:text-4xl lg:text-5xl",
titleClassName
)}
style={titleStyle}
>
{title}
</h2>
)}
{text && (
<div
className={cn(
'prose prose-lg max-w-none',
'prose-h1:text-3xl md:prose-h1:text-4xl lg:prose-h1:text-5xl prose-h1:font-bold prose-h1:mt-6 prose-h1:mb-4',
'prose-h2:text-2xl md:prose-h2:text-3xl lg:prose-h2:text-4xl prose-h2:font-bold prose-h2:mt-5 prose-h2:mb-3',
'prose-h3:text-xl md:prose-h3:text-2xl lg:prose-h3:text-3xl prose-h3:font-bold prose-h3:mt-4 prose-h3:mb-2',
'prose-headings:text-[var(--tw-prose-headings)]',
'prose-p:text-[var(--tw-prose-body)]',
'text-[var(--tw-prose-body)]',
className,
textClassName
)}
style={proseStyle}
dangerouslySetInnerHTML={{ __html: text }}
/>
)}
{text && (
<div
className={cn(
'prose prose-lg max-w-none',
'prose-h1:text-3xl md:prose-h1:text-4xl lg:prose-h1:text-5xl prose-h1:font-bold prose-h1:mt-6 prose-h1:mb-4',
'prose-h2:text-2xl md:prose-h2:text-3xl lg:prose-h2:text-4xl prose-h2:font-bold prose-h2:mt-5 prose-h2:mb-3',
'prose-h3:text-xl md:prose-h3:text-2xl lg:prose-h3:text-3xl prose-h3:font-bold prose-h3:mt-4 prose-h3:mb-2',
'prose-headings:text-[var(--tw-prose-headings)]',
'prose-p:text-[var(--tw-prose-body)]',
'text-[var(--tw-prose-body)]',
className,
textClassName
)}
style={proseStyle}
dangerouslySetInnerHTML={{ __html: text }}
/>
)}
{/* Buttons */}
{buttons && buttons.length > 0 && (
<div className="mt-8 flex flex-wrap gap-4">
{buttons.map((btn, idx) => (
btn.text && btn.url && (
<a
key={idx}
href={btn.url}
className={cn(
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 h-10 px-4 py-2",
!buttonStyle?.style?.backgroundColor && "bg-primary",
!buttonStyle?.style?.color && "text-primary-foreground hover:bg-primary/90",
buttonStyle?.classNames
)}
style={buttonStyle?.style}
>
{btn.text}
</a>
)
))}
</div>
)}
</div>
{/* Buttons */}
{buttons && buttons.length > 0 && (
<div className="mt-8 flex flex-wrap gap-4">
{buttons.map((btn, idx) => (
btn.text && btn.url && (
<a
key={idx}
href={btn.url}
className={cn(
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 h-10 px-4 py-2",
!buttonStyle?.style?.backgroundColor && "bg-primary",
!buttonStyle?.style?.color && "text-primary-foreground hover:bg-primary/90",
buttonStyle?.classNames
)}
style={buttonStyle?.style}
>
{btn.text}
</a>
)
))}
</div>
)}
</div>
)}
</div>
</div>
);
};

View File

@@ -5,7 +5,6 @@ import Placeholder from '@tiptap/extension-placeholder';
import Link from '@tiptap/extension-link';
import TextAlign from '@tiptap/extension-text-align';
import Image from '@tiptap/extension-image';
import { ButtonExtension } from './tiptap-button-extension';
import { openWPMediaImage } from '@/lib/wp-media';
import {
Bold,
@@ -17,7 +16,6 @@ import {
AlignCenter,
AlignRight,
ImageIcon,
MousePointer,
Undo,
Redo,
} from 'lucide-react';
@@ -50,8 +48,6 @@ export function RichTextEditor({
Placeholder.configure({
placeholder,
}),
// ButtonExtension MUST come before Link to ensure buttons are parsed first
ButtonExtension,
Link.configure({
openOnClick: false,
HTMLAttributes: {
@@ -109,13 +105,6 @@ export function RichTextEditor({
}
};
const [buttonDialogOpen, setButtonDialogOpen] = useState(false);
const [buttonText, setButtonText] = useState('Click Here');
const [buttonHref, setButtonHref] = useState('{order_url}');
const [buttonStyle, setButtonStyle] = useState<'solid' | 'outline' | 'link'>('solid');
const [isEditingButton, setIsEditingButton] = useState(false);
const [editingButtonPos, setEditingButtonPos] = useState<number | null>(null);
const addImage = () => {
openWPMediaImage((file) => {
editor.chain().focus().setImage({
@@ -126,87 +115,6 @@ export function RichTextEditor({
});
};
const openButtonDialog = () => {
setButtonText('Click Here');
setButtonHref('{order_url}');
setButtonStyle('solid');
setIsEditingButton(false);
setEditingButtonPos(null);
setButtonDialogOpen(true);
};
// Handle clicking on buttons in the editor to edit them
const handleEditorClick = (e: React.MouseEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement;
const buttonEl = target.closest('a[data-button]') as HTMLElement | null;
if (buttonEl && editor) {
e.preventDefault();
e.stopPropagation();
// Get button attributes
const text = buttonEl.getAttribute('data-text') || buttonEl.textContent?.replace('🔘 ', '') || 'Click Here';
const href = buttonEl.getAttribute('data-href') || '#';
const style = (buttonEl.getAttribute('data-style') as 'solid' | 'outline') || 'solid';
// Find the position of this button node
const { state } = editor.view;
let foundPos: number | null = null;
state.doc.descendants((node, pos) => {
if (node.type.name === 'button' &&
node.attrs.text === text &&
node.attrs.href === href) {
foundPos = pos;
return false; // Stop iteration
}
return true;
});
// Open dialog in edit mode
setButtonText(text);
setButtonHref(href);
setButtonStyle(style);
setIsEditingButton(true);
setEditingButtonPos(foundPos);
setButtonDialogOpen(true);
}
};
const insertButton = () => {
if (isEditingButton && editingButtonPos !== null && editor) {
// Delete old button and insert new one at same position
editor
.chain()
.focus()
.deleteRange({ from: editingButtonPos, to: editingButtonPos + 1 })
.insertContentAt(editingButtonPos, {
type: 'button',
attrs: { text: buttonText, href: buttonHref, style: buttonStyle },
})
.run();
} else {
// Insert new button
editor.chain().focus().setButton({ text: buttonText, href: buttonHref, style: buttonStyle }).run();
}
setButtonDialogOpen(false);
setIsEditingButton(false);
setEditingButtonPos(null);
};
const deleteButton = () => {
if (editingButtonPos !== null && editor) {
editor
.chain()
.focus()
.deleteRange({ from: editingButtonPos, to: editingButtonPos + 1 })
.run();
setButtonDialogOpen(false);
setIsEditingButton(false);
setEditingButtonPos(null);
}
};
const getActiveHeading = () => {
if (editor.isActive('heading', { level: 1 })) return 'h1';
if (editor.isActive('heading', { level: 2 })) return 'h2';
@@ -326,14 +234,6 @@ export function RichTextEditor({
>
<ImageIcon className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={openButtonDialog}
>
<MousePointer className="h-4 w-4" />
</Button>
<div className="w-px h-6 bg-border mx-1" />
<Button
type="button"
@@ -356,7 +256,7 @@ export function RichTextEditor({
</div>
{/* Editor */}
<div onClick={handleEditorClick}>
<div>
<EditorContent editor={editor} />
</div>
@@ -444,91 +344,6 @@ export function RichTextEditor({
</div>
</details>
)}
{/* Button Dialog */}
<Dialog open={buttonDialogOpen} onOpenChange={(open) => {
setButtonDialogOpen(open);
if (!open) {
setIsEditingButton(false);
setEditingButtonPos(null);
}
}}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{isEditingButton ? __('Edit Button') : __('Insert Button')}</DialogTitle>
<DialogDescription>
{isEditingButton
? __('Edit the button properties below. Click on the button to save.')
: __('Add a styled button to your content. Use variables for dynamic links.')}
</DialogDescription>
</DialogHeader>
<DialogBody>
<div className="space-y-4 !p-4">
<div className="space-y-2">
<Label htmlFor="btn-text">{__('Button Text')}</Label>
<Input
id="btn-text"
value={buttonText}
onChange={(e) => setButtonText(e.target.value)}
placeholder={__('e.g., View Order')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="btn-href">{__('Button Link')}</Label>
<Input
id="btn-href"
value={buttonHref}
onChange={(e) => setButtonHref(e.target.value)}
placeholder="{order_url}"
/>
{variables.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{variables.filter(v => v.includes('_url') || v.includes('_link')).map((variable) => (
<code
key={variable}
className="text-xs bg-muted px-2 py-1 rounded cursor-pointer hover:bg-muted/80"
onClick={() => setButtonHref(buttonHref + `{${variable}}`)}
>
{`{${variable}}`}
</code>
))}
</div>
)}
</div>
<div className="space-y-2">
<Label htmlFor="btn-style">{__('Button Style')}</Label>
<Select value={buttonStyle} onValueChange={(value: 'solid' | 'outline' | 'link') => setButtonStyle(value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="solid">{__('Solid (Primary)')}</SelectItem>
<SelectItem value="outline">{__('Outline (Secondary)')}</SelectItem>
<SelectItem value="link">{__('Plain Link')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</DialogBody>
<DialogFooter className="flex-col sm:flex-row gap-2">
{isEditingButton && (
<Button variant="destructive" onClick={deleteButton} className="sm:mr-auto">
{__('Delete')}
</Button>
)}
<Button variant="outline" onClick={() => setButtonDialogOpen(false)}>
{__('Cancel')}
</Button>
<Button onClick={insertButton}>
{isEditingButton ? __('Update Button') : __('Insert Button')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -1,2 +1,3 @@
import { api } from '../api';
export const apiClient = api;
export { api };

View File

@@ -107,6 +107,8 @@ function withSectionWrapper(Component: any) {
colorScheme={section.colorScheme}
elementStyles={section.elementStyles}
styles={section.styles}
isEditor={true}
section={section}
{...flatProps}
/>
);
@@ -210,7 +212,7 @@ export function CanvasRenderer({
'bg-white transition-all duration-300 min-h-[500px] wn-page',
deviceMode === 'mobile'
? 'max-w-sm mx-auto shadow-2xl rounded-[2.5rem] border-[12px] border-gray-800 my-8 overflow-hidden'
: cn('h-full', containerWidth === 'boxed' ? 'container mx-auto max-w-6xl shadow-sm border-x border-b' : 'w-full')
: cn('min-h-full', containerWidth === 'boxed' ? 'container mx-auto max-w-6xl shadow-sm border-x' : 'w-full')
)}
>
{sections.length === 0 ? (

View File

@@ -82,8 +82,15 @@ export function CanvasSection({
{/* Section content with Styles */}
<div
className={cn(
"relative overflow-hidden rounded-lg",
!section.styles?.backgroundColor && !section.styles?.backgroundType && "bg-white/50"
"relative overflow-hidden",
!section.styles?.backgroundColor && !section.styles?.backgroundType && "bg-white/50",
{
'default': 'py-16 md:py-24',
'small': 'py-8 md:py-12',
'medium': 'py-16 md:py-24',
'large': 'py-24 md:py-36',
'fullscreen': 'min-h-[600px] flex items-center',
}[section.styles?.heightPreset || 'default'] || 'py-16 md:py-24'
)}
style={{
...(section.styles?.backgroundType === 'gradient'
@@ -153,7 +160,16 @@ export function CanvasSection({
{/* Content Wrapper */}
{section.styles?.contentWidth === 'boxed' ? (
<div className="relative z-10 container mx-auto px-4 max-w-5xl">
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
<div
className="rounded-2xl shadow-sm border border-gray-200 overflow-hidden"
style={{
backgroundColor: section.styles?.cardBackgroundColor || '#ffffff',
paddingTop: section.styles?.cardPaddingTop || undefined,
paddingRight: section.styles?.cardPaddingRight || undefined,
paddingBottom: section.styles?.cardPaddingBottom || undefined,
paddingLeft: section.styles?.cardPaddingLeft || undefined,
}}
>
{children}
</div>
</div>

View File

@@ -127,7 +127,7 @@ export function InspectorField({
placeholder={fieldType === 'url' ? 'https://' : `Enter ${fieldLabel.toLowerCase()}`}
className="flex-1"
/>
{(fieldType === 'url' || fieldType === 'image') && (
{(fieldType === 'image') && (
<MediaUploader
onSelect={(url) => handleValueChange(url)}
type="image"

View File

@@ -3,6 +3,7 @@ import { cn } from '@/lib/utils';
import { __ } from '@/lib/i18n';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
@@ -413,6 +414,7 @@ export function InspectorPanel({
{ name: 'label', label: 'Label', type: 'text' },
{ name: 'image', label: 'Image', type: 'image' },
{ name: 'url', label: 'Link URL', type: 'text' },
{ name: 'backgroundColor', label: 'Background Color', type: 'color' },
{ name: 'size', label: 'Size (small/medium/large/tall)', type: 'text' },
]}
itemLabelKey="label"
@@ -436,7 +438,7 @@ export function InspectorPanel({
// 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: 'product_image', label: 'Product Image URL', type: 'image' },
{ name: 'x', label: 'X Position (%)', type: 'text' },
{ name: 'y', label: 'Y Position (%)', type: 'text' },
]}
@@ -448,6 +450,36 @@ export function InspectorPanel({
</div>
);
})()}
{/* Contact Form Fields Repeater */}
{selectedSection.type === 'contact-form' && (() => {
const fieldsProp = selectedSection.props.fields;
const fields = Array.isArray(fieldsProp?.value) ? fieldsProp.value : [];
return (
<div className="pt-4 border-t">
<InspectorRepeater
label={__('Form Fields')}
items={fields}
onChange={(newItems) => onSectionPropChange('fields', { type: 'static', value: newItems })}
fields={[
{ name: 'name', label: 'Field Name (Key)', type: 'text' },
{ name: 'label', label: 'Label / Placeholder', type: 'text' },
{ name: 'type', label: 'Input Type', type: 'select', options: [
{ label: 'Text', value: 'text' },
{ label: 'Email', value: 'email' },
{ label: 'Telephone', value: 'tel' },
{ label: 'Textarea (Multiline)', value: 'textarea' },
]},
{ name: 'required', label: 'Is Required?', type: 'checkbox' }
]}
itemLabelKey="label"
/>
<p className="text-xs text-muted-foreground mt-2">
The <strong>Field Name (Key)</strong> will be the key used when sending data to your webhook.
</p>
</div>
);
})()}
</TabsContent>
{/* Design Tab */}
@@ -491,10 +523,10 @@ export function InspectorPanel({
onChange={(e) => onSectionStylesChange({ backgroundColor: e.target.value })}
/>
</div>
<input
<Input
type="text"
placeholder="#FFFFFF"
className="flex-1 h-8 rounded-md border border-input bg-background px-3 py-1 text-sm"
className="flex-1 h-8 px-3 py-1 text-sm"
value={selectedSection.styles?.backgroundColor || ''}
onChange={(e) => onSectionStylesChange({ backgroundColor: e.target.value })}
/>
@@ -525,9 +557,9 @@ export function InspectorPanel({
onChange={(e) => onSectionStylesChange({ gradientFrom: e.target.value })}
/>
</div>
<input
<Input
type="text"
className="flex-1 h-8 rounded-md border border-input bg-background px-2 py-1 text-xs"
className="flex-1 h-8 px-2 py-1 text-xs"
value={selectedSection.styles?.gradientFrom || '#9333ea'}
onChange={(e) => onSectionStylesChange({ gradientFrom: e.target.value })}
/>
@@ -545,9 +577,9 @@ export function InspectorPanel({
onChange={(e) => onSectionStylesChange({ gradientTo: e.target.value })}
/>
</div>
<input
<Input
type="text"
className="flex-1 h-8 rounded-md border border-input bg-background px-2 py-1 text-xs"
className="flex-1 h-8 px-2 py-1 text-xs"
value={selectedSection.styles?.gradientTo || '#3b82f6'}
onChange={(e) => onSectionStylesChange({ gradientTo: e.target.value })}
/>
@@ -660,20 +692,20 @@ export function InspectorPanel({
<div className="grid grid-cols-2 gap-2 pt-2 border-t mt-4">
<div className="space-y-1">
<Label className="text-xs text-gray-500">{__('Padding Top')}</Label>
<input
<Input
type="text"
placeholder="e.g. 40px, 4rem"
className="w-full h-8 text-xs rounded border px-2"
className="h-8 text-xs px-2"
value={selectedSection.styles?.paddingTop || ''}
onChange={(e) => onSectionStylesChange({ paddingTop: e.target.value })}
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-500">{__('Padding Bottom')}</Label>
<input
<Input
type="text"
placeholder="e.g. 40px, 4rem"
className="w-full h-8 text-xs rounded border px-2"
className="h-8 text-xs px-2"
value={selectedSection.styles?.paddingBottom || ''}
onChange={(e) => onSectionStylesChange({ paddingBottom: e.target.value })}
/>
@@ -702,6 +734,49 @@ export function InspectorPanel({
</RadioGroup>
</div>
{selectedSection.styles?.contentWidth === 'boxed' && (
<>
<div className="space-y-2 pt-2 mt-4">
<Label className="text-xs text-gray-500">{__('Card Background Color')}</Label>
<div className="flex items-center gap-2 mt-1">
<div className="relative w-6 h-6 rounded border shadow-sm shrink-0 overflow-hidden">
<div className="absolute inset-0" style={{ backgroundColor: selectedSection.styles?.cardBackgroundColor || '#ffffff' }} />
<Input
type="color"
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full p-0 border-0"
value={selectedSection.styles?.cardBackgroundColor || '#ffffff'}
onChange={(e) => onSectionStylesChange({ cardBackgroundColor: e.target.value })}
/>
</div>
<Input
type="text"
placeholder="#ffffff"
className="flex-1 h-8 text-xs px-2"
value={selectedSection.styles?.cardBackgroundColor || ''}
onChange={(e) => onSectionStylesChange({ cardBackgroundColor: e.target.value })}
/>
</div>
</div>
<div className="space-y-2 pt-2 mt-4 border-t">
<Label className="text-xs text-gray-500">{__('Card Padding')}</Label>
<div className="grid grid-cols-4 gap-2">
<div className="space-y-1 text-center">
<Input type="text" placeholder="Top" className="h-8 text-xs px-2 text-center" value={selectedSection.styles?.cardPaddingTop || ''} onChange={(e) => onSectionStylesChange({ cardPaddingTop: e.target.value })} />
</div>
<div className="space-y-1 text-center">
<Input type="text" placeholder="Right" className="h-8 text-xs px-2 text-center" value={selectedSection.styles?.cardPaddingRight || ''} onChange={(e) => onSectionStylesChange({ cardPaddingRight: e.target.value })} />
</div>
<div className="space-y-1 text-center">
<Input type="text" placeholder="Bottom" className="h-8 text-xs px-2 text-center" value={selectedSection.styles?.cardPaddingBottom || ''} onChange={(e) => onSectionStylesChange({ cardPaddingBottom: e.target.value })} />
</div>
<div className="space-y-1 text-center">
<Input type="text" placeholder="Left" className="h-8 text-xs px-2 text-center" value={selectedSection.styles?.cardPaddingLeft || ''} onChange={(e) => onSectionStylesChange({ cardPaddingLeft: e.target.value })} />
</div>
</div>
</div>
</>
)}
<div className="space-y-2 pt-2 border-t mt-4">
<Label className="text-xs">{__('Section Height')}</Label>
<Select
@@ -739,29 +814,31 @@ export function InspectorPanel({
<AccordionTrigger className="text-xs hover:no-underline py-2">{field.label}</AccordionTrigger>
<AccordionContent className="space-y-4 pt-2">
{/* Common: Background Wrapper */}
<div className="space-y-2">
<Label className="text-xs text-gray-500">{__('Background (Wrapper)')}</Label>
<div className="flex items-center gap-2">
<div className="relative w-6 h-6 rounded border shadow-sm shrink-0 overflow-hidden">
<div className="absolute inset-0" style={{ backgroundColor: styles.backgroundColor || 'transparent' }} />
<input
type="color"
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full p-0 border-0"
value={styles.backgroundColor || '#ffffff'}
{!field.disableBackground && (
<div className="space-y-2">
<Label className="text-xs text-gray-500">{__('Background (Wrapper)')}</Label>
<div className="flex items-center gap-2">
<div className="relative w-6 h-6 rounded border shadow-sm shrink-0 overflow-hidden">
<div className="absolute inset-0" style={{ backgroundColor: styles.backgroundColor || 'transparent' }} />
<input
type="color"
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full p-0 border-0"
value={styles.backgroundColor || '#ffffff'}
onChange={(e) => onElementStylesChange(field.name, { backgroundColor: e.target.value })}
/>
</div>
<Input
type="text"
placeholder="Color (#fff)"
className="flex-1 h-8 text-xs px-2"
value={styles.backgroundColor || ''}
onChange={(e) => onElementStylesChange(field.name, { backgroundColor: e.target.value })}
/>
</div>
<input
type="text"
placeholder="Color (#fff)"
className="flex-1 h-7 text-xs rounded border px-2"
value={styles.backgroundColor || ''}
onChange={(e) => onElementStylesChange(field.name, { backgroundColor: e.target.value })}
/>
</div>
</div>
)}
{!isImage ? (
{(!isImage && field.type !== 'container') && (
<>
{/* Text Color */}
<div className="space-y-2">
@@ -776,10 +853,10 @@ export function InspectorPanel({
onChange={(e) => onElementStylesChange(field.name, { color: e.target.value })}
/>
</div>
<input
<Input
type="text"
placeholder="Color (#000)"
className="flex-1 h-7 text-xs rounded border px-2"
className="flex-1 h-8 text-xs px-2"
value={styles.color || ''}
onChange={(e) => onElementStylesChange(field.name, { color: e.target.value })}
/>
@@ -830,15 +907,17 @@ export function InspectorPanel({
</Select>
</div>
<Select value={styles.textAlign || 'default'} onValueChange={(val) => onElementStylesChange(field.name, { textAlign: val === 'default' ? undefined : val as any })}>
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="Alignment" /></SelectTrigger>
<SelectContent>
<SelectItem value="default">Default Align</SelectItem>
<SelectItem value="left">Left</SelectItem>
<SelectItem value="center">Center</SelectItem>
<SelectItem value="right">Right</SelectItem>
</SelectContent>
</Select>
{!field.disableAlignment && (
<Select value={styles.textAlign || 'default'} onValueChange={(val) => onElementStylesChange(field.name, { textAlign: val === 'default' ? undefined : val as any })}>
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="Alignment" /></SelectTrigger>
<SelectContent>
<SelectItem value="default">Default Align</SelectItem>
<SelectItem value="left">Left</SelectItem>
<SelectItem value="center">Center</SelectItem>
<SelectItem value="right">Right</SelectItem>
</SelectContent>
</Select>
)}
</div>
{/* Link Specific Styles */}
@@ -865,10 +944,10 @@ export function InspectorPanel({
onChange={(e) => onElementStylesChange(field.name, { hoverColor: e.target.value })}
/>
</div>
<input
<Input
type="text"
placeholder="Hover Color"
className="flex-1 h-7 text-xs rounded border px-2"
className="flex-1 h-8 text-xs px-2"
value={styles.hoverColor || ''}
onChange={(e) => onElementStylesChange(field.name, { hoverColor: e.target.value })}
/>
@@ -876,45 +955,12 @@ export function InspectorPanel({
</div>
</div>
)}
{/* Button/Box Specific Styles */}
{field.name === 'button' && (
<div className="space-y-2">
<Label className="text-xs text-gray-500">{__('Box Styles')}</Label>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs text-gray-400">{__('Border Color')}</Label>
<div className="flex items-center gap-2 h-7">
<div className="relative w-6 h-6 rounded border shadow-sm shrink-0 overflow-hidden">
<div className="absolute inset-0" style={{ backgroundColor: styles.borderColor || 'transparent' }} />
<input
type="color"
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full p-0 border-0"
value={styles.borderColor || '#000000'}
onChange={(e) => onElementStylesChange(field.name, { borderColor: e.target.value })}
/>
</div>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-400">{__('Border Width')}</Label>
<input type="text" placeholder="e.g. 1px" className="w-full h-7 text-xs rounded border px-2" value={styles.borderWidth || ''} onChange={(e) => onElementStylesChange(field.name, { borderWidth: e.target.value })} />
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-400">{__('Radius')}</Label>
<input type="text" placeholder="e.g. 4px" className="w-full h-7 text-xs rounded border px-2" value={styles.borderRadius || ''} onChange={(e) => onElementStylesChange(field.name, { borderRadius: e.target.value })} />
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-400">{__('Padding')}</Label>
<input type="text" placeholder="e.g. 8px 16px" className="w-full h-7 text-xs rounded border px-2" value={styles.padding || ''} onChange={(e) => onElementStylesChange(field.name, { padding: e.target.value })} />
</div>
</div>
</div>
)}
</>
) : (
)}
{/* Image Settings */}
{isImage && (
<>
{/* Image Settings */}
<div className="space-y-2">
<Label className="text-xs text-gray-500">{__('Image Fit')}</Label>
<Select value={styles.objectFit || 'default'} onValueChange={(val) => onElementStylesChange(field.name, { objectFit: val === 'default' ? undefined : val as any })}>
@@ -927,19 +973,80 @@ export function InspectorPanel({
</SelectContent>
</Select>
</div>
<div className="space-y-2 pt-2">
<Label className="text-xs text-gray-500">{__('Image Focal Point')}</Label>
<Select value={styles.objectPosition || 'default'} onValueChange={(val) => onElementStylesChange(field.name, { objectPosition: val === 'default' ? undefined : val as any })}>
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="Position (e.g. center, top)" /></SelectTrigger>
<SelectContent>
<SelectItem value="default">Default</SelectItem>
<SelectItem value="center">Center</SelectItem>
<SelectItem value="top">Top</SelectItem>
<SelectItem value="bottom">Bottom</SelectItem>
<SelectItem value="left">Left</SelectItem>
<SelectItem value="right">Right</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2 pt-2 pb-2">
<Label className="text-xs text-gray-500">{__('Wrapper Alignment')}</Label>
<Select value={styles.alignment || 'default'} onValueChange={(val) => onElementStylesChange(field.name, { alignment: val === 'default' ? undefined : val as any })}>
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="Alignment" /></SelectTrigger>
<SelectContent>
<SelectItem value="default">Default</SelectItem>
<SelectItem value="left">Left</SelectItem>
<SelectItem value="center">Center</SelectItem>
<SelectItem value="right">Right</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs text-gray-500">{__('Width')}</Label>
<input type="text" placeholder="e.g. 100%" className="w-full h-7 text-xs rounded border px-2" value={styles.width || ''} onChange={(e) => onElementStylesChange(field.name, { width: e.target.value })} />
<Input type="text" placeholder="e.g. 100%" className="h-8 text-xs px-2" value={styles.width || ''} onChange={(e) => onElementStylesChange(field.name, { width: e.target.value })} />
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-500">{__('Height')}</Label>
<input type="text" placeholder="e.g. auto" className="w-full h-7 text-xs rounded border px-2" value={styles.height || ''} onChange={(e) => onElementStylesChange(field.name, { height: e.target.value })} />
<Input type="text" placeholder="e.g. auto" className="h-8 text-xs px-2" value={styles.height || ''} onChange={(e) => onElementStylesChange(field.name, { height: e.target.value })} />
</div>
</div>
</>
)}
{/* Button/Box Specific Styles */}
{(field.name === 'button' || field.type === 'container') && (
<div className="space-y-2">
<Label className="text-xs text-gray-500">{__('Box Styles')}</Label>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs text-gray-400">{__('Border Color')}</Label>
<div className="flex items-center gap-2 h-7">
<div className="relative w-6 h-6 rounded border shadow-sm shrink-0 overflow-hidden">
<div className="absolute inset-0" style={{ backgroundColor: styles.borderColor || 'transparent' }} />
<input
type="color"
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full p-0 border-0"
value={styles.borderColor || '#000000'}
onChange={(e) => onElementStylesChange(field.name, { borderColor: e.target.value })}
/>
</div>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-400">{__('Border Width')}</Label>
<Input type="text" placeholder="e.g. 1px" className="h-8 text-xs px-2" value={styles.borderWidth || ''} onChange={(e) => onElementStylesChange(field.name, { borderWidth: e.target.value })} />
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-400">{__('Radius')}</Label>
<Input type="text" placeholder="e.g. 4px" className="h-8 text-xs px-2" value={styles.borderRadius || ''} onChange={(e) => onElementStylesChange(field.name, { borderRadius: e.target.value })} />
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-400">{__('Padding')}</Label>
<Input type="text" placeholder="e.g. 8px 16px" className="h-8 text-xs px-2" value={styles.padding || ''} onChange={(e) => onElementStylesChange(field.name, { padding: e.target.value })} />
</div>
</div>
</div>
)}
</AccordionContent>
</AccordionItem>
);

View File

@@ -16,7 +16,8 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Plus, Trash2, GripVertical } from 'lucide-react';
import { Plus, Trash2, GripVertical, Image as ImageIcon } from 'lucide-react';
import { Switch } from '@/components/ui/switch';
import { cn } from '@/lib/utils';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
@@ -41,8 +42,9 @@ import RepeaterProductField from './RepeaterProductField';
interface RepeaterFieldDef {
name: string;
label: string;
type: 'text' | 'textarea' | 'url' | 'image' | 'icon' | 'product';
type: 'text' | 'textarea' | 'url' | 'image' | 'icon' | 'product' | 'select' | 'checkbox' | 'color';
placeholder?: string;
options?: { label: string; value: string }[];
}
interface InspectorRepeaterProps {
@@ -91,8 +93,8 @@ function SortableItem({
'Wifi', 'Wrench',
].sort();
const handleFieldChange = (fieldName: string, value: any) => {
onChange(index, fieldName, value);
const handleFieldChange = (fieldNameOrUpdates: string | Record<string, any>, value?: any) => {
onChange(index, fieldNameOrUpdates, value);
};
return (
@@ -151,7 +153,7 @@ function RepeaterFieldRenderer({
field: RepeaterFieldDef;
item: any;
index: number;
onChange: (fieldName: string, value: any) => void;
onChange: (fieldNameOrUpdates: string | Record<string, any>, value?: any) => void;
ICON_OPTIONS: string[];
}) {
const value = item[field.name] || '';
@@ -195,44 +197,53 @@ function RepeaterFieldRenderer({
);
}
if (field.type === 'color') {
return (
<div className="space-y-1.5">
<Label className="text-xs text-gray-500">{field.label}</Label>
<div className="flex items-center gap-2">
<div className="relative w-8 h-8 rounded border shadow-sm shrink-0 overflow-hidden">
<div className="absolute inset-0" style={{ backgroundColor: value || 'transparent' }} />
<input
type="color"
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full p-0 border-0"
value={value || '#ffffff'}
onChange={(e) => onChange(field.name, e.target.value)}
/>
</div>
<Input
type="text"
placeholder="#ffffff"
className="flex-1 h-8 text-xs px-2"
value={value || ''}
onChange={(e) => onChange(field.name, e.target.value)}
/>
</div>
</div>
);
}
if (field.type === 'image') {
return (
<div className="space-y-1.5">
<Label className="text-xs text-gray-500">{field.label}</Label>
<div className="space-y-2">
{value ? (
<MediaUploader
onSelect={(url) => onChange(field.name, url)}
type="image"
>
<div className="relative group cursor-pointer border rounded overflow-hidden h-24 bg-gray-50 flex items-center justify-center">
<img src={value} alt={field.label} className="w-full h-full object-cover" />
<div className="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<span className="text-white text-xs font-medium">Change</span>
</div>
<button
onClick={(e) => {
e.stopPropagation();
onChange(field.name, '');
}}
className="absolute top-1 right-1 bg-white/90 p-1 rounded-full text-gray-600 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity"
type="button"
aria-label="Remove image"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
</MediaUploader>
) : (
<MediaUploader
onSelect={(url) => onChange(field.name, url)}
type="image"
>
<Button variant="outline" className="w-full h-24 border-dashed flex flex-row gap-2 text-gray-400 font-normal justify-start">
Select Image
</Button>
</MediaUploader>
)}
<div className="flex gap-2">
<Input
type="text"
value={value}
onChange={(e) => onChange(field.name, e.target.value)}
placeholder="https://..."
className="flex-1 text-xs h-8"
/>
<MediaUploader
onSelect={(url) => onChange(field.name, url)}
type="image"
className="shrink-0"
>
<Button variant="outline" size="icon" className="h-8 w-8 shrink-0" title="Select Image" type="button">
<ImageIcon className="w-4 h-4" />
</Button>
</MediaUploader>
</div>
</div>
);
@@ -251,6 +262,41 @@ function RepeaterFieldRenderer({
);
}
if (field.type === 'select') {
return (
<div className="space-y-1.5">
<Label className="text-xs text-gray-500">{field.label}</Label>
<Select
value={value}
onValueChange={(val) => onChange(field.name, val)}
>
<SelectTrigger className="h-8 text-xs w-full">
<SelectValue placeholder={field.placeholder || "Select an option"} />
</SelectTrigger>
<SelectContent>
{field.options?.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
if (field.type === 'checkbox') {
return (
<div className="flex items-center justify-between space-x-2 py-1">
<Label className="text-xs text-gray-500">{field.label}</Label>
<Switch
checked={!!value}
onCheckedChange={(checked) => onChange(field.name, checked)}
/>
</div>
);
}
// default: text/url inputs
const inputType = field.type === 'url' ? 'url' : 'text';
@@ -296,9 +342,13 @@ export function InspectorRepeater({
}
};
const handleItemChange = (index: number, fieldName: string, value: string) => {
const handleItemChange = (index: number, fieldNameOrUpdates: string | Record<string, any>, value?: any) => {
const newItems = [...items];
newItems[index] = { ...newItems[index], [fieldName]: value };
if (typeof fieldNameOrUpdates === 'string') {
newItems[index] = { ...newItems[index], [fieldNameOrUpdates]: value };
} else {
newItems[index] = { ...newItems[index], ...fieldNameOrUpdates };
}
onChange(newItems);
};
@@ -341,7 +391,7 @@ export function InspectorRepeater({
item={item}
fields={fields}
itemLabelKey={itemLabelKey}
onChange={(idx: number, fieldName: string, value: string) => handleItemChange(idx, fieldName, value)}
onChange={(idx: number, fieldNameOrUpdates: string | Record<string, any>, value?: any) => handleItemChange(idx, fieldNameOrUpdates, value)}
onDelete={handleDeleteItem}
/>
))}

View File

@@ -10,7 +10,7 @@ export default function RepeaterProductField({
}: {
label: string;
value: string;
onChange: (fieldName: string, nextValue: any) => void;
onChange: (fieldNameOrUpdates: string | Record<string, any>, nextValue?: any) => void;
}) {
const [search, setSearch] = React.useState('');
const [options, setOptions] = React.useState<any[]>([]);
@@ -77,11 +77,13 @@ export default function RepeaterProductField({
const selected = options.find((o) => o.value === v)?.product;
if (!selected) return;
onChange('product_slug', selected.product_slug || '');
onChange('product_name', selected.name || '');
onChange('product_price', selected.sale_price ?? selected.price ?? '');
onChange('product_image', selected.image_url ?? '');
onChange('product_id', selected.id ? Number(selected.id) : 0);
onChange({
product_slug: selected.product_slug || '',
product_name: selected.name || '',
product_price: selected.sale_price ?? selected.price ?? '',
product_image: selected.image_url ?? '',
product_id: selected.id ? Number(selected.id) : 0,
});
}}
options={options.map((o) => ({
value: String(o.value ?? ''),

View File

@@ -32,16 +32,6 @@ export function CTABannerRenderer({ section, className }: CTABannerRendererProps
const buttonText = section.props?.button_text?.value || 'Get Started';
const buttonUrl = section.props?.button_url?.value || '#';
const heightMap: Record<string, string> = {
'default': 'py-12 md:py-20',
'small': 'py-8 md:py-12',
'medium': 'py-16 md:py-24',
'large': 'py-24 md:py-36',
'fullscreen': 'min-h-[50vh] flex flex-col justify-center',
};
const customPadding = section.styles?.paddingTop || section.styles?.paddingBottom;
const heightClasses = customPadding ? '' : (heightMap[section.styles?.heightPreset || 'default'] || 'py-12 md:py-20');
// Helper to get text styles (including font family)
const getTextStyles = (elementName: string) => {
const styles = section.elementStyles?.[elementName] || {};
@@ -69,7 +59,7 @@ export function CTABannerRenderer({ section, className }: CTABannerRendererProps
const hasCustomBackground = !!section.styles?.backgroundColor || !!section.styles?.backgroundImage || section.styles?.backgroundType === 'gradient';
return (
<div className={cn(heightClasses, 'px-4 md:px-8', !hasCustomBackground && scheme.bg, scheme.text, className)}>
<div className={cn('px-4 md:px-8', !hasCustomBackground && scheme.bg, scheme.text, className)}>
<div className="max-w-4xl mx-auto text-center space-y-6">
<h2
className={cn(
@@ -88,7 +78,7 @@ export function CTABannerRenderer({ section, className }: CTABannerRendererProps
)}
style={textStyle.style}
>
{text}
{text || "Description text missing"}
</p>
<button className={cn(
'inline-flex items-center gap-2 px-8 py-4 rounded-lg font-semibold transition hover:opacity-90',

View File

@@ -69,21 +69,11 @@ export function ContactFormRenderer({ section, className }: ContactFormRendererP
return undefined;
};
const heightMap: Record<string, string> = {
'default': 'py-12 md:py-20',
'small': 'py-8 md:py-12',
'medium': 'py-16 md:py-24',
'large': 'py-24 md:py-36',
'fullscreen': 'min-h-[50vh] flex flex-col justify-center',
};
const customPadding = section.styles?.paddingTop || section.styles?.paddingBottom;
const heightClasses = customPadding ? '' : (heightMap[section.styles?.heightPreset || 'default'] || 'py-12 md:py-20');
const hasCustomBackground = !!section.styles?.backgroundColor || !!section.styles?.backgroundImage || section.styles?.backgroundType === 'gradient';
return (
<div
className={cn(heightClasses, 'px-4 md:px-8', !hasCustomBackground && !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
className={cn('px-4 md:px-8', !hasCustomBackground && !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
style={hasCustomBackground ? {} : getBackgroundStyle()}
>
<div className="max-w-xl mx-auto">
@@ -98,59 +88,57 @@ export function ContactFormRenderer({ section, className }: ContactFormRendererP
</h2>
<form className="space-y-4" onSubmit={(e) => e.preventDefault()}>
{/* Name field */}
<div className="relative">
<User className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
placeholder="Your Name"
className={cn(
'w-full pl-12 pr-4 py-3 rounded-lg border border-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500',
!fieldsStyle.backgroundColor && scheme.inputBg
)}
style={{
backgroundColor: fieldsStyle.backgroundColor,
color: fieldsStyle.color
}}
disabled
/>
</div>
{/* Render fields from config, fallback to default if missing */}
{(() => {
const defaultFields = [
{ name: 'name', label: 'Your Name', type: 'text', required: true },
{ name: 'email', label: 'Your Email', type: 'email', required: true },
{ name: 'message', label: 'Your Message', type: 'textarea', required: true }
];
const fieldsProp = section.props?.fields?.value;
const fields = Array.isArray(fieldsProp) && fieldsProp.length > 0 ? fieldsProp : defaultFields;
{/* Email field */}
<div className="relative">
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="email"
placeholder="Your Email"
className={cn(
'w-full pl-12 pr-4 py-3 rounded-lg border border-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500',
!fieldsStyle.backgroundColor && scheme.inputBg
)}
style={{
backgroundColor: fieldsStyle.backgroundColor,
color: fieldsStyle.color
}}
disabled
/>
</div>
{/* Message field */}
<div className="relative">
<MessageSquare className="absolute left-4 top-4 w-5 h-5 text-gray-400" />
<textarea
placeholder="Your Message"
rows={4}
className={cn(
'w-full pl-12 pr-4 py-3 rounded-lg border border-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none',
!fieldsStyle.backgroundColor && scheme.inputBg
)}
style={{
backgroundColor: fieldsStyle.backgroundColor,
color: fieldsStyle.color
}}
disabled
/>
</div>
return fields.map((field: any, idx: number) => {
const Icon = field.type === 'email' ? Mail : field.type === 'textarea' ? MessageSquare : User;
return (
<div key={field.name || idx} className="relative">
<Icon className={cn(
"absolute left-4 text-gray-400 w-5 h-5",
field.type === 'textarea' ? "top-4" : "top-1/2 -translate-y-1/2"
)} />
{field.type === 'textarea' ? (
<textarea
placeholder={field.label + (field.required ? ' *' : '')}
rows={4}
className={cn(
'w-full pl-12 pr-4 py-3 rounded-lg border border-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none',
!fieldsStyle.backgroundColor && scheme.inputBg
)}
style={{
backgroundColor: fieldsStyle.backgroundColor,
color: fieldsStyle.color
}}
disabled
/>
) : (
<input
type={field.type || 'text'}
placeholder={field.label + (field.required ? ' *' : '')}
className={cn(
'w-full pl-12 pr-4 py-3 rounded-lg border border-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500',
!fieldsStyle.backgroundColor && scheme.inputBg
)}
style={{
backgroundColor: fieldsStyle.backgroundColor,
color: fieldsStyle.color
}}
disabled
/>
)}
</div>
);
});
})()}
{/* Submit button */}
<button

View File

@@ -155,18 +155,6 @@ export function ContentRenderer({ section, className }: ContentRendererProps) {
const layout = section.layoutVariant || 'default';
const widthClass = WIDTH_CLASSES[layout] || WIDTH_CLASSES.default;
const heightPreset = section.styles?.heightPreset || 'default';
const heightMap: Record<string, string> = {
'default': 'py-12 md:py-20',
'small': 'py-8 md:py-12',
'medium': 'py-16 md:py-24',
'large': 'py-24 md:py-32',
'screen': 'min-h-screen py-20 flex items-center',
};
const heightClasses = heightMap[heightPreset] || 'py-12 md:py-20';
const content = section.props?.content?.value || 'Your content goes here. Edit this in the inspector panel.';
const isDynamic = section.props?.content?.type === 'dynamic';
@@ -218,7 +206,6 @@ export function ContentRenderer({ section, className }: ContentRendererProps) {
className={cn(
'relative w-full overflow-hidden',
'px-4 md:px-8',
heightClasses,
!hasCustomBackground && !scheme.bg.startsWith('wn-') && scheme.bg,
scheme.text,
className

View File

@@ -119,21 +119,11 @@ export function FeatureGridRenderer({ section, className }: FeatureGridRendererP
return undefined;
};
const heightMap: Record<string, string> = {
'default': 'py-12 md:py-20',
'small': 'py-8 md:py-12',
'medium': 'py-16 md:py-24',
'large': 'py-24 md:py-36',
'fullscreen': 'min-h-[50vh] flex flex-col justify-center',
};
const customPadding = section.styles?.paddingTop || section.styles?.paddingBottom;
const heightClasses = customPadding ? '' : (heightMap[section.styles?.heightPreset || 'default'] || 'py-12 md:py-20');
const hasCustomBackground = !!section.styles?.backgroundColor || !!section.styles?.backgroundImage || section.styles?.backgroundType === 'gradient';
return (
<div
className={cn(heightClasses, 'px-4 md:px-8', !hasCustomBackground && !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
className={cn('px-4 md:px-8', !hasCustomBackground && !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
style={hasCustomBackground ? {} : getBackgroundStyle()}
>
<div className="max-w-6xl mx-auto">

View File

@@ -27,16 +27,6 @@ export function HeroRenderer({ section, className }: HeroRendererProps) {
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'] ?? COLOR_SCHEMES['default'];
const layout = section.layoutVariant || 'default';
const heightMap: Record<string, string> = {
'default': 'py-12 md:py-20',
'small': 'py-8 md:py-12',
'medium': 'py-16 md:py-24',
'large': 'py-24 md:py-36',
'fullscreen': 'min-h-[50vh] flex flex-col justify-center',
};
const customPadding = section.styles?.paddingTop || section.styles?.paddingBottom;
const heightClasses = customPadding ? '' : (heightMap[section.styles?.heightPreset || 'default'] || 'py-12 md:py-20');
const title = section.props?.title?.value || 'Hero Title';
const subtitle = section.props?.subtitle?.value || 'Your amazing subtitle here';
const image = section.props?.image?.value;
@@ -81,7 +71,7 @@ export function HeroRenderer({ section, className }: HeroRendererProps) {
if (layout === 'hero-left-image' || layout === 'hero-right-image') {
return (
<div
className={cn(heightClasses, 'px-4 md:px-8', !hasCustomBackground && !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
className={cn('px-4 md:px-8', !hasCustomBackground && !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
>
<div className={cn(
'max-w-6xl mx-auto flex items-center gap-12',
@@ -156,7 +146,7 @@ export function HeroRenderer({ section, className }: HeroRendererProps) {
// Default centered layout
return (
<div
className={cn(heightClasses, 'px-4 md:px-8 text-center', !hasCustomBackground && !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
className={cn('px-4 md:px-8 text-center', !hasCustomBackground && !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
>
<div className="max-w-4xl mx-auto space-y-6">
<h1

View File

@@ -72,21 +72,11 @@ export function ImageTextRenderer({ section, className }: ImageTextRendererProps
return undefined;
};
const heightMap: Record<string, string> = {
'default': 'py-12 md:py-20',
'small': 'py-8 md:py-12',
'medium': 'py-16 md:py-24',
'large': 'py-24 md:py-36',
'fullscreen': 'min-h-[50vh] flex flex-col justify-center',
};
const customPadding = section.styles?.paddingTop || section.styles?.paddingBottom;
const heightClasses = customPadding ? '' : (heightMap[section.styles?.heightPreset || 'default'] || 'py-12 md:py-20');
const hasCustomBackground = !!section.styles?.backgroundColor || !!section.styles?.backgroundImage || section.styles?.backgroundType === 'gradient';
return (
<div
className={cn(heightClasses, 'px-4 md:px-8', !hasCustomBackground && !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
className={cn('px-4 md:px-8', !hasCustomBackground && !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
style={hasCustomBackground ? {} : getBackgroundStyle()}
>
<div className={cn(

View File

@@ -6,6 +6,7 @@ import { cn } from '@/lib/utils';
export function MarqueeBannerRenderer({ section, className }: { section: any; className?: string }) {
const { text, separator } = section.props;
const styles = section.styles || {};
const elementStyles = section.elementStyles || {};
const displayText = text?.value || 'Marquee Banner Text Here';
const displaySeparator = separator?.value || '✦';
@@ -19,7 +20,14 @@ export function MarqueeBannerRenderer({ section, className }: { section: any; cl
<div className="flex whitespace-nowrap opacity-70">
<div className="flex items-center gap-8 pr-8">
{[1, 2, 3].map((idx) => (
<span key={idx} className="flex items-center gap-8 text-sm font-medium tracking-wide uppercase">
<span
key={idx}
className="flex items-center gap-8 text-sm font-medium tracking-wide uppercase"
style={{
color: elementStyles?.text?.color,
fontSize: elementStyles?.text?.fontSize?.replace('text-', '') ? undefined : 'inherit' // Basic mock
}}
>
{displayText}
<span className="opacity-50 text-xs">{displaySeparator}</span>
</span>

View File

@@ -37,17 +37,21 @@ export function ShoppableImageRenderer({ section, className }: { section: any; c
<div className="relative rounded-xl overflow-hidden bg-gray-100 aspect-[16/9] border-2 border-dashed border-gray-300 flex items-center justify-center">
{displayImage ? (
<>
<img src={displayImage} alt="Shoppable Preview" className="w-full h-full object-cover opacity-50" />
{displayHotspots.map((hotspot: any, idx: number) => (
<div
key={idx}
className="absolute w-6 h-6 rounded-full bg-primary text-white flex items-center justify-center border-2 border-white shadow-lg text-xs font-bold"
style={{ left: `${hotspot.x}%`, top: `${hotspot.y}%`, transform: 'translate(-50%, -50%)' }}
>
{idx + 1}
</div>
))}
{displayHotspots.map((hotspot: any, idx: number) => {
const xVal = parseFloat(String(hotspot.x ?? 0).replace('%', '')) || 0;
const yVal = parseFloat(String(hotspot.y ?? 0).replace('%', '')) || 0;
return (
<div
key={idx}
className="absolute w-6 h-6 rounded-full bg-primary text-white flex items-center justify-center border-2 border-white shadow-lg text-xs font-bold"
style={{ left: `${xVal}%`, top: `${yVal}%`, transform: 'translate(-50%, -50%)' }}
>
{idx + 1}
</div>
);
})}
</>
) : (
<div className="text-center text-gray-400">

View File

@@ -23,7 +23,9 @@ export interface SectionOption {
export interface StylableElementSchema {
name: string;
label: string;
type: 'text' | 'image';
type: 'text' | 'image' | 'container';
disableAlignment?: boolean;
disableBackground?: boolean;
}
export interface SectionSchema {
@@ -53,7 +55,7 @@ export const SECTION_SCHEMAS: Record<string, SectionSchema> = {
fields: [
{ 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: 'image', label: 'Image', type: 'image', dynamic: true },
{ name: 'cta_text', label: 'Button Text', type: 'text' },
{ name: 'cta_url', label: 'Button URL', type: 'url' },
],
@@ -89,12 +91,12 @@ export const SECTION_SCHEMAS: Record<string, SectionSchema> = {
{ value: 'medium', label: 'Medium' },
],
stylableElements: [
{ 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: 'content', label: 'Container', type: 'container', disableAlignment: true },
{ name: 'heading', label: 'Headings', type: 'text', disableAlignment: true },
{ name: 'text', label: 'Body Text', type: 'text', disableAlignment: true },
{ name: 'link', label: 'Links', type: 'text', disableAlignment: true },
{ name: 'image', label: 'Images', type: 'image', disableAlignment: true },
{ name: 'button', label: 'Button', type: 'text' },
{ name: 'content', label: 'Container', type: 'text' },
],
},
'image-text': {
@@ -111,7 +113,7 @@ export const SECTION_SCHEMAS: Record<string, SectionSchema> = {
fields: [
{ 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: 'image', label: 'Image', type: 'image', dynamic: true },
{ name: 'cta_text', label: 'Button Text', type: 'text' },
{ name: 'cta_url', label: 'Button URL', type: 'url' },
],
@@ -178,6 +180,14 @@ export const SECTION_SCHEMAS: Record<string, SectionSchema> = {
title: { type: 'static', value: 'Contact Us' },
webhook_url: { type: 'static', value: '' },
redirect_url: { type: 'static', value: '' },
fields: {
type: 'static',
value: [
{ name: 'name', label: 'Your Name', type: 'text', required: true },
{ name: 'email', label: 'Your Email', type: 'email', required: true },
{ name: 'message', label: 'Your Message', type: 'textarea', required: true },
]
}
},
fields: [
{ name: 'title', label: 'Title', type: 'text' },
@@ -232,9 +242,8 @@ export const SECTION_SCHEMAS: Record<string, SectionSchema> = {
{ value: 'featured', label: 'Featured' },
],
stylableElements: [
{ name: 'title', label: 'Title', type: 'text' },
{ name: 'subtitle', label: 'Subtitle', type: 'text' },
{ name: 'link', label: 'CTA Link', type: 'text' },
{ name: 'title', label: 'Title', type: 'text', disableAlignment: true },
{ name: 'subtitle', label: 'Subtitle', type: 'text', disableAlignment: true },
],
},
'shoppable-image': {
@@ -251,11 +260,12 @@ export const SECTION_SCHEMAS: Record<string, SectionSchema> = {
fields: [
{ name: 'title', label: 'Title', type: 'text' },
{ name: 'subtitle', label: 'Subtitle', type: 'text' },
{ name: 'image', label: 'Image URL', type: 'url' },
{ name: 'image', label: 'Image', type: 'image' },
{ name: 'alt', label: 'Image Alt Text', type: 'text' },
],
stylableElements: [
{ name: 'title', label: 'Title', type: 'text' },
{ name: 'subtitle', label: 'Subtitle', type: 'text' },
],
},
'marquee-banner': {
@@ -272,6 +282,9 @@ export const SECTION_SCHEMAS: Record<string, SectionSchema> = {
{ name: 'separator', label: 'Separator', type: 'text' },
{ name: 'speed', label: 'Speed (seconds)', type: 'text' },
],
stylableElements: [
{ name: 'text', label: 'Banner Text', type: 'text', disableBackground: true, disableAlignment: true },
],
},
};

View File

@@ -21,6 +21,11 @@ export interface SectionStyles {
paddingTop?: string;
paddingBottom?: string;
contentWidth?: 'full' | 'contained' | 'boxed';
cardBackgroundColor?: string;
cardPaddingTop?: string;
cardPaddingRight?: string;
cardPaddingBottom?: string;
cardPaddingLeft?: string;
heightPreset?: string;
dynamicBackground?: string; // e.g. 'post_featured_image'
}
@@ -34,6 +39,8 @@ export interface ElementStyle {
// Image specific
objectFit?: 'cover' | 'contain' | 'fill';
objectPosition?: string;
alignment?: 'left' | 'center' | 'right';
backgroundColor?: string; // Wrapper BG
width?: string;
height?: string;