Fixed notification composition

This commit is contained in:
2026-10-22 23:36:03 +02:00
parent 46c6ddcc80
commit 5050c901bc
9 changed files with 2949 additions and 358 deletions
@@ -9,12 +9,12 @@ import {
CheckCircle2,
Bold,
Italic,
Underline,
Heading2,
List,
ListOrdered,
Quote,
Table,
Code,
Minus,
Sparkles,
Eye,
@@ -25,7 +25,13 @@ import {
import { UserSearchPicker, SelectedUser } from './UserSearchPicker';
import { useInAppNotifications, DraftMessage } from '../../hooks/useInAppNotifications';
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 { useAuth } from '../../auth/AuthProvider';
@@ -49,7 +55,10 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
draftToEdit,
}) => {
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 [selectedUsers, setSelectedUsers] = useState<SelectedUser[]>([]);
@@ -60,10 +69,19 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
const [error, setError] = 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
useEffect(() => {
if (!isOpen) return;
let initialHtml = '';
if (draftToEdit) {
setSelectedUsers(
draftToEdit.recipientId
@@ -77,23 +95,40 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
: []
);
setSubject(draftToEdit.subject || '');
setMessage(draftToEdit.message || '');
setPlatform(NotificationPlatform.InApp);
initialHtml = toInitialRichHtml(draftToEdit.message || '');
setMessage(initialHtml);
setPlatform(draftToEdit.platform || NotificationPlatform.InApp);
} else {
setSelectedUsers(initialRecipient ? [initialRecipient] : []);
setSubject(initialSubject || '');
setMessage(initialMessage || '');
initialHtml = toInitialRichHtml(initialMessage || '');
setMessage(initialHtml);
setPlatform(initialPlatform);
}
setActiveTab('write');
setError(null);
setSuccessNotice(null);
setTimeout(() => {
if (editorRef.current) {
editorRef.current.innerHTML = initialHtml;
}
}, 0);
}, [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;
const handleSelectUser = (user: SelectedUser) => {
setSelectedUsers([user]);
const handleSelectUser = (selected: SelectedUser) => {
setSelectedUsers([selected]);
setError(null);
};
@@ -101,51 +136,59 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
setSelectedUsers([]);
};
// Helper to insert markdown text at cursor in textarea
const insertMarkdown = (before: string, after: string = '', defaultText: string = '') => {
const textarea = textareaRef.current;
if (!textarea) return;
const syncEditorContent = () => {
if (editorRef.current) {
setMessage(editorRef.current.innerHTML);
}
};
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const text = textarea.value;
const selection = text.substring(start, end) || defaultText;
const applyFormat = (command: string, value?: string) => {
const editor = editorRef.current;
if (!editor) return;
editor.focus();
document.execCommand(command, false, value);
syncEditorContent();
};
const replacement = `${before}${selection}${after}`;
const newText = text.substring(0, start) + replacement + text.substring(end);
setMessage(newText);
setTimeout(() => {
textarea.focus();
textarea.setSelectionRange(
start + before.length,
start + before.length + selection.length
);
}, 0);
const insertHtmlBlock = (htmlSnippet: string) => {
const editor = editorRef.current;
if (!editor) return;
editor.focus();
const inserted = document.execCommand('insertHTML', false, htmlSnippet);
if (!inserted) {
editor.innerHTML += htmlSnippet;
}
syncEditorContent();
};
// Quick insertions for PFM branding and templates
const insertBrandedCallout = () => {
insertMarkdown(
`> [!NOTE]\n> **Post-Fund Management Official Notice**\n> Please verify all supporting evidence documentation before tranche disbursement sign-off.\n\n`
insertHtmlBlock(
`<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 = () => {
insertMarkdown(
`| 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`
insertHtmlBlock(
`<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 = () => {
insertMarkdown(
`![PFM Platform Logo](${PFM_LOGO_DATA_URI})\n\n`
insertHtmlBlock(
`<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 = () => {
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');
return;
}
@@ -157,18 +200,21 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
recipientEmail: firstUser?.email || '',
recipientName: firstUser?.name || '',
subject: subject.trim() || 'Untitled Draft',
message: message.trim(),
message: currentHtml.trim(),
platform,
});
setSuccessNotice('Draft saved successfully in Drafts folder');
setTimeout(() => {
onClose();
}, 800);
}, 600);
};
const handleSend = async (e: React.FormEvent) => {
e.preventDefault();
const currentHtml = editorRef.current ? editorRef.current.innerHTML : message;
if (selectedUsers.length === 0) {
setError('Please select a recipient from the tenant directory');
return;
@@ -179,7 +225,7 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
return;
}
if (!message.trim()) {
if (!hasMeaningfulContent(currentHtml)) {
setError('Please enter a message body');
return;
}
@@ -188,18 +234,15 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
try {
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 =
platform === NotificationPlatform.Email
? renderMarkdownToHtml(message.trim(), {
? renderMarkdownToHtml(currentHtml.trim(), {
applyBranding: true,
emailFormat: true,
subject: subject.trim(),
tenantName,
})
: message.trim();
: currentHtml.trim();
await sendMessage({
recipientId: recipient.id,
@@ -207,13 +250,14 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
recipientName: recipient.name,
subject: subject.trim(),
message: finalMessage,
platform,
draftId: draftToEdit?.id,
});
setSuccessNotice(
platform === NotificationPlatform.Email
? 'Email notification rendered to HTML and dispatched!'
: 'In-App Markdown message sent successfully!'
? 'Email notification dispatched!'
: 'In-App message sent successfully!'
);
setTimeout(() => {
onClose();
@@ -239,7 +283,9 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
subject: subject || 'Notice Preview',
tenantName,
})
: parseMarkdownToHtml(message);
: message;
const plainCharCount = stripMarkdown(message).length;
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">
@@ -258,11 +304,11 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
: 'Compose New Communication'}
</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">
Rich Markdown Engine
Rich Text Composer
</span>
</div>
<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>
</div>
</div>
@@ -312,7 +358,7 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
}`}
>
<Smartphone className="w-3.5 h-3.5" />
<span>In-App (Markdown)</span>
<span>In-App</span>
</button>
<button
@@ -325,7 +371,7 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
}`}
>
<Mail className="w-3.5 h-3.5" />
<span>Email (HTML + Brand)</span>
<span>Email</span>
</button>
</div>
</div>
@@ -335,12 +381,12 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
{platform === NotificationPlatform.InApp ? (
<span className="text-blue-300 flex items-center gap-1.5">
<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 className="text-emerald-300 flex items-center gap-1.5">
<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>
)}
</div>
@@ -373,14 +419,14 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
/>
</div>
{/* Markdown Editor / Preview Tabs */}
{/* Rich Text Editor / Branded Preview Tabs */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-xs font-semibold text-slate-300">
Message Content <span className="text-rose-400">*</span>
</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">
<button
type="button"
@@ -392,11 +438,14 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
}`}
>
<Edit3 className="w-3 h-3" />
<span>Write Markdown</span>
<span>Compose</span>
</button>
<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 ${
activeTab === 'preview'
? '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" />
<span>Preview Rendered</span>
<span>Preview Delivery</span>
</button>
</div>
</div>
{/* Markdown Toolbar (Only when 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">
{/* 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">
<button
type="button"
onClick={() => insertMarkdown('**', '**', 'bold text')}
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
title="Bold (**text**)"
onMouseDown={e => e.preventDefault()}
onClick={() => applyFormat('bold')}
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" />
</button>
<button
type="button"
onClick={() => insertMarkdown('*', '*', 'italic text')}
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
title="Italic (*text*)"
onMouseDown={e => e.preventDefault()}
onClick={() => applyFormat('italic')}
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" />
</button>
<button
type="button"
onClick={() => insertMarkdown('### ', '', 'Heading Title')}
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
title="Heading (### )"
onMouseDown={e => e.preventDefault()}
onClick={() => applyFormat('underline')}
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" />
</button>
@@ -443,25 +503,28 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
<button
type="button"
onClick={() => insertMarkdown('- ', '', 'List item')}
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
title="Bullet List (- )"
onMouseDown={e => e.preventDefault()}
onClick={() => applyFormat('insertUnorderedList')}
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" />
</button>
<button
type="button"
onClick={() => insertMarkdown('1. ', '', 'First item')}
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
title="Numbered List (1. )"
onMouseDown={e => e.preventDefault()}
onClick={() => applyFormat('insertOrderedList')}
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" />
</button>
<button
type="button"
onClick={() => insertMarkdown('> ', '', 'Quoted text')}
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
title="Blockquote (> )"
onMouseDown={e => e.preventDefault()}
onClick={() => applyFormat('formatBlock', '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" />
</button>
@@ -470,25 +533,19 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
<button
type="button"
onMouseDown={e => e.preventDefault()}
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"
>
<Table className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => insertMarkdown('```\n', '\n```', 'code block')}
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
title="Code Block (```)"
>
<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 (---)"
onMouseDown={e => e.preventDefault()}
onClick={() => applyFormat('insertHorizontalRule')}
className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
title="Insert Divider Line"
>
<Minus className="w-3.5 h-3.5" />
</button>
@@ -498,6 +555,7 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
{/* Platform Brand Insertions */}
<button
type="button"
onMouseDown={e => e.preventDefault()}
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"
title="Insert official PFM callout block"
@@ -508,46 +566,54 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
<button
type="button"
onMouseDown={e => e.preventDefault()}
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"
title="Embed official SVG PFM logo into markdown"
title="Embed official PFM logo"
>
<BrandLogo size={14} showGlow={false} animateOnHover={false} />
<span>Embed Logo</span>
</button>
</div>
{/* Textarea */}
<textarea
ref={textareaRef}
rows={8}
value={message}
onChange={e => setMessage(e.target.value)}
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"
/>
{/* Rich Text WYSIWYG Surface */}
<div className="relative">
{!hasMeaningfulContent(message) && (
<div className="pointer-events-none absolute top-4 left-4 right-4 text-slate-500 text-xs select-none">
Type your message here and highlight text or use the toolbar above to format headings, bullet points, tables, and official notices...
</div>
)}
<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>
) : (
/* 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="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">
<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 className="text-[10px] text-slate-400 font-mono">
Subject: {subject || 'Untitled'}
</span>
</div>
{message.trim() ? (
{hasMeaningfulContent(message) ? (
<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 }}
/>
) : (
<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>
@@ -560,7 +626,7 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
<span>Tenant Partition:</span>
<strong className="text-slate-300">{tenantName}</strong>
</span>
<span className="font-mono">{message.length} characters</span>
<span className="font-mono">{plainCharCount} characters</span>
</div>
{/* Modal Footer Action Buttons */}
@@ -608,7 +674,7 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
)}
<span>
{platform === NotificationPlatform.Email
? 'Dispatch Email (HTML)'
? 'Dispatch Email'
: 'Send In-App Message'}
</span>
</button>
@@ -1,14 +1,11 @@
import React, { useState, useMemo } from 'react';
import {
Code,
Eye,
Copy,
Check,
ShieldCheck,
ExternalLink,
} from 'lucide-react';
import { BrandLogo } from '../ui/BrandLogo';
import { parseMarkdownToHtml, isHtmlContent } from '../../lib/markdownUtils';
import { parseMarkdownToHtml, isHtmlContent, stripMarkdown } from '../../lib/markdownUtils';
interface MarkdownViewerProps {
content: string;
@@ -19,11 +16,9 @@ interface MarkdownViewerProps {
export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
content,
subject,
showBrandingHeader = true,
className = '',
}) => {
const [viewMode, setViewMode] = useState<'rendered' | 'raw'>('rendered');
const [copied, setCopied] = useState(false);
const isHtml = useMemo(() => isHtmlContent(content), [content]);
@@ -35,7 +30,7 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
}, [content, isHtml]);
const handleCopy = () => {
navigator.clipboard.writeText(content);
navigator.clipboard.writeText(stripMarkdown(content) || content);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
@@ -48,49 +43,21 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
{showBrandingHeader && (
<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} />
<span className="text-[11px] tracking-tight">PFM Markdown Engine</span>
<span className="text-[11px] tracking-tight">PFM Communications</span>
</div>
)}
<span className="text-[10px] text-blue-400 font-mono">
{isHtml ? 'Format: HTML Email' : 'Format: GitHub Flavored Markdown'}
In-App Message
</span>
</div>
<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 */}
<button
type="button"
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"
title="Copy content to clipboard"
title="Copy message text to clipboard"
>
{copied ? (
<>
@@ -108,25 +75,12 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
</div>
{/* Content Rendering */}
{viewMode === 'rendered' ? (
<div className="p-6 overflow-x-auto text-sm leading-relaxed text-slate-200 font-sans">
{isHtml ? (
<div
className="prose prose-invert max-w-none"
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>
) : (
<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>
)}
<div className="p-6 overflow-x-auto text-sm leading-relaxed text-slate-200 font-sans">
<div
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 }}
/>
</div>
{/* Branded Footer Stamp */}
{showBrandingHeader && (
@@ -136,7 +90,7 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
<span>Verified PFM Secure Platform Formatting</span>
</div>
<span className="font-mono text-[10px] text-slate-400">
{content.length} characters
{stripMarkdown(content).length} characters
</span>
</div>
)}
@@ -39,70 +39,73 @@ export function useInAppNotifications() {
const trashStorageKey = `pfm_mailbox_trash_${userId || 'default'}`;
const sentStorageKey = `pfm_mailbox_sent_${userId || 'default'}`;
// Starred messages set
const [starredIds, setStarredIds] = useState<string[]>(() => {
const readStorageList = useCallback(<T,>(key: string, fallbackKey?: string): T[] => {
try {
const stored = localStorage.getItem(starredStorageKey);
return stored ? JSON.parse(stored) : [];
const stored = localStorage.getItem(key);
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 {
return [];
}
});
}, []);
// Starred messages set
const [starredIds, setStarredIds] = useState<string[]>(() =>
readStorageList<string>(starredStorageKey, 'pfm_mailbox_starred_default')
);
// Trashed messages set
const [trashedIds, setTrashedIds] = useState<string[]>(() => {
try {
const stored = localStorage.getItem(trashStorageKey);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
});
const [trashedIds, setTrashedIds] = useState<string[]>(() =>
readStorageList<string>(trashStorageKey, 'pfm_mailbox_trash_default')
);
// Local drafts list
const [drafts, setDrafts] = useState<DraftMessage[]>(() => {
try {
const stored = localStorage.getItem(draftsStorageKey);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
});
const [drafts, setDrafts] = useState<DraftMessage[]>(() =>
readStorageList<DraftMessage>(draftsStorageKey, 'pfm_mailbox_drafts_default')
);
// Local sent messages history
const [localSent, setLocalSent] = useState<Notification[]>(() => {
try {
const stored = localStorage.getItem(sentStorageKey);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
});
const [localSent, setLocalSent] = useState<Notification[]>(() =>
readStorageList<Notification>(sentStorageKey, 'pfm_mailbox_sent_default')
);
// Sync state to localStorage
useEffect(() => {
try {
localStorage.setItem(starredStorageKey, JSON.stringify(starredIds));
} catch {}
}, [starredIds, starredStorageKey]);
// Synchronize across hook instances and userId changes
const syncFromStorage = useCallback(() => {
setStarredIds(readStorageList<string>(starredStorageKey, 'pfm_mailbox_starred_default'));
setTrashedIds(readStorageList<string>(trashStorageKey, 'pfm_mailbox_trash_default'));
setDrafts(readStorageList<DraftMessage>(draftsStorageKey, 'pfm_mailbox_drafts_default'));
setLocalSent(readStorageList<Notification>(sentStorageKey, 'pfm_mailbox_sent_default'));
}, [readStorageList, starredStorageKey, trashStorageKey, draftsStorageKey, sentStorageKey]);
useEffect(() => {
try {
localStorage.setItem(trashStorageKey, JSON.stringify(trashedIds));
} catch {}
}, [trashedIds, trashStorageKey]);
syncFromStorage();
}, [syncFromStorage]);
useEffect(() => {
try {
localStorage.setItem(draftsStorageKey, JSON.stringify(drafts));
} catch {}
}, [drafts, draftsStorageKey]);
useEffect(() => {
try {
localStorage.setItem(sentStorageKey, JSON.stringify(localSent));
} catch {}
}, [localSent, sentStorageKey]);
const handleSync = () => syncFromStorage();
window.addEventListener('pfm-mailbox-sync', handleSync);
window.addEventListener('storage', handleSync);
return () => {
window.removeEventListener('pfm-mailbox-sync', handleSync);
window.removeEventListener('storage', handleSync);
};
}, [syncFromStorage]);
// Fetch notifications from backend
const { data: rawNotifications = [], isLoading, refetch } = useQuery<Notification[]>({
@@ -131,23 +134,16 @@ export function useInAppNotifications() {
);
}, [rawNotifications]);
// Active inbox notifications (InApp + Email for mailbox, 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
// Active in-app notifications in inbox (excluding trashed and drafts)
const inboxNotifications = useMemo(() => {
return inAppNotifications.filter(
n => !trashedIds.includes(String(n.id)) && Number(n.status) !== NotificationStatus.Draft
);
}, [inAppNotifications, trashedIds]);
// Active mailbox notifications (always In-App notifications)
const allMailboxNotifications = inboxNotifications;
// Unread InApp notifications: status !== Read && status !== Draft && status !== Cancelled && status !== Failed
const unreadNotifications = useMemo(() => {
return inboxNotifications.filter(
@@ -161,20 +157,19 @@ export function useInAppNotifications() {
const unreadCount = unreadNotifications.length;
const top5Unread = useMemo(() => unreadNotifications.slice(0, 5), [unreadNotifications]);
// Starred notifications
// Starred notifications (In-App only)
const starredNotifications = useMemo(() => {
return rawNotifications.filter(
return inAppNotifications.filter(
n =>
starredIds.includes(String(n.id)) &&
!trashedIds.includes(String(n.id)) &&
(Number(n.platform) === NotificationPlatform.InApp || Number(n.platform) === NotificationPlatform.Email)
!trashedIds.includes(String(n.id))
);
}, [rawNotifications, starredIds, trashedIds]);
}, [inAppNotifications, starredIds, trashedIds]);
// Trashed notifications
// Trashed notifications (In-App only)
const trashedNotifications = useMemo(() => {
return rawNotifications.filter(n => trashedIds.includes(String(n.id)));
}, [rawNotifications, trashedIds]);
return inAppNotifications.filter(n => trashedIds.includes(String(n.id)));
}, [inAppNotifications, trashedIds]);
// Mutation to mark notification as read
const markAsReadMutation = useMutation({
@@ -206,27 +201,61 @@ export function useInAppNotifications() {
// Toggle star
const toggleStar = useCallback((id: string | number) => {
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)
const deleteMessage = useCallback((id: string | number) => {
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
const restoreMessage = useCallback((id: string | number) => {
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
const permanentDelete = useCallback((id: string | number) => {
const strId = String(id);
setTrashedIds(prev => prev.filter(x => x !== strId));
setLocalSent(prev => prev.filter(n => String(n.id) !== strId));
}, []);
setTrashedIds(prev => {
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
const saveDraft = useCallback((draft: Omit<DraftMessage, 'id' | 'updatedAt'> & { id?: string }) => {
@@ -237,15 +266,31 @@ export function useInAppNotifications() {
updatedAt: new Date().toISOString(),
};
setDrafts(prev => {
const filtered = prev.filter(d => d.id !== draftId);
return [newDraft, ...filtered];
const current = readStorageList<DraftMessage>(draftsStorageKey, 'pfm_mailbox_drafts_default');
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;
}, []);
}, [draftsStorageKey, readStorageList]);
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)
const sendMessageMutation = useMutation({
@@ -288,7 +333,14 @@ export function useInAppNotifications() {
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 (payload.draftId) {
@@ -64,8 +64,13 @@ export function isHtmlContent(content: string): boolean {
trimmed.startsWith('<html') ||
trimmed.startsWith('<div') ||
trimmed.startsWith('<table') ||
trimmed.startsWith('<p>') ||
(trimmed.includes('</') && trimmed.includes('>'))
trimmed.startsWith('<p') ||
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,
options?: MarkdownRenderOptions
): 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) {
return contentHtml;
@@ -17,7 +17,6 @@ import {
RotateCcw,
X,
Smartphone,
Filter,
} from 'lucide-react';
import { useInAppNotifications, DraftMessage } from '../hooks/useInAppNotifications';
import { ComposeMessageModal } from '../components/notifications/ComposeMessageModal';
@@ -28,7 +27,6 @@ import { useAuth } from '../auth/AuthProvider';
import { stripMarkdown } from '../lib/markdownUtils';
type MailboxFolder = 'inbox' | 'starred' | 'sent' | 'drafts' | 'trash';
type PlatformFilter = 'all' | 'inapp' | 'email';
export const InboxPage: React.FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
@@ -60,8 +58,6 @@ export const InboxPage: React.FC = () => {
// Active folder
const [activeFolder, setActiveFolder] = useState<MailboxFolder>('inbox');
// Platform filter
const [platformFilter, setPlatformFilter] = useState<PlatformFilter>('all');
// Search query
const [searchTerm, setSearchTerm] = useState('');
// Filter for unread only in Inbox
@@ -82,11 +78,11 @@ export const InboxPage: React.FC = () => {
const [inlineReplyText, setInlineReplyText] = useState('');
const [inlineReplySuccess, setInlineReplySuccess] = useState(false);
// Active folder message items
// Active folder message items (always In-App notifications)
const folderMessages = useMemo(() => {
switch (activeFolder) {
case 'inbox':
return allMailboxNotifications;
return inboxNotifications;
case 'starred':
return starredNotifications;
case 'sent':
@@ -96,20 +92,13 @@ export const InboxPage: React.FC = () => {
case 'drafts':
return [];
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(() => {
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
if (activeFolder === 'inbox' && showUnreadOnly) {
if (Number(item.status) === NotificationStatus.Read) return false;
@@ -122,7 +111,7 @@ export const InboxPage: React.FC = () => {
const dest = (item.destination || '').toLowerCase();
return subject.includes(term) || message.includes(term) || dest.includes(term);
});
}, [folderMessages, platformFilter, activeFolder, showUnreadOnly, searchTerm]);
}, [folderMessages, activeFolder, showUnreadOnly, searchTerm]);
// Filtered drafts for Drafts folder
const filteredDrafts = useMemo(() => {
@@ -266,11 +255,11 @@ export const InboxPage: React.FC = () => {
<div className="flex items-center gap-2">
<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">
Markdown & Branded HTML Engine
In-App Messaging
</span>
</div>
<p className="text-[11px] text-slate-400">
In-App markdown messaging and executive branded email notifications
In-App notifications and internal platform communications
</p>
</div>
</div>
@@ -406,10 +395,14 @@ export const InboxPage: React.FC = () => {
<FileText className="w-4 h-4 text-purple-400" />
<span>Drafts</span>
</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">
{drafts.length}
</span>
) : (
<span className="text-[11px] text-slate-400 font-mono">
0
</span>
)}
</button>
@@ -436,57 +429,6 @@ export const InboxPage: React.FC = () => {
</button>
</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 */}
{activeFolder === 'inbox' && (
<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="flex items-center gap-1.5 text-slate-300 font-semibold">
<span className="w-2 h-2 rounded-full bg-emerald-400" />
<span>Format Standards</span>
<span>In-App Notifications</span>
</div>
<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>
</div>
</aside>
@@ -571,7 +513,7 @@ export const InboxPage: React.FC = () => {
>
<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">
Draft (Markdown)
Draft
</span>
<span className="text-[10px] text-slate-400 font-mono">
{formatDate(d.updatedAt)}
@@ -620,7 +562,6 @@ export const InboxPage: React.FC = () => {
const isSelected = selectedMessage?.id === item.id;
const isUnread = Number(item.status) !== NotificationStatus.Read;
const isStarred = starredIds.includes(String(item.id));
const isEmail = Number(item.platform) === NotificationPlatform.Email;
return (
<div
@@ -649,15 +590,9 @@ export const InboxPage: React.FC = () => {
</div>
<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">
InApp
</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">
In-App
</span>
<button
type="button"
@@ -707,7 +642,7 @@ export const InboxPage: React.FC = () => {
<FileText className="w-12 h-12 text-slate-600 mb-3" />
<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">
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>
<button
type="button"
@@ -725,7 +660,7 @@ export const InboxPage: React.FC = () => {
</div>
<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">
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>
<button
type="button"
@@ -815,17 +750,10 @@ export const InboxPage: React.FC = () => {
{selectedMessage.subject || 'In-App Notification'}
</h2>
<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">
<Smartphone className="w-3 h-3 text-blue-400" />
Channel: InApp (Markdown)
</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">
<Smartphone className="w-3 h-3 text-blue-400" />
Channel: In-App
</span>
<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}
@@ -852,7 +780,7 @@ export const InboxPage: React.FC = () => {
: selectedMessage.destination || 'PFM Automated Dispatcher'}
</p>
<span className="text-[10px] text-slate-400 font-mono">
({Number(selectedMessage.platform) === NotificationPlatform.Email ? 'SMTP Protocol' : 'InApp Protocol'})
(In-App Notification)
</span>
</div>
<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">
<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" />
<span>Quick Markdown Reply</span>
<span>Quick Reply</span>
</h4>
<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>
</div>
@@ -902,8 +830,8 @@ export const InboxPage: React.FC = () => {
rows={4}
value={inlineReplyText}
onChange={e => setInlineReplyText(e.target.value)}
placeholder="Type your markdown reply... (e.g. **Approved** milestone deliverable proof)"
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"
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 leading-relaxed"
/>
<div className="flex items-center justify-between">