feat(interests): Email / Call / WhatsApp deep-links on interest header

The interest detail is the rep's workbench — but until now, calling or
emailing the lead meant navigating away to the client page first. Surface
the same Email / Call / WhatsApp affordances that already live on the
client header right where the work is happening.

  - getInterestById: extended to also resolve the linked client's primary
    phone (display value + canonical E.164 form for wa.me).
    `clientPrimaryEmail` is the same column we surfaced earlier for the
    EOI prereq checklist; this commit just adds the phone columns
    alongside it.

  - InterestDetailHeader: new contact-actions row tucked under the meta
    line. Each button is asChild over a real <a href> so middle-click,
    Cmd-click, and screen-readers behave correctly. Renders only the
    buttons whose underlying contact channel is present (Email-only when
    no phone is on file, etc.). The whole row is hidden when the client
    has no contacts at all.

  - WhatsApp number prefers the E.164 form; falls back to digits-stripped
    display value when the canonical form is missing.

tsc clean. vitest 835/835 pass. ESLint clean on every file touched.

Closes audit recommendation #1 (top-of-list — biggest sales-workflow
win per click saved).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-05-02 03:33:13 +02:00
parent d197f8b321
commit c824b2df12
3 changed files with 119 additions and 4 deletions

View File

@@ -2,9 +2,20 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Pencil, Archive, RotateCcw, Trophy, XCircle, RefreshCcw } from 'lucide-react';
import {
Pencil,
Archive,
RotateCcw,
Trophy,
XCircle,
RefreshCcw,
Mail,
MessageCircle,
Phone,
} from 'lucide-react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { TagBadge } from '@/components/shared/tag-badge';
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
@@ -36,6 +47,12 @@ interface InterestDetailHeaderProps {
id: string;
clientId: string;
clientName: string | null;
/** Primary contact channels resolved from the linked client. The header
* uses these to render Email / Call / WhatsApp buttons so the rep
* doesn't have to navigate to the client page just to reach out. */
clientPrimaryEmail?: string | null;
clientPrimaryPhone?: string | null;
clientPrimaryPhoneE164?: string | null;
berthId: string | null;
berthMooringNumber: string | null;
pipelineStage: string;
@@ -71,6 +88,16 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
const outcomeBadge = interest.outcome ? OUTCOME_BADGE[interest.outcome] : null;
const isClosed = !!interest.outcome;
// Contact deep-links — resolved from the linked client's primary channels.
// wa.me requires the digits-only E.164 number (no leading "+"); fall back to
// stripping non-digits from the display value when the canonical form is
// missing.
const whatsappNumber = interest.clientPrimaryPhoneE164
? interest.clientPrimaryPhoneE164.replace(/^\+/, '')
: interest.clientPrimaryPhone
? interest.clientPrimaryPhone.replace(/[^\d]/g, '')
: null;
const reopenMutation = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/interests/${interest.id}/outcome`, { method: 'DELETE', body: {} }),
@@ -200,6 +227,65 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
))}
</div>
)}
{/* Contact deep-links — let the rep email / call / WhatsApp the
client without leaving the interest workspace. Resolved from
the linked client's primary contact channels (server-side
fetch in getInterestById). */}
{interest.clientPrimaryEmail || interest.clientPrimaryPhone || whatsappNumber ? (
<div className="flex flex-wrap items-center gap-1.5 pt-1">
{interest.clientPrimaryEmail ? (
<Button
asChild
variant="outline"
size="sm"
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
>
<a
href={`mailto:${interest.clientPrimaryEmail}`}
aria-label={`Email ${interest.clientPrimaryEmail}`}
>
<Mail />
Email
</a>
</Button>
) : null}
{interest.clientPrimaryPhone ? (
<Button
asChild
variant="outline"
size="sm"
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
>
<a
href={`tel:${interest.clientPrimaryPhone}`}
aria-label={`Call ${interest.clientPrimaryPhone}`}
>
<Phone />
Call
</a>
</Button>
) : null}
{whatsappNumber ? (
<Button
asChild
variant="outline"
size="sm"
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
>
<a
href={`https://wa.me/${whatsappNumber}`}
target="_blank"
rel="noopener noreferrer"
aria-label={`Message on WhatsApp`}
>
<MessageCircle />
WhatsApp
</a>
</Button>
) : null}
</div>
) : null}
</div>
{/* Top-right actions. Won/Lost are sales-critical and read as text

View File

@@ -16,6 +16,18 @@ interface InterestData {
portId: string;
clientId: string;
clientName: string | null;
/** Linked client's primary email (display value). Powers the header
* "Email" button and the EOI prereq checklist. */
clientPrimaryEmail: string | null;
/** Linked client's primary phone (display value). Powers the header
* "Call" button. */
clientPrimaryPhone: string | null;
/** Linked client's primary phone in E.164 form ("+1XXXXXXXXXX"). Used
* by wa.me to assemble the WhatsApp deep-link. */
clientPrimaryPhoneE164: string | null;
/** True when the linked client has any primary address row. Used by
* the EOI prereq checklist on the Documents tab. */
clientHasAddress: boolean;
berthId: string | null;
berthMooringNumber: string | null;
pipelineStage: string;
@@ -39,6 +51,8 @@ interface InterestData {
archivedAt: string | null;
createdAt: string;
updatedAt: string;
outcome?: string | null;
outcomeReason?: string | null;
tags: Array<{ id: string; name: string; color: string }>;
}