Fixed notification composition
This commit is contained in:
+172
-106
@@ -9,12 +9,12 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Bold,
|
Bold,
|
||||||
Italic,
|
Italic,
|
||||||
|
Underline,
|
||||||
Heading2,
|
Heading2,
|
||||||
List,
|
List,
|
||||||
ListOrdered,
|
ListOrdered,
|
||||||
Quote,
|
Quote,
|
||||||
Table,
|
Table,
|
||||||
Code,
|
|
||||||
Minus,
|
Minus,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Eye,
|
Eye,
|
||||||
@@ -25,7 +25,13 @@ import {
|
|||||||
import { UserSearchPicker, SelectedUser } from './UserSearchPicker';
|
import { UserSearchPicker, SelectedUser } from './UserSearchPicker';
|
||||||
import { useInAppNotifications, DraftMessage } from '../../hooks/useInAppNotifications';
|
import { useInAppNotifications, DraftMessage } from '../../hooks/useInAppNotifications';
|
||||||
import { NotificationPlatform } from '../../types';
|
import { NotificationPlatform } from '../../types';
|
||||||
import { renderMarkdownToHtml, parseMarkdownToHtml, PFM_LOGO_DATA_URI } from '../../lib/markdownUtils';
|
import {
|
||||||
|
renderMarkdownToHtml,
|
||||||
|
parseMarkdownToHtml,
|
||||||
|
isHtmlContent,
|
||||||
|
stripMarkdown,
|
||||||
|
PFM_LOGO_DATA_URI,
|
||||||
|
} from '../../lib/markdownUtils';
|
||||||
import { BrandLogo } from '../ui/BrandLogo';
|
import { BrandLogo } from '../ui/BrandLogo';
|
||||||
import { useAuth } from '../../auth/AuthProvider';
|
import { useAuth } from '../../auth/AuthProvider';
|
||||||
|
|
||||||
@@ -49,7 +55,10 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
|||||||
draftToEdit,
|
draftToEdit,
|
||||||
}) => {
|
}) => {
|
||||||
const { user, claims } = useAuth();
|
const { user, claims } = useAuth();
|
||||||
const tenantName = (claims?.tenant as string) || user?.tenant || 'Department of Science, Innovation & Technology';
|
const tenantName =
|
||||||
|
(claims?.tenant as string) ||
|
||||||
|
user?.tenant ||
|
||||||
|
'Department of Science, Innovation & Technology';
|
||||||
const { sendMessage, isSending, saveDraft, deleteDraft } = useInAppNotifications();
|
const { sendMessage, isSending, saveDraft, deleteDraft } = useInAppNotifications();
|
||||||
|
|
||||||
const [selectedUsers, setSelectedUsers] = useState<SelectedUser[]>([]);
|
const [selectedUsers, setSelectedUsers] = useState<SelectedUser[]>([]);
|
||||||
@@ -60,10 +69,19 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [successNotice, setSuccessNotice] = useState<string | null>(null);
|
const [successNotice, setSuccessNotice] = useState<string | null>(null);
|
||||||
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const editorRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const toInitialRichHtml = (raw: string): string => {
|
||||||
|
if (!raw || !raw.trim()) return '';
|
||||||
|
if (isHtmlContent(raw)) return raw;
|
||||||
|
return parseMarkdownToHtml(raw);
|
||||||
|
};
|
||||||
|
|
||||||
// Initialize or populate from draft or props
|
// Initialize or populate from draft or props
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
|
||||||
|
let initialHtml = '';
|
||||||
if (draftToEdit) {
|
if (draftToEdit) {
|
||||||
setSelectedUsers(
|
setSelectedUsers(
|
||||||
draftToEdit.recipientId
|
draftToEdit.recipientId
|
||||||
@@ -77,23 +95,40 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
|||||||
: []
|
: []
|
||||||
);
|
);
|
||||||
setSubject(draftToEdit.subject || '');
|
setSubject(draftToEdit.subject || '');
|
||||||
setMessage(draftToEdit.message || '');
|
initialHtml = toInitialRichHtml(draftToEdit.message || '');
|
||||||
setPlatform(NotificationPlatform.InApp);
|
setMessage(initialHtml);
|
||||||
|
setPlatform(draftToEdit.platform || NotificationPlatform.InApp);
|
||||||
} else {
|
} else {
|
||||||
setSelectedUsers(initialRecipient ? [initialRecipient] : []);
|
setSelectedUsers(initialRecipient ? [initialRecipient] : []);
|
||||||
setSubject(initialSubject || '');
|
setSubject(initialSubject || '');
|
||||||
setMessage(initialMessage || '');
|
initialHtml = toInitialRichHtml(initialMessage || '');
|
||||||
|
setMessage(initialHtml);
|
||||||
setPlatform(initialPlatform);
|
setPlatform(initialPlatform);
|
||||||
}
|
}
|
||||||
setActiveTab('write');
|
setActiveTab('write');
|
||||||
setError(null);
|
setError(null);
|
||||||
setSuccessNotice(null);
|
setSuccessNotice(null);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (editorRef.current) {
|
||||||
|
editorRef.current.innerHTML = initialHtml;
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
}, [isOpen, initialRecipient, initialSubject, initialMessage, initialPlatform, draftToEdit]);
|
}, [isOpen, initialRecipient, initialSubject, initialMessage, initialPlatform, draftToEdit]);
|
||||||
|
|
||||||
|
// Restore editor innerHTML when switching back from preview tab
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'write' && editorRef.current) {
|
||||||
|
if (editorRef.current.innerHTML !== message) {
|
||||||
|
editorRef.current.innerHTML = message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [activeTab]);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
const handleSelectUser = (user: SelectedUser) => {
|
const handleSelectUser = (selected: SelectedUser) => {
|
||||||
setSelectedUsers([user]);
|
setSelectedUsers([selected]);
|
||||||
setError(null);
|
setError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -101,51 +136,59 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
|||||||
setSelectedUsers([]);
|
setSelectedUsers([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper to insert markdown text at cursor in textarea
|
const syncEditorContent = () => {
|
||||||
const insertMarkdown = (before: string, after: string = '', defaultText: string = '') => {
|
if (editorRef.current) {
|
||||||
const textarea = textareaRef.current;
|
setMessage(editorRef.current.innerHTML);
|
||||||
if (!textarea) return;
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const start = textarea.selectionStart;
|
const applyFormat = (command: string, value?: string) => {
|
||||||
const end = textarea.selectionEnd;
|
const editor = editorRef.current;
|
||||||
const text = textarea.value;
|
if (!editor) return;
|
||||||
const selection = text.substring(start, end) || defaultText;
|
editor.focus();
|
||||||
|
document.execCommand(command, false, value);
|
||||||
|
syncEditorContent();
|
||||||
|
};
|
||||||
|
|
||||||
const replacement = `${before}${selection}${after}`;
|
const insertHtmlBlock = (htmlSnippet: string) => {
|
||||||
const newText = text.substring(0, start) + replacement + text.substring(end);
|
const editor = editorRef.current;
|
||||||
|
if (!editor) return;
|
||||||
setMessage(newText);
|
editor.focus();
|
||||||
|
const inserted = document.execCommand('insertHTML', false, htmlSnippet);
|
||||||
setTimeout(() => {
|
if (!inserted) {
|
||||||
textarea.focus();
|
editor.innerHTML += htmlSnippet;
|
||||||
textarea.setSelectionRange(
|
}
|
||||||
start + before.length,
|
syncEditorContent();
|
||||||
start + before.length + selection.length
|
|
||||||
);
|
|
||||||
}, 0);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Quick insertions for PFM branding and templates
|
// Quick insertions for PFM branding and templates
|
||||||
const insertBrandedCallout = () => {
|
const insertBrandedCallout = () => {
|
||||||
insertMarkdown(
|
insertHtmlBlock(
|
||||||
`> [!NOTE]\n> **Post-Fund Management Official Notice**\n> Please verify all supporting evidence documentation before tranche disbursement sign-off.\n\n`
|
`<div style="background:rgba(12,74,110,0.25);border-left:4px solid #38bdf8;padding:10px 14px;border-radius:8px;margin:10px 0;color:#bae6fd;font-size:12px;"><strong>Post-Fund Management Official Notice</strong><br/>Please verify all supporting evidence documentation before tranche disbursement sign-off.</div><p><br/></p>`
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const insertMilestoneTable = () => {
|
const insertMilestoneTable = () => {
|
||||||
insertMarkdown(
|
insertHtmlBlock(
|
||||||
`| Deliverable Code | Milestone Name | Status | Due Date |\n| :--- | :--- | :--- | :--- |\n| DEL-01 | Technical Architecture Review | Verified | 2026-11-15 |\n| DEL-02 | Financial Expenditure Proof | Under Review | 2026-12-01 |\n\n`
|
`<div style="overflow-x:auto;margin:12px 0;"><table style="width:100%;border-collapse:collapse;border:1px solid #334155;font-size:12px;text-align:left;"><thead><tr style="background:#1e293b;color:#f8fafc;"><th style="padding:8px 12px;border:1px solid #334155;">Deliverable Code</th><th style="padding:8px 12px;border:1px solid #334155;">Milestone Name</th><th style="padding:8px 12px;border:1px solid #334155;">Status</th><th style="padding:8px 12px;border:1px solid #334155;">Due Date</th></tr></thead><tbody><tr style="background:#0f172a;color:#cbd5e1;"><td style="padding:8px 12px;border:1px solid #334155;">DEL-01</td><td style="padding:8px 12px;border:1px solid #334155;">Technical Architecture Review</td><td style="padding:8px 12px;border:1px solid #334155;">Verified</td><td style="padding:8px 12px;border:1px solid #334155;">2026-11-15</td></tr><tr style="background:#0f172a;color:#cbd5e1;"><td style="padding:8px 12px;border:1px solid #334155;">DEL-02</td><td style="padding:8px 12px;border:1px solid #334155;">Financial Expenditure Proof</td><td style="padding:8px 12px;border:1px solid #334155;">Under Review</td><td style="padding:8px 12px;border:1px solid #334155;">2026-12-01</td></tr></tbody></table></div><p><br/></p>`
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const insertPlatformLogo = () => {
|
const insertPlatformLogo = () => {
|
||||||
insertMarkdown(
|
insertHtmlBlock(
|
||||||
`\n\n`
|
`<p><img src="${PFM_LOGO_DATA_URI}" alt="PFM Platform Logo" width="48" height="48" style="display:inline-block;border-radius:10px;margin:6px 0;" /></p><p><br/></p>`
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasMeaningfulContent = (html: string) => {
|
||||||
|
if (!html) return false;
|
||||||
|
if (stripMarkdown(html).trim().length > 0) return true;
|
||||||
|
return /<(img|table|hr)\b/i.test(html);
|
||||||
|
};
|
||||||
|
|
||||||
const handleSaveDraft = () => {
|
const handleSaveDraft = () => {
|
||||||
if (!subject.trim() && !message.trim()) {
|
const currentHtml = editorRef.current ? editorRef.current.innerHTML : message;
|
||||||
|
if (!subject.trim() && !hasMeaningfulContent(currentHtml)) {
|
||||||
setError('Please add a subject or message to save as draft');
|
setError('Please add a subject or message to save as draft');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -157,18 +200,21 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
|||||||
recipientEmail: firstUser?.email || '',
|
recipientEmail: firstUser?.email || '',
|
||||||
recipientName: firstUser?.name || '',
|
recipientName: firstUser?.name || '',
|
||||||
subject: subject.trim() || 'Untitled Draft',
|
subject: subject.trim() || 'Untitled Draft',
|
||||||
message: message.trim(),
|
message: currentHtml.trim(),
|
||||||
|
platform,
|
||||||
});
|
});
|
||||||
|
|
||||||
setSuccessNotice('Draft saved successfully in Drafts folder');
|
setSuccessNotice('Draft saved successfully in Drafts folder');
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
onClose();
|
onClose();
|
||||||
}, 800);
|
}, 600);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSend = async (e: React.FormEvent) => {
|
const handleSend = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
const currentHtml = editorRef.current ? editorRef.current.innerHTML : message;
|
||||||
|
|
||||||
if (selectedUsers.length === 0) {
|
if (selectedUsers.length === 0) {
|
||||||
setError('Please select a recipient from the tenant directory');
|
setError('Please select a recipient from the tenant directory');
|
||||||
return;
|
return;
|
||||||
@@ -179,7 +225,7 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!message.trim()) {
|
if (!hasMeaningfulContent(currentHtml)) {
|
||||||
setError('Please enter a message body');
|
setError('Please enter a message body');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -188,18 +234,15 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
|||||||
try {
|
try {
|
||||||
const recipient = selectedUsers[0];
|
const recipient = selectedUsers[0];
|
||||||
|
|
||||||
// Format payload according to chosen platform:
|
|
||||||
// InApp messages are saved in markdown format.
|
|
||||||
// Email notifications are transformed from markdown to branded executive HTML before saving.
|
|
||||||
const finalMessage =
|
const finalMessage =
|
||||||
platform === NotificationPlatform.Email
|
platform === NotificationPlatform.Email
|
||||||
? renderMarkdownToHtml(message.trim(), {
|
? renderMarkdownToHtml(currentHtml.trim(), {
|
||||||
applyBranding: true,
|
applyBranding: true,
|
||||||
emailFormat: true,
|
emailFormat: true,
|
||||||
subject: subject.trim(),
|
subject: subject.trim(),
|
||||||
tenantName,
|
tenantName,
|
||||||
})
|
})
|
||||||
: message.trim();
|
: currentHtml.trim();
|
||||||
|
|
||||||
await sendMessage({
|
await sendMessage({
|
||||||
recipientId: recipient.id,
|
recipientId: recipient.id,
|
||||||
@@ -207,13 +250,14 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
|||||||
recipientName: recipient.name,
|
recipientName: recipient.name,
|
||||||
subject: subject.trim(),
|
subject: subject.trim(),
|
||||||
message: finalMessage,
|
message: finalMessage,
|
||||||
|
platform,
|
||||||
draftId: draftToEdit?.id,
|
draftId: draftToEdit?.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
setSuccessNotice(
|
setSuccessNotice(
|
||||||
platform === NotificationPlatform.Email
|
platform === NotificationPlatform.Email
|
||||||
? 'Email notification rendered to HTML and dispatched!'
|
? 'Email notification dispatched!'
|
||||||
: 'In-App Markdown message sent successfully!'
|
: 'In-App message sent successfully!'
|
||||||
);
|
);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
onClose();
|
onClose();
|
||||||
@@ -239,7 +283,9 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
|||||||
subject: subject || 'Notice Preview',
|
subject: subject || 'Notice Preview',
|
||||||
tenantName,
|
tenantName,
|
||||||
})
|
})
|
||||||
: parseMarkdownToHtml(message);
|
: message;
|
||||||
|
|
||||||
|
const plainCharCount = stripMarkdown(message).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/85 backdrop-blur-md animate-in fade-in duration-150">
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/85 backdrop-blur-md animate-in fade-in duration-150">
|
||||||
@@ -258,11 +304,11 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
|||||||
: 'Compose New Communication'}
|
: 'Compose New Communication'}
|
||||||
</h3>
|
</h3>
|
||||||
<span className="text-[10px] font-semibold text-blue-300 bg-blue-500/15 border border-blue-500/30 px-2 py-0.5 rounded-full">
|
<span className="text-[10px] font-semibold text-blue-300 bg-blue-500/15 border border-blue-500/30 px-2 py-0.5 rounded-full">
|
||||||
Rich Markdown Engine
|
Rich Text Composer
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-slate-400">
|
<p className="text-[11px] text-slate-400">
|
||||||
Author in Markdown with live preview, SVG branding, and dual InApp / Email delivery
|
Type and format your message using the toolbar for In-App or branded Email delivery
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -312,7 +358,7 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Smartphone className="w-3.5 h-3.5" />
|
<Smartphone className="w-3.5 h-3.5" />
|
||||||
<span>In-App (Markdown)</span>
|
<span>In-App</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -325,7 +371,7 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Mail className="w-3.5 h-3.5" />
|
<Mail className="w-3.5 h-3.5" />
|
||||||
<span>Email (HTML + Brand)</span>
|
<span>Email</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -335,12 +381,12 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
|||||||
{platform === NotificationPlatform.InApp ? (
|
{platform === NotificationPlatform.InApp ? (
|
||||||
<span className="text-blue-300 flex items-center gap-1.5">
|
<span className="text-blue-300 flex items-center gap-1.5">
|
||||||
<span className="w-2 h-2 rounded-full bg-blue-400 animate-pulse" />
|
<span className="w-2 h-2 rounded-full bg-blue-400 animate-pulse" />
|
||||||
Saved directly as <strong>Markdown</strong>. Delivered instantly to the recipient's InApp Mailbox & Notification Bell.
|
Delivered instantly to the recipient's <strong>In-App</strong> Mailbox & Notification Bell.
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-emerald-300 flex items-center gap-1.5">
|
<span className="text-emerald-300 flex items-center gap-1.5">
|
||||||
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
|
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
|
||||||
Composed in Markdown, transformed into <strong>Branded Executive HTML</strong> with embedded SVG logo before dispatching.
|
Formatted with <strong>PFM Executive Branding</strong> and embedded logo before dispatching via email.
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -373,14 +419,14 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Markdown Editor / Preview Tabs */}
|
{/* Rich Text Editor / Branded Preview Tabs */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className="text-xs font-semibold text-slate-300">
|
<label className="text-xs font-semibold text-slate-300">
|
||||||
Message Content <span className="text-rose-400">*</span>
|
Message Content <span className="text-rose-400">*</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{/* Write vs Preview Tabs */}
|
{/* Compose vs Email Preview Tabs */}
|
||||||
<div className="flex items-center rounded-lg bg-slate-900 p-0.5 border border-slate-700/80">
|
<div className="flex items-center rounded-lg bg-slate-900 p-0.5 border border-slate-700/80">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -392,11 +438,14 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Edit3 className="w-3 h-3" />
|
<Edit3 className="w-3 h-3" />
|
||||||
<span>Write Markdown</span>
|
<span>Compose</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setActiveTab('preview')}
|
onClick={() => {
|
||||||
|
syncEditorContent();
|
||||||
|
setActiveTab('preview');
|
||||||
|
}}
|
||||||
className={`flex items-center gap-1.5 px-3 py-1 rounded-md text-xs font-medium transition cursor-pointer ${
|
className={`flex items-center gap-1.5 px-3 py-1 rounded-md text-xs font-medium transition cursor-pointer ${
|
||||||
activeTab === 'preview'
|
activeTab === 'preview'
|
||||||
? 'bg-blue-600 text-white font-semibold shadow-xs'
|
? 'bg-blue-600 text-white font-semibold shadow-xs'
|
||||||
@@ -404,37 +453,48 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Eye className="w-3 h-3" />
|
<Eye className="w-3 h-3" />
|
||||||
<span>Preview Rendered</span>
|
<span>Preview Delivery</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Markdown Toolbar (Only when activeTab === 'write') */}
|
|
||||||
{activeTab === 'write' ? (
|
{activeTab === 'write' ? (
|
||||||
<div className="rounded-2xl border border-slate-700/80 bg-slate-900/90 overflow-hidden shadow-inner focus-within:border-blue-500 transition">
|
<div className="rounded-2xl border border-slate-700/80 bg-slate-900/90 overflow-hidden shadow-inner focus-within:border-blue-500 transition">
|
||||||
{/* Toolbar */}
|
{/* WYSIWYG Formatting Toolbar */}
|
||||||
<div className="px-3 py-2 border-b border-slate-800 bg-slate-950/70 flex flex-wrap items-center gap-1">
|
<div className="px-3 py-2 border-b border-slate-800 bg-slate-950/70 flex flex-wrap items-center gap-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => insertMarkdown('**', '**', 'bold text')}
|
onMouseDown={e => e.preventDefault()}
|
||||||
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
onClick={() => applyFormat('bold')}
|
||||||
title="Bold (**text**)"
|
className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||||
|
title="Bold"
|
||||||
>
|
>
|
||||||
<Bold className="w-3.5 h-3.5" />
|
<Bold className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => insertMarkdown('*', '*', 'italic text')}
|
onMouseDown={e => e.preventDefault()}
|
||||||
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
onClick={() => applyFormat('italic')}
|
||||||
title="Italic (*text*)"
|
className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||||
|
title="Italic"
|
||||||
>
|
>
|
||||||
<Italic className="w-3.5 h-3.5" />
|
<Italic className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => insertMarkdown('### ', '', 'Heading Title')}
|
onMouseDown={e => e.preventDefault()}
|
||||||
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
onClick={() => applyFormat('underline')}
|
||||||
title="Heading (### )"
|
className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||||
|
title="Underline"
|
||||||
|
>
|
||||||
|
<Underline className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseDown={e => e.preventDefault()}
|
||||||
|
onClick={() => applyFormat('formatBlock', 'H3')}
|
||||||
|
className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||||
|
title="Heading"
|
||||||
>
|
>
|
||||||
<Heading2 className="w-3.5 h-3.5" />
|
<Heading2 className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -443,25 +503,28 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => insertMarkdown('- ', '', 'List item')}
|
onMouseDown={e => e.preventDefault()}
|
||||||
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
onClick={() => applyFormat('insertUnorderedList')}
|
||||||
title="Bullet List (- )"
|
className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||||
|
title="Bullet List"
|
||||||
>
|
>
|
||||||
<List className="w-3.5 h-3.5" />
|
<List className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => insertMarkdown('1. ', '', 'First item')}
|
onMouseDown={e => e.preventDefault()}
|
||||||
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
onClick={() => applyFormat('insertOrderedList')}
|
||||||
title="Numbered List (1. )"
|
className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||||
|
title="Numbered List"
|
||||||
>
|
>
|
||||||
<ListOrdered className="w-3.5 h-3.5" />
|
<ListOrdered className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => insertMarkdown('> ', '', 'Quoted text')}
|
onMouseDown={e => e.preventDefault()}
|
||||||
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
onClick={() => applyFormat('formatBlock', 'BLOCKQUOTE')}
|
||||||
title="Blockquote (> )"
|
className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||||
|
title="Quote Block"
|
||||||
>
|
>
|
||||||
<Quote className="w-3.5 h-3.5" />
|
<Quote className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -470,25 +533,19 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
onMouseDown={e => e.preventDefault()}
|
||||||
onClick={insertMilestoneTable}
|
onClick={insertMilestoneTable}
|
||||||
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||||
title="Insert Deliverable Table"
|
title="Insert Deliverable Table"
|
||||||
>
|
>
|
||||||
<Table className="w-3.5 h-3.5" />
|
<Table className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => insertMarkdown('```\n', '\n```', 'code block')}
|
onMouseDown={e => e.preventDefault()}
|
||||||
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
onClick={() => applyFormat('insertHorizontalRule')}
|
||||||
title="Code Block (```)"
|
className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||||
>
|
title="Insert Divider Line"
|
||||||
<Code className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => insertMarkdown('\n---\n')}
|
|
||||||
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
|
||||||
title="Horizontal Rule (---)"
|
|
||||||
>
|
>
|
||||||
<Minus className="w-3.5 h-3.5" />
|
<Minus className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -498,6 +555,7 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
|||||||
{/* Platform Brand Insertions */}
|
{/* Platform Brand Insertions */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
onMouseDown={e => e.preventDefault()}
|
||||||
onClick={insertBrandedCallout}
|
onClick={insertBrandedCallout}
|
||||||
className="flex items-center gap-1 px-2 py-1 rounded-lg bg-blue-500/10 hover:bg-blue-500/20 text-blue-300 text-[11px] font-medium border border-blue-500/25 transition cursor-pointer"
|
className="flex items-center gap-1 px-2 py-1 rounded-lg bg-blue-500/10 hover:bg-blue-500/20 text-blue-300 text-[11px] font-medium border border-blue-500/25 transition cursor-pointer"
|
||||||
title="Insert official PFM callout block"
|
title="Insert official PFM callout block"
|
||||||
@@ -508,46 +566,54 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
onMouseDown={e => e.preventDefault()}
|
||||||
onClick={insertPlatformLogo}
|
onClick={insertPlatformLogo}
|
||||||
className="flex items-center gap-1 px-2 py-1 rounded-lg bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-300 text-[11px] font-medium border border-emerald-500/25 transition cursor-pointer"
|
className="flex items-center gap-1 px-2 py-1 rounded-lg bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-300 text-[11px] font-medium border border-emerald-500/25 transition cursor-pointer"
|
||||||
title="Embed official SVG PFM logo into markdown"
|
title="Embed official PFM logo"
|
||||||
>
|
>
|
||||||
<BrandLogo size={14} showGlow={false} animateOnHover={false} />
|
<BrandLogo size={14} showGlow={false} animateOnHover={false} />
|
||||||
<span>Embed Logo</span>
|
<span>Embed Logo</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Textarea */}
|
{/* Rich Text WYSIWYG Surface */}
|
||||||
<textarea
|
<div className="relative">
|
||||||
ref={textareaRef}
|
{!hasMeaningfulContent(message) && (
|
||||||
rows={8}
|
<div className="pointer-events-none absolute top-4 left-4 right-4 text-slate-500 text-xs select-none">
|
||||||
value={message}
|
Type your message here and highlight text or use the toolbar above to format headings, bullet points, tables, and official notices...
|
||||||
onChange={e => setMessage(e.target.value)}
|
</div>
|
||||||
placeholder="Compose your message in Markdown... Use formatting buttons above for headings, bullet points, deliverable tables, and official PFM notices."
|
)}
|
||||||
className="w-full p-4 bg-transparent text-slate-100 placeholder-slate-400 text-xs focus:outline-none transition resize-y leading-relaxed font-mono"
|
<div
|
||||||
|
ref={editorRef}
|
||||||
|
contentEditable
|
||||||
|
suppressContentEditableWarning
|
||||||
|
onInput={syncEditorContent}
|
||||||
|
onBlur={syncEditorContent}
|
||||||
|
className="w-full min-h-[200px] max-h-[320px] overflow-y-auto p-4 bg-transparent text-slate-100 text-xs focus:outline-none leading-relaxed font-sans [&_h1]:text-lg [&_h1]:font-bold [&_h1]:text-white [&_h1]:my-2 [&_h2]:text-base [&_h2]:font-bold [&_h2]:text-white [&_h2]:my-2 [&_h3]:text-sm [&_h3]:font-bold [&_h3]:text-white [&_h3]:my-1.5 [&_ul]:list-disc [&_ul]:pl-5 [&_ul]:my-1.5 [&_ol]:list-decimal [&_ol]:pl-5 [&_ol]:my-1.5 [&_blockquote]:border-l-4 [&_blockquote]:border-blue-500 [&_blockquote]:pl-3 [&_blockquote]:italic [&_blockquote]:text-slate-300 [&_blockquote]:my-2 [&_hr]:border-slate-700 [&_hr]:my-3"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* Live Fully-Rendered Markdown / HTML Preview */
|
/* Live Fully-Rendered Delivery Preview */
|
||||||
<div className="rounded-2xl border border-slate-700/80 bg-slate-950/80 p-5 overflow-y-auto max-h-72 min-h-56">
|
<div className="rounded-2xl border border-slate-700/80 bg-slate-950/80 p-5 overflow-y-auto max-h-72 min-h-56">
|
||||||
<div className="mb-3 pb-2 border-b border-slate-800 flex items-center justify-between text-[11px] text-slate-400">
|
<div className="mb-3 pb-2 border-b border-slate-800 flex items-center justify-between text-[11px] text-slate-400">
|
||||||
<span className="flex items-center gap-1.5 text-blue-300 font-semibold">
|
<span className="flex items-center gap-1.5 text-blue-300 font-semibold">
|
||||||
<ShieldCheck className="w-3.5 h-3.5 text-blue-400" />
|
<ShieldCheck className="w-3.5 h-3.5 text-blue-400" />
|
||||||
Live Rendered Preview ({platform === NotificationPlatform.Email ? 'HTML Email' : 'InApp Markdown'})
|
Delivery Preview ({platform === NotificationPlatform.Email ? 'Email' : 'In-App'})
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] text-slate-400 font-mono">
|
<span className="text-[10px] text-slate-400 font-mono">
|
||||||
Subject: {subject || 'Untitled'}
|
Subject: {subject || 'Untitled'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{message.trim() ? (
|
{hasMeaningfulContent(message) ? (
|
||||||
<div
|
<div
|
||||||
className="prose prose-invert max-w-none text-xs leading-relaxed"
|
className="prose prose-invert max-w-none text-xs leading-relaxed [&_h1]:text-white [&_h2]:text-white [&_h3]:text-white [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5 [&_blockquote]:border-l-4 [&_blockquote]:border-blue-500 [&_blockquote]:pl-3 [&_blockquote]:italic"
|
||||||
dangerouslySetInnerHTML={{ __html: livePreviewHtml }}
|
dangerouslySetInnerHTML={{ __html: livePreviewHtml }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="py-12 text-center text-slate-400 text-xs italic">
|
<div className="py-12 text-center text-slate-400 text-xs italic">
|
||||||
Type your markdown message in the Write tab to see it live-rendered here.
|
Type your message in the Compose tab to preview how it will appear upon delivery.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -560,7 +626,7 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
|||||||
<span>Tenant Partition:</span>
|
<span>Tenant Partition:</span>
|
||||||
<strong className="text-slate-300">{tenantName}</strong>
|
<strong className="text-slate-300">{tenantName}</strong>
|
||||||
</span>
|
</span>
|
||||||
<span className="font-mono">{message.length} characters</span>
|
<span className="font-mono">{plainCharCount} characters</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Modal Footer Action Buttons */}
|
{/* Modal Footer Action Buttons */}
|
||||||
@@ -608,7 +674,7 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
|||||||
)}
|
)}
|
||||||
<span>
|
<span>
|
||||||
{platform === NotificationPlatform.Email
|
{platform === NotificationPlatform.Email
|
||||||
? 'Dispatch Email (HTML)'
|
? 'Dispatch Email'
|
||||||
: 'Send In-App Message'}
|
: 'Send In-App Message'}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Code,
|
|
||||||
Eye,
|
|
||||||
Copy,
|
Copy,
|
||||||
Check,
|
Check,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
ExternalLink,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { BrandLogo } from '../ui/BrandLogo';
|
import { BrandLogo } from '../ui/BrandLogo';
|
||||||
import { parseMarkdownToHtml, isHtmlContent } from '../../lib/markdownUtils';
|
import { parseMarkdownToHtml, isHtmlContent, stripMarkdown } from '../../lib/markdownUtils';
|
||||||
|
|
||||||
interface MarkdownViewerProps {
|
interface MarkdownViewerProps {
|
||||||
content: string;
|
content: string;
|
||||||
@@ -19,11 +16,9 @@ interface MarkdownViewerProps {
|
|||||||
|
|
||||||
export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
||||||
content,
|
content,
|
||||||
subject,
|
|
||||||
showBrandingHeader = true,
|
showBrandingHeader = true,
|
||||||
className = '',
|
className = '',
|
||||||
}) => {
|
}) => {
|
||||||
const [viewMode, setViewMode] = useState<'rendered' | 'raw'>('rendered');
|
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
const isHtml = useMemo(() => isHtmlContent(content), [content]);
|
const isHtml = useMemo(() => isHtmlContent(content), [content]);
|
||||||
@@ -35,7 +30,7 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
|||||||
}, [content, isHtml]);
|
}, [content, isHtml]);
|
||||||
|
|
||||||
const handleCopy = () => {
|
const handleCopy = () => {
|
||||||
navigator.clipboard.writeText(content);
|
navigator.clipboard.writeText(stripMarkdown(content) || content);
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
setTimeout(() => setCopied(false), 2000);
|
setTimeout(() => setCopied(false), 2000);
|
||||||
};
|
};
|
||||||
@@ -48,49 +43,21 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
|||||||
{showBrandingHeader && (
|
{showBrandingHeader && (
|
||||||
<div className="flex items-center gap-1.5 text-slate-300 font-semibold pr-2 border-r border-slate-700/60">
|
<div className="flex items-center gap-1.5 text-slate-300 font-semibold pr-2 border-r border-slate-700/60">
|
||||||
<BrandLogo size={18} showGlow={false} animateOnHover={false} />
|
<BrandLogo size={18} showGlow={false} animateOnHover={false} />
|
||||||
<span className="text-[11px] tracking-tight">PFM Markdown Engine</span>
|
<span className="text-[11px] tracking-tight">PFM Communications</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<span className="text-[10px] text-blue-400 font-mono">
|
<span className="text-[10px] text-blue-400 font-mono">
|
||||||
{isHtml ? 'Format: HTML Email' : 'Format: GitHub Flavored Markdown'}
|
In-App Message
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* Toggle View Mode */}
|
|
||||||
<div className="flex items-center rounded-lg bg-slate-950/70 p-0.5 border border-slate-800">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setViewMode('rendered')}
|
|
||||||
className={`flex items-center gap-1 px-2 py-1 rounded-md text-[11px] font-medium transition cursor-pointer ${
|
|
||||||
viewMode === 'rendered'
|
|
||||||
? 'bg-blue-600/30 text-white font-semibold shadow-xs'
|
|
||||||
: 'text-slate-400 hover:text-white'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Eye className="w-3 h-3" />
|
|
||||||
<span>Rendered</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setViewMode('raw')}
|
|
||||||
className={`flex items-center gap-1 px-2 py-1 rounded-md text-[11px] font-medium transition cursor-pointer ${
|
|
||||||
viewMode === 'raw'
|
|
||||||
? 'bg-blue-600/30 text-white font-semibold shadow-xs'
|
|
||||||
: 'text-slate-400 hover:text-white'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Code className="w-3 h-3" />
|
|
||||||
<span>Raw Source</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Copy Button */}
|
{/* Copy Button */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
className="flex items-center gap-1 px-2 py-1 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 hover:text-white text-[11px] font-medium transition cursor-pointer border border-slate-700/60"
|
className="flex items-center gap-1 px-2 py-1 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 hover:text-white text-[11px] font-medium transition cursor-pointer border border-slate-700/60"
|
||||||
title="Copy content to clipboard"
|
title="Copy message text to clipboard"
|
||||||
>
|
>
|
||||||
{copied ? (
|
{copied ? (
|
||||||
<>
|
<>
|
||||||
@@ -108,25 +75,12 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content Rendering */}
|
{/* Content Rendering */}
|
||||||
{viewMode === 'rendered' ? (
|
|
||||||
<div className="p-6 overflow-x-auto text-sm leading-relaxed text-slate-200 font-sans">
|
<div className="p-6 overflow-x-auto text-sm leading-relaxed text-slate-200 font-sans">
|
||||||
{isHtml ? (
|
|
||||||
<div
|
<div
|
||||||
className="prose prose-invert max-w-none"
|
className="prose prose-invert max-w-none [&_h1]:text-white [&_h1]:text-xl [&_h1]:font-bold [&_h1]:mb-3 [&_h2]:text-white [&_h2]:text-lg [&_h2]:font-bold [&_h2]:mb-2 [&_h3]:text-white [&_h3]:text-base [&_h3]:font-bold [&_h3]:mb-2 [&_h4]:text-white [&_h4]:font-semibold [&_a]:text-blue-400 [&_a:hover]:underline [&_ul]:list-disc [&_ul]:pl-5 [&_ul]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_ol]:my-2 [&_table]:w-full [&_table]:border-collapse [&_th]:border [&_th]:border-slate-700 [&_th]:bg-slate-800/80 [&_th]:p-2 [&_td]:border [&_td]:border-slate-800 [&_td]:p-2 [&_blockquote]:border-l-4 [&_blockquote]:border-blue-500 [&_blockquote]:pl-4 [&_blockquote]:italic [&_blockquote]:my-2 [&_code]:bg-slate-800 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-blue-300"
|
||||||
dangerouslySetInnerHTML={{ __html: renderedHtml }}
|
dangerouslySetInnerHTML={{ __html: renderedHtml }}
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className="prose prose-invert max-w-none [&_h1]:text-white [&_h2]:text-white [&_h3]:text-white [&_h4]:text-white [&_a]:text-blue-400 [&_a:hover]:underline [&_table]:border-collapse [&_th]:border [&_th]:border-slate-700 [&_th]:p-2 [&_td]:border [&_td]:border-slate-800 [&_td]:p-2 [&_blockquote]:border-l-4 [&_blockquote]:border-blue-500 [&_blockquote]:pl-4 [&_blockquote]:italic [&_code]:bg-slate-800 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-blue-300"
|
|
||||||
dangerouslySetInnerHTML={{ __html: renderedHtml }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<pre className="p-5 overflow-x-auto font-mono text-xs text-slate-300 bg-[#070b13] select-all whitespace-pre-wrap leading-relaxed">
|
|
||||||
{content}
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Branded Footer Stamp */}
|
{/* Branded Footer Stamp */}
|
||||||
{showBrandingHeader && (
|
{showBrandingHeader && (
|
||||||
@@ -136,7 +90,7 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
|||||||
<span>Verified PFM Secure Platform Formatting</span>
|
<span>Verified PFM Secure Platform Formatting</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="font-mono text-[10px] text-slate-400">
|
<span className="font-mono text-[10px] text-slate-400">
|
||||||
{content.length} characters
|
{stripMarkdown(content).length} characters
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -39,70 +39,73 @@ export function useInAppNotifications() {
|
|||||||
const trashStorageKey = `pfm_mailbox_trash_${userId || 'default'}`;
|
const trashStorageKey = `pfm_mailbox_trash_${userId || 'default'}`;
|
||||||
const sentStorageKey = `pfm_mailbox_sent_${userId || 'default'}`;
|
const sentStorageKey = `pfm_mailbox_sent_${userId || 'default'}`;
|
||||||
|
|
||||||
// Starred messages set
|
const readStorageList = useCallback(<T,>(key: string, fallbackKey?: string): T[] => {
|
||||||
const [starredIds, setStarredIds] = useState<string[]>(() => {
|
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem(starredStorageKey);
|
const stored = localStorage.getItem(key);
|
||||||
return stored ? JSON.parse(stored) : [];
|
const parsed: T[] = stored ? JSON.parse(stored) : [];
|
||||||
|
if (fallbackKey && fallbackKey !== key) {
|
||||||
|
const fallbackStored = localStorage.getItem(fallbackKey);
|
||||||
|
const fallbackParsed: T[] = fallbackStored ? JSON.parse(fallbackStored) : [];
|
||||||
|
if (fallbackParsed.length > 0) {
|
||||||
|
const merged = [...parsed];
|
||||||
|
for (const item of fallbackParsed) {
|
||||||
|
const itemId = (item as any)?.id ?? item;
|
||||||
|
if (!merged.some(m => ((m as any)?.id ?? m) === itemId)) {
|
||||||
|
merged.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
localStorage.setItem(key, JSON.stringify(merged));
|
||||||
|
localStorage.removeItem(fallbackKey);
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
});
|
}, []);
|
||||||
|
|
||||||
|
// Starred messages set
|
||||||
|
const [starredIds, setStarredIds] = useState<string[]>(() =>
|
||||||
|
readStorageList<string>(starredStorageKey, 'pfm_mailbox_starred_default')
|
||||||
|
);
|
||||||
|
|
||||||
// Trashed messages set
|
// Trashed messages set
|
||||||
const [trashedIds, setTrashedIds] = useState<string[]>(() => {
|
const [trashedIds, setTrashedIds] = useState<string[]>(() =>
|
||||||
try {
|
readStorageList<string>(trashStorageKey, 'pfm_mailbox_trash_default')
|
||||||
const stored = localStorage.getItem(trashStorageKey);
|
);
|
||||||
return stored ? JSON.parse(stored) : [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Local drafts list
|
// Local drafts list
|
||||||
const [drafts, setDrafts] = useState<DraftMessage[]>(() => {
|
const [drafts, setDrafts] = useState<DraftMessage[]>(() =>
|
||||||
try {
|
readStorageList<DraftMessage>(draftsStorageKey, 'pfm_mailbox_drafts_default')
|
||||||
const stored = localStorage.getItem(draftsStorageKey);
|
);
|
||||||
return stored ? JSON.parse(stored) : [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Local sent messages history
|
// Local sent messages history
|
||||||
const [localSent, setLocalSent] = useState<Notification[]>(() => {
|
const [localSent, setLocalSent] = useState<Notification[]>(() =>
|
||||||
try {
|
readStorageList<Notification>(sentStorageKey, 'pfm_mailbox_sent_default')
|
||||||
const stored = localStorage.getItem(sentStorageKey);
|
);
|
||||||
return stored ? JSON.parse(stored) : [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sync state to localStorage
|
// Synchronize across hook instances and userId changes
|
||||||
useEffect(() => {
|
const syncFromStorage = useCallback(() => {
|
||||||
try {
|
setStarredIds(readStorageList<string>(starredStorageKey, 'pfm_mailbox_starred_default'));
|
||||||
localStorage.setItem(starredStorageKey, JSON.stringify(starredIds));
|
setTrashedIds(readStorageList<string>(trashStorageKey, 'pfm_mailbox_trash_default'));
|
||||||
} catch {}
|
setDrafts(readStorageList<DraftMessage>(draftsStorageKey, 'pfm_mailbox_drafts_default'));
|
||||||
}, [starredIds, starredStorageKey]);
|
setLocalSent(readStorageList<Notification>(sentStorageKey, 'pfm_mailbox_sent_default'));
|
||||||
|
}, [readStorageList, starredStorageKey, trashStorageKey, draftsStorageKey, sentStorageKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
syncFromStorage();
|
||||||
localStorage.setItem(trashStorageKey, JSON.stringify(trashedIds));
|
}, [syncFromStorage]);
|
||||||
} catch {}
|
|
||||||
}, [trashedIds, trashStorageKey]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
const handleSync = () => syncFromStorage();
|
||||||
localStorage.setItem(draftsStorageKey, JSON.stringify(drafts));
|
window.addEventListener('pfm-mailbox-sync', handleSync);
|
||||||
} catch {}
|
window.addEventListener('storage', handleSync);
|
||||||
}, [drafts, draftsStorageKey]);
|
return () => {
|
||||||
|
window.removeEventListener('pfm-mailbox-sync', handleSync);
|
||||||
useEffect(() => {
|
window.removeEventListener('storage', handleSync);
|
||||||
try {
|
};
|
||||||
localStorage.setItem(sentStorageKey, JSON.stringify(localSent));
|
}, [syncFromStorage]);
|
||||||
} catch {}
|
|
||||||
}, [localSent, sentStorageKey]);
|
|
||||||
|
|
||||||
// Fetch notifications from backend
|
// Fetch notifications from backend
|
||||||
const { data: rawNotifications = [], isLoading, refetch } = useQuery<Notification[]>({
|
const { data: rawNotifications = [], isLoading, refetch } = useQuery<Notification[]>({
|
||||||
@@ -131,23 +134,16 @@ export function useInAppNotifications() {
|
|||||||
);
|
);
|
||||||
}, [rawNotifications]);
|
}, [rawNotifications]);
|
||||||
|
|
||||||
// Active inbox notifications (InApp + Email for mailbox, excluding trashed and drafts)
|
// Active in-app notifications in inbox (excluding trashed and drafts)
|
||||||
const allMailboxNotifications = useMemo(() => {
|
|
||||||
return rawNotifications.filter(
|
|
||||||
n =>
|
|
||||||
(Number(n.platform) === NotificationPlatform.InApp || Number(n.platform) === NotificationPlatform.Email) &&
|
|
||||||
!trashedIds.includes(String(n.id)) &&
|
|
||||||
Number(n.status) !== NotificationStatus.Draft
|
|
||||||
);
|
|
||||||
}, [rawNotifications, trashedIds]);
|
|
||||||
|
|
||||||
// Active in-app notifications in inbox
|
|
||||||
const inboxNotifications = useMemo(() => {
|
const inboxNotifications = useMemo(() => {
|
||||||
return inAppNotifications.filter(
|
return inAppNotifications.filter(
|
||||||
n => !trashedIds.includes(String(n.id)) && Number(n.status) !== NotificationStatus.Draft
|
n => !trashedIds.includes(String(n.id)) && Number(n.status) !== NotificationStatus.Draft
|
||||||
);
|
);
|
||||||
}, [inAppNotifications, trashedIds]);
|
}, [inAppNotifications, trashedIds]);
|
||||||
|
|
||||||
|
// Active mailbox notifications (always In-App notifications)
|
||||||
|
const allMailboxNotifications = inboxNotifications;
|
||||||
|
|
||||||
// Unread InApp notifications: status !== Read && status !== Draft && status !== Cancelled && status !== Failed
|
// Unread InApp notifications: status !== Read && status !== Draft && status !== Cancelled && status !== Failed
|
||||||
const unreadNotifications = useMemo(() => {
|
const unreadNotifications = useMemo(() => {
|
||||||
return inboxNotifications.filter(
|
return inboxNotifications.filter(
|
||||||
@@ -161,20 +157,19 @@ export function useInAppNotifications() {
|
|||||||
const unreadCount = unreadNotifications.length;
|
const unreadCount = unreadNotifications.length;
|
||||||
const top5Unread = useMemo(() => unreadNotifications.slice(0, 5), [unreadNotifications]);
|
const top5Unread = useMemo(() => unreadNotifications.slice(0, 5), [unreadNotifications]);
|
||||||
|
|
||||||
// Starred notifications
|
// Starred notifications (In-App only)
|
||||||
const starredNotifications = useMemo(() => {
|
const starredNotifications = useMemo(() => {
|
||||||
return rawNotifications.filter(
|
return inAppNotifications.filter(
|
||||||
n =>
|
n =>
|
||||||
starredIds.includes(String(n.id)) &&
|
starredIds.includes(String(n.id)) &&
|
||||||
!trashedIds.includes(String(n.id)) &&
|
!trashedIds.includes(String(n.id))
|
||||||
(Number(n.platform) === NotificationPlatform.InApp || Number(n.platform) === NotificationPlatform.Email)
|
|
||||||
);
|
);
|
||||||
}, [rawNotifications, starredIds, trashedIds]);
|
}, [inAppNotifications, starredIds, trashedIds]);
|
||||||
|
|
||||||
// Trashed notifications
|
// Trashed notifications (In-App only)
|
||||||
const trashedNotifications = useMemo(() => {
|
const trashedNotifications = useMemo(() => {
|
||||||
return rawNotifications.filter(n => trashedIds.includes(String(n.id)));
|
return inAppNotifications.filter(n => trashedIds.includes(String(n.id)));
|
||||||
}, [rawNotifications, trashedIds]);
|
}, [inAppNotifications, trashedIds]);
|
||||||
|
|
||||||
// Mutation to mark notification as read
|
// Mutation to mark notification as read
|
||||||
const markAsReadMutation = useMutation({
|
const markAsReadMutation = useMutation({
|
||||||
@@ -206,27 +201,61 @@ export function useInAppNotifications() {
|
|||||||
// Toggle star
|
// Toggle star
|
||||||
const toggleStar = useCallback((id: string | number) => {
|
const toggleStar = useCallback((id: string | number) => {
|
||||||
const strId = String(id);
|
const strId = String(id);
|
||||||
setStarredIds(prev => (prev.includes(strId) ? prev.filter(x => x !== strId) : [...prev, strId]));
|
setStarredIds(prev => {
|
||||||
}, []);
|
const next = prev.includes(strId) ? prev.filter(x => x !== strId) : [...prev, strId];
|
||||||
|
try {
|
||||||
|
localStorage.setItem(starredStorageKey, JSON.stringify(next));
|
||||||
|
window.dispatchEvent(new Event('pfm-mailbox-sync'));
|
||||||
|
} catch {}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [starredStorageKey]);
|
||||||
|
|
||||||
// Delete message (move to trash)
|
// Delete message (move to trash)
|
||||||
const deleteMessage = useCallback((id: string | number) => {
|
const deleteMessage = useCallback((id: string | number) => {
|
||||||
const strId = String(id);
|
const strId = String(id);
|
||||||
setTrashedIds(prev => (prev.includes(strId) ? prev : [...prev, strId]));
|
setTrashedIds(prev => {
|
||||||
}, []);
|
const next = prev.includes(strId) ? prev : [...prev, strId];
|
||||||
|
try {
|
||||||
|
localStorage.setItem(trashStorageKey, JSON.stringify(next));
|
||||||
|
window.dispatchEvent(new Event('pfm-mailbox-sync'));
|
||||||
|
} catch {}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [trashStorageKey]);
|
||||||
|
|
||||||
// Restore from trash
|
// Restore from trash
|
||||||
const restoreMessage = useCallback((id: string | number) => {
|
const restoreMessage = useCallback((id: string | number) => {
|
||||||
const strId = String(id);
|
const strId = String(id);
|
||||||
setTrashedIds(prev => prev.filter(x => x !== strId));
|
setTrashedIds(prev => {
|
||||||
}, []);
|
const next = prev.filter(x => x !== strId);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(trashStorageKey, JSON.stringify(next));
|
||||||
|
window.dispatchEvent(new Event('pfm-mailbox-sync'));
|
||||||
|
} catch {}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [trashStorageKey]);
|
||||||
|
|
||||||
// Permanent delete
|
// Permanent delete
|
||||||
const permanentDelete = useCallback((id: string | number) => {
|
const permanentDelete = useCallback((id: string | number) => {
|
||||||
const strId = String(id);
|
const strId = String(id);
|
||||||
setTrashedIds(prev => prev.filter(x => x !== strId));
|
setTrashedIds(prev => {
|
||||||
setLocalSent(prev => prev.filter(n => String(n.id) !== strId));
|
const next = prev.filter(x => x !== strId);
|
||||||
}, []);
|
try {
|
||||||
|
localStorage.setItem(trashStorageKey, JSON.stringify(next));
|
||||||
|
} catch {}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setLocalSent(prev => {
|
||||||
|
const next = prev.filter(n => String(n.id) !== strId);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(sentStorageKey, JSON.stringify(next));
|
||||||
|
window.dispatchEvent(new Event('pfm-mailbox-sync'));
|
||||||
|
} catch {}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [trashStorageKey, sentStorageKey]);
|
||||||
|
|
||||||
// Draft management
|
// Draft management
|
||||||
const saveDraft = useCallback((draft: Omit<DraftMessage, 'id' | 'updatedAt'> & { id?: string }) => {
|
const saveDraft = useCallback((draft: Omit<DraftMessage, 'id' | 'updatedAt'> & { id?: string }) => {
|
||||||
@@ -237,15 +266,31 @@ export function useInAppNotifications() {
|
|||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
setDrafts(prev => {
|
setDrafts(prev => {
|
||||||
const filtered = prev.filter(d => d.id !== draftId);
|
const current = readStorageList<DraftMessage>(draftsStorageKey, 'pfm_mailbox_drafts_default');
|
||||||
return [newDraft, ...filtered];
|
const base = current.length >= prev.length ? current : prev;
|
||||||
|
const filtered = base.filter(d => d.id !== draftId);
|
||||||
|
const next = [newDraft, ...filtered];
|
||||||
|
try {
|
||||||
|
localStorage.setItem(draftsStorageKey, JSON.stringify(next));
|
||||||
|
window.dispatchEvent(new Event('pfm-mailbox-sync'));
|
||||||
|
} catch {}
|
||||||
|
return next;
|
||||||
});
|
});
|
||||||
return draftId;
|
return draftId;
|
||||||
}, []);
|
}, [draftsStorageKey, readStorageList]);
|
||||||
|
|
||||||
const deleteDraft = useCallback((id: string) => {
|
const deleteDraft = useCallback((id: string) => {
|
||||||
setDrafts(prev => prev.filter(d => d.id !== id));
|
setDrafts(prev => {
|
||||||
}, []);
|
const current = readStorageList<DraftMessage>(draftsStorageKey, 'pfm_mailbox_drafts_default');
|
||||||
|
const base = current.length >= prev.length ? current : prev;
|
||||||
|
const next = base.filter(d => d.id !== id);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(draftsStorageKey, JSON.stringify(next));
|
||||||
|
window.dispatchEvent(new Event('pfm-mailbox-sync'));
|
||||||
|
} catch {}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [draftsStorageKey, readStorageList]);
|
||||||
|
|
||||||
// Send new notification (InApp or Email)
|
// Send new notification (InApp or Email)
|
||||||
const sendMessageMutation = useMutation({
|
const sendMessageMutation = useMutation({
|
||||||
@@ -288,7 +333,14 @@ export function useInAppNotifications() {
|
|||||||
sentAt: new Date().toISOString(),
|
sentAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
setLocalSent(prev => [sentItem, ...prev]);
|
setLocalSent(prev => {
|
||||||
|
const next = [sentItem, ...prev];
|
||||||
|
try {
|
||||||
|
localStorage.setItem(sentStorageKey, JSON.stringify(next));
|
||||||
|
window.dispatchEvent(new Event('pfm-mailbox-sync'));
|
||||||
|
} catch {}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
// If sending a draft, remove the draft
|
// If sending a draft, remove the draft
|
||||||
if (payload.draftId) {
|
if (payload.draftId) {
|
||||||
|
|||||||
@@ -64,8 +64,13 @@ export function isHtmlContent(content: string): boolean {
|
|||||||
trimmed.startsWith('<html') ||
|
trimmed.startsWith('<html') ||
|
||||||
trimmed.startsWith('<div') ||
|
trimmed.startsWith('<div') ||
|
||||||
trimmed.startsWith('<table') ||
|
trimmed.startsWith('<table') ||
|
||||||
trimmed.startsWith('<p>') ||
|
trimmed.startsWith('<p') ||
|
||||||
(trimmed.includes('</') && trimmed.includes('>'))
|
trimmed.startsWith('<h') ||
|
||||||
|
trimmed.startsWith('<ul') ||
|
||||||
|
trimmed.startsWith('<ol') ||
|
||||||
|
trimmed.startsWith('<blockquote') ||
|
||||||
|
(trimmed.includes('</') && trimmed.includes('>')) ||
|
||||||
|
/<(strong|b|em|i|u|h[1-6]|ul|ol|li|table|blockquote|div|p|img|hr|br)\b/i.test(trimmed)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,7 +229,14 @@ export function renderMarkdownToHtml(
|
|||||||
markdown: string,
|
markdown: string,
|
||||||
options?: MarkdownRenderOptions
|
options?: MarkdownRenderOptions
|
||||||
): string {
|
): string {
|
||||||
const contentHtml = parseMarkdownToHtml(markdown);
|
const trimmed = (markdown || '').trim();
|
||||||
|
if (trimmed.startsWith('<!DOCTYPE') || trimmed.startsWith('<html')) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentHtml = isHtmlContent(trimmed)
|
||||||
|
? trimmed.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
||||||
|
: parseMarkdownToHtml(trimmed);
|
||||||
|
|
||||||
if (!options?.applyBranding && !options?.emailFormat) {
|
if (!options?.applyBranding && !options?.emailFormat) {
|
||||||
return contentHtml;
|
return contentHtml;
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
RotateCcw,
|
RotateCcw,
|
||||||
X,
|
X,
|
||||||
Smartphone,
|
Smartphone,
|
||||||
Filter,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useInAppNotifications, DraftMessage } from '../hooks/useInAppNotifications';
|
import { useInAppNotifications, DraftMessage } from '../hooks/useInAppNotifications';
|
||||||
import { ComposeMessageModal } from '../components/notifications/ComposeMessageModal';
|
import { ComposeMessageModal } from '../components/notifications/ComposeMessageModal';
|
||||||
@@ -28,7 +27,6 @@ import { useAuth } from '../auth/AuthProvider';
|
|||||||
import { stripMarkdown } from '../lib/markdownUtils';
|
import { stripMarkdown } from '../lib/markdownUtils';
|
||||||
|
|
||||||
type MailboxFolder = 'inbox' | 'starred' | 'sent' | 'drafts' | 'trash';
|
type MailboxFolder = 'inbox' | 'starred' | 'sent' | 'drafts' | 'trash';
|
||||||
type PlatformFilter = 'all' | 'inapp' | 'email';
|
|
||||||
|
|
||||||
export const InboxPage: React.FC = () => {
|
export const InboxPage: React.FC = () => {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
@@ -60,8 +58,6 @@ export const InboxPage: React.FC = () => {
|
|||||||
|
|
||||||
// Active folder
|
// Active folder
|
||||||
const [activeFolder, setActiveFolder] = useState<MailboxFolder>('inbox');
|
const [activeFolder, setActiveFolder] = useState<MailboxFolder>('inbox');
|
||||||
// Platform filter
|
|
||||||
const [platformFilter, setPlatformFilter] = useState<PlatformFilter>('all');
|
|
||||||
// Search query
|
// Search query
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
// Filter for unread only in Inbox
|
// Filter for unread only in Inbox
|
||||||
@@ -82,11 +78,11 @@ export const InboxPage: React.FC = () => {
|
|||||||
const [inlineReplyText, setInlineReplyText] = useState('');
|
const [inlineReplyText, setInlineReplyText] = useState('');
|
||||||
const [inlineReplySuccess, setInlineReplySuccess] = useState(false);
|
const [inlineReplySuccess, setInlineReplySuccess] = useState(false);
|
||||||
|
|
||||||
// Active folder message items
|
// Active folder message items (always In-App notifications)
|
||||||
const folderMessages = useMemo(() => {
|
const folderMessages = useMemo(() => {
|
||||||
switch (activeFolder) {
|
switch (activeFolder) {
|
||||||
case 'inbox':
|
case 'inbox':
|
||||||
return allMailboxNotifications;
|
return inboxNotifications;
|
||||||
case 'starred':
|
case 'starred':
|
||||||
return starredNotifications;
|
return starredNotifications;
|
||||||
case 'sent':
|
case 'sent':
|
||||||
@@ -96,20 +92,13 @@ export const InboxPage: React.FC = () => {
|
|||||||
case 'drafts':
|
case 'drafts':
|
||||||
return [];
|
return [];
|
||||||
default:
|
default:
|
||||||
return allMailboxNotifications;
|
return inboxNotifications;
|
||||||
}
|
}
|
||||||
}, [activeFolder, allMailboxNotifications, starredNotifications, sentNotifications, trashedNotifications]);
|
}, [activeFolder, inboxNotifications, starredNotifications, sentNotifications, trashedNotifications]);
|
||||||
|
|
||||||
// Filtered by search, platform, and unread toggle
|
// Filtered by search and unread toggle
|
||||||
const filteredMessages = useMemo(() => {
|
const filteredMessages = useMemo(() => {
|
||||||
return folderMessages.filter(item => {
|
return folderMessages.filter(item => {
|
||||||
// Platform filter
|
|
||||||
if (platformFilter === 'inapp' && Number(item.platform) !== NotificationPlatform.InApp) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (platformFilter === 'email' && Number(item.platform) !== NotificationPlatform.Email) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Unread only filter
|
// Unread only filter
|
||||||
if (activeFolder === 'inbox' && showUnreadOnly) {
|
if (activeFolder === 'inbox' && showUnreadOnly) {
|
||||||
if (Number(item.status) === NotificationStatus.Read) return false;
|
if (Number(item.status) === NotificationStatus.Read) return false;
|
||||||
@@ -122,7 +111,7 @@ export const InboxPage: React.FC = () => {
|
|||||||
const dest = (item.destination || '').toLowerCase();
|
const dest = (item.destination || '').toLowerCase();
|
||||||
return subject.includes(term) || message.includes(term) || dest.includes(term);
|
return subject.includes(term) || message.includes(term) || dest.includes(term);
|
||||||
});
|
});
|
||||||
}, [folderMessages, platformFilter, activeFolder, showUnreadOnly, searchTerm]);
|
}, [folderMessages, activeFolder, showUnreadOnly, searchTerm]);
|
||||||
|
|
||||||
// Filtered drafts for Drafts folder
|
// Filtered drafts for Drafts folder
|
||||||
const filteredDrafts = useMemo(() => {
|
const filteredDrafts = useMemo(() => {
|
||||||
@@ -266,11 +255,11 @@ export const InboxPage: React.FC = () => {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h1 className="text-sm font-bold text-white tracking-tight">Mailbox & Communications</h1>
|
<h1 className="text-sm font-bold text-white tracking-tight">Mailbox & Communications</h1>
|
||||||
<span className="text-[10px] font-semibold text-blue-300 bg-blue-500/10 border border-blue-500/30 px-2 py-0.5 rounded-full flex items-center gap-1">
|
<span className="text-[10px] font-semibold text-blue-300 bg-blue-500/10 border border-blue-500/30 px-2 py-0.5 rounded-full flex items-center gap-1">
|
||||||
Markdown & Branded HTML Engine
|
In-App Messaging
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-slate-400">
|
<p className="text-[11px] text-slate-400">
|
||||||
In-App markdown messaging and executive branded email notifications
|
In-App notifications and internal platform communications
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -406,10 +395,14 @@ export const InboxPage: React.FC = () => {
|
|||||||
<FileText className="w-4 h-4 text-purple-400" />
|
<FileText className="w-4 h-4 text-purple-400" />
|
||||||
<span>Drafts</span>
|
<span>Drafts</span>
|
||||||
</div>
|
</div>
|
||||||
{drafts.length > 0 && (
|
{drafts.length > 0 ? (
|
||||||
<span className="text-[10px] font-bold text-purple-300 bg-purple-500/20 border border-purple-500/30 px-2 py-0.5 rounded-full">
|
<span className="text-[10px] font-bold text-purple-300 bg-purple-500/20 border border-purple-500/30 px-2 py-0.5 rounded-full">
|
||||||
{drafts.length}
|
{drafts.length}
|
||||||
</span>
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[11px] text-slate-400 font-mono">
|
||||||
|
0
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -436,57 +429,6 @@ export const InboxPage: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Platform Filter Buttons */}
|
|
||||||
<div className="pt-3 border-t border-slate-800/80 space-y-2">
|
|
||||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-slate-400 px-2 block">
|
|
||||||
Channel Filter
|
|
||||||
</span>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setPlatformFilter('all')}
|
|
||||||
className={`w-full flex items-center justify-between px-2.5 py-1.5 rounded-lg text-xs font-medium transition cursor-pointer ${
|
|
||||||
platformFilter === 'all'
|
|
||||||
? 'bg-slate-800 text-white font-semibold'
|
|
||||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-900'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Filter className="w-3.5 h-3.5 text-blue-400" />
|
|
||||||
<span>All Channels</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setPlatformFilter('inapp')}
|
|
||||||
className={`w-full flex items-center justify-between px-2.5 py-1.5 rounded-lg text-xs font-medium transition cursor-pointer ${
|
|
||||||
platformFilter === 'inapp'
|
|
||||||
? 'bg-blue-600/25 text-blue-200 font-semibold border border-blue-500/30'
|
|
||||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-900'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Smartphone className="w-3.5 h-3.5 text-blue-400" />
|
|
||||||
<span>In-App Markdown</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setPlatformFilter('email')}
|
|
||||||
className={`w-full flex items-center justify-between px-2.5 py-1.5 rounded-lg text-xs font-medium transition cursor-pointer ${
|
|
||||||
platformFilter === 'email'
|
|
||||||
? 'bg-emerald-600/25 text-emerald-200 font-semibold border border-emerald-500/30'
|
|
||||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-900'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Mail className="w-3.5 h-3.5 text-emerald-400" />
|
|
||||||
<span>Email HTML</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick Filter: Unread Toggle */}
|
{/* Quick Filter: Unread Toggle */}
|
||||||
{activeFolder === 'inbox' && (
|
{activeFolder === 'inbox' && (
|
||||||
<div className="pt-2 border-t border-slate-800/80">
|
<div className="pt-2 border-t border-slate-800/80">
|
||||||
@@ -507,10 +449,10 @@ export const InboxPage: React.FC = () => {
|
|||||||
<div className="p-3 rounded-2xl bg-slate-900/40 border border-slate-800/60 space-y-1 text-[11px] text-slate-400">
|
<div className="p-3 rounded-2xl bg-slate-900/40 border border-slate-800/60 space-y-1 text-[11px] text-slate-400">
|
||||||
<div className="flex items-center gap-1.5 text-slate-300 font-semibold">
|
<div className="flex items-center gap-1.5 text-slate-300 font-semibold">
|
||||||
<span className="w-2 h-2 rounded-full bg-emerald-400" />
|
<span className="w-2 h-2 rounded-full bg-emerald-400" />
|
||||||
<span>Format Standards</span>
|
<span>In-App Notifications</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] text-slate-400 leading-snug">
|
<p className="text-[10px] text-slate-400 leading-snug">
|
||||||
InApp messages are stored as Markdown. Email notifications are transformed to branded HTML.
|
Messages are delivered directly to your In-App Mailbox and Notification Bell.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -571,7 +513,7 @@ export const InboxPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-1">
|
<div className="flex items-center justify-between gap-1">
|
||||||
<span className="text-[10px] font-bold text-purple-300 bg-purple-500/15 border border-purple-500/30 px-1.5 py-0.2 rounded">
|
<span className="text-[10px] font-bold text-purple-300 bg-purple-500/15 border border-purple-500/30 px-1.5 py-0.2 rounded">
|
||||||
Draft (Markdown)
|
Draft
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] text-slate-400 font-mono">
|
<span className="text-[10px] text-slate-400 font-mono">
|
||||||
{formatDate(d.updatedAt)}
|
{formatDate(d.updatedAt)}
|
||||||
@@ -620,7 +562,6 @@ export const InboxPage: React.FC = () => {
|
|||||||
const isSelected = selectedMessage?.id === item.id;
|
const isSelected = selectedMessage?.id === item.id;
|
||||||
const isUnread = Number(item.status) !== NotificationStatus.Read;
|
const isUnread = Number(item.status) !== NotificationStatus.Read;
|
||||||
const isStarred = starredIds.includes(String(item.id));
|
const isStarred = starredIds.includes(String(item.id));
|
||||||
const isEmail = Number(item.platform) === NotificationPlatform.Email;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -649,15 +590,9 @@ export const InboxPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5 shrink-0">
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
{isEmail ? (
|
|
||||||
<span className="text-[9px] font-bold text-emerald-300 bg-emerald-500/15 border border-emerald-500/30 px-1.5 py-0.2 rounded font-mono">
|
|
||||||
Email
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-[9px] font-bold text-blue-300 bg-blue-500/15 border border-blue-500/30 px-1.5 py-0.2 rounded font-mono">
|
<span className="text-[9px] font-bold text-blue-300 bg-blue-500/15 border border-blue-500/30 px-1.5 py-0.2 rounded font-mono">
|
||||||
InApp
|
In-App
|
||||||
</span>
|
</span>
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -707,7 +642,7 @@ export const InboxPage: React.FC = () => {
|
|||||||
<FileText className="w-12 h-12 text-slate-600 mb-3" />
|
<FileText className="w-12 h-12 text-slate-600 mb-3" />
|
||||||
<h3 className="text-sm font-bold text-white">Drafts Folder</h3>
|
<h3 className="text-sm font-bold text-white">Drafts Folder</h3>
|
||||||
<p className="text-xs text-slate-400 max-w-md mt-1 mb-4">
|
<p className="text-xs text-slate-400 max-w-md mt-1 mb-4">
|
||||||
Click any draft on the left pane to resume composing in Markdown and dispatch your notification.
|
Click any draft on the left pane to resume composing and dispatch your notification.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -725,7 +660,7 @@ export const InboxPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<h3 className="text-sm font-bold text-white">No Message Selected</h3>
|
<h3 className="text-sm font-bold text-white">No Message Selected</h3>
|
||||||
<p className="text-xs text-slate-400 max-w-sm mt-1 mb-4">
|
<p className="text-xs text-slate-400 max-w-sm mt-1 mb-4">
|
||||||
Select an in-app conversation or email notice from the list to view its rendered markdown and attachments.
|
Select an in-app conversation or email notice from the list to view its contents and attachments.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -815,17 +750,10 @@ export const InboxPage: React.FC = () => {
|
|||||||
{selectedMessage.subject || 'In-App Notification'}
|
{selectedMessage.subject || 'In-App Notification'}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex flex-wrap items-center gap-2 mt-2">
|
<div className="flex flex-wrap items-center gap-2 mt-2">
|
||||||
{Number(selectedMessage.platform) === NotificationPlatform.Email ? (
|
|
||||||
<span className="text-[10px] font-semibold text-emerald-300 bg-emerald-500/15 border border-emerald-500/30 px-2 py-0.5 rounded-md flex items-center gap-1">
|
|
||||||
<Mail className="w-3 h-3 text-emerald-400" />
|
|
||||||
Channel: Email (Branded HTML)
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-[10px] font-semibold text-blue-300 bg-blue-500/15 border border-blue-500/30 px-2 py-0.5 rounded-md flex items-center gap-1">
|
<span className="text-[10px] font-semibold text-blue-300 bg-blue-500/15 border border-blue-500/30 px-2 py-0.5 rounded-md flex items-center gap-1">
|
||||||
<Smartphone className="w-3 h-3 text-blue-400" />
|
<Smartphone className="w-3 h-3 text-blue-400" />
|
||||||
Channel: InApp (Markdown)
|
Channel: In-App
|
||||||
</span>
|
</span>
|
||||||
)}
|
|
||||||
|
|
||||||
<span className="text-[10px] font-mono text-slate-400 bg-slate-900 px-2 py-0.5 rounded-md border border-slate-800">
|
<span className="text-[10px] font-mono text-slate-400 bg-slate-900 px-2 py-0.5 rounded-md border border-slate-800">
|
||||||
ID: #{selectedMessage.id}
|
ID: #{selectedMessage.id}
|
||||||
@@ -852,7 +780,7 @@ export const InboxPage: React.FC = () => {
|
|||||||
: selectedMessage.destination || 'PFM Automated Dispatcher'}
|
: selectedMessage.destination || 'PFM Automated Dispatcher'}
|
||||||
</p>
|
</p>
|
||||||
<span className="text-[10px] text-slate-400 font-mono">
|
<span className="text-[10px] text-slate-400 font-mono">
|
||||||
({Number(selectedMessage.platform) === NotificationPlatform.Email ? 'SMTP Protocol' : 'InApp Protocol'})
|
(In-App Notification)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-slate-400 mt-0.5">
|
<p className="text-[11px] text-slate-400 mt-0.5">
|
||||||
@@ -884,10 +812,10 @@ export const InboxPage: React.FC = () => {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h4 className="text-xs font-bold text-white flex items-center gap-1.5">
|
<h4 className="text-xs font-bold text-white flex items-center gap-1.5">
|
||||||
<Reply className="w-3.5 h-3.5 text-blue-400" />
|
<Reply className="w-3.5 h-3.5 text-blue-400" />
|
||||||
<span>Quick Markdown Reply</span>
|
<span>Quick Reply</span>
|
||||||
</h4>
|
</h4>
|
||||||
<span className="text-[10px] text-slate-400">
|
<span className="text-[10px] text-slate-400">
|
||||||
Supports Markdown formatting (e.g. **bold**, - lists, `code`)
|
Send a quick response below or open the rich text composer
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -902,8 +830,8 @@ export const InboxPage: React.FC = () => {
|
|||||||
rows={4}
|
rows={4}
|
||||||
value={inlineReplyText}
|
value={inlineReplyText}
|
||||||
onChange={e => setInlineReplyText(e.target.value)}
|
onChange={e => setInlineReplyText(e.target.value)}
|
||||||
placeholder="Type your markdown reply... (e.g. **Approved** milestone deliverable proof)"
|
placeholder="Type your reply..."
|
||||||
className="w-full p-3.5 rounded-2xl bg-slate-900/90 border border-slate-700/80 hover:border-slate-600 focus:border-blue-500 text-slate-100 placeholder-slate-400 text-xs focus:outline-none transition shadow-inner resize-y font-mono leading-relaxed"
|
className="w-full p-3.5 rounded-2xl bg-slate-900/90 border border-slate-700/80 hover:border-slate-600 focus:border-blue-500 text-slate-100 placeholder-slate-400 text-xs focus:outline-none transition shadow-inner resize-y leading-relaxed"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|||||||
@@ -27,5 +27,5 @@ public static class Constants
|
|||||||
public const int ShortLabelLength = 100;
|
public const int ShortLabelLength = 100;
|
||||||
public const int TextLength = 1024;
|
public const int TextLength = 1024;
|
||||||
public const int MediumTextLength = 2048;
|
public const int MediumTextLength = 2048;
|
||||||
public const int LargeTextLength = 4096;
|
public const int LargeTextLength = 1_048_576; // 1 MB — accommodates branded HTML emails with embedded logo
|
||||||
}
|
}
|
||||||
+2519
File diff suppressed because it is too large
Load Diff
+60
@@ -0,0 +1,60 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PostFundManagement.Infrastructure.Database.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class IncreasedLargeTextLengthTo1MB : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "Findings",
|
||||||
|
table: "SiteVisits",
|
||||||
|
type: "character varying(1048576)",
|
||||||
|
maxLength: 1048576,
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "character varying(4096)",
|
||||||
|
oldMaxLength: 4096,
|
||||||
|
oldNullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "Message",
|
||||||
|
table: "Notifications",
|
||||||
|
type: "character varying(1048576)",
|
||||||
|
maxLength: 1048576,
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "character varying(4096)",
|
||||||
|
oldMaxLength: 4096);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "Findings",
|
||||||
|
table: "SiteVisits",
|
||||||
|
type: "character varying(4096)",
|
||||||
|
maxLength: 4096,
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "character varying(1048576)",
|
||||||
|
oldMaxLength: 1048576,
|
||||||
|
oldNullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "Message",
|
||||||
|
table: "Notifications",
|
||||||
|
type: "character varying(4096)",
|
||||||
|
maxLength: 4096,
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "character varying(1048576)",
|
||||||
|
oldMaxLength: 1048576);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-4
@@ -958,8 +958,8 @@ namespace PostFundManagement.Infrastructure.Database.Migrations
|
|||||||
|
|
||||||
b.Property<string>("Message")
|
b.Property<string>("Message")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(4096)
|
.HasMaxLength(1048576)
|
||||||
.HasColumnType("character varying(4096)");
|
.HasColumnType("character varying(1048576)");
|
||||||
|
|
||||||
b.Property<long>("OrganisationId")
|
b.Property<long>("OrganisationId")
|
||||||
.HasColumnType("bigint");
|
.HasColumnType("bigint");
|
||||||
@@ -1405,8 +1405,8 @@ namespace PostFundManagement.Infrastructure.Database.Migrations
|
|||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
b.Property<string>("Findings")
|
b.Property<string>("Findings")
|
||||||
.HasMaxLength(4096)
|
.HasMaxLength(1048576)
|
||||||
.HasColumnType("character varying(4096)");
|
.HasColumnType("character varying(1048576)");
|
||||||
|
|
||||||
b.Property<string>("InspectorName")
|
b.Property<string>("InspectorName")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
|
|||||||
Reference in New Issue
Block a user