Files
pn-new-crm/src/components/admin/sends-log.tsx
Matt a7cbee09ee feat(uat-batch): Group O — Umami in-repo polish
O48, O51-O54 from the 2026-05-21 plan. Phase 4a / 3 / 5 marketing-site
work explicitly deferred — they live in the marketing repo + are
blocked on instrumentation that isn't this codebase's to ship.

Shipped:
  O48  Tracked-link composer button.
       New POST /api/v1/tracked-links mints a redirect-link the rep can
       drop into an outgoing email. Body { targetUrl, sendId? }; returns
       { id, slug, targetUrl, url }. Gated on `email.send` (same as the
       server-side check on existing send routes). `sendId` lets the
       click-tracker attribute back to a specific document_sends row.
       <TrackedLinkComposerButton> renders a small inline button (or a
       sized default variant) that opens a dialog: rep pastes the
       destination URL → Create → gets the public /q/<slug> URL with
       a Copy + an "Insert into message" action that calls back to the
       parent compose surface. Wired into <SendDocumentDialog>'s
       Message body label row so reps can mint + insert without
       leaving the dialog.
  O51  Quiet-range nudge. WebsiteAnalyticsShell surfaces a small amber
       banner when the active range returned <5 visitors so the rep
       doesn't think the integration is broken on a fresh port or
       off-season range. Threshold keeps the banner off legitimate
       traffic.
  O52  Apple Mail privacy disclaimer. The sends-log "Not opened" badge
       carries an inline tooltip explaining that Apple Mail's privacy
       protection routes opens through Apple's proxy and can suppress
       this signal even when the recipient read the email.
  O53  Open-rate column on the document_sends list. SendRow type
       extended with `trackOpens` / `openCount` / `firstOpenedAt`; the
       sends-log card chrome renders an "Opened × N" badge with the
       first-open timestamp in the title, or "Not opened" when tracking
       is on but no opens yet, or no badge at all when tracking was
       disabled for that send.
  O54  Click-to-filter world map. VisitorWorldMap already supported
       `onCountryClick`; wired it through to copy the
       `/<portSlug>/clients?nationality=<ISO>` deep-link to the
       clipboard with a toast on click. Inline filtering of the
       analytics view itself stays parked alongside Phase 5 — the
       useUmami* hooks don't yet accept a country filter.

Deferred (not in this repo or blocked):
  O47  Phase 4a marketing-site instrumentation — marketing repo work.
  O49  Phase 3 Events tab — blocked on 4a.
  O50  Phase 5 Funnels + Journeys — blocked on 4a.

Verified: tsc clean, vitest 1454/1454.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 23:39:19 +02:00

266 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { formatDistanceToNow, format } from 'date-fns';
import { PageHeader } from '@/components/shared/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { apiFetch } from '@/lib/api/client';
interface SendRow {
id: string;
portId: string;
recipientEmail: string;
documentKind: 'berth_pdf' | 'brochure' | string;
fromAddress: string;
bodyMarkdown: string | null;
sentAt: string;
failedAt: string | null;
errorReason: string | null;
fallbackToLinkReason: string | null;
messageId: string | null;
berthId: string | null;
brochureId: string | null;
clientId: string | null;
interestId: string | null;
/** Phase 6 — populated by the IMAP bounce poller when a delivery
* failure for this send was matched in the configured mailbox. */
bounceStatus: 'hard' | 'soft' | 'ooo' | null;
bounceReason: string | null;
bounceDetectedAt: string | null;
/** Phase 4b email-open tracking. `openCount` is denormalised on every
* pixel hit; `firstOpenedAt` stamps the first time the recipient
* loaded the email. Both stay 0 / null when `trackOpens` is off. */
trackOpens: boolean;
openCount: number;
firstOpenedAt: string | null;
}
interface ListResponse {
data: SendRow[];
pagination: { nextCursor: { sentAt: string; id: string } | null };
counts: { sent: number; failed: number; all: number };
}
export function SendsLog() {
const [status, setStatus] = useState<'all' | 'sent' | 'failed'>('all');
const [expanded, setExpanded] = useState<string | null>(null);
const { data, isLoading, error } = useQuery({
queryKey: ['document-sends', status],
queryFn: () => apiFetch<ListResponse>(`/api/v1/admin/document-sends?status=${status}`),
});
const counts = data?.counts ?? { sent: 0, failed: 0, all: 0 };
const rows = data?.data ?? [];
return (
<div>
<PageHeader
title="Send log"
description="Every brochure and per-berth PDF sent from the CRM, with delivery failures surfaced for retry."
/>
<div className="flex items-center gap-2 mt-6 flex-wrap">
<FilterChip
label={`All (${counts.all})`}
active={status === 'all'}
onClick={() => setStatus('all')}
/>
<FilterChip
label={`Sent (${counts.sent})`}
active={status === 'sent'}
onClick={() => setStatus('sent')}
/>
<FilterChip
label={`Failed (${counts.failed})`}
active={status === 'failed'}
onClick={() => setStatus('failed')}
accent={counts.failed > 0 ? 'danger' : undefined}
/>
</div>
<div className="mt-6">
{isLoading ? (
<p className="text-sm text-muted-foreground py-6">Loading</p>
) : error ? (
<p className="text-sm text-red-600 py-6">
Failed to load sends: {error instanceof Error ? error.message : 'unknown error'}
</p>
) : rows.length === 0 ? (
<Card>
<CardContent className="py-10 text-center text-sm text-muted-foreground">
No sends recorded for this filter yet.
</CardContent>
</Card>
) : (
<div className="space-y-3">
{rows.map((row) => {
const sent = new Date(row.sentAt);
const ago = formatDistanceToNow(sent, { addSuffix: true });
const isOpen = expanded === row.id;
const failed = !!row.failedAt;
return (
<Card key={row.id}>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<div className="flex-1">
<div className="flex items-center gap-2 flex-wrap">
<Badge
className={
failed ? 'bg-red-100 text-red-800' : 'bg-green-100 text-green-800'
}
>
{failed ? 'Failed' : 'Sent'}
</Badge>
<Badge variant="secondary">
{row.documentKind === 'berth_pdf'
? 'Berth PDF'
: row.documentKind === 'brochure'
? 'Brochure'
: row.documentKind}
</Badge>
{row.fallbackToLinkReason ? (
<Badge className="bg-amber-100 text-amber-800">
Switched to download link
</Badge>
) : null}
{row.bounceStatus ? (
<Badge
className={
row.bounceStatus === 'ooo'
? 'bg-slate-100 text-slate-800'
: 'bg-rose-100 text-rose-800'
}
>
{row.bounceStatus === 'hard'
? 'Hard bounce'
: row.bounceStatus === 'soft'
? 'Soft bounce'
: 'Out of office'}
</Badge>
) : null}
{row.trackOpens ? (
row.openCount > 0 ? (
<Badge
className="bg-emerald-100 text-emerald-800"
title={
row.firstOpenedAt
? `First opened ${format(new Date(row.firstOpenedAt), 'PP p')} · ${row.openCount} open${row.openCount === 1 ? '' : 's'}`
: `${row.openCount} open${row.openCount === 1 ? '' : 's'}`
}
>
Opened {row.openCount > 1 ? `× ${row.openCount}` : ''}
</Badge>
) : (
<Badge
variant="secondary"
className="text-xs"
title="Tracking pixel embedded but no opens recorded yet. Apple Mail's privacy protection routes opens through Apple's proxy, which can suppress this signal even when the recipient read the email."
>
Not opened
</Badge>
)
) : null}
<span
className="text-xs text-muted-foreground"
title={sent.toISOString()}
>
{ago} · {format(sent, 'PP p')}
</span>
</div>
<CardTitle className="mt-2 text-base font-medium">
{row.recipientEmail}
</CardTitle>
<div className="text-sm text-muted-foreground mt-1">
From {row.fromAddress}
{row.messageId ? (
<span className="text-xs ml-2 font-mono">{row.messageId}</span>
) : null}
</div>
{failed && row.errorReason ? (
<div className="mt-2 text-sm text-red-700 bg-red-50 rounded-md p-2">
{row.errorReason}
</div>
) : null}
{row.fallbackToLinkReason ? (
<div className="mt-2 text-sm text-amber-700 bg-amber-50 rounded-md p-2">
Attachment dropped sent as link. Reason: {row.fallbackToLinkReason}
</div>
) : null}
{row.bounceStatus && row.bounceReason ? (
<div
className={`mt-2 text-sm rounded-md p-2 ${
row.bounceStatus === 'ooo'
? 'text-slate-700 bg-slate-50'
: 'text-rose-700 bg-rose-50'
}`}
>
Bounced
{row.bounceDetectedAt
? ` ${formatDistanceToNow(new Date(row.bounceDetectedAt), {
addSuffix: true,
})}`
: ''}
: {row.bounceReason}
</div>
) : null}
</div>
{row.bodyMarkdown ? (
<Button
size="sm"
variant="outline"
onClick={() => setExpanded(isOpen ? null : row.id)}
>
{isOpen ? 'Hide body' : 'View body'}
</Button>
) : null}
</div>
</CardHeader>
{isOpen && row.bodyMarkdown ? (
<CardContent>
<pre className="bg-muted/40 rounded-md p-3 text-xs overflow-auto max-h-96 whitespace-pre-wrap">
{row.bodyMarkdown}
</pre>
</CardContent>
) : null}
</Card>
);
})}
</div>
)}
</div>
</div>
);
}
function FilterChip({
label,
active,
onClick,
accent,
}: {
label: string;
active: boolean;
onClick: () => void;
accent?: 'danger';
}) {
const base = active
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background text-foreground border-border hover:bg-muted';
const dangerActive =
accent === 'danger' && active ? 'bg-red-600 text-white border-red-600' : null;
return (
<button
type="button"
onClick={onClick}
className={`px-3 py-1.5 rounded-full text-sm border transition ${dangerActive ?? base}`}
>
{label}
</button>
);
}