145 lines
5.5 KiB
TypeScript
145 lines
5.5 KiB
TypeScript
import * as React from 'react';
|
|
import ReactQuill from 'react-quill';
|
|
import 'react-quill/dist/quill.snow.css';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
|
import { Info } from 'lucide-react';
|
|
import { cn } from '@/components/ui/utils';
|
|
|
|
const quillModules = {
|
|
toolbar: [
|
|
[{ header: [1, 2, 3, false] }],
|
|
['bold', 'italic', 'underline'],
|
|
[{ list: 'ordered' }, { list: 'bullet' }],
|
|
['link'],
|
|
['blockquote'],
|
|
['clean'],
|
|
],
|
|
};
|
|
|
|
const quillFormats = ['header', 'bold', 'italic', 'underline', 'list', 'bullet', 'link', 'blockquote'];
|
|
|
|
/** Handlebars helpers/partials or full HTML docs must be edited as source — rich editors strip or corrupt them. */
|
|
export function requiresAdvancedHtmlEditing(html: string): boolean {
|
|
const s = html || '';
|
|
return (
|
|
/\{\{\s*[>#\/]/.test(s) ||
|
|
/\{\{>\s*\w+/.test(s) ||
|
|
/<!DOCTYPE/i.test(s) ||
|
|
/<html[\s>]/i.test(s) ||
|
|
/<\/html>/i.test(s) ||
|
|
/<style\b/i.test(s) ||
|
|
/<head[\s>]/i.test(s)
|
|
);
|
|
}
|
|
|
|
export type EmailTemplateBodyEditorHandle = {
|
|
insertPlaceholder: (placeholder: string) => void;
|
|
};
|
|
|
|
type Props = {
|
|
value: string;
|
|
onChange: (html: string) => void;
|
|
/** visual | html */
|
|
tab: string;
|
|
onTabChange: (v: string) => void;
|
|
textareaRef: React.RefObject<HTMLTextAreaElement>;
|
|
};
|
|
|
|
export const EmailTemplateBodyEditor = React.forwardRef<EmailTemplateBodyEditorHandle, Props>(
|
|
({ value, onChange, tab, onTabChange, textareaRef }, ref) => {
|
|
const quillRef = React.useRef<ReactQuill>(null);
|
|
const advanced = React.useMemo(() => requiresAdvancedHtmlEditing(value), [value]);
|
|
|
|
React.useImperativeHandle(ref, () => ({
|
|
insertPlaceholder: (placeholder: string) => {
|
|
const token = `{{${placeholder}}}`;
|
|
if (tab === 'html' && textareaRef.current) {
|
|
const el = textareaRef.current;
|
|
const start = el.selectionStart ?? 0;
|
|
const end = el.selectionEnd ?? 0;
|
|
const next = value.slice(0, start) + token + value.slice(end);
|
|
onChange(next);
|
|
requestAnimationFrame(() => {
|
|
el.focus();
|
|
const pos = start + token.length;
|
|
el.setSelectionRange(pos, pos);
|
|
});
|
|
return;
|
|
}
|
|
const editor = quillRef.current?.getEditor?.();
|
|
if (!editor) return;
|
|
const range = editor.getSelection(true);
|
|
const idx = range?.index ?? editor.getLength();
|
|
editor.insertText(idx, token, 'user');
|
|
},
|
|
}));
|
|
|
|
return (
|
|
<div className="flex flex-col gap-2 flex-1 min-h-0">
|
|
<Tabs value={tab} onValueChange={onTabChange} className="flex flex-col flex-1 gap-2 min-h-0">
|
|
<TabsList className="w-fit">
|
|
<TabsTrigger value="visual" disabled={advanced} className="text-xs">
|
|
Rich text
|
|
</TabsTrigger>
|
|
<TabsTrigger value="html" className="text-xs font-mono">
|
|
HTML source
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
{advanced && (
|
|
<Alert className="border-amber-200 bg-amber-50 py-2">
|
|
<Info className="h-4 w-4 text-amber-700" />
|
|
<AlertDescription className="text-[11px] text-amber-900">
|
|
This template uses layout partials (<code className="font-mono">{'{{> ...}}'}</code>), block helpers, or a full HTML
|
|
document. Edit it in <strong>HTML source</strong> so nothing is stripped. Use placeholders on the left to insert
|
|
fields safely.
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
<TabsContent value="visual" className="flex-1 flex flex-col gap-2 mt-2 min-h-[420px] data-[state=inactive]:hidden">
|
|
<div
|
|
className={cn(
|
|
'email-quill rounded-md border border-slate-200 bg-white overflow-hidden flex flex-col flex-1',
|
|
'[&_.ql-toolbar]:border-slate-200 [&_.ql-toolbar]:bg-slate-50',
|
|
'[&_.ql-container]:border-0 [&_.ql-editor]:min-h-[380px] [&_.ql-editor]:text-sm [&_.ql-editor]:text-slate-900'
|
|
)}
|
|
>
|
|
<ReactQuill
|
|
ref={quillRef}
|
|
theme="snow"
|
|
value={value || ''}
|
|
onChange={onChange}
|
|
modules={quillModules}
|
|
formats={quillFormats}
|
|
/>
|
|
</div>
|
|
<p className="text-[10px] text-slate-500">
|
|
Use headings, lists, and links. Insert dynamic fields from <strong>Available Placeholders</strong> on the left.
|
|
</p>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="html" className="flex-1 flex flex-col mt-2 min-h-[420px] data-[state=inactive]:hidden">
|
|
<Textarea
|
|
ref={textareaRef}
|
|
placeholder="Raw HTML / Handlebars — use for partials {{> ...}} or full layouts"
|
|
className="flex-1 min-h-[420px] font-mono text-xs text-slate-900 bg-slate-900/5 focus:bg-white p-4 resize-y"
|
|
value={value || ''}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
spellCheck={false}
|
|
/>
|
|
<p className="text-[10px] text-slate-500 mt-1">
|
|
Full control for <code className="font-mono">{'{{> email_header}}'}</code>, styles, and edge cases. Backend still
|
|
sanitizes on save.
|
|
</p>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
);
|
|
}
|
|
);
|
|
|
|
EmailTemplateBodyEditor.displayName = 'EmailTemplateBodyEditor';
|