Fixed partner message reader
This commit is contained in:
@@ -61,6 +61,8 @@ import {
|
||||
NotificationStatus,
|
||||
} from '../../types';
|
||||
import { formatZAR } from '../../lib/formatters';
|
||||
import { MessageViewer } from '../notifications/MessageViewer';
|
||||
import { stripMarkdown } from '../../lib/markdownUtils';
|
||||
|
||||
interface EnrichedPartnerProject {
|
||||
project: Project;
|
||||
@@ -1321,7 +1323,7 @@ export function DashboardPage() {
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 truncate mt-1">
|
||||
{msg.message}
|
||||
{stripMarkdown(msg.message)}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
@@ -1393,7 +1395,7 @@ export function DashboardPage() {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-slate-300 mt-1 line-clamp-2">{msg.message}</p>
|
||||
<p className="text-xs text-slate-300 mt-1 line-clamp-2">{stripMarkdown(msg.message)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-slate-500 shrink-0">
|
||||
@@ -2120,11 +2122,11 @@ export function DashboardPage() {
|
||||
MODAL 3: MESSAGE READER (Clicking an Inbox item)
|
||||
===================================================================== */}
|
||||
{selectedNotification && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/80 backdrop-blur-md p-4">
|
||||
<div className="w-full max-w-lg rounded-2xl bg-[#0d1322] border border-white/10 shadow-2xl overflow-hidden">
|
||||
<div className="p-5 border-b border-white/[0.08] flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-white">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/80 backdrop-blur-md p-4 animate-in fade-in duration-200">
|
||||
<div className="w-full max-w-2xl rounded-2xl bg-[#0d1322] border border-white/10 shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
|
||||
<div className="p-5 border-b border-white/[0.08] flex items-center justify-between bg-[#0b101d] shrink-0">
|
||||
<div className="min-w-0 pr-4">
|
||||
<h3 className="text-base font-bold text-white truncate">
|
||||
{selectedNotification.subject || 'Message'}
|
||||
</h3>
|
||||
<p className="text-[11px] text-slate-400 mt-0.5">
|
||||
@@ -2134,20 +2136,22 @@ export function DashboardPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedNotification(null)}
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:text-white cursor-pointer"
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:text-white cursor-pointer hover:bg-white/5 transition shrink-0"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-5 space-y-5">
|
||||
<p className="text-xs sm:text-sm text-slate-200 leading-relaxed whitespace-pre-wrap">
|
||||
{selectedNotification.message}
|
||||
</p>
|
||||
<div className="flex items-center justify-end gap-3 pt-3 border-t border-white/[0.08]">
|
||||
<div className="p-5 space-y-4 overflow-y-auto flex-1">
|
||||
<MessageViewer
|
||||
content={selectedNotification.message || ''}
|
||||
platform={selectedNotification.platform}
|
||||
subject={selectedNotification.subject}
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-3 pt-3 border-t border-white/[0.08] shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedNotification(null)}
|
||||
className="px-4 py-2 rounded-xl bg-slate-800 text-slate-300 text-xs font-semibold cursor-pointer"
|
||||
className="px-4 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs font-semibold cursor-pointer transition"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
@@ -2163,7 +2167,7 @@ export function DashboardPage() {
|
||||
setMessageSubject(subj.startsWith('Re:') ? subj : `Re: ${subj}`);
|
||||
setMessageBody('');
|
||||
}}
|
||||
className="px-4 py-2 rounded-xl bg-emerald-500 hover:bg-emerald-400 text-slate-950 font-semibold text-xs flex items-center gap-1.5 cursor-pointer"
|
||||
className="px-4 py-2 rounded-xl bg-emerald-500 hover:bg-emerald-400 text-slate-950 font-semibold text-xs flex items-center gap-1.5 cursor-pointer transition shadow-lg shadow-emerald-500/20"
|
||||
>
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
<span>Reply</span>
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import {
|
||||
Copy,
|
||||
Check,
|
||||
Mail,
|
||||
MessageSquare,
|
||||
Code2,
|
||||
Eye,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
import { NotificationPlatform } from '../../types';
|
||||
import { parseMarkdownToHtml, isHtmlContent, stripMarkdown } from '../../lib/markdownUtils';
|
||||
|
||||
export interface MessageViewerProps {
|
||||
content: string;
|
||||
platform?: NotificationPlatform | number | string;
|
||||
subject?: string;
|
||||
className?: string;
|
||||
showFormatHeader?: boolean;
|
||||
}
|
||||
|
||||
export const MessageViewer: React.FC<MessageViewerProps> = ({
|
||||
content,
|
||||
platform,
|
||||
subject,
|
||||
className = '',
|
||||
showFormatHeader = true,
|
||||
}) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [showRaw, setShowRaw] = useState(false);
|
||||
|
||||
// Determine whether this message represents an Email (HTML) or In-App (Markup)
|
||||
const isEmail = useMemo(() => {
|
||||
if (
|
||||
platform === NotificationPlatform.Email ||
|
||||
platform === 1 ||
|
||||
platform === '1' ||
|
||||
String(platform).toLowerCase() === 'email'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// If platform is not explicitly InApp, detect if content is structured HTML
|
||||
if (
|
||||
platform !== NotificationPlatform.InApp &&
|
||||
platform !== 5 &&
|
||||
platform !== '5' &&
|
||||
String(platform).toLowerCase() !== 'inapp' &&
|
||||
isHtmlContent(content)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, [platform, content]);
|
||||
|
||||
const isInApp = useMemo(() => {
|
||||
if (
|
||||
platform === NotificationPlatform.InApp ||
|
||||
platform === 5 ||
|
||||
platform === '5' ||
|
||||
String(platform).toLowerCase() === 'inapp'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return !isEmail;
|
||||
}, [platform, isEmail]);
|
||||
|
||||
// Convert the message to its respective format:
|
||||
// - Email: HTML rendered
|
||||
// - In-App: Markup (Markdown) parsed to HTML
|
||||
const renderedHtml = useMemo(() => {
|
||||
if (!content) return '<p class="text-slate-400 italic">No message content</p>';
|
||||
|
||||
// Format 1: Email => HTML
|
||||
if (isEmail) {
|
||||
if (isHtmlContent(content)) {
|
||||
return content.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
|
||||
}
|
||||
return parseMarkdownToHtml(content);
|
||||
}
|
||||
|
||||
// Format 2: In-App => Markup (Markdown)
|
||||
return parseMarkdownToHtml(content);
|
||||
}, [content, isEmail]);
|
||||
|
||||
const handleCopy = () => {
|
||||
const textToCopy = showRaw ? content : stripMarkdown(content) || content;
|
||||
navigator.clipboard.writeText(textToCopy);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col rounded-2xl bg-slate-900/50 border border-white/10 overflow-hidden ${className}`}>
|
||||
{/* Format Header & Controls Bar */}
|
||||
{showFormatHeader && (
|
||||
<div className="px-4 py-2.5 border-b border-white/[0.08] bg-[#0b101d]/90 flex items-center justify-between text-xs text-slate-400 gap-3">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{isEmail ? (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-cyan-500/10 border border-cyan-500/20 text-cyan-300 font-semibold text-[11px]">
|
||||
<Mail className="w-3.5 h-3.5 text-cyan-400" />
|
||||
<span>Email (HTML)</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-emerald-500/10 border border-emerald-500/20 text-emerald-300 font-semibold text-[11px]">
|
||||
<MessageSquare className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>In-App (Markup)</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="text-[11px] text-slate-400 hidden sm:inline">
|
||||
{isEmail ? 'Rendered as HTML Email' : 'Converted from Markup to Formatted View'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* View Mode Toggle: Formatted vs Raw */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowRaw(!showRaw)}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-lg bg-slate-800/80 hover:bg-slate-700 text-slate-300 hover:text-white text-[11px] font-medium transition cursor-pointer border border-white/10"
|
||||
title={showRaw ? 'Switch to Formatted view' : 'View Raw message content'}
|
||||
>
|
||||
{showRaw ? (
|
||||
<>
|
||||
<Eye className="w-3 h-3 text-emerald-400" />
|
||||
<span>Formatted</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Code2 className="w-3 h-3" />
|
||||
<span>Raw</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Copy Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-lg bg-slate-800/80 hover:bg-slate-700 text-slate-300 hover:text-white text-[11px] font-medium transition cursor-pointer border border-white/10"
|
||||
title="Copy message content"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="w-3 h-3 text-emerald-400" />
|
||||
<span className="text-emerald-300">Copied</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-3 h-3" />
|
||||
<span>Copy</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message Body Content */}
|
||||
<div className="p-5 sm:p-6 overflow-x-auto max-h-[55vh] overflow-y-auto text-sm leading-relaxed text-slate-200">
|
||||
{showRaw ? (
|
||||
<pre className="font-mono text-xs text-slate-300 whitespace-pre-wrap break-words bg-[#080d18] p-4 rounded-xl border border-white/5">
|
||||
{content}
|
||||
</pre>
|
||||
) : (
|
||||
<div
|
||||
className="prose prose-invert max-w-none text-xs sm:text-sm leading-relaxed text-slate-200 [&_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-emerald-400 [&_a:hover]:underline [&_ul]:list-disc [&_ul]:pl-5 [&_ul]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_ol]:my-2 [&_li]:text-slate-300 [&_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-emerald-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-emerald-300"
|
||||
dangerouslySetInnerHTML={{ __html: renderedHtml }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer Info Stamp */}
|
||||
<div className="px-5 py-2.5 border-t border-white/[0.08] bg-[#090e1a] flex items-center justify-between text-[11px] text-slate-400">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ShieldCheck className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>PFM Communications • {isEmail ? 'Email HTML Format' : 'In-App Markup Format'}</span>
|
||||
</div>
|
||||
<span className="font-mono text-[10px] text-slate-400">
|
||||
{stripMarkdown(content).length} characters
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -62,11 +62,62 @@ export function parseMarkdownToHtml(markdown: string): string {
|
||||
return `<code style="background:#1e293b;color:#34d399;padding:2px 6px;border-radius:6px;font-family:monospace;font-size:12px;">${escaped}</code>`;
|
||||
});
|
||||
|
||||
// Tables (| A | B |\n|---|---|\n| C | D |)
|
||||
html = html.replace(/((?:\|[^\n]+\|\n?)+)/g, (match) => {
|
||||
const rows = match.trim().split('\n');
|
||||
if (rows.length < 2) return match;
|
||||
|
||||
const hasSeparator = rows[1] && rows[1].includes('---');
|
||||
if (!hasSeparator) return match;
|
||||
|
||||
const parseCells = (row: string) =>
|
||||
row
|
||||
.split('|')
|
||||
.slice(1, -1)
|
||||
.map((c) => c.trim());
|
||||
|
||||
const headers = parseCells(rows[0]);
|
||||
const bodyRows = rows.slice(2);
|
||||
|
||||
let tableHtml = `<div style="overflow-x:auto;margin:14px 0;"><table style="width:100%;border-collapse:collapse;border:1px solid #334155;border-radius:8px;font-size:12px;text-align:left;">`;
|
||||
tableHtml += `<thead><tr style="background:#1e293b;color:#f8fafc;">`;
|
||||
headers.forEach((h) => {
|
||||
tableHtml += `<th style="padding:8px 12px;border:1px solid #334155;font-weight:600;">${h}</th>`;
|
||||
});
|
||||
tableHtml += `</tr></thead><tbody>`;
|
||||
|
||||
bodyRows.forEach((row, i) => {
|
||||
const cells = parseCells(row);
|
||||
const bg = i % 2 === 0 ? '#0f172a' : '#1e293b/40';
|
||||
tableHtml += `<tr style="background:${bg};color:#cbd5e1;">`;
|
||||
cells.forEach((c) => {
|
||||
tableHtml += `<td style="padding:8px 12px;border:1px solid #334155;">${c}</td>`;
|
||||
});
|
||||
tableHtml += `</tr>`;
|
||||
});
|
||||
|
||||
tableHtml += `</tbody></table></div>`;
|
||||
return tableHtml;
|
||||
});
|
||||
|
||||
html = html.replace(/^#### (.*?)$/gm, '<h4 style="font-size:14px;font-weight:700;color:#f8fafc;margin:12px 0 6px 0;">$1</h4>');
|
||||
html = html.replace(/^### (.*?)$/gm, '<h3 style="font-size:16px;font-weight:700;color:#f8fafc;margin:14px 0 8px 0;">$1</h3>');
|
||||
html = html.replace(/^## (.*?)$/gm, '<h2 style="font-size:18px;font-weight:800;color:#ffffff;margin:16px 0 8px 0;border-bottom:1px solid #1e293b;padding-bottom:6px;">$1</h2>');
|
||||
html = html.replace(/^# (.*?)$/gm, '<h1 style="font-size:22px;font-weight:800;color:#ffffff;margin:18px 0 10px 0;border-bottom:1px solid #334155;padding-bottom:8px;">$1</h1>');
|
||||
|
||||
// Blockquotes and GitHub alerts
|
||||
html = html.replace(/^>\s*\[!NOTE\]\s*(.*?)$/gim, '<div style="background:#0c4a6e22;border-left:4px solid #0284c7;padding:10px 14px;border-radius:8px;margin:10px 0;color:#bae6fd;font-size:12px;"><strong>NOTE:</strong> $1</div>');
|
||||
html = html.replace(/^>\s*\[!IMPORTANT\]\s*(.*?)$/gim, '<div style="background:#83184322;border-left:4px solid #db2777;padding:10px 14px;border-radius:8px;margin:10px 0;color:#fbcfe8;font-size:12px;"><strong>IMPORTANT:</strong> $1</div>');
|
||||
html = html.replace(/^>\s*\[!WARNING\]\s*(.*?)$/gim, '<div style="background:#713f1222;border-left:4px solid #d97706;padding:10px 14px;border-radius:8px;margin:10px 0;color:#fef08a;font-size:12px;"><strong>WARNING:</strong> $1</div>');
|
||||
html = html.replace(/^>\s*\[!TIP\]\s*(.*?)$/gim, '<div style="background:#064e3b22;border-left:4px solid #059669;padding:10px 14px;border-radius:8px;margin:10px 0;color:#a7f3d0;font-size:12px;"><strong>TIP:</strong> $1</div>');
|
||||
html = html.replace(/^>\s*(.*?)$/gm, '<blockquote style="border-left:3px solid #10b981;padding-left:12px;margin:8px 0;color:#94a3b8;font-style:italic;">$1</blockquote>');
|
||||
|
||||
// Horizontal Rules (---)
|
||||
html = html.replace(/^(?:---|\*\*\*|___)\s*$/gm, '<hr style="border:none;border-top:1px solid #334155;margin:16px 0;" />');
|
||||
|
||||
// Strikethrough
|
||||
html = html.replace(/~~(.*?)~~/g, '<del style="color:#64748b;">$1</del>');
|
||||
|
||||
html = html.replace(/\*\*\*(.*?)\*\*\*/g, '<strong><em>$1</em></strong>');
|
||||
html = html.replace(/\*\*(.*?)\*\*/g, '<strong style="color:#ffffff;">$1</strong>');
|
||||
html = html.replace(/__(.*?)__/g, '<strong style="color:#ffffff;">$1</strong>');
|
||||
|
||||
Reference in New Issue
Block a user