fix: dialog portal scope + UX improvements

1. Dialog Portal: Render inside #woonoow-admin-app container instead
   of document.body to fix Tailwind CSS scoping in WordPress admin

2. Variables Panel: Redesigned from flat list to collapsible accordion
   - Collapsed by default (less visual noise)
   - Categorized: Order (blue), Customer (green), Shipping (orange), Store (purple)
   - Color-coded pills for quick recognition
   - Shows count of available variables

3. StarterKit: Disable built-in Link to prevent duplicate extension warning
This commit is contained in:
Dwindi Ramadhana
2026-01-01 01:53:22 +07:00
parent 861c45638b
commit 875ab7af34
3 changed files with 192 additions and 100 deletions

View File

@@ -101,11 +101,13 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
}; };
const openEditDialog = (block: EmailBlock) => { const openEditDialog = (block: EmailBlock) => {
console.log('[EmailBuilder] openEditDialog called', { blockId: block.id, blockType: block.type });
setEditingBlockId(block.id); setEditingBlockId(block.id);
if (block.type === 'card') { if (block.type === 'card') {
// Convert markdown to HTML for rich text editor // Convert markdown to HTML for rich text editor
const htmlContent = parseMarkdownBasics(block.content); const htmlContent = parseMarkdownBasics(block.content);
console.log('[EmailBuilder] Card content parsed', { original: block.content, html: htmlContent });
setEditingContent(htmlContent); setEditingContent(htmlContent);
setEditingCardType(block.cardType); setEditingCardType(block.cardType);
} else if (block.type === 'button') { } else if (block.type === 'button') {
@@ -122,6 +124,7 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
setEditingAlign(block.align); setEditingAlign(block.align);
} }
console.log('[EmailBuilder] Setting editDialogOpen to true');
setEditDialogOpen(true); setEditDialogOpen(true);
}; };

View File

@@ -30,25 +30,43 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef< const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>, React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => ( >(({ className, children, ...props }, ref) => {
<DialogPortal> // Get or create portal container inside the app for proper CSS scoping
<DialogOverlay /> const getPortalContainer = () => {
<DialogPrimitive.Content const appContainer = document.getElementById('woonoow-admin-app');
ref={ref} if (!appContainer) return document.body;
className={cn(
"fixed left-[50%] top-[50%] z-[99999] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", let portalRoot = document.getElementById('woonoow-dialog-portal');
className if (!portalRoot) {
)} portalRoot = document.createElement('div');
{...props} portalRoot.id = 'woonoow-dialog-portal';
> appContainer.appendChild(portalRoot);
{children} }
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"> return portalRoot;
<X className="h-4 w-4" /> };
<span className="sr-only">Close</span>
</DialogPrimitive.Close> return (
</DialogPrimitive.Content> <DialogPortal container={getPortalContainer()}>
</DialogPortal> <DialogOverlay />
)) <DialogPrimitive.Content
ref={ref}
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
className={cn(
"fixed left-[50%] top-[50%] z-[99999] flex flex-col w-full max-w-lg max-h-[90vh] translate-x-[-50%] translate-y-[-50%] border bg-background shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground z-10">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
);
})
DialogContent.displayName = DialogPrimitive.Content.displayName DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({ const DialogHeader = ({
@@ -57,7 +75,7 @@ const DialogHeader = ({
}: React.HTMLAttributes<HTMLDivElement>) => ( }: React.HTMLAttributes<HTMLDivElement>) => (
<div <div
className={cn( className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left", "flex flex-col space-y-1.5 text-center sm:text-left px-6 pt-6 pb-4 border-b",
className className
)} )}
{...props} {...props}
@@ -71,7 +89,7 @@ const DialogFooter = ({
}: React.HTMLAttributes<HTMLDivElement>) => ( }: React.HTMLAttributes<HTMLDivElement>) => (
<div <div
className={cn( className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 px-6 py-4 border-t mt-auto",
className className
)} )}
{...props} {...props}
@@ -106,6 +124,20 @@ const DialogDescription = React.forwardRef<
)) ))
DialogDescription.displayName = DialogPrimitive.Description.displayName DialogDescription.displayName = DialogPrimitive.Description.displayName
const DialogBody = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex-1 overflow-y-auto px-6 py-4",
className
)}
{...props}
/>
)
DialogBody.displayName = "DialogBody"
export { export {
Dialog, Dialog,
DialogPortal, DialogPortal,
@@ -117,4 +149,5 @@ export {
DialogFooter, DialogFooter,
DialogTitle, DialogTitle,
DialogDescription, DialogDescription,
DialogBody,
} }

View File

@@ -25,7 +25,7 @@ import { Button } from './button';
import { Input } from './input'; import { Input } from './input';
import { Label } from './label'; import { Label } from './label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './select';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './dialog'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogBody } from './dialog';
import { __ } from '@/lib/i18n'; import { __ } from '@/lib/i18n';
interface RichTextEditorProps { interface RichTextEditorProps {
@@ -45,11 +45,8 @@ export function RichTextEditor({
}: RichTextEditorProps) { }: RichTextEditorProps) {
const editor = useEditor({ const editor = useEditor({
extensions: [ extensions: [
// StarterKit 3.10+ includes Link by default, so disable it here // StarterKit 3.10+ includes Link by default, disable since we configure separately
// since we configure Link separately below with custom options StarterKit.configure({ link: false }),
StarterKit.configure({
link: false,
}),
Placeholder.configure({ Placeholder.configure({
placeholder, placeholder,
}), }),
@@ -94,12 +91,9 @@ export function RichTextEditor({
useEffect(() => { useEffect(() => {
if (editor && content) { if (editor && content) {
const currentContent = editor.getHTML(); const currentContent = editor.getHTML();
// Normalize whitespace before comparing to avoid infinite loops // Only update if content is different (avoid infinite loops)
// The editor normalizes HTML, so "\n" becomes "" in output if (content !== currentContent) {
const normalizedContent = content.replace(/\s+/g, ' ').trim(); console.log('RichTextEditor: Updating content', { content, currentContent });
const normalizedCurrent = currentContent.replace(/\s+/g, ' ').trim();
if (normalizedContent !== normalizedCurrent) {
editor.commands.setContent(content); editor.commands.setContent(content);
} }
} }
@@ -299,36 +293,96 @@ export function RichTextEditor({
</div> </div>
{/* Editor */} {/* Editor */}
<div className="overflow-y-auto max-h-[400px] min-h-[200px]"> <EditorContent editor={editor} />
<EditorContent editor={editor} />
</div>
{/* Variables Dropdown */} {/* Variables - Collapsible and Categorized */}
{variables.length > 0 && ( {variables.length > 0 && (
<div className="border-t bg-muted/30 p-3"> <details className="border-t bg-muted/30">
<div className="flex items-center gap-2"> <summary className="p-3 text-xs text-muted-foreground cursor-pointer hover:bg-muted/50 flex items-center gap-2 select-none">
<Label htmlFor="variable-select" className="text-xs text-muted-foreground whitespace-nowrap"> <span className="text-[10px]"></span>
{__('Insert Variable:')} {__('Insert Variable')}
</Label> <span className="text-[10px] opacity-60">({variables.length})</span>
<Select onValueChange={(value) => insertVariable(value)}> </summary>
<SelectTrigger id="variable-select" className="h-8 text-xs"> <div className="p-3 pt-0 space-y-3">
<SelectValue placeholder={__('Choose a variable...')} /> {/* Order Variables */}
</SelectTrigger> {variables.some(v => v.startsWith('order')) && (
<SelectContent> <div>
{variables.map((variable) => ( <div className="text-[10px] uppercase text-muted-foreground mb-1.5 font-medium">{__('Order')}</div>
<SelectItem key={variable} value={variable} className="text-xs"> <div className="flex flex-wrap gap-1">
{`{${variable}}`} {variables.filter(v => v.startsWith('order')).map((variable) => (
</SelectItem> <button
))} key={variable}
</SelectContent> type="button"
</Select> onClick={() => insertVariable(variable)}
className="text-[11px] px-1.5 py-0.5 bg-blue-50 text-blue-700 rounded hover:bg-blue-100 transition-colors"
>
{`{${variable}}`}
</button>
))}
</div>
</div>
)}
{/* Customer Variables */}
{variables.some(v => v.startsWith('customer') || v.includes('_name') && !v.startsWith('order') && !v.startsWith('site')) && (
<div>
<div className="text-[10px] uppercase text-muted-foreground mb-1.5 font-medium">{__('Customer')}</div>
<div className="flex flex-wrap gap-1">
{variables.filter(v => v.startsWith('customer') || (v.includes('address') && !v.startsWith('shipping'))).map((variable) => (
<button
key={variable}
type="button"
onClick={() => insertVariable(variable)}
className="text-[11px] px-1.5 py-0.5 bg-green-50 text-green-700 rounded hover:bg-green-100 transition-colors"
>
{`{${variable}}`}
</button>
))}
</div>
</div>
)}
{/* Shipping/Payment Variables */}
{variables.some(v => v.startsWith('shipping') || v.startsWith('payment') || v.startsWith('tracking')) && (
<div>
<div className="text-[10px] uppercase text-muted-foreground mb-1.5 font-medium">{__('Shipping & Payment')}</div>
<div className="flex flex-wrap gap-1">
{variables.filter(v => v.startsWith('shipping') || v.startsWith('payment') || v.startsWith('tracking')).map((variable) => (
<button
key={variable}
type="button"
onClick={() => insertVariable(variable)}
className="text-[11px] px-1.5 py-0.5 bg-orange-50 text-orange-700 rounded hover:bg-orange-100 transition-colors"
>
{`{${variable}}`}
</button>
))}
</div>
</div>
)}
{/* Store/Site Variables */}
{variables.some(v => v.startsWith('site') || v.startsWith('store') || v.startsWith('shop') || v.includes('_url') || v.startsWith('support') || v.startsWith('review')) && (
<div>
<div className="text-[10px] uppercase text-muted-foreground mb-1.5 font-medium">{__('Store & Links')}</div>
<div className="flex flex-wrap gap-1">
{variables.filter(v => v.startsWith('site') || v.startsWith('store') || v.startsWith('shop') || v.startsWith('my_account') || v.startsWith('support') || v.startsWith('review') || (v.includes('_url') && !v.startsWith('order') && !v.startsWith('tracking') && !v.startsWith('payment'))).map((variable) => (
<button
key={variable}
type="button"
onClick={() => insertVariable(variable)}
className="text-[11px] px-1.5 py-0.5 bg-purple-50 text-purple-700 rounded hover:bg-purple-100 transition-colors"
>
{`{${variable}}`}
</button>
))}
</div>
</div>
)}
</div> </div>
</div> </details>
)} )}
{/* Button Dialog */} {/* Button Dialog */}
<Dialog open={buttonDialogOpen} onOpenChange={setButtonDialogOpen}> <Dialog open={buttonDialogOpen} onOpenChange={setButtonDialogOpen}>
<DialogContent className="sm:max-w-md max-h-[90vh] overflow-y-auto"> <DialogContent className="sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>{__('Insert Button')}</DialogTitle> <DialogTitle>{__('Insert Button')}</DialogTitle>
<DialogDescription> <DialogDescription>
@@ -336,53 +390,55 @@ export function RichTextEditor({
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4"> <DialogBody>
<div className="space-y-2"> <div className="space-y-4">
<Label htmlFor="btn-text">{__('Button Text')}</Label> <div className="space-y-2">
<Input <Label htmlFor="btn-text">{__('Button Text')}</Label>
id="btn-text" <Input
value={buttonText} id="btn-text"
onChange={(e) => setButtonText(e.target.value)} value={buttonText}
placeholder={__('e.g., View Order')} onChange={(e) => setButtonText(e.target.value)}
/> placeholder={__('e.g., View Order')}
</div> />
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="btn-href">{__('Button Link')}</Label> <Label htmlFor="btn-href">{__('Button Link')}</Label>
<Input <Input
id="btn-href" id="btn-href"
value={buttonHref} value={buttonHref}
onChange={(e) => setButtonHref(e.target.value)} onChange={(e) => setButtonHref(e.target.value)}
placeholder="{order_url}" placeholder="{order_url}"
/> />
{variables.length > 0 && ( {variables.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2"> <div className="flex flex-wrap gap-1 mt-2">
{variables.filter(v => v.includes('_url')).map((variable) => ( {variables.filter(v => v.includes('_url')).map((variable) => (
<code <code
key={variable} key={variable}
className="text-xs bg-muted px-2 py-1 rounded cursor-pointer hover:bg-muted/80" className="text-xs bg-muted px-2 py-1 rounded cursor-pointer hover:bg-muted/80"
onClick={() => setButtonHref(buttonHref + `{${variable}}`)} onClick={() => setButtonHref(buttonHref + `{${variable}}`)}
> >
{`{${variable}}`} {`{${variable}}`}
</code> </code>
))} ))}
</div> </div>
)} )}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="btn-style">{__('Button Style')}</Label> <Label htmlFor="btn-style">{__('Button Style')}</Label>
<Select value={buttonStyle} onValueChange={(value: 'solid' | 'outline') => setButtonStyle(value)}> <Select value={buttonStyle} onValueChange={(value: 'solid' | 'outline') => setButtonStyle(value)}>
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="solid">{__('Solid (Primary)')}</SelectItem> <SelectItem value="solid">{__('Solid (Primary)')}</SelectItem>
<SelectItem value="outline">{__('Outline (Secondary)')}</SelectItem> <SelectItem value="outline">{__('Outline (Secondary)')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div>
</div> </div>
</div> </DialogBody>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setButtonDialogOpen(false)}> <Button variant="outline" onClick={() => setButtonDialogOpen(false)}>