From a7cbee09eed70bb8de67e3676e542cdc929332c2 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 21 May 2026 23:39:19 +0200 Subject: [PATCH] =?UTF-8?q?feat(uat-batch):=20Group=20O=20=E2=80=94=20Umam?= =?UTF-8?q?i=20in-repo=20polish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. renders a small inline button (or a sized default variant) that opens a dialog: rep pastes the destination URL → Create → gets the public /q/ URL with a Copy + an "Insert into message" action that calls back to the parent compose surface. Wired into '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 `//clients?nationality=` 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) --- src/app/api/v1/tracked-links/route.ts | 50 +++++ src/components/admin/sends-log.tsx | 28 +++ .../email/tracked-link-composer-button.tsx | 199 ++++++++++++++++++ .../shared/send-document-dialog.tsx | 13 +- .../website-analytics-shell.tsx | 32 ++- 5 files changed, 320 insertions(+), 2 deletions(-) create mode 100644 src/app/api/v1/tracked-links/route.ts create mode 100644 src/components/email/tracked-link-composer-button.tsx diff --git a/src/app/api/v1/tracked-links/route.ts b/src/app/api/v1/tracked-links/route.ts new file mode 100644 index 00000000..ad2eae68 --- /dev/null +++ b/src/app/api/v1/tracked-links/route.ts @@ -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 (`/q/`) — 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); + } +}); diff --git a/src/components/admin/sends-log.tsx b/src/components/admin/sends-log.tsx index 52b2a8c4..b38141a1 100644 --- a/src/components/admin/sends-log.tsx +++ b/src/components/admin/sends-log.tsx @@ -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'} ) : null} + {row.trackOpens ? ( + row.openCount > 0 ? ( + + Opened {row.openCount > 1 ? `× ${row.openCount}` : ''} + + ) : ( + + Not opened + + ) + ) : null} 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/` 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(null); + + const create = useMutation({ + mutationFn: async () => { + const res = await apiFetch('/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 ( + <> + + + { + setOpen(o); + if (!o) reset(); + }} + > + + + Mint a tracked link + + 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. + + + {result ? ( +
+ +
+ + +
+

+ Redirects to{' '} + + {result.targetUrl} + + . +

+
+ ) : ( +
+ + setTarget(e.target.value)} + placeholder="https://portnimara.com/yachts/sapphire" + /> +
+ )} + + {result ? ( + <> + + {onInsert ? ( + + ) : null} + + ) : ( + <> + + + + )} + +
+
+ + ); +} diff --git a/src/components/shared/send-document-dialog.tsx b/src/components/shared/send-document-dialog.tsx index 8d20981f..9ca4a63b 100644 --- a/src/components/shared/send-document-dialog.tsx +++ b/src/components/shared/send-document-dialog.tsx @@ -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({ )}
- +
+ + {/* Tracked-link composer — appends a trackable redirect + URL to the body so click-throughs reconcile back to + the send's analytics. */} + + setCustomBody((prev) => (prev ? `${prev}\n\n${url}` : url).slice(0, BODY_MAX)) + } + /> +