Fix button roundtrip in editor, alignment persistence, and test email rendering
This commit is contained in:
@@ -14,24 +14,24 @@ interface BlockRendererProps {
|
||||
isLast: boolean;
|
||||
}
|
||||
|
||||
export function BlockRenderer({
|
||||
block,
|
||||
isEditing,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onMoveUp,
|
||||
export function BlockRenderer({
|
||||
block,
|
||||
isEditing,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
isFirst,
|
||||
isLast
|
||||
isLast
|
||||
}: BlockRendererProps) {
|
||||
|
||||
|
||||
// Prevent navigation in builder
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (
|
||||
target.tagName === 'A' ||
|
||||
target.tagName === 'BUTTON' ||
|
||||
target.closest('a') ||
|
||||
target.tagName === 'A' ||
|
||||
target.tagName === 'BUTTON' ||
|
||||
target.closest('a') ||
|
||||
target.closest('button') ||
|
||||
target.classList.contains('button') ||
|
||||
target.classList.contains('button-outline') ||
|
||||
@@ -42,7 +42,7 @@ export function BlockRenderer({
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const renderBlockContent = () => {
|
||||
switch (block.type) {
|
||||
case 'card':
|
||||
@@ -75,48 +75,48 @@ export function BlockRenderer({
|
||||
marginBottom: '24px'
|
||||
},
|
||||
hero: {
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
background: 'linear-gradient(135deg, var(--wn-gradient-start, #667eea) 0%, var(--wn-gradient-end, #764ba2) 100%)',
|
||||
color: '#fff',
|
||||
borderRadius: '8px',
|
||||
padding: '32px 40px',
|
||||
marginBottom: '24px'
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Convert markdown to HTML for visual rendering
|
||||
const htmlContent = parseMarkdownBasics(block.content);
|
||||
|
||||
|
||||
return (
|
||||
<div style={cardStyles[block.cardType]}>
|
||||
<div
|
||||
<div
|
||||
className="prose prose-sm max-w-none [&_h1]:text-3xl [&_h1]:font-bold [&_h1]:mt-0 [&_h1]:mb-4 [&_h2]:text-2xl [&_h2]:font-bold [&_h2]:mt-0 [&_h2]:mb-3 [&_h3]:text-xl [&_h3]:font-bold [&_h3]:mt-0 [&_h3]:mb-2 [&_h4]:text-lg [&_h4]:font-bold [&_h4]:mt-0 [&_h4]:mb-2 [&_.button]:inline-block [&_.button]:bg-purple-600 [&_.button]:text-white [&_.button]:px-7 [&_.button]:py-3.5 [&_.button]:rounded-md [&_.button]:no-underline [&_.button]:font-semibold [&_.button-outline]:inline-block [&_.button-outline]:bg-transparent [&_.button-outline]:text-purple-600 [&_.button-outline]:px-6 [&_.button-outline]:py-3 [&_.button-outline]:rounded-md [&_.button-outline]:no-underline [&_.button-outline]:font-semibold [&_.button-outline]:border-2 [&_.button-outline]:border-purple-600"
|
||||
style={block.cardType === 'hero' ? { color: '#fff' } : {}}
|
||||
dangerouslySetInnerHTML={{ __html: htmlContent }}
|
||||
dangerouslySetInnerHTML={{ __html: htmlContent }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
case 'button': {
|
||||
const buttonStyle: React.CSSProperties = block.style === 'solid'
|
||||
? {
|
||||
display: 'inline-block',
|
||||
background: '#7f54b3',
|
||||
color: '#fff',
|
||||
padding: '14px 28px',
|
||||
borderRadius: '6px',
|
||||
textDecoration: 'none',
|
||||
fontWeight: 600,
|
||||
}
|
||||
display: 'inline-block',
|
||||
background: 'var(--wn-primary, #7f54b3)',
|
||||
color: '#fff',
|
||||
padding: '14px 28px',
|
||||
borderRadius: '6px',
|
||||
textDecoration: 'none',
|
||||
fontWeight: 600,
|
||||
}
|
||||
: {
|
||||
display: 'inline-block',
|
||||
background: 'transparent',
|
||||
color: '#7f54b3',
|
||||
padding: '12px 26px',
|
||||
border: '2px solid #7f54b3',
|
||||
borderRadius: '6px',
|
||||
textDecoration: 'none',
|
||||
fontWeight: 600,
|
||||
};
|
||||
display: 'inline-block',
|
||||
background: 'transparent',
|
||||
color: 'var(--wn-secondary, #7f54b3)',
|
||||
padding: '12px 26px',
|
||||
border: '2px solid var(--wn-secondary, #7f54b3)',
|
||||
borderRadius: '6px',
|
||||
textDecoration: 'none',
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
textAlign: block.align || 'center',
|
||||
@@ -130,7 +130,7 @@ export function BlockRenderer({
|
||||
buttonStyle.maxWidth = `${block.customMaxWidth}px`;
|
||||
buttonStyle.width = '100%';
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<a href={block.link} style={buttonStyle}>
|
||||
@@ -166,13 +166,13 @@ export function BlockRenderer({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
case 'divider':
|
||||
return <hr className="border-t border-gray-300 my-4" />;
|
||||
|
||||
|
||||
case 'spacer':
|
||||
return <div style={{ height: `${block.height}px` }} />;
|
||||
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -184,7 +184,7 @@ export function BlockRenderer({
|
||||
<div className={`transition-all ${isEditing ? 'ring-2 ring-purple-500 ring-offset-2' : ''}`}>
|
||||
{renderBlockContent()}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Hover Controls */}
|
||||
<div className="absolute -right-2 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col gap-1 bg-white rounded-md shadow-lg border p-1">
|
||||
{!isFirst && (
|
||||
|
||||
@@ -107,7 +107,6 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
|
||||
if (block.type === 'card') {
|
||||
// Convert markdown to HTML for rich text editor
|
||||
const htmlContent = parseMarkdownBasics(block.content);
|
||||
console.log('[EmailBuilder] Card content parsed', { original: block.content, html: htmlContent });
|
||||
setEditingContent(htmlContent);
|
||||
setEditingCardType(block.cardType);
|
||||
} else if (block.type === 'button') {
|
||||
|
||||
77
admin-spa/src/components/MediaUploader.tsx
Normal file
77
admin-spa/src/components/MediaUploader.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Image, Upload } from 'lucide-react';
|
||||
import { __ } from '@/lib/i18n';
|
||||
|
||||
interface MediaUploaderProps {
|
||||
onSelect: (url: string, id?: number) => void;
|
||||
type?: 'image' | 'video' | 'audio' | 'file';
|
||||
title?: string;
|
||||
buttonText?: string;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function MediaUploader({
|
||||
onSelect,
|
||||
type = 'image',
|
||||
title = __('Select Image'),
|
||||
buttonText = __('Use Image'),
|
||||
className,
|
||||
children
|
||||
}: MediaUploaderProps) {
|
||||
const frameRef = useRef<any>(null);
|
||||
|
||||
const openMediaModal = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Check if wp.media is available
|
||||
const wp = (window as any).wp;
|
||||
if (!wp || !wp.media) {
|
||||
console.warn('WordPress media library not available');
|
||||
return;
|
||||
}
|
||||
|
||||
// Reuse existing frame
|
||||
if (frameRef.current) {
|
||||
frameRef.current.open();
|
||||
return;
|
||||
}
|
||||
|
||||
// Create new frame
|
||||
frameRef.current = wp.media({
|
||||
title,
|
||||
button: {
|
||||
text: buttonText,
|
||||
},
|
||||
library: {
|
||||
type,
|
||||
},
|
||||
multiple: false,
|
||||
});
|
||||
|
||||
// Handle selection
|
||||
frameRef.current.on('select', () => {
|
||||
const state = frameRef.current.state();
|
||||
const selection = state.get('selection');
|
||||
|
||||
if (selection.length > 0) {
|
||||
const attachment = selection.first().toJSON();
|
||||
onSelect(attachment.url, attachment.id);
|
||||
}
|
||||
});
|
||||
|
||||
frameRef.current.open();
|
||||
};
|
||||
|
||||
return (
|
||||
<div onClick={openMediaModal} className={className}>
|
||||
{children || (
|
||||
<Button variant="outline" size="sm" type="button">
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
{__('Select Image')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -50,6 +50,8 @@ export function RichTextEditor({
|
||||
Placeholder.configure({
|
||||
placeholder,
|
||||
}),
|
||||
// ButtonExtension MUST come before Link to ensure buttons are parsed first
|
||||
ButtonExtension,
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
HTMLAttributes: {
|
||||
@@ -65,7 +67,6 @@ export function RichTextEditor({
|
||||
class: 'max-w-full h-auto rounded',
|
||||
},
|
||||
}),
|
||||
ButtonExtension,
|
||||
],
|
||||
content,
|
||||
onUpdate: ({ editor }) => {
|
||||
|
||||
@@ -21,7 +21,7 @@ export interface Option {
|
||||
/** What to render in the button/list. Can be a string or React node. */
|
||||
label: React.ReactNode;
|
||||
/** Optional text used for filtering. Falls back to string label or value. */
|
||||
searchText?: string;
|
||||
triggerLabel?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -55,7 +55,7 @@ export function SearchableSelect({
|
||||
React.useEffect(() => { if (disabled && open) setOpen(false); }, [disabled, open]);
|
||||
|
||||
return (
|
||||
<Popover open={disabled ? false : open} onOpenChange={(o)=> !disabled && setOpen(o)}>
|
||||
<Popover open={disabled ? false : open} onOpenChange={(o) => !disabled && setOpen(o)}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -65,7 +65,7 @@ export function SearchableSelect({
|
||||
aria-disabled={disabled}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
>
|
||||
{selected ? selected.label : placeholder}
|
||||
{selected ? (selected.triggerLabel ?? selected.label) : placeholder}
|
||||
<ChevronsUpDown className="opacity-50 h-4 w-4 shrink-0" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
@@ -89,7 +89,7 @@ const SelectContent = React.forwardRef<
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
|
||||
"relative z-[9999] max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
|
||||
@@ -39,6 +39,7 @@ export const ButtonExtension = Node.create<ButtonOptions>({
|
||||
return [
|
||||
{
|
||||
tag: 'a[data-button]',
|
||||
priority: 100, // Higher priority than Link extension (default 50)
|
||||
getAttrs: (node: HTMLElement) => ({
|
||||
text: node.getAttribute('data-text') || node.textContent || 'Click Here',
|
||||
href: node.getAttribute('data-href') || node.getAttribute('href') || '#',
|
||||
@@ -47,6 +48,7 @@ export const ButtonExtension = Node.create<ButtonOptions>({
|
||||
},
|
||||
{
|
||||
tag: 'a.button',
|
||||
priority: 100,
|
||||
getAttrs: (node: HTMLElement) => ({
|
||||
text: node.textContent || 'Click Here',
|
||||
href: node.getAttribute('href') || '#',
|
||||
@@ -55,6 +57,7 @@ export const ButtonExtension = Node.create<ButtonOptions>({
|
||||
},
|
||||
{
|
||||
tag: 'a.button-outline',
|
||||
priority: 100,
|
||||
getAttrs: (node: HTMLElement) => ({
|
||||
text: node.textContent || 'Click Here',
|
||||
href: node.getAttribute('href') || '#',
|
||||
|
||||
Reference in New Issue
Block a user