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

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