feat: support HTML body in vacation responder
This commit is contained in:
@@ -4,9 +4,12 @@ import { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RichTextEditor } from '@/components/email/rich-text-editor';
|
||||
import { useVacationStore } from '@/stores/vacation-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useManagedAccountStore } from '@/stores/managed-account-store';
|
||||
import { sanitizeEmailHtml } from '@/lib/email-sanitization';
|
||||
import { htmlToPlainText } from '@/lib/html-to-text';
|
||||
import { Loader2, AlertTriangle, Eye, EyeOff } from 'lucide-react';
|
||||
import { toast } from '@/stores/toast-store';
|
||||
|
||||
@@ -27,6 +30,7 @@ export function VacationSettings() {
|
||||
toDate,
|
||||
subject,
|
||||
textBody,
|
||||
htmlBody,
|
||||
isLoading,
|
||||
isSaving,
|
||||
error,
|
||||
@@ -40,6 +44,8 @@ export function VacationSettings() {
|
||||
const [localToDate, setLocalToDate] = useState(toDate || '');
|
||||
const [localSubject, setLocalSubject] = useState(subject);
|
||||
const [localTextBody, setLocalTextBody] = useState(textBody);
|
||||
const [htmlEnabled, setHtmlEnabled] = useState(!!htmlBody);
|
||||
const [localHtmlBody, setLocalHtmlBody] = useState(htmlBody || '');
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [validationWarnings, setValidationWarnings] = useState<string[]>([]);
|
||||
|
||||
@@ -55,7 +61,9 @@ export function VacationSettings() {
|
||||
setLocalToDate(toDate || '');
|
||||
setLocalSubject(subject);
|
||||
setLocalTextBody(textBody);
|
||||
}, [isEnabled, fromDate, toDate, subject, textBody]);
|
||||
setHtmlEnabled(!!htmlBody);
|
||||
setLocalHtmlBody(htmlBody || '');
|
||||
}, [isEnabled, fromDate, toDate, subject, textBody, htmlBody]);
|
||||
|
||||
const validate = useCallback(() => {
|
||||
const warnings: string[] = [];
|
||||
@@ -70,13 +78,14 @@ export function VacationSettings() {
|
||||
warnings.push(t('warnings.start_in_past'));
|
||||
}
|
||||
|
||||
if (localEnabled && !localTextBody.trim()) {
|
||||
const hasHtmlContent = htmlEnabled && !!htmlToPlainText(localHtmlBody).trim();
|
||||
if (localEnabled && !localTextBody.trim() && !hasHtmlContent) {
|
||||
warnings.push(t('warnings.empty_body'));
|
||||
}
|
||||
|
||||
setValidationWarnings(warnings);
|
||||
return warnings;
|
||||
}, [localFromDate, localToDate, localEnabled, localTextBody, t]);
|
||||
}, [localFromDate, localToDate, localEnabled, localTextBody, htmlEnabled, localHtmlBody, t]);
|
||||
|
||||
useEffect(() => {
|
||||
validate();
|
||||
@@ -87,7 +96,8 @@ export function VacationSettings() {
|
||||
(localFromDate || null) !== (fromDate || null) ||
|
||||
(localToDate || null) !== (toDate || null) ||
|
||||
localSubject !== subject ||
|
||||
localTextBody !== textBody;
|
||||
localTextBody !== textBody ||
|
||||
(htmlEnabled ? localHtmlBody : '') !== (htmlBody || '');
|
||||
|
||||
const hasBlockingError = !!(localFromDate && localToDate && new Date(localToDate) <= new Date(localFromDate));
|
||||
|
||||
@@ -96,13 +106,25 @@ export function VacationSettings() {
|
||||
validate();
|
||||
if (hasBlockingError) return;
|
||||
|
||||
const sanitizedHtml =
|
||||
htmlEnabled && htmlToPlainText(localHtmlBody).trim()
|
||||
? sanitizeEmailHtml(localHtmlBody)
|
||||
: null;
|
||||
// Keep a plain-text part as the fallback for clients that don't render
|
||||
// HTML. If the user left it blank, derive it from the HTML body.
|
||||
const textBody =
|
||||
localTextBody.trim() || !sanitizedHtml
|
||||
? localTextBody
|
||||
: htmlToPlainText(sanitizedHtml, { paragraphSpacing: true });
|
||||
|
||||
try {
|
||||
await updateVacationResponse(client, {
|
||||
isEnabled: localEnabled,
|
||||
fromDate: localFromDate || null,
|
||||
toDate: localToDate || null,
|
||||
subject: localSubject,
|
||||
textBody: localTextBody,
|
||||
textBody,
|
||||
htmlBody: sanitizedHtml,
|
||||
}, managedAccountId ?? undefined);
|
||||
|
||||
toast.success(tNotifications('vacation_saved'));
|
||||
@@ -217,28 +239,66 @@ export function VacationSettings() {
|
||||
className="w-full px-3 py-2 text-sm rounded-md bg-muted border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 hover:border-muted-foreground resize-y"
|
||||
/>
|
||||
</div>
|
||||
<SettingItem
|
||||
label={t('message.html_label')}
|
||||
description={t('message.html_description')}
|
||||
>
|
||||
<ToggleSwitch checked={htmlEnabled} onChange={setHtmlEnabled} />
|
||||
</SettingItem>
|
||||
{htmlEnabled && (
|
||||
<div className="pb-3">
|
||||
<div className="rounded-md border border-border overflow-hidden">
|
||||
<RichTextEditor
|
||||
content={localHtmlBody}
|
||||
onChange={setLocalHtmlBody}
|
||||
placeholder={t('message.html_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
{localTextBody.trim() && (
|
||||
<SettingsSection title={t('preview.title')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
className="flex items-center gap-2 text-sm text-primary hover:underline"
|
||||
>
|
||||
{showPreview ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
{showPreview ? t('preview.hide') : t('preview.show')}
|
||||
</button>
|
||||
{showPreview && (
|
||||
<div className="mt-3 p-4 rounded border border-border bg-background">
|
||||
{localSubject && (
|
||||
<p className="font-medium text-foreground mb-2">{localSubject}</p>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{localTextBody}</p>
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
)}
|
||||
{(() => {
|
||||
const showHtmlPreview = htmlEnabled && !!htmlToPlainText(localHtmlBody).trim();
|
||||
if (!localTextBody.trim() && !showHtmlPreview) return null;
|
||||
return (
|
||||
<SettingsSection title={t('preview.title')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
className="flex items-center gap-2 text-sm text-primary hover:underline"
|
||||
>
|
||||
{showPreview ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
{showPreview ? t('preview.hide') : t('preview.show')}
|
||||
</button>
|
||||
{showPreview && (
|
||||
<div className="mt-3 p-4 rounded border border-border bg-background">
|
||||
{localSubject && (
|
||||
<p className="font-medium text-foreground mb-2">{localSubject}</p>
|
||||
)}
|
||||
{showHtmlPreview ? (
|
||||
<div
|
||||
className="text-sm text-foreground [&_a]:text-primary [&_a]:underline"
|
||||
// Preview renders into the app's own DOM. Intercept anchor
|
||||
// clicks so following a link doesn't navigate the whole app
|
||||
// away (and lose the unsaved responder), opening a new tab.
|
||||
onClick={(e) => {
|
||||
const anchor = (e.target as HTMLElement).closest('a');
|
||||
if (anchor?.href) {
|
||||
e.preventDefault();
|
||||
window.open(anchor.href, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: sanitizeEmailHtml(localHtmlBody) }}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{localTextBody}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
})()}
|
||||
|
||||
{validationWarnings.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
|
||||
Reference in New Issue
Block a user