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:
50
src/app/api/v1/tracked-links/route.ts
Normal file
50
src/app/api/v1/tracked-links/route.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
@@ -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()}
|
||||
|
||||
199
src/components/email/tracked-link-composer-button.tsx
Normal file
199
src/components/email/tracked-link-composer-button.tsx
Normal 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 <APP_URL>/q/<slug> 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
<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}
|
||||
|
||||
@@ -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.`,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user