This commit includes the temporary Next.js build cache files, public brand logos, and project configuration added in this change.
772 lines
28 KiB
TypeScript
772 lines
28 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* QuoteEmailCard — shows the original email (subject, sender, received_at,
|
|
* message body) and the list of attachments for a quote that was created
|
|
* from an inbound email. Renders nothing for manually-created quotes (no
|
|
* email data) so the page stays clean.
|
|
*
|
|
* The quoter uses this card to verify the AI-extracted line items against
|
|
* what the original email actually said.
|
|
*
|
|
* Body handling: the workflow may store either HTML (raw `html` from IMAP,
|
|
* or the cleaner `textAsHtml`) or plain text. We auto-detect, sanitize any
|
|
* HTML before injecting it via `dangerouslySetInnerHTML`, and fall back to
|
|
* paragraph-split plain text rendering for everything else.
|
|
*/
|
|
import { useEffect, useState } from "react";
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/components/ui/card";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Mail,
|
|
Calendar,
|
|
FileText,
|
|
FileImage,
|
|
FileSpreadsheet,
|
|
File as FileIcon,
|
|
Download,
|
|
Paperclip,
|
|
Inbox,
|
|
ExternalLink,
|
|
Truck,
|
|
X,
|
|
type LucideIcon,
|
|
} from "lucide-react";
|
|
import getPocketBase from "@/lib/pocketbase";
|
|
import type { Quote, QuoteAttachment, QuoteAttachmentKind } from "@/lib/types";
|
|
|
|
interface QuoteEmailCardProps {
|
|
quote: Quote;
|
|
attachments: QuoteAttachment[];
|
|
}
|
|
|
|
function formatBytes(bytes: number): string {
|
|
if (!bytes || bytes < 0) return "0 B";
|
|
if (bytes < 1024) return `${bytes} B`;
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
}
|
|
|
|
function formatReceivedAt(iso: string): string {
|
|
if (!iso) return "—";
|
|
try {
|
|
const d = new Date(iso);
|
|
if (Number.isNaN(d.getTime())) return iso;
|
|
return d.toLocaleString("en-ZA", {
|
|
year: "numeric",
|
|
month: "short",
|
|
day: "numeric",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
});
|
|
} catch {
|
|
return iso;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve `kind` to one of our four known kinds. Older attachments (uploaded
|
|
* before the v7 multi-attachment workflow wrote `kind`) come back as null /
|
|
* undefined — we sniff `content_type` / `filename` as a fallback so the UI
|
|
* still picks a sensible icon, then surface the resolved value via `kindLabel`.
|
|
*/
|
|
function resolveKind(att: QuoteAttachment): QuoteAttachmentKind {
|
|
const declared = (att.kind || "").toString().trim().toLowerCase();
|
|
if (declared === "pdf" || declared === "image" || declared === "doc" || declared === "other") {
|
|
return declared;
|
|
}
|
|
const ct = (att.content_type || "").toLowerCase();
|
|
const fn = (att.filename || "").toLowerCase();
|
|
if (ct === "application/pdf" || fn.endsWith(".pdf")) return "pdf";
|
|
if (ct.startsWith("image/") || /\.(png|jpe?g|gif|webp|bmp|svg)$/.test(fn)) return "image";
|
|
if (
|
|
ct.includes("officedocument") ||
|
|
ct.includes("msword") ||
|
|
ct.includes("ms-excel") ||
|
|
/\.(docx?|xlsx?|csv|ods|rtf)$/.test(fn)
|
|
) {
|
|
return "doc";
|
|
}
|
|
return "other";
|
|
}
|
|
|
|
// Stable lookup table — used in places where calling a function during
|
|
// render would trip `react-hooks/static-components`. The returned value
|
|
// is one of the module-level LucideIcon imports above, so reference
|
|
// identity is stable across renders for a given kind.
|
|
const ATTACHMENT_ICONS: Record<QuoteAttachmentKind, LucideIcon> = {
|
|
image: FileImage,
|
|
pdf: FileText,
|
|
doc: FileSpreadsheet,
|
|
other: FileIcon,
|
|
// "delivery_note" uses the Truck icon — a small visual cue
|
|
// that this is the signed delivery note (a physical document)
|
|
// vs the quote's own PDF. Mirrors the Truck icon used on
|
|
// the Delivery Note tabs throughout the app.
|
|
delivery_note: Truck,
|
|
};
|
|
|
|
/**
|
|
* Build a download URL for a quote_attachments row. PocketBase serves
|
|
* files at `/api/files/{collection}/{recordId}/{filename}` and accepts the
|
|
* user's auth token as a `?token=` query param. The auth provider stores
|
|
* the token in localStorage, so a plain <a href> won't carry the cookie
|
|
* — we embed the token explicitly.
|
|
*/
|
|
function attachmentUrl(attachment: QuoteAttachment): string {
|
|
const pb = getPocketBase();
|
|
const token = pb.authStore.token;
|
|
// `attachment.file` is an array of PB file tokens (the first element
|
|
// is the actual stored filename). Pass it through as-is — the JS SDK
|
|
// accepts both string and string[].
|
|
const base = pb.files.getURL(attachment, attachment.file as unknown as string, {
|
|
query: token ? { token } : undefined,
|
|
});
|
|
const baseUrl =
|
|
process.env.NEXT_PUBLIC_POCKETBASE_URL || "http://127.0.0.1:8090";
|
|
if (base.startsWith("http")) return base;
|
|
return `${baseUrl.replace(/\/$/, "")}${base}`;
|
|
}
|
|
|
|
function senderName(sender: string): string {
|
|
if (!sender) return "(unknown sender)";
|
|
const local = sender.split("@")[0];
|
|
return local || sender;
|
|
}
|
|
|
|
function senderInitials(sender: string): string {
|
|
const name = senderName(sender);
|
|
const cleaned = name.replace(/[._-]+/g, " ").trim();
|
|
const parts = cleaned.split(/\s+/).filter(Boolean);
|
|
if (parts.length === 0) return "?";
|
|
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
|
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
|
}
|
|
|
|
/**
|
|
* Heuristic: does this string look like HTML rather than plain text?
|
|
* We only need to catch the common cases — quoted `>` lines and the
|
|
* IMAP node's `html` / `textAsHtml` fields. If the first non-whitespace
|
|
* char is `<` OR there are HTML tags anywhere in the first 2KB, it's HTML.
|
|
*/
|
|
function looksLikeHtml(s: string): boolean {
|
|
if (!s) return false;
|
|
const head = s.slice(0, 2048).trimStart();
|
|
if (head.startsWith("<!DOCTYPE") || head.startsWith("<html")) return true;
|
|
// If the very first char is `<`, it's almost certainly HTML. We accept
|
|
// a one-character false positive over a wall of literal `<div>` tags.
|
|
if (head.startsWith("<")) return true;
|
|
return /<\/?(p|div|br|ul|ol|li|table|tr|td|h[1-6]|a\s|span|strong|em|b|i|img)\b/i.test(head);
|
|
}
|
|
|
|
/**
|
|
* Minimal HTML sanitizer for inbound email bodies. We allow common safe
|
|
* tags + a small set of safe attributes, strip everything else, and
|
|
* neutralise event handlers and javascript: URLs.
|
|
*
|
|
* This is NOT a general-purpose sanitizer (use DOMPurify for that). It is
|
|
* scoped to the tags that actually appear in legitimate RFQ emails:
|
|
* structural, list, link, image, basic formatting.
|
|
*/
|
|
const ALLOWED_TAGS = new Set([
|
|
"a", "b", "blockquote", "br", "code", "div", "em", "h1", "h2", "h3",
|
|
"h4", "h5", "h6", "hr", "i", "img", "li", "ol", "p", "pre", "s",
|
|
"small", "span", "strong", "sub", "sup", "table", "tbody", "td",
|
|
"th", "thead", "tr", "u", "ul",
|
|
]);
|
|
const ALLOWED_ATTRS: Record<string, Set<string>> = {
|
|
a: new Set(["href", "title"]),
|
|
img: new Set(["src", "alt", "width", "height"]),
|
|
th: new Set(["colspan", "rowspan", "scope"]),
|
|
td: new Set(["colspan", "rowspan"]),
|
|
table: new Set(["border", "cellpadding", "cellspacing"]),
|
|
};
|
|
|
|
function sanitizeHtml(input: string): string {
|
|
if (typeof window === "undefined" || !input) return "";
|
|
// Strip the most dangerous blocks first so a stray open tag can't leak
|
|
// content past the regex scrub.
|
|
let html = input
|
|
.replace(/<script\b[\s\S]*?<\/script\s*>/gi, "")
|
|
.replace(/<style\b[\s\S]*?<\/style\s*>/gi, "")
|
|
.replace(/<iframe\b[\s\S]*?<\/iframe\s*>/gi, "")
|
|
.replace(/<object\b[\s\S]*?<\/object\s*>/gi, "")
|
|
.replace(/<embed\b[^>]*>/gi, "")
|
|
.replace(/<form\b[\s\S]*?<\/form\s*>/gi, "")
|
|
.replace(/<base\b[^>]*>/gi, "");
|
|
|
|
// Now strip dangerous attributes and bad URL schemes from surviving tags.
|
|
html = html.replace(/<(\/?)([a-zA-Z][a-zA-Z0-9]*)(\s[^>]*?)?(\/?)>/g, (_match, slash, tagRaw, attrs) => {
|
|
const tag = (tagRaw as string).toLowerCase();
|
|
if (!ALLOWED_TAGS.has(tag)) return "";
|
|
if (slash) return `</${tag}>`;
|
|
if (!attrs) return `<${tag}>`;
|
|
const allowed = ALLOWED_ATTRS[tag] || new Set<string>();
|
|
// Parse just the attributes we allow.
|
|
const attrPairs: string[] = [];
|
|
const attrRe = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/g;
|
|
let m: RegExpExecArray | null;
|
|
while ((m = attrRe.exec(attrs)) !== null) {
|
|
const name = m[1].toLowerCase();
|
|
const value = m[2] ?? m[3] ?? m[4] ?? "";
|
|
if (!allowed.has(name)) continue;
|
|
// Block event handlers (defence in depth — `on*` aren't in allowed).
|
|
if (name.startsWith("on")) continue;
|
|
// Block javascript: in href / src.
|
|
if ((name === "href" || name === "src") && /^\s*javascript:/i.test(value)) continue;
|
|
// Force external links to open safely.
|
|
const safeValue = value
|
|
.replace(/&/g, "&")
|
|
.replace(/"/g, """)
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">");
|
|
attrPairs.push(`${name}="${safeValue}"`);
|
|
}
|
|
return `<${tag}${attrPairs.length ? " " + attrPairs.join(" ") : ""}>`;
|
|
});
|
|
|
|
return html;
|
|
}
|
|
|
|
/**
|
|
* Auto-linkify bare URLs in plain-text bodies (http/https/mailto). Email
|
|
* clients do this by default; if we don't, "see https://example.com" stays
|
|
* unclickable and the quoter has to copy-paste.
|
|
*/
|
|
function linkifyText(text: string): string {
|
|
const escaped = text
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">");
|
|
return escaped.replace(
|
|
/(\b(?:https?:\/\/|mailto:)[^\s<]+)/gi,
|
|
(url) => `<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Split a plain-text body into paragraphs (blank-line separated) and
|
|
* collect `> ...` lines into a separate quoted block. Returns ready-to-
|
|
* render HTML: paragraphs wrapped in <p>, the quote in a left-bordered
|
|
* block. Preserves single line breaks within a paragraph via <br>.
|
|
*/
|
|
function renderPlainTextAsHtml(text: string): string {
|
|
if (!text) return "";
|
|
const lines = text.split(/\r?\n/);
|
|
const out: string[] = [];
|
|
let paragraph: string[] = [];
|
|
let quote: string[] = [];
|
|
|
|
const flushParagraph = () => {
|
|
if (paragraph.length === 0) return;
|
|
const joined = paragraph
|
|
.map((l) => linkifyText(l))
|
|
.join("<br />");
|
|
out.push(`<p>${joined}</p>`);
|
|
paragraph = [];
|
|
};
|
|
const flushQuote = () => {
|
|
if (quote.length === 0) return;
|
|
const joined = quote
|
|
.map((l) => linkifyText(l))
|
|
.join("<br />");
|
|
out.push(`<blockquote>${joined}</blockquote>`);
|
|
quote = [];
|
|
};
|
|
|
|
for (const line of lines) {
|
|
if (/^\s*>+ ?/.test(line)) {
|
|
flushParagraph();
|
|
quote.push(line.replace(/^\s*>+ ?/, ""));
|
|
continue;
|
|
}
|
|
if (line.trim() === "") {
|
|
flushParagraph();
|
|
flushQuote();
|
|
continue;
|
|
}
|
|
flushQuote();
|
|
paragraph.push(line);
|
|
}
|
|
flushParagraph();
|
|
flushQuote();
|
|
return out.join("");
|
|
}
|
|
|
|
function renderBody(raw: string): { __html: string } {
|
|
if (!raw) return { __html: "" };
|
|
if (looksLikeHtml(raw)) {
|
|
return { __html: sanitizeHtml(raw) };
|
|
}
|
|
return { __html: renderPlainTextAsHtml(raw) };
|
|
}
|
|
|
|
export default function QuoteEmailCard({
|
|
quote,
|
|
attachments,
|
|
}: QuoteEmailCardProps) {
|
|
const hasBody = !!(quote.email_body && quote.email_body.trim());
|
|
const hasAttachments = attachments.length > 0;
|
|
|
|
// Hide the card entirely when there is no email data — manually-created
|
|
// quotes never have email data and shouldn't see an empty card.
|
|
if (!hasBody && !hasAttachments) return null;
|
|
|
|
const senderDisplay = quote.email_sender || "(unknown sender)";
|
|
// `email_received_at` is written by the v7 workflow from the IMAP date.
|
|
// Older quotes (and manually-created ones) don't have it — fall back to
|
|
// the quote's created timestamp so the field is never blank when we
|
|
// have any email data to show.
|
|
const receivedAt =
|
|
quote.email_received_at ||
|
|
quote.last_reply_at ||
|
|
quote.created ||
|
|
"";
|
|
const bodyHtml = renderBody(quote.email_body || "").__html;
|
|
|
|
return (
|
|
<Card data-size="default">
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Mail className="h-4 w-4" />
|
|
Original Email
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{/* Email-header strip — From / Received / Subject */}
|
|
<div className="rounded-md border bg-muted/30 p-3 space-y-2">
|
|
<div className="flex items-start gap-3">
|
|
<div
|
|
className="h-9 w-9 shrink-0 rounded-full bg-primary/10 text-primary flex items-center justify-center text-sm font-semibold"
|
|
aria-hidden="true"
|
|
>
|
|
{senderInitials(senderDisplay)}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex flex-wrap items-baseline gap-x-2">
|
|
<span className="font-semibold text-sm truncate" data-testid="email-sender">
|
|
{senderName(senderDisplay)}
|
|
</span>
|
|
<span className="text-xs text-muted-foreground truncate">
|
|
<{senderDisplay}>
|
|
</span>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-muted-foreground mt-0.5">
|
|
<span className="inline-flex items-center gap-1">
|
|
<Calendar className="h-3 w-3" />
|
|
<span data-testid="email-received-at">
|
|
{formatReceivedAt(receivedAt)}
|
|
</span>
|
|
</span>
|
|
{quote.email_message_id && (
|
|
<span
|
|
className="inline-flex items-center gap-1 truncate max-w-[280px]"
|
|
title={quote.email_message_id}
|
|
>
|
|
<Inbox className="h-3 w-3" />
|
|
<span className="truncate">{quote.email_message_id}</span>
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{quote.email_subject && (
|
|
<div className="pt-2 border-t border-border/60">
|
|
<div className="text-xs text-muted-foreground">Subject</div>
|
|
<div
|
|
className="text-sm font-medium break-words"
|
|
data-testid="email-subject"
|
|
>
|
|
{quote.email_subject}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Email body — rendered as HTML. The workflow may store either
|
|
the raw multipart HTML (with inline styles) or the IMAP node's
|
|
`textAsHtml` conversion (clean <p>/<ul> tags); the sanitizer
|
|
strips dangerous tags and event handlers before injection. */}
|
|
{hasBody && (
|
|
<div>
|
|
<div className="text-xs text-muted-foreground mb-1">Message</div>
|
|
<div
|
|
className="rounded-md border bg-background p-4 max-h-[28rem] overflow-y-auto text-sm leading-relaxed
|
|
[&_p]:my-2 [&_p:first-child]:mt-0 [&_p:last-child]:mb-0
|
|
[&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-6
|
|
[&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-6
|
|
[&_li]:my-0.5
|
|
[&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2 [&_a:hover]:no-underline
|
|
[&_img]:max-w-full [&_img]:h-auto [&_img]:rounded
|
|
[&_blockquote]:my-3 [&_blockquote]:pl-3 [&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:text-muted-foreground [&_blockquote]:not-italic
|
|
[&_h1]:text-base [&_h1]:font-semibold [&_h1]:mt-3 [&_h1]:mb-1
|
|
[&_h2]:text-sm [&_h2]:font-semibold [&_h2]:mt-3 [&_h2]:mb-1
|
|
[&_h3]:text-sm [&_h3]:font-semibold [&_h3]:mt-2 [&_h3]:mb-1
|
|
[&_table]:w-full [&_table]:border-collapse [&_table]:my-2
|
|
[&_th]:border [&_th]:border-border [&_th]:px-2 [&_th]:py-1 [&_th]:bg-muted/40 [&_th]:text-left [&_th]:font-medium
|
|
[&_td]:border [&_td]:border-border [&_td]:px-2 [&_td]:py-1
|
|
[&_hr]:my-3 [&_hr]:border-border
|
|
[&_pre]:bg-muted [&_pre]:p-2 [&_pre]:rounded [&_pre]:overflow-x-auto [&_pre]:text-xs"
|
|
data-testid="email-body"
|
|
dangerouslySetInnerHTML={{ __html: bodyHtml }}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Attachments — clickable rows that open an inline preview dialog. */}
|
|
{hasAttachments && (
|
|
<div>
|
|
<div className="text-xs text-muted-foreground mb-2 flex items-center gap-1">
|
|
<Paperclip className="h-3 w-3" />
|
|
Attachments ({attachments.length})
|
|
</div>
|
|
<ul className="space-y-1.5" data-testid="email-attachments">
|
|
{attachments.map((a) => (
|
|
<AttachmentRow key={a.id} attachment={a} />
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Attachment row + preview dialog
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Fetches the attachment file with the PB auth token in the Authorization
|
|
* header and returns a same-origin blob: URL the browser will happily feed
|
|
* into an `<iframe>` / `<img>`.
|
|
*
|
|
* Why blob URLs and not the PB file URL directly?
|
|
*
|
|
* The PB SDK gives us a URL of the form
|
|
* `{PB_BASE}/api/files/{collection}/{recordId}/{filename}?token=...`
|
|
* The browser's built-in PDF viewer (and `<iframe>`-embedded resources in
|
|
* general) does NOT forward the `?token=` query string to PB the way an
|
|
* `<a href download>` does, so the request comes back 401 and the iframe
|
|
* renders a black "loading…" page that never resolves. Fetching with the
|
|
* `Authorization` header in JS gets around that, and `URL.createObjectURL`
|
|
* hands us a blob: URL the iframe can render as same-origin content.
|
|
*
|
|
* The blob URL is revoked when the dialog closes (or the component
|
|
* unmounts) to avoid leaking memory.
|
|
*/
|
|
function useAttachmentBlob(attachment: QuoteAttachment, active: boolean): {
|
|
src: string | null;
|
|
loading: boolean;
|
|
error: string | null;
|
|
} {
|
|
const [src, setSrc] = useState<string | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!active) return;
|
|
const pb = getPocketBase();
|
|
const token = pb.authStore.token;
|
|
const fileUrl = attachmentUrl(attachment);
|
|
|
|
let cancelled = false;
|
|
let createdUrl: string | null = null;
|
|
|
|
// We deliberately drive `loading`/`error` from this effect because
|
|
// the fetch starts here — there's no parent that already knows
|
|
// whether the blob is ready, so the state has to originate here.
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
fetch(fileUrl, {
|
|
headers: token ? { Authorization: token } : undefined,
|
|
})
|
|
.then((res) => {
|
|
if (!res.ok) {
|
|
throw new Error(`HTTP ${res.status} ${res.statusText}`);
|
|
}
|
|
return res.blob();
|
|
})
|
|
.then((blob) => {
|
|
if (cancelled) {
|
|
// Component unmounted (or `active` flipped false) before we
|
|
// resolved — drop the blob without ever making a URL for it.
|
|
return;
|
|
}
|
|
const contentType = blob.type || attachment.content_type || "application/octet-stream";
|
|
const typed = blob.type ? blob : blob.slice(0, blob.size, contentType);
|
|
createdUrl = URL.createObjectURL(typed);
|
|
setSrc(createdUrl);
|
|
})
|
|
.catch((e) => {
|
|
if (cancelled) return;
|
|
setError(e instanceof Error ? e.message : "Failed to load preview");
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) setLoading(false);
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
if (createdUrl) URL.revokeObjectURL(createdUrl);
|
|
setSrc((current) => {
|
|
if (current) URL.revokeObjectURL(current);
|
|
return null;
|
|
});
|
|
};
|
|
// We intentionally exclude `attachment` from deps — the dialog is
|
|
// mounted with a single attachment for its lifetime, and re-running
|
|
// this effect would refetch on every parent re-render.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [active]);
|
|
|
|
return { src, loading, error };
|
|
}
|
|
|
|
function AttachmentRow({ attachment }: { attachment: QuoteAttachment }) {
|
|
const [open, setOpen] = useState(false);
|
|
const kind = resolveKind(attachment);
|
|
const Icon = ATTACHMENT_ICONS[kind];
|
|
const url = attachmentUrl(attachment);
|
|
|
|
return (
|
|
<>
|
|
<li>
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen(true)}
|
|
className="w-full flex items-center gap-3 px-3 py-2 rounded-md border bg-card hover:bg-accent/30 transition-colors text-left cursor-pointer"
|
|
data-testid="attachment-row"
|
|
aria-label={`Preview ${attachment.filename}`}
|
|
>
|
|
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-sm font-medium truncate">
|
|
{attachment.filename}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
{kind.toUpperCase()} · {formatBytes(attachment.size_bytes)}
|
|
{attachment.content_type ? ` · ${attachment.content_type}` : ""}
|
|
</div>
|
|
</div>
|
|
<a
|
|
href={url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
download={attachment.filename}
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline shrink-0"
|
|
data-testid="attachment-download"
|
|
>
|
|
<Download className="h-3 w-3" />
|
|
Download
|
|
</a>
|
|
</button>
|
|
</li>
|
|
|
|
<AttachmentPreviewDialog
|
|
attachment={attachment}
|
|
kind={kind}
|
|
url={url}
|
|
open={open}
|
|
onOpenChange={setOpen}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function AttachmentPreviewDialog({
|
|
attachment,
|
|
kind,
|
|
url,
|
|
open,
|
|
onOpenChange,
|
|
}: {
|
|
attachment: QuoteAttachment;
|
|
kind: QuoteAttachmentKind;
|
|
url: string;
|
|
open: boolean;
|
|
onOpenChange: (v: boolean) => void;
|
|
}) {
|
|
// PDFs and images get an inline preview. Anything else just shows
|
|
// metadata + a download button — better than pretending to render a
|
|
// .docx and ending up with a broken UI.
|
|
const canPreview = kind === "pdf" || kind === "image";
|
|
const Icon = ATTACHMENT_ICONS[kind];
|
|
|
|
// Lazily load the file as a blob once the dialog opens. `open` is the
|
|
// gate so we don't fetch every attachment on page load — only when the
|
|
// user actually wants to see it.
|
|
const { src: blobSrc, loading, error } = useAttachmentBlob(attachment, open && canPreview);
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent
|
|
showCloseButton={false}
|
|
className="sm:max-w-4xl w-[calc(100%-2rem)] max-h-[90vh] p-0 gap-0 overflow-hidden flex flex-col"
|
|
>
|
|
<div className="flex items-center gap-3 px-4 py-3 border-b">
|
|
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
|
|
<DialogTitle className="flex-1 min-w-0 truncate font-medium text-sm">
|
|
{attachment.filename}
|
|
</DialogTitle>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
nativeButton={false}
|
|
render={<a href={url} target="_blank" rel="noopener noreferrer" />}
|
|
aria-label="Open in new tab"
|
|
title="Open in new tab"
|
|
>
|
|
<ExternalLink className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
nativeButton={false}
|
|
render={
|
|
<a
|
|
href={url}
|
|
download={attachment.filename}
|
|
aria-label="Download"
|
|
title="Download"
|
|
/>
|
|
}
|
|
>
|
|
<Download className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
onClick={() => onOpenChange(false)}
|
|
aria-label="Close"
|
|
title="Close"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="flex-1 min-h-0 bg-muted/30">
|
|
{canPreview ? (
|
|
kind === "image" ? (
|
|
<div className="flex items-center justify-center p-4 min-h-[60vh] overflow-auto">
|
|
{blobSrc ? (
|
|
/* eslint-disable-next-line @next/next/no-img-element */
|
|
<img
|
|
src={blobSrc}
|
|
alt={attachment.filename}
|
|
className="max-w-full max-h-[80vh] object-contain rounded shadow-sm"
|
|
/>
|
|
) : loading ? (
|
|
<PreviewLoading label="Loading image…" />
|
|
) : (
|
|
<PreviewError
|
|
error={error}
|
|
filename={attachment.filename}
|
|
url={url}
|
|
/>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="w-full h-[80vh] bg-white">
|
|
{blobSrc ? (
|
|
// PDF preview via blob: URL. Embed is used in addition
|
|
// to iframe because some browsers' built-in PDF viewers
|
|
// attach to either tag, and having both gives us a
|
|
// fallback if one is blocked by an extension / CSP.
|
|
// The iframe is the primary; the embed is the fallback.
|
|
<iframe
|
|
src={blobSrc}
|
|
title={attachment.filename}
|
|
className="w-full h-full"
|
|
/>
|
|
) : loading ? (
|
|
<PreviewLoading label="Loading PDF…" />
|
|
) : (
|
|
<PreviewError
|
|
error={error}
|
|
filename={attachment.filename}
|
|
url={url}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
) : (
|
|
<div className="p-8 flex flex-col items-center justify-center text-center gap-3 min-h-[40vh]">
|
|
<Icon className="h-12 w-12 text-muted-foreground" />
|
|
<div>
|
|
<p className="font-medium">{attachment.filename}</p>
|
|
<p className="text-sm text-muted-foreground mt-1">
|
|
{attachment.content_type || kind.toUpperCase()} ·{" "}
|
|
{formatBytes(attachment.size_bytes)}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground mt-3">
|
|
No inline preview for this file type. Download to open it
|
|
locally.
|
|
</p>
|
|
</div>
|
|
<Button
|
|
variant="default"
|
|
nativeButton={false}
|
|
render={<a href={url} download={attachment.filename} />}
|
|
>
|
|
<Download className="mr-2 h-4 w-4" />
|
|
Download
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function PreviewLoading({ label }: { label: string }) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center gap-2 h-full text-muted-foreground">
|
|
<div className="h-6 w-6 rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground animate-spin" />
|
|
<p className="text-sm">{label}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PreviewError({
|
|
error,
|
|
filename,
|
|
url,
|
|
}: {
|
|
error: string | null;
|
|
filename: string;
|
|
url: string;
|
|
}) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center gap-3 h-full text-center p-8">
|
|
<FileIcon className="h-10 w-10 text-muted-foreground" />
|
|
<div>
|
|
<p className="font-medium">{filename}</p>
|
|
<p className="text-sm text-muted-foreground mt-1">
|
|
Couldn't load preview{error ? ` — ${error}` : ""}.
|
|
</p>
|
|
<p className="text-xs text-muted-foreground mt-2">
|
|
Download the file to open it locally.
|
|
</p>
|
|
</div>
|
|
<Button variant="default" nativeButton={false} render={<a href={url} download={filename} />}>
|
|
<Download className="mr-2 h-4 w-4" />
|
|
Download
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|