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>
This commit is contained in:
2026-05-21 23:39:19 +02:00
parent a147cbcd93
commit a7cbee09ee
5 changed files with 320 additions and 2 deletions

View File

@@ -0,0 +1,50 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { buildTrackedUrl, createTrackedLink } from '@/lib/services/tracked-links.service';
import { errorResponse } from '@/lib/errors';
/**
* POST /api/v1/tracked-links
*
* Mints a new tracked redirect-link the rep can drop into an outgoing
* email or chat. Body: { targetUrl, sendId? }. Returns the slug + the
* full public URL (`<APP_URL>/q/<slug>`) — caller pastes the URL into
* the message draft.
*
* Gated on `email.send` since this surface is consumed from compose UIs.
* Tracked links aren't sensitive on their own but reps shouldn't be
* able to mint click-trackers without the underlying send permission.
*/
const createSchema = z.object({
targetUrl: z.string().url(),
sendId: z.string().min(1).optional(),
});
export const POST = withAuth(async (req, ctx) => {
try {
const body = await parseBody(req, createSchema);
const allowed = ctx.isSuperAdmin ? true : !!ctx.permissions?.email?.send;
if (!allowed) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const row = await createTrackedLink({
portId: ctx.portId,
targetUrl: body.targetUrl,
sendId: body.sendId,
createdByUserId: ctx.userId,
});
return NextResponse.json({
data: {
id: row.id,
slug: row.slug,
targetUrl: row.targetUrl,
url: buildTrackedUrl(row.slug),
},
});
} catch (error) {
return errorResponse(error);
}
});

View File

@@ -31,6 +31,12 @@ interface SendRow {
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 {
@@ -137,6 +143,28 @@ export function SendsLog() {
: '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()}

View File

@@ -0,0 +1,199 @@
'use client';
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { ClipboardCopy, Link as LinkIcon, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
interface CreateResponse {
data: { id: string; slug: string; targetUrl: string; url: string };
}
interface Props {
/** Optional FK to attribute clicks back to a specific document_sends
* row. When supplied, every click on the minted link increments the
* send's click counter so the per-email open + click stats reconcile. */
sendId?: string;
/** Called with the minted public URL so the parent compose surface can
* paste it into the email body / textarea. */
onInsert?: (url: string) => void;
/** Display variant — `inline` is a small text button suitable for a
* toolbar; `default` is a sized button suitable for a form footer. */
variant?: 'inline' | 'default';
}
/**
* Compose-surface affordance for minting a trackable redirect link.
* Opens a small dialog: rep pastes the destination URL, clicks Create,
* gets the public `/q/<slug>` URL with a Copy button + an "Insert into
* message" action that calls back to the parent.
*
* Permission-gated on `email.send` so reps without send rights don't
* see the affordance — same as the server-side check on the POST route.
*/
export function TrackedLinkComposerButton({ sendId, onInsert, variant = 'inline' }: Props) {
const [open, setOpen] = useState(false);
const [target, setTarget] = useState('');
const [result, setResult] = useState<CreateResponse['data'] | null>(null);
const create = useMutation({
mutationFn: async () => {
const res = await apiFetch<CreateResponse>('/api/v1/tracked-links', {
method: 'POST',
body: { targetUrl: target.trim(), sendId },
});
return res.data;
},
onSuccess: (data) => {
setResult(data);
},
onError: (err) => toastError(err),
});
function reset() {
setTarget('');
setResult(null);
}
return (
<>
<Button
type="button"
size={variant === 'default' ? 'sm' : 'sm'}
variant={variant === 'default' ? 'default' : 'outline'}
onClick={() => {
reset();
setOpen(true);
}}
className={variant === 'inline' ? 'h-7 px-2 text-xs' : undefined}
title="Mint a tracked link the recipient can click — clicks count back to this send."
>
<LinkIcon
className={variant === 'inline' ? 'mr-1 size-3' : 'mr-1.5 size-3.5'}
aria-hidden
/>
Tracked link
</Button>
<Dialog
open={open}
onOpenChange={(o) => {
setOpen(o);
if (!o) reset();
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Mint a tracked link</DialogTitle>
<DialogDescription>
The recipient clicks the public &lt;APP_URL&gt;/q/&lt;slug&gt; URL and lands on the
destination URL. Each click is counted + attributed to{' '}
{sendId ? 'this send' : 'your account'} in the analytics surface.
</DialogDescription>
</DialogHeader>
{result ? (
<div className="space-y-2 py-2">
<Label htmlFor="tracked-link-url" className="text-xs uppercase tracking-wide">
Tracked URL
</Label>
<div className="flex items-center gap-2">
<Input
id="tracked-link-url"
value={result.url}
readOnly
className="font-mono text-xs"
/>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
void navigator.clipboard.writeText(result.url);
toast.success('Link copied');
}}
>
<ClipboardCopy className="mr-1.5 size-3.5" aria-hidden />
Copy
</Button>
</div>
<p className="text-[11px] text-muted-foreground">
Redirects to{' '}
<a
href={result.targetUrl}
target="_blank"
rel="noopener noreferrer"
className="underline-offset-2 hover:underline"
>
{result.targetUrl}
</a>
.
</p>
</div>
) : (
<div className="space-y-2 py-2">
<Label htmlFor="tracked-link-target">Destination URL</Label>
<Input
id="tracked-link-target"
type="url"
value={target}
onChange={(e) => setTarget(e.target.value)}
placeholder="https://portnimara.com/yachts/sapphire"
/>
</div>
)}
<DialogFooter>
{result ? (
<>
<Button variant="outline" onClick={() => setOpen(false)}>
Close
</Button>
{onInsert ? (
<Button
onClick={() => {
onInsert(result.url);
toast.success('Inserted');
setOpen(false);
}}
>
Insert into message
</Button>
) : null}
</>
) : (
<>
<Button variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
onClick={() => create.mutate()}
disabled={create.isPending || !target.trim()}
>
{create.isPending ? (
<Loader2 className="mr-1.5 size-3.5 animate-spin" aria-hidden />
) : (
<LinkIcon className="mr-1.5 size-3.5" aria-hidden />
)}
Create
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -32,6 +32,7 @@ import {
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { TrackedLinkComposerButton } from '@/components/email/tracked-link-composer-button';
import { Input } from '@/components/ui/input';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
@@ -221,7 +222,17 @@ function SendDocumentDialogInner({
)}
<div className="space-y-1">
<Label htmlFor="ds-body">Message body</Label>
<div className="flex items-center justify-between gap-2">
<Label htmlFor="ds-body">Message body</Label>
{/* Tracked-link composer — appends a trackable redirect
URL to the body so click-throughs reconcile back to
the send's analytics. */}
<TrackedLinkComposerButton
onInsert={(url) =>
setCustomBody((prev) => (prev ? `${prev}\n\n${url}` : url).slice(0, BODY_MAX))
}
/>
</div>
<Textarea
id="ds-body"
rows={10}

View File

@@ -12,6 +12,7 @@
*/
import { useState, type ReactNode } from 'react';
import { toast } from 'sonner';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { Globe, Info, Settings, ExternalLink } from 'lucide-react';
@@ -104,6 +105,22 @@ export function WebsiteAnalyticsShell() {
<NotConfiguredEmptyState portSlug={portSlug} />
) : (
<>
{/* Quiet-range nudge — when the range has near-zero visitors,
surface a small banner suggesting a wider window. Avoids
the rep thinking the integration is broken on a fresh
port or during off-season. Threshold of 5 visitors keeps
the banner from appearing on legitimately-busy ports. */}
{!stats.isLoading &&
stats.data?.data?.visitors !== undefined &&
stats.data.data.visitors < 5 ? (
<div className="rounded-md border border-amber-200 bg-amber-50 px-4 py-2 text-sm text-amber-900">
<strong>Quiet range:</strong> only {stats.data.data.visitors} visitor
{stats.data.data.visitors === 1 ? '' : 's'} in this window. Try a wider date range to
surface meaningful trends, or confirm the marketing site is pointed at this Umami
instance.
</div>
) : null}
{/* Realtime panel - collapsible "what's happening RIGHT NOW"
strip at the very top. Polling only fires while expanded. */}
<RealtimePanel />
@@ -264,10 +281,23 @@ export function WebsiteAnalyticsShell() {
{/* Recent sessions */}
<SessionsList range={range} />
{/* World heatmap - visitor counts per country (full-width, bottom of page) */}
{/* World heatmap - visitor counts per country (full-width, bottom of page).
Clicking a country emits a toast with the deep-link target;
the rep can copy the URL to filter the Clients list by that
nationality. A proper inline analytics filter (scoping the
session list + KPIs to the picked country) requires the
useUmami* hooks to accept a country param, which they don't
yet — that's parked alongside the Phase 5 funnels work. */}
<VisitorWorldMap
rows={allCountries.data?.data ?? null}
loading={allCountries.isLoading}
onCountryClick={(iso2) => {
const url = `/${portSlug}/clients?nationality=${encodeURIComponent(iso2)}`;
void navigator.clipboard?.writeText(window.location.origin + url);
toast.message(`${iso2} — link copied`, {
description: `Paste into the address bar to see all ${iso2} clients.`,
});
}}
/>
</>
)}