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>
266 lines
10 KiB
TypeScript
266 lines
10 KiB
TypeScript
'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>
|
||
);
|
||
}
|