feat: refine dynamic page sections, fix renderers, improve styling controls and editor schema
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -78,87 +78,6 @@ 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>
|
||||
</div>
|
||||
) : (
|
||||
<div className={gridClasses}>
|
||||
{/* Image Side */}
|
||||
{hasImage && (
|
||||
@@ -232,7 +151,6 @@ export const SharedContentLayout: React.FC<SharedContentProps> = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
import { api } from '../api';
|
||||
export const apiClient = api;
|
||||
export { api };
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,6 +814,7 @@ 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 */}
|
||||
{!field.disableBackground && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-gray-500">{__('Background (Wrapper)')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -751,17 +827,18 @@ export function InspectorPanel({
|
||||
onChange={(e) => onElementStylesChange(field.name, { backgroundColor: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Color (#fff)"
|
||||
className="flex-1 h-7 text-xs rounded border px-2"
|
||||
className="flex-1 h-8 text-xs 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,6 +907,7 @@ export function InspectorPanel({
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{!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>
|
||||
@@ -839,6 +917,7 @@ export function InspectorPanel({
|
||||
<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,9 +955,66 @@ export function InspectorPanel({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Image Settings */}
|
||||
{isImage && (
|
||||
<>
|
||||
<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 })}>
|
||||
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="Object Fit" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Default</SelectItem>
|
||||
<SelectItem value="cover">Cover</SelectItem>
|
||||
<SelectItem value="contain">Contain</SelectItem>
|
||||
<SelectItem value="fill">Fill</SelectItem>
|
||||
</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="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="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.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">
|
||||
@@ -898,48 +1034,19 @@ export function InspectorPanel({
|
||||
</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 })} />
|
||||
<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="w-full h-7 text-xs rounded border px-2" value={styles.borderRadius || ''} onChange={(e) => onElementStylesChange(field.name, { borderRadius: e.target.value })} />
|
||||
<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="w-full h-7 text-xs rounded border px-2" value={styles.padding || ''} onChange={(e) => onElementStylesChange(field.name, { padding: e.target.value })} />
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* 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 })}>
|
||||
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="Object Fit" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Default</SelectItem>
|
||||
<SelectItem value="cover">Cover</SelectItem>
|
||||
<SelectItem value="contain">Contain</SelectItem>
|
||||
<SelectItem value="fill">Fill</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 })} />
|
||||
</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 })} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
|
||||
@@ -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 ? (
|
||||
<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"
|
||||
>
|
||||
<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 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}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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 ?? ''),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,47 +88,27 @@ 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" />
|
||||
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="Your Message"
|
||||
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',
|
||||
@@ -150,7 +120,25 @@ export function ContactFormRenderer({ section, className }: ContactFormRendererP
|
||||
}}
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => (
|
||||
{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: `${hotspot.x}%`, top: `${hotspot.y}%`, transform: 'translate(-50%, -50%)' }}
|
||||
style={{ left: `${xVal}%`, top: `${yVal}%`, transform: 'translate(-50%, -50%)' }}
|
||||
>
|
||||
{idx + 1}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center text-gray-400">
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -22,6 +22,7 @@ interface SharedContentProps {
|
||||
textClassName?: string;
|
||||
headingStyle?: React.CSSProperties; // For prose headings override
|
||||
imageStyle?: React.CSSProperties;
|
||||
cardStyle?: React.CSSProperties; // For boxed layout background
|
||||
|
||||
// Pro Features (for future)
|
||||
buttons?: Array<{ text: string, url: string }>;
|
||||
@@ -44,6 +45,7 @@ export const SharedContentLayout: React.FC<SharedContentProps> = ({
|
||||
buttons,
|
||||
|
||||
imageStyle,
|
||||
cardStyle,
|
||||
buttonStyle
|
||||
}) => {
|
||||
|
||||
@@ -56,7 +58,8 @@ export const SharedContentLayout: React.FC<SharedContentProps> = ({
|
||||
// Wrapper classes — no width constraints applied here, parent handles it
|
||||
const containerClasses = cn(
|
||||
'w-full mx-auto px-4 sm:px-6 lg:px-8',
|
||||
containerWidth === 'contained' ? 'max-w-4xl' : '' // only constraint needed is for contained narrow text
|
||||
containerWidth === 'contained' ? 'max-w-4xl' : '',
|
||||
containerWidth === 'boxed' ? 'max-w-5xl' : ''
|
||||
);
|
||||
|
||||
const gridClasses = cn(
|
||||
@@ -68,8 +71,11 @@ export const SharedContentLayout: React.FC<SharedContentProps> = ({
|
||||
|
||||
const imageWrapperOrder = isImageRight ? 'lg:order-last' : 'lg:order-first';
|
||||
|
||||
const safeTextStyle = { ...textStyle };
|
||||
delete safeTextStyle.textAlign;
|
||||
|
||||
const proseStyle = {
|
||||
...textStyle,
|
||||
...safeTextStyle,
|
||||
'--tw-prose-headings': headingStyle?.color,
|
||||
'--tw-prose-body': textStyle?.color,
|
||||
} as React.CSSProperties;
|
||||
@@ -80,16 +86,34 @@ export const SharedContentLayout: React.FC<SharedContentProps> = ({
|
||||
{/* Image Side */}
|
||||
{hasImage && (
|
||||
<div className={cn(
|
||||
'relative w-full aspect-[4/3] rounded-2xl overflow-hidden shadow-lg',
|
||||
'flex flex-col',
|
||||
imageWrapperOrder,
|
||||
(isImageTop || isImageBottom) && 'mb-8'
|
||||
)} style={imageStyle}>
|
||||
(isImageTop || isImageBottom) && 'mb-8',
|
||||
{
|
||||
'items-start': (imageStyle as any)?.alignment === 'left',
|
||||
'items-center': (imageStyle as any)?.alignment === 'center',
|
||||
'items-end': (imageStyle as any)?.alignment === 'right',
|
||||
}
|
||||
)}>
|
||||
<div className={cn(
|
||||
'relative w-full aspect-[4/3] rounded-2xl overflow-hidden shadow-lg',
|
||||
)} style={{
|
||||
backgroundColor: imageStyle?.backgroundColor,
|
||||
width: imageStyle?.width,
|
||||
height: imageStyle?.height,
|
||||
maxWidth: '100%'
|
||||
}}>
|
||||
<img
|
||||
src={image}
|
||||
alt={title || 'Section Image'}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
style={{
|
||||
objectFit: imageStyle?.objectFit,
|
||||
objectPosition: (imageStyle as any)?.objectPosition,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content Side */}
|
||||
@@ -127,7 +151,12 @@ export const SharedContentLayout: React.FC<SharedContentProps> = ({
|
||||
|
||||
{/* Buttons */}
|
||||
{buttons && buttons.length > 0 && (
|
||||
<div className="mt-8 flex flex-wrap gap-4">
|
||||
<div className={cn(
|
||||
"mt-8 flex flex-wrap gap-4",
|
||||
buttonStyle?.style?.textAlign === 'center' && "justify-center",
|
||||
buttonStyle?.style?.textAlign === 'right' && "justify-end",
|
||||
(!buttonStyle?.style?.textAlign || buttonStyle?.style?.textAlign === 'left') && "justify-start"
|
||||
)}>
|
||||
{buttons.map((btn, idx) => (
|
||||
btn.text && btn.url && (
|
||||
<a
|
||||
|
||||
@@ -35,6 +35,12 @@ interface SectionStyles {
|
||||
gradientAngle?: number;
|
||||
gradientFrom?: string;
|
||||
gradientTo?: string;
|
||||
cardBackgroundColor?: string;
|
||||
cardPaddingTop?: string;
|
||||
cardPaddingRight?: string;
|
||||
cardPaddingBottom?: string;
|
||||
cardPaddingLeft?: string;
|
||||
heightPreset?: string;
|
||||
}
|
||||
|
||||
interface ElementStyle {
|
||||
@@ -272,7 +278,14 @@ export function DynamicPageRenderer({ slug: propSlug }: DynamicPageRendererProps
|
||||
key={section.id}
|
||||
className={cn(
|
||||
"relative overflow-hidden",
|
||||
!section.styles?.backgroundColor && !section.styles?.backgroundType && "bg-white/50"
|
||||
!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-screen flex items-center',
|
||||
}[(section.styles?.heightPreset as string) || 'default'] || 'py-16 md:py-24'
|
||||
)}
|
||||
style={{
|
||||
...(section.styles?.backgroundType === 'gradient'
|
||||
@@ -313,10 +326,21 @@ export function DynamicPageRenderer({ slug: propSlug }: DynamicPageRendererProps
|
||||
{/* 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,
|
||||
}}
|
||||
>
|
||||
<SectionComponent
|
||||
id={section.id}
|
||||
section={section}
|
||||
sourceType={isStructuralPage ? 'page' : 'template'}
|
||||
sourceId={isStructuralPage ? pageData.id : pageData.cpt}
|
||||
layout={section.layoutVariant || 'default'}
|
||||
colorScheme={section.colorScheme || 'default'}
|
||||
styles={section.styles}
|
||||
@@ -333,6 +357,8 @@ export function DynamicPageRenderer({ slug: propSlug }: DynamicPageRendererProps
|
||||
<SectionComponent
|
||||
id={section.id}
|
||||
section={section}
|
||||
sourceType={isStructuralPage ? 'page' : 'template'}
|
||||
sourceId={isStructuralPage ? pageData.id : pageData.cpt}
|
||||
layout={section.layoutVariant || 'default'}
|
||||
colorScheme={section.colorScheme || 'default'}
|
||||
styles={section.styles}
|
||||
|
||||
@@ -7,6 +7,7 @@ interface BentoItem {
|
||||
label: string;
|
||||
image?: string;
|
||||
url?: string;
|
||||
backgroundColor?: string;
|
||||
size?: 'small' | 'medium' | 'large' | 'tall';
|
||||
}
|
||||
|
||||
@@ -69,6 +70,31 @@ export function BentoCategoryGrid({
|
||||
});
|
||||
})();
|
||||
|
||||
// Helper to get text styles (including font family)
|
||||
const getTextStyles = (elementName: string) => {
|
||||
const es = elementStyles?.[elementName] || {};
|
||||
return {
|
||||
classNames: cn(
|
||||
es.fontSize,
|
||||
es.fontWeight,
|
||||
{
|
||||
'font-sans': es.fontFamily === 'secondary',
|
||||
'font-serif': es.fontFamily === 'primary',
|
||||
}
|
||||
),
|
||||
style: {
|
||||
color: es.color,
|
||||
textAlign: es.textAlign,
|
||||
backgroundColor: es.backgroundColor,
|
||||
borderColor: es.borderColor,
|
||||
borderWidth: es.borderWidth,
|
||||
borderRadius: es.borderRadius,
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const titleStyle = getTextStyles('title');
|
||||
|
||||
const sectionBg = getSectionBackground(styles);
|
||||
const hasCustomPadding = styles?.paddingTop || styles?.paddingBottom;
|
||||
|
||||
@@ -76,14 +102,17 @@ export function BentoCategoryGrid({
|
||||
<section
|
||||
id={id}
|
||||
className={cn("wn-section wn-bento-grid relative overflow-hidden w-full", hasCustomPadding ? "" : "py-12 md:py-16")}
|
||||
style={sectionBg.style}
|
||||
>
|
||||
<SectionBackgroundRenderer bg={sectionBg} />
|
||||
<div className="w-full mx-auto px-4 relative z-10">
|
||||
{title && (
|
||||
<h2
|
||||
className="text-3xl md:text-4xl font-bold mb-8"
|
||||
style={{ color: elementStyles?.title?.color }}
|
||||
className={cn(
|
||||
"mb-8",
|
||||
!elementStyles?.title?.fontSize && "text-3xl md:text-4xl",
|
||||
!elementStyles?.title?.fontWeight && "font-bold",
|
||||
titleStyle.classNames
|
||||
)}
|
||||
style={titleStyle.style}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
@@ -109,6 +138,13 @@ export function BentoCategoryGrid({
|
||||
alt={item.label}
|
||||
className="absolute inset-0 w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||
/>
|
||||
) : item.backgroundColor ? (
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background: `linear-gradient(to bottom right, ${item.backgroundColor}, color-mix(in srgb, ${item.backgroundColor}, black 35%))`
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className={cn('absolute inset-0 bg-gradient-to-br', gradientClass)} />
|
||||
)}
|
||||
|
||||
@@ -23,7 +23,9 @@ export function CTABannerSection({
|
||||
button_url,
|
||||
elementStyles,
|
||||
styles,
|
||||
}: CTABannerSectionProps & { styles?: Record<string, any> }) {
|
||||
isEditor,
|
||||
}: CTABannerSectionProps & { styles?: Record<string, any>; isEditor?: boolean }) {
|
||||
|
||||
const heightMap: Record<string, string> = {
|
||||
'default': 'py-12 md:py-20',
|
||||
'small': 'py-8 md:py-12',
|
||||
@@ -84,17 +86,19 @@ export function CTABannerSection({
|
||||
styles?.contentWidth !== 'boxed' && {
|
||||
'text-white/90': colorScheme === 'primary',
|
||||
'text-gray-600': colorScheme === 'muted',
|
||||
'text-gray-700': colorScheme === 'default',
|
||||
},
|
||||
styles?.contentWidth === 'boxed' && 'text-gray-600',
|
||||
textStyle.classNames
|
||||
)}
|
||||
style={textStyle.style}
|
||||
>
|
||||
{text}
|
||||
{text || "Description text missing"}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{button_text && button_url && (
|
||||
<div className="w-full mt-4" style={{ textAlign: (btnStyle.style.textAlign as React.CSSProperties['textAlign']) || 'center' }}>
|
||||
<a
|
||||
href={button_url}
|
||||
className={cn(
|
||||
@@ -113,29 +117,28 @@ export function CTABannerSection({
|
||||
}),
|
||||
btnStyle.classNames
|
||||
)}
|
||||
style={btnStyle.style}
|
||||
style={{ ...btnStyle.style, textAlign: undefined }}
|
||||
>
|
||||
{button_text}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const sectionBg = getSectionBackground(styles);
|
||||
const isBoxed = styles?.contentWidth === 'boxed';
|
||||
|
||||
return (
|
||||
<section
|
||||
id={id}
|
||||
className={cn(
|
||||
'wn-section wn-cta-banner relative overflow-hidden w-full',
|
||||
'wn-section wn-cta-banner relative w-full flex flex-col items-center justify-center',
|
||||
`wn-cta-banner--${layout}`,
|
||||
`wn-scheme--${colorScheme}`,
|
||||
heightClasses
|
||||
heightClasses // Might not be needed if handled by outer, but safe to keep
|
||||
)}
|
||||
style={sectionBg.style}
|
||||
>
|
||||
<SectionBackgroundRenderer bg={sectionBg} />
|
||||
<div className="mx-auto px-4 text-center relative z-10 w-full">
|
||||
<div className="mx-auto px-4 text-center relative z-10 w-full max-w-5xl">
|
||||
{innerContent}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -2,38 +2,42 @@ import { useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getSectionBackground } from '@/lib/sectionStyles';
|
||||
import { SectionBackgroundRenderer } from '@/components/SectionBackgroundRenderer';
|
||||
import { api } from '@/lib/api/client';
|
||||
|
||||
interface ContactFormField {
|
||||
name: string;
|
||||
label: string;
|
||||
type: string;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
interface ContactFormSectionProps {
|
||||
id: string;
|
||||
sourceType?: string;
|
||||
sourceId?: string;
|
||||
layout?: string;
|
||||
colorScheme?: string;
|
||||
title?: string;
|
||||
webhook_url?: string;
|
||||
redirect_url?: string;
|
||||
fields?: string[];
|
||||
fields?: ContactFormField[];
|
||||
elementStyles?: Record<string, any>;
|
||||
}
|
||||
|
||||
export function ContactFormSection({
|
||||
id,
|
||||
sourceType,
|
||||
sourceId,
|
||||
layout = 'default',
|
||||
colorScheme = 'default',
|
||||
title,
|
||||
webhook_url,
|
||||
redirect_url,
|
||||
fields = ['name', 'email', 'message'],
|
||||
fields,
|
||||
elementStyles,
|
||||
styles,
|
||||
}: ContactFormSectionProps & { styles?: Record<string, any> }) {
|
||||
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-screen flex flex-col justify-center',
|
||||
};
|
||||
const customPadding = styles?.paddingTop || styles?.paddingBottom;
|
||||
const heightClasses = customPadding ? '' : (heightMap[styles?.heightPreset || 'default'] || 'py-12 md:py-20');
|
||||
isEditor,
|
||||
}: ContactFormSectionProps & { styles?: Record<string, any>; isEditor?: boolean }) {
|
||||
const [formData, setFormData] = useState<Record<string, string>>({});
|
||||
|
||||
// Helper to get text styles (including font family)
|
||||
@@ -64,23 +68,82 @@ export function ContactFormSection({
|
||||
const fieldsStyle = getTextStyles('fields');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||
const [touched, setTouched] = useState<Record<string, boolean>>({});
|
||||
|
||||
const defaultFields: ContactFormField[] = [
|
||||
{ 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 activeFields = Array.isArray(fields) && fields.length > 0 ? fields : defaultFields;
|
||||
|
||||
const validateField = (name: string, value: string, field: ContactFormField) => {
|
||||
if (field.required && !value?.trim()) {
|
||||
return `${field.label} is required`;
|
||||
}
|
||||
if (field.type === 'email' && value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
|
||||
return 'Please enter a valid email address';
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
setFormData({ ...formData, [e.target.name]: e.target.value });
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
|
||||
// Clear error when user types
|
||||
if (fieldErrors[name]) {
|
||||
setFieldErrors(prev => ({ ...prev, [name]: '' }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setTouched(prev => ({ ...prev, [name]: true }));
|
||||
|
||||
const field = activeFields.find(f => f.name === name);
|
||||
if (field) {
|
||||
const errorMsg = validateField(name, value, field);
|
||||
setFieldErrors(prev => ({ ...prev, [name]: errorMsg }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Validate all fields
|
||||
const newErrors: Record<string, string> = {};
|
||||
let isValid = true;
|
||||
|
||||
activeFields.forEach(field => {
|
||||
const errorMsg = validateField(field.name, formData[field.name] || '', field);
|
||||
if (errorMsg) {
|
||||
newErrors[field.name] = errorMsg;
|
||||
isValid = false;
|
||||
}
|
||||
});
|
||||
|
||||
if (!isValid) {
|
||||
setFieldErrors(newErrors);
|
||||
// Mark all fields with errors as touched
|
||||
const allTouched = Object.keys(newErrors).reduce((acc, key) => ({...acc, [key]: true}), {});
|
||||
setTouched(prev => ({ ...prev, ...allTouched }));
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Submit to webhook if provided
|
||||
if (webhook_url) {
|
||||
await fetch(webhook_url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData),
|
||||
// Submit to webhook proxy if configured
|
||||
if (webhook_url && !isEditor) {
|
||||
await api.post('/pages/submit-section-form', {
|
||||
source_type: sourceType,
|
||||
source_id: sourceId,
|
||||
section_id: id,
|
||||
form_data: formData,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -107,12 +170,9 @@ export function ContactFormSection({
|
||||
id={id}
|
||||
className={cn(
|
||||
'wn-section wn-contact-form relative overflow-hidden w-full',
|
||||
`wn-scheme--${colorScheme}`,
|
||||
heightClasses
|
||||
`wn-scheme--${colorScheme}`
|
||||
)}
|
||||
style={sectionBg.style}
|
||||
>
|
||||
<SectionBackgroundRenderer bg={sectionBg} />
|
||||
<div className="mx-auto px-4 relative z-10 w-full">
|
||||
<div className={cn(
|
||||
'max-w-xl mx-auto',
|
||||
@@ -133,55 +193,62 @@ export function ContactFormSection({
|
||||
</h2>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{fields.map((field) => {
|
||||
const fieldLabel = field.charAt(0).toUpperCase() + field.slice(1).replace('_', ' ');
|
||||
const isTextarea = field === 'message' || field === 'content';
|
||||
<form onSubmit={handleSubmit} className="space-y-6" noValidate>
|
||||
{activeFields.map((field, idx) => {
|
||||
const isTextarea = field.type === 'textarea';
|
||||
const fieldError = fieldErrors[field.name];
|
||||
const isTouched = touched[field.name];
|
||||
const showError = isTouched && fieldError;
|
||||
|
||||
return (
|
||||
<div key={field} className="wn-contact-form__field">
|
||||
<div key={field.name || idx} className="wn-contact-form__field">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
{fieldLabel}
|
||||
{field.label} {field.required && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
{isTextarea ? (
|
||||
<textarea
|
||||
name={field}
|
||||
value={formData[field] || ''}
|
||||
name={field.name}
|
||||
value={formData[field.name] || ''}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
rows={5}
|
||||
className={cn(
|
||||
"w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-primary/20 focus:border-primary",
|
||||
"w-full px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary",
|
||||
showError ? "border-red-500 focus:border-red-500 focus:ring-red-200" : "border-gray-200",
|
||||
fieldsStyle.classNames
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: fieldsStyle.style?.backgroundColor,
|
||||
color: fieldsStyle.style?.color,
|
||||
borderColor: fieldsStyle.style?.borderColor,
|
||||
borderColor: showError ? undefined : fieldsStyle.style?.borderColor,
|
||||
borderRadius: fieldsStyle.style?.borderRadius,
|
||||
}}
|
||||
placeholder={`Enter your ${fieldLabel.toLowerCase()}`}
|
||||
required
|
||||
placeholder={`Enter ${field.label.toLowerCase()}`}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type={field === 'email' ? 'email' : 'text'}
|
||||
name={field}
|
||||
value={formData[field] || ''}
|
||||
type={field.type || 'text'}
|
||||
name={field.name}
|
||||
value={formData[field.name] || ''}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
className={cn(
|
||||
"w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-primary/20 focus:border-primary",
|
||||
"w-full px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary",
|
||||
showError ? "border-red-500 focus:border-red-500 focus:ring-red-200" : "border-gray-200",
|
||||
fieldsStyle.classNames
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: fieldsStyle.style?.backgroundColor,
|
||||
color: fieldsStyle.style?.color,
|
||||
borderColor: fieldsStyle.style?.borderColor,
|
||||
borderColor: showError ? undefined : fieldsStyle.style?.borderColor,
|
||||
borderRadius: fieldsStyle.style?.borderRadius,
|
||||
}}
|
||||
placeholder={`Enter your ${fieldLabel.toLowerCase()}`}
|
||||
required
|
||||
placeholder={`Enter ${field.label.toLowerCase()}`}
|
||||
/>
|
||||
)}
|
||||
{showError && (
|
||||
<p className="mt-1 text-sm text-red-500">{fieldError}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -48,6 +48,14 @@ const fontSizeToCSS = (className?: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const fontFamilyToCSS = (fontFamily?: string) => {
|
||||
switch (fontFamily) {
|
||||
case 'primary': return "'Playfair Display', Georgia, serif";
|
||||
case 'secondary': return "'Inter', system-ui, sans-serif";
|
||||
default: return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const fontWeightToCSS = (className?: string) => {
|
||||
switch (className) {
|
||||
case 'font-thin': return '100';
|
||||
@@ -74,10 +82,10 @@ const generateScopedStyles = (sectionId: string, elementStyles: Record<string, a
|
||||
const headingRules = [
|
||||
hs.color && `color: ${hs.color} !important;`,
|
||||
hs.fontWeight && `font-weight: ${fontWeightToCSS(hs.fontWeight)} !important;`,
|
||||
hs.fontFamily && `font-family: var(--font-${hs.fontFamily}, inherit) !important;`,
|
||||
hs.fontFamily && `font-family: ${fontFamilyToCSS(hs.fontFamily)} !important;`,
|
||||
hs.fontSize && `font-size: ${fontSizeToCSS(hs.fontSize)} !important;`,
|
||||
hs.backgroundColor && `background-color: ${hs.backgroundColor} !important;`,
|
||||
hs.backgroundColor && `padding: 0.2em 0.4em; border-radius: 4px; display: inline-block;`,
|
||||
hs.backgroundColor && `padding: 0.2em 0.4em; border-radius: 4px;`,
|
||||
].filter(Boolean).join(' ');
|
||||
if (headingRules) {
|
||||
styles.push(`${scope} h1, ${scope} h2, ${scope} h3, ${scope} h4 { ${headingRules} }`);
|
||||
@@ -91,7 +99,7 @@ const generateScopedStyles = (sectionId: string, elementStyles: Record<string, a
|
||||
ts.color && `color: ${ts.color} !important;`,
|
||||
ts.fontSize && `font-size: ${fontSizeToCSS(ts.fontSize)} !important;`,
|
||||
ts.fontWeight && `font-weight: ${fontWeightToCSS(ts.fontWeight)} !important;`,
|
||||
ts.fontFamily && `font-family: var(--font-${ts.fontFamily}, inherit) !important;`,
|
||||
ts.fontFamily && `font-family: ${fontFamilyToCSS(ts.fontFamily)} !important;`,
|
||||
].filter(Boolean).join(' ');
|
||||
if (textRules) {
|
||||
styles.push(`${scope} p, ${scope} li, ${scope} ul, ${scope} ol { ${textRules} }`);
|
||||
@@ -147,6 +155,7 @@ const generateScopedStyles = (sectionId: string, elementStyles: Record<string, a
|
||||
if (is) {
|
||||
const imgRules = [
|
||||
is.objectFit && `object-fit: ${is.objectFit} !important;`,
|
||||
is.objectPosition && `object-position: ${is.objectPosition} !important;`,
|
||||
is.borderRadius && `border-radius: ${is.borderRadius} !important;`,
|
||||
is.width && `width: ${is.width} !important;`,
|
||||
is.height && `height: ${is.height} !important;`,
|
||||
@@ -159,26 +168,15 @@ const generateScopedStyles = (sectionId: string, elementStyles: Record<string, a
|
||||
return styles.join('\n');
|
||||
};
|
||||
|
||||
export function ContentSection({ section, content: propContent, cta_text: propCtaText, cta_url: propCtaUrl }: ContentSectionProps & { outerPadding?: boolean }) {
|
||||
export function ContentSection({ section, content: propContent, cta_text: propCtaText, cta_url: propCtaUrl, isEditor }: ContentSectionProps & { outerPadding?: boolean; isEditor?: boolean }) {
|
||||
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'] ?? COLOR_SCHEMES['default'];
|
||||
// Default to 'default' width if not specified
|
||||
const _layout = section.layoutVariant || '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',
|
||||
'fullscreen': 'min-h-screen py-20 flex items-center',
|
||||
};
|
||||
|
||||
const heightClasses = heightMap[heightPreset] || 'py-12 md:py-20';
|
||||
const sectionBg = getSectionBackground(section.styles);
|
||||
const finalHeightClasses = (section.styles?.paddingTop || section.styles?.paddingBottom) ? '' : heightClasses;
|
||||
|
||||
const content = propContent || section.props?.content?.value || section.props?.content || '';
|
||||
const content = propContent !== undefined ? propContent : (section.props?.content?.value ?? '');
|
||||
|
||||
// Helper to get text styles
|
||||
const getTextStyles = (elementName: string) => {
|
||||
@@ -206,27 +204,25 @@ export function ContentSection({ section, content: propContent, cta_text: propCt
|
||||
const buttonStyle = getTextStyles('button');
|
||||
|
||||
const containerWidth = section.styles?.contentWidth ?? 'contained';
|
||||
const cta_text = propCtaText || section.props?.cta_text?.value || section.props?.cta_text;
|
||||
const cta_url = propCtaUrl || section.props?.cta_url?.value || section.props?.cta_url;
|
||||
const cta_text = propCtaText !== undefined ? propCtaText : (section.props?.cta_text?.value ?? '');
|
||||
const cta_url = propCtaUrl !== undefined ? propCtaUrl : (section.props?.cta_url?.value ?? '');
|
||||
|
||||
return (
|
||||
<>
|
||||
<style dangerouslySetInnerHTML={{ __html: generateScopedStyles(section.id, section.elementStyles || {}) }} />
|
||||
<section
|
||||
<div
|
||||
id={section.id}
|
||||
className={cn(
|
||||
'wn-content relative w-full overflow-hidden',
|
||||
finalHeightClasses,
|
||||
'wn-content relative w-full',
|
||||
scheme.text
|
||||
)}
|
||||
style={sectionBg.style}
|
||||
>
|
||||
<SectionBackgroundRenderer bg={sectionBg} />
|
||||
<SharedContentLayout
|
||||
text={content}
|
||||
textStyle={textStyle.style}
|
||||
headingStyle={headingStyle.style}
|
||||
containerWidth={containerWidth as any}
|
||||
cardStyle={{ backgroundColor: section.styles?.cardBackgroundColor }}
|
||||
className={contentStyle.classNames}
|
||||
buttons={cta_text && cta_url ? [{ text: cta_text, url: cta_url }] : []}
|
||||
buttonStyle={{
|
||||
@@ -234,7 +230,7 @@ export function ContentSection({ section, content: propContent, cta_text: propCt
|
||||
style: buttonStyle.style
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,16 +32,8 @@ export function FeatureGridSection({
|
||||
features = [],
|
||||
elementStyles,
|
||||
styles,
|
||||
}: FeatureGridSectionProps & { features?: FeatureItem[], styles?: Record<string, any> }) {
|
||||
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-screen flex flex-col justify-center',
|
||||
};
|
||||
const customPadding = styles?.paddingTop || styles?.paddingBottom;
|
||||
const heightClasses = customPadding ? '' : (heightMap[styles?.heightPreset || 'default'] || 'py-12 md:py-20');
|
||||
isEditor,
|
||||
}: FeatureGridSectionProps & { features?: FeatureItem[], styles?: Record<string, any>, isEditor?: boolean }) {
|
||||
const safeItems = Array.isArray(items) ? items : [];
|
||||
const safeFeatures = Array.isArray(features) ? features : [];
|
||||
const listItems = safeItems.length > 0 ? safeItems : safeFeatures;
|
||||
@@ -84,17 +76,14 @@ export function FeatureGridSection({
|
||||
const sectionBg = getSectionBackground(styles);
|
||||
|
||||
return (
|
||||
<section
|
||||
<div
|
||||
id={id}
|
||||
className={cn(
|
||||
'wn-section wn-feature-grid relative overflow-hidden w-full',
|
||||
'wn-section wn-feature-grid relative w-full',
|
||||
`wn-feature-grid--${layout}`,
|
||||
`wn-scheme--${colorScheme}`,
|
||||
heightClasses
|
||||
`wn-scheme--${colorScheme}`
|
||||
)}
|
||||
style={sectionBg.style}
|
||||
>
|
||||
<SectionBackgroundRenderer bg={sectionBg} />
|
||||
<div className="mx-auto px-4 relative z-10 w-full">
|
||||
{heading && (
|
||||
<h2
|
||||
@@ -233,6 +222,6 @@ export function FeatureGridSection({
|
||||
<p className="text-center text-gray-400 text-sm py-8">No related articles found.</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ interface HeroSectionProps {
|
||||
export function HeroSection({
|
||||
id,
|
||||
layout = 'default',
|
||||
colorScheme,
|
||||
title,
|
||||
subtitle,
|
||||
image,
|
||||
@@ -24,16 +25,8 @@ export function HeroSection({
|
||||
cta_url,
|
||||
elementStyles,
|
||||
styles,
|
||||
}: HeroSectionProps & { styles?: Record<string, any> }) {
|
||||
const heightMap: Record<string, string> = {
|
||||
'default': 'py-16 md:py-28',
|
||||
'small': 'py-8 md:py-12',
|
||||
'medium': 'py-16 md:py-24',
|
||||
'large': 'py-24 md:py-36',
|
||||
'fullscreen': 'min-h-screen flex flex-col justify-center',
|
||||
};
|
||||
const customPadding = styles?.paddingTop || styles?.paddingBottom;
|
||||
const heightClasses = customPadding ? '' : (heightMap[styles?.heightPreset || 'default'] || 'py-16 md:py-28');
|
||||
isEditor,
|
||||
}: HeroSectionProps & { styles?: Record<string, any>; isEditor?: boolean }) {
|
||||
const isImageLeft = layout === 'hero-left-image' || layout === 'image-left';
|
||||
const isImageRight = layout === 'hero-right-image' || layout === 'image-right';
|
||||
const isCentered = layout === 'centered' || layout === 'default';
|
||||
@@ -84,6 +77,15 @@ export function HeroSection({
|
||||
return undefined;
|
||||
}; */
|
||||
|
||||
const colorSchemeClasses = {
|
||||
primary: 'bg-primary text-primary-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground',
|
||||
muted: 'bg-muted text-muted-foreground',
|
||||
dark: 'bg-slate-900 text-white',
|
||||
}[colorScheme || ''] || '';
|
||||
|
||||
const isBoxed = styles?.contentWidth === 'boxed';
|
||||
|
||||
return (
|
||||
<section
|
||||
id={id}
|
||||
@@ -91,11 +93,9 @@ export function HeroSection({
|
||||
'wn-section wn-hero',
|
||||
`wn-hero--${layout}`,
|
||||
'relative overflow-hidden w-full',
|
||||
heightClasses,
|
||||
!isBoxed && !sectionBg.style?.backgroundColor && !sectionBg.style?.backgroundImage && colorSchemeClasses
|
||||
)}
|
||||
style={sectionBg.style}
|
||||
>
|
||||
<SectionBackgroundRenderer bg={sectionBg} />
|
||||
<div className={cn(
|
||||
'mx-auto z-10 relative flex w-full',
|
||||
{
|
||||
@@ -105,7 +105,11 @@ export function HeroSection({
|
||||
)}>
|
||||
{/* Image - Left */}
|
||||
{image && isImageLeft && (
|
||||
<div className="w-full md:w-1/2">
|
||||
<div className={cn("w-full md:w-1/2 flex flex-col", {
|
||||
'items-start': imageStyle.alignment === 'left',
|
||||
'items-center': imageStyle.alignment === 'center',
|
||||
'items-end': imageStyle.alignment === 'right',
|
||||
})}>
|
||||
<div
|
||||
className={cn("shadow-xl overflow-hidden", !imageStyle.borderRadius && "rounded-lg")}
|
||||
style={{
|
||||
@@ -121,10 +125,11 @@ export function HeroSection({
|
||||
<img
|
||||
src={image}
|
||||
alt={title || 'Hero image'}
|
||||
className="w-full h-auto block"
|
||||
className="w-full h-full object-cover"
|
||||
style={{
|
||||
objectFit: imageStyle.objectFit,
|
||||
height: imageStyle.height,
|
||||
objectPosition: imageStyle.objectPosition,
|
||||
height: imageStyle.height || 'auto',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -169,9 +174,14 @@ export function HeroSection({
|
||||
{/* Centered Image */}
|
||||
<div
|
||||
className={cn(
|
||||
"mt-12 mx-auto shadow-xl overflow-hidden",
|
||||
"mt-12 mx-auto shadow-xl overflow-hidden flex flex-col",
|
||||
!imageStyle.borderRadius && "rounded-lg",
|
||||
imageStyle.width ? "" : "max-w-4xl"
|
||||
imageStyle.width ? "" : "max-w-4xl",
|
||||
{
|
||||
'mr-auto mx-0': imageStyle.alignment === 'left',
|
||||
'ml-auto mx-0': imageStyle.alignment === 'right',
|
||||
'mx-auto': imageStyle.alignment === 'center' || !imageStyle.alignment,
|
||||
}
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: imageStyle.backgroundColor,
|
||||
@@ -189,12 +199,13 @@ export function HeroSection({
|
||||
alt={title || 'Hero image'}
|
||||
className={cn(
|
||||
"w-full rounded-[inherit]",
|
||||
!imageStyle.height && "h-auto",
|
||||
!imageStyle.objectFit && "object-cover"
|
||||
!imageStyle.objectFit && "object-cover",
|
||||
"h-full"
|
||||
)}
|
||||
style={{
|
||||
objectFit: imageStyle.objectFit,
|
||||
height: imageStyle.height,
|
||||
objectPosition: imageStyle.objectPosition,
|
||||
height: imageStyle.height || 'auto',
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
/>
|
||||
@@ -202,24 +213,33 @@ export function HeroSection({
|
||||
</div>
|
||||
|
||||
{cta_text && cta_url && (
|
||||
<div className="w-full mt-8" style={{ textAlign: ctaStyle.style?.textAlign || (isCentered ? 'center' : 'left') as React.CSSProperties['textAlign'] }}>
|
||||
<a
|
||||
href={cta_url}
|
||||
className={cn(
|
||||
"wn-hero__cta inline-block px-8 py-3 rounded-lg font-semibold hover:opacity-90 transition-colors mt-8",
|
||||
"wn-hero__cta inline-block px-8 py-3 rounded-lg font-semibold hover:opacity-90 transition-colors",
|
||||
!ctaStyle.style?.backgroundColor && "bg-primary",
|
||||
!ctaStyle.style?.color && "text-primary-foreground",
|
||||
ctaStyle.classNames
|
||||
)}
|
||||
style={ctaStyle.style}
|
||||
style={{
|
||||
...ctaStyle.style,
|
||||
textAlign: undefined
|
||||
}}
|
||||
>
|
||||
{cta_text}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Image - Right */}
|
||||
{image && isImageRight && (
|
||||
<div className="w-full md:w-1/2">
|
||||
<div className={cn("w-full md:w-1/2 flex flex-col", {
|
||||
'items-start': imageStyle.alignment === 'left',
|
||||
'items-center': imageStyle.alignment === 'center',
|
||||
'items-end': imageStyle.alignment === 'right',
|
||||
})}>
|
||||
<div
|
||||
className="rounded-lg shadow-xl overflow-hidden"
|
||||
style={{
|
||||
@@ -231,10 +251,11 @@ export function HeroSection({
|
||||
<img
|
||||
src={image}
|
||||
alt={title || 'Hero image'}
|
||||
className="w-full h-auto block"
|
||||
className="w-full h-full object-cover"
|
||||
style={{
|
||||
objectFit: imageStyle.objectFit,
|
||||
height: imageStyle.height,
|
||||
objectPosition: imageStyle.objectPosition,
|
||||
height: imageStyle.height || 'auto',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -24,7 +24,8 @@ export function ImageTextSection({
|
||||
cta_url,
|
||||
elementStyles,
|
||||
styles,
|
||||
}: ImageTextSectionProps & { styles?: Record<string, any>, cta_text?: string, cta_url?: string }) {
|
||||
isEditor,
|
||||
}: ImageTextSectionProps & { styles?: Record<string, any>, cta_text?: string, cta_url?: string, isEditor?: boolean }) {
|
||||
const isImageRight = layout === 'image-right' || layout === 'right';
|
||||
|
||||
// Helper to get text styles (including font family)
|
||||
@@ -58,28 +59,14 @@ export function ImageTextSection({
|
||||
|
||||
const sectionBg = getSectionBackground(styles);
|
||||
|
||||
// Height preset support
|
||||
const heightPreset = styles?.heightPreset || 'default';
|
||||
const heightMap: Record<string, string> = {
|
||||
'default': 'py-12 md:py-24',
|
||||
'small': 'py-8 md:py-16',
|
||||
'medium': 'py-16 md:py-32',
|
||||
'large': 'py-24 md:py-48',
|
||||
'fullscreen': 'min-h-screen py-20 flex items-center',
|
||||
};
|
||||
const heightClasses = heightMap[heightPreset] || 'py-12 md:py-24';
|
||||
|
||||
return (
|
||||
<section
|
||||
<div
|
||||
id={id}
|
||||
className={cn(
|
||||
'wn-section wn-image-text relative overflow-hidden w-full',
|
||||
`wn-scheme--${colorScheme}`,
|
||||
!styles?.paddingTop && !styles?.paddingBottom && heightClasses
|
||||
'wn-section wn-image-text relative w-full',
|
||||
`wn-scheme--${colorScheme}`
|
||||
)}
|
||||
style={sectionBg.style}
|
||||
>
|
||||
<SectionBackgroundRenderer bg={sectionBg} />
|
||||
<SharedContentLayout
|
||||
title={title}
|
||||
text={text}
|
||||
@@ -90,16 +77,14 @@ export function ImageTextSection({
|
||||
titleClassName={titleStyle.classNames}
|
||||
textStyle={textStyle.style}
|
||||
textClassName={textStyle.classNames}
|
||||
imageStyle={{
|
||||
backgroundColor: imageStyle.backgroundColor,
|
||||
objectFit: imageStyle.objectFit,
|
||||
}}
|
||||
imageStyle={imageStyle}
|
||||
cardStyle={{ backgroundColor: styles?.cardBackgroundColor }}
|
||||
buttons={cta_text && cta_url ? [{ text: cta_text, url: cta_url }] : []}
|
||||
buttonStyle={{
|
||||
classNames: buttonStyle.classNames,
|
||||
style: buttonStyle.style
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ interface MarqueeBannerProps {
|
||||
speed?: number; // seconds for one full cycle
|
||||
separator?: string;
|
||||
styles?: Record<string, any>;
|
||||
elementStyles?: Record<string, any>;
|
||||
}
|
||||
|
||||
export function MarqueeBanner({
|
||||
@@ -17,9 +18,30 @@ export function MarqueeBanner({
|
||||
speed = 30,
|
||||
separator = '✦',
|
||||
styles,
|
||||
elementStyles,
|
||||
}: MarqueeBannerProps) {
|
||||
const items = text.split(separator).map(t => t.trim()).filter(Boolean);
|
||||
|
||||
const getTextStyles = (elementName: string) => {
|
||||
const es = elementStyles?.[elementName] || {};
|
||||
return {
|
||||
classNames: cn(
|
||||
es.fontSize,
|
||||
es.fontWeight,
|
||||
{
|
||||
'font-sans': es.fontFamily === 'secondary',
|
||||
'font-serif': es.fontFamily === 'primary',
|
||||
}
|
||||
),
|
||||
style: {
|
||||
color: es.color,
|
||||
textAlign: es.textAlign as any,
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const textStyle = getTextStyles('text');
|
||||
|
||||
// If the parent didn't set a custom background, we fallback to primary for the marquee.
|
||||
const hasCustomBg = !!styles?.backgroundColor || !!styles?.backgroundImage || styles?.backgroundType === 'gradient';
|
||||
|
||||
@@ -31,12 +53,10 @@ export function MarqueeBanner({
|
||||
id={id}
|
||||
className={cn("wn-section wn-marquee relative overflow-hidden w-full", hasCustomPadding ? "" : "py-3")}
|
||||
style={{
|
||||
...sectionBg.style,
|
||||
backgroundColor: !hasCustomBg ? 'var(--wn-primary, #1a1a1a)' : sectionBg.style.backgroundColor,
|
||||
backgroundColor: !hasCustomBg ? 'var(--wn-primary, #1a1a1a)' : undefined,
|
||||
color: !hasCustomBg ? '#fff' : 'inherit',
|
||||
}}
|
||||
>
|
||||
<SectionBackgroundRenderer bg={sectionBg} />
|
||||
<div className="flex whitespace-nowrap relative z-10">
|
||||
{/* Duplicate twice for seamless infinite scroll */}
|
||||
{[0, 1].map((i) => (
|
||||
@@ -47,9 +67,13 @@ export function MarqueeBanner({
|
||||
aria-hidden={i === 1}
|
||||
>
|
||||
{items.map((item, idx) => (
|
||||
<span key={idx} className="flex items-center gap-8 text-sm font-medium tracking-wide uppercase">
|
||||
<span
|
||||
key={idx}
|
||||
className={cn("flex items-center gap-8 text-sm font-medium tracking-wide uppercase", textStyle.classNames)}
|
||||
style={textStyle.style}
|
||||
>
|
||||
{item}
|
||||
{idx < items.length - 1 && <span className="opacity-50 text-xs">●</span>}
|
||||
{idx < items.length - 1 && <span className="opacity-50 text-xs">{separator}</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -102,16 +102,14 @@ export function ProductCarousel({
|
||||
<section
|
||||
id={id}
|
||||
className={cn("wn-section wn-product-carousel relative overflow-hidden w-full", hasCustomPadding ? "" : "py-12 md:py-16")}
|
||||
style={sectionBg.style}
|
||||
>
|
||||
<SectionBackgroundRenderer bg={sectionBg} />
|
||||
<div className="w-full mx-auto px-4 relative z-10">
|
||||
{/* Header */}
|
||||
<div className="flex items-end justify-between mb-8">
|
||||
<div>
|
||||
{title && (
|
||||
<h2
|
||||
className={cn("text-3xl md:text-4xl font-bold", titleStyle.classNames)}
|
||||
className={cn("text-3xl font-bold", titleStyle.classNames)}
|
||||
style={titleStyle.style}
|
||||
>
|
||||
{title}
|
||||
@@ -124,8 +122,8 @@ export function ProductCarousel({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{cta_text && cta_url && (
|
||||
<Link to={cta_url} className={cn("text-sm font-semibold hover:underline mr-4 whitespace-nowrap", linkStyle.classNames)} style={linkStyle.style}>
|
||||
{cta_text && (
|
||||
<Link to={cta_url || '#'} className={cn("text-sm font-semibold hover:underline mr-4 whitespace-nowrap", linkStyle.classNames)} style={linkStyle.style}>
|
||||
{cta_text} →
|
||||
</Link>
|
||||
)}
|
||||
|
||||
@@ -54,6 +54,32 @@ export function ShoppableImage({
|
||||
const displayHotspots = (hotspots && hotspots.length > 0) ? hotspots : DEMO_HOTSPOTS;
|
||||
const hasImage = !!image;
|
||||
|
||||
// Helper to get text styles (including font family)
|
||||
const getTextStyles = (elementName: string) => {
|
||||
const es = elementStyles?.[elementName] || {};
|
||||
return {
|
||||
classNames: cn(
|
||||
es.fontSize,
|
||||
es.fontWeight,
|
||||
{
|
||||
'font-sans': es.fontFamily === 'secondary',
|
||||
'font-serif': es.fontFamily === 'primary',
|
||||
}
|
||||
),
|
||||
style: {
|
||||
color: es.color,
|
||||
textAlign: es.textAlign as any,
|
||||
backgroundColor: es.backgroundColor,
|
||||
borderColor: es.borderColor,
|
||||
borderWidth: es.borderWidth,
|
||||
borderRadius: es.borderRadius,
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const titleStyle = getTextStyles('title');
|
||||
const subtitleStyle = getTextStyles('subtitle');
|
||||
|
||||
const handleAddToCart = async (hotspot: Hotspot, e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -88,19 +114,31 @@ export function ShoppableImage({
|
||||
<section
|
||||
id={id}
|
||||
className={cn("wn-section wn-shoppable-image relative overflow-hidden w-full", hasCustomPadding ? "" : "py-12 md:py-16")}
|
||||
style={sectionBg.style}
|
||||
>
|
||||
<SectionBackgroundRenderer bg={sectionBg} />
|
||||
<div className="w-full mx-auto px-4 relative z-10">
|
||||
{(title || subtitle) && (
|
||||
<div className="mb-8 text-center">
|
||||
{title && (
|
||||
<h2 className="text-3xl md:text-4xl font-bold" style={{ color: elementStyles?.title?.color }}>
|
||||
<h2
|
||||
className={cn(
|
||||
!elementStyles?.title?.fontSize && "text-3xl md:text-4xl",
|
||||
!elementStyles?.title?.fontWeight && "font-bold",
|
||||
titleStyle.classNames
|
||||
)}
|
||||
style={titleStyle.style}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
{subtitle && (
|
||||
<p className="text-muted-foreground mt-2" style={{ color: elementStyles?.subtitle?.color }}>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-2",
|
||||
!elementStyles?.subtitle?.fontSize && "text-muted-foreground",
|
||||
subtitleStyle.classNames
|
||||
)}
|
||||
style={subtitleStyle.style}
|
||||
>
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
@@ -124,12 +162,14 @@ export function ShoppableImage({
|
||||
{/* Hotspot pins */}
|
||||
{displayHotspots.map((hotspot, idx) => {
|
||||
const isActive = activeHotspot === idx;
|
||||
const xVal = parseFloat(String(hotspot.x ?? 0).replace('%', '')) || 0;
|
||||
const yVal = parseFloat(String(hotspot.y ?? 0).replace('%', '')) || 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="absolute"
|
||||
style={{ left: `${hotspot.x}%`, top: `${hotspot.y}%`, transform: 'translate(-50%, -50%)' }}
|
||||
style={{ left: `${xVal}%`, top: `${yVal}%`, transform: 'translate(-50%, -50%)' }}
|
||||
>
|
||||
{/* Pulsing pin */}
|
||||
<button
|
||||
@@ -151,8 +191,8 @@ export function ShoppableImage({
|
||||
<div
|
||||
className={cn(
|
||||
'absolute z-20 w-56 bg-white rounded-xl shadow-2xl border p-3',
|
||||
hotspot.x > 60 ? 'right-full mr-3' : 'left-full ml-3',
|
||||
hotspot.y > 60 ? 'bottom-0' : 'top-0',
|
||||
xVal > 60 ? 'right-full mr-3' : 'left-full ml-3',
|
||||
yVal > 60 ? 'bottom-0' : 'top-0',
|
||||
)}
|
||||
>
|
||||
{/* Close */}
|
||||
|
||||
@@ -38,6 +38,13 @@ class PagesController
|
||||
'permission_callback' => '__return_true',
|
||||
]);
|
||||
|
||||
// Proxy section form submission (e.g. Contact Form webhooks) (Must be before generic slug route)
|
||||
register_rest_route($namespace, '/pages/submit-section-form', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'submit_section_form'],
|
||||
'permission_callback' => '__return_true', // Public endpoint
|
||||
]);
|
||||
|
||||
// Get/Save page structure (structural pages)
|
||||
register_rest_route($namespace, '/pages/(?P<slug>[a-zA-Z0-9_-]+)', [
|
||||
[
|
||||
@@ -666,6 +673,80 @@ class PagesController
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy form submission for a specific section (e.g. Contact Form to Webhook)
|
||||
*/
|
||||
public static function submit_section_form(WP_REST_Request $request)
|
||||
{
|
||||
$body = $request->get_json_params();
|
||||
$source_type = sanitize_text_field($body['source_type'] ?? 'page');
|
||||
$source_id = sanitize_text_field($body['source_id'] ?? '');
|
||||
$section_id = sanitize_text_field($body['section_id'] ?? '');
|
||||
$form_data = $body['form_data'] ?? [];
|
||||
|
||||
if (empty($source_id) || empty($section_id)) {
|
||||
return new WP_Error('invalid_params', 'Missing required parameters', ['status' => 400]);
|
||||
}
|
||||
|
||||
$structure = null;
|
||||
|
||||
if ($source_type === 'template') {
|
||||
$template = get_option("wn_template_{$source_id}", null);
|
||||
if ($template) {
|
||||
$structure = $template;
|
||||
}
|
||||
} else {
|
||||
// It's a page
|
||||
$page = get_post($source_id);
|
||||
if ($page) {
|
||||
$structure = get_post_meta($page->ID, '_wn_page_structure', true);
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($structure) || empty($structure['sections'])) {
|
||||
return new WP_Error('not_found', 'Structure not found', ['status' => 404]);
|
||||
}
|
||||
|
||||
// Find the section
|
||||
$section = null;
|
||||
foreach ($structure['sections'] as $s) {
|
||||
if ($s['id'] === $section_id) {
|
||||
$section = $s;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$section) {
|
||||
return new WP_Error('not_found', 'Section not found', ['status' => 404]);
|
||||
}
|
||||
|
||||
$webhook_url = $section['props']['webhook_url']['value'] ?? null;
|
||||
if (empty($webhook_url)) {
|
||||
return new WP_Error('invalid_config', 'Webhook URL not configured', ['status' => 400]);
|
||||
}
|
||||
|
||||
// Send to webhook
|
||||
$response = wp_remote_post($webhook_url, [
|
||||
'body' => wp_json_encode($form_data),
|
||||
'headers' => [
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'timeout' => 15,
|
||||
'data_format' => 'body'
|
||||
]);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
return new WP_Error('webhook_failed', 'Failed to contact webhook: ' . $response->get_error_message(), ['status' => 502]);
|
||||
}
|
||||
|
||||
$code = wp_remote_retrieve_response_code($response);
|
||||
if ($code >= 400) {
|
||||
return new WP_Error('webhook_error', 'Webhook returned error code: ' . $code, ['status' => 502]);
|
||||
}
|
||||
|
||||
return new WP_REST_Response(['success' => true], 200);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Helper Methods
|
||||
// ========================================
|
||||
|
||||
Reference in New Issue
Block a user